mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
refactor(repo): replace legacy openflare-server with Wavelet rename
Delete the old monolithic openflare-server implementation and rename Wavelet/ to openflare-server/ to complete the migration consolidation. Update CI workflows, agent Dockerfiles, and deployment docs for the new layout (frontend/, docker/Dockerfile).
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
# dmux Hooks System - Agent Reference
|
||||
|
||||
**Auto-generated documentation for AI agents**
|
||||
|
||||
This document contains everything an AI agent needs to create, modify, and understand dmux hooks. It is automatically generated from the dmux source code and embedded in the binary.
|
||||
|
||||
## What You're Working On
|
||||
|
||||
You are editing hooks for **dmux**, a tmux pane manager that creates AI-powered development workflows. Each pane runs in its own git worktree with an AI agent.
|
||||
|
||||
## Your Goal
|
||||
|
||||
Create executable bash scripts in `.dmux-hooks/` that run automatically at key lifecycle events.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Create a hook file**: `touch .dmux-hooks/worktree_created`
|
||||
2. **Make it executable**: `chmod +x .dmux-hooks/worktree_created`
|
||||
3. **Add shebang**: Start with `#!/bin/bash`
|
||||
4. **Use environment variables**: Access `$DMUX_ROOT`, `$DMUX_WORKTREE_PATH`, etc.
|
||||
5. **Test it**: Set env vars manually and run the script
|
||||
|
||||
## Hook Execution Model
|
||||
|
||||
- **Mostly non-blocking**: Most hooks run in background (detached processes)
|
||||
- **Bootstrap-gated**: `worktree_created` blocks agent launch so setup can finish first, with no fixed timeout
|
||||
- **Live bootstrap output**: During `worktree_created`, stdout/stderr is streamed into the new pane's setup UI
|
||||
- **Failure behavior**: Background hook errors are logged; gated hooks can abort the operation
|
||||
- **Environment-based**: All context passed via environment variables
|
||||
- **Version controlled**: Hooks in `.dmux-hooks/` are shared with team
|
||||
- **Priority resolution**: `.dmux-hooks/` → `.dmux/hooks/` → `~/.dmux/hooks/`
|
||||
|
||||
## Available Hooks
|
||||
|
||||
### Pane Lifecycle Hooks
|
||||
|
||||
| Hook | When | Common Use Cases |
|
||||
|------|------|------------------|
|
||||
| `before_pane_create` | Before pane creation | Validation, notifications, pre-flight checks |
|
||||
| `pane_created` | After pane, before worktree | Configure tmux settings, prepare environment |
|
||||
| `worktree_created` | After worktree creation, before agent launch | Install deps, copy configs, setup git |
|
||||
| `before_pane_close` | Before closing | Save state, backup uncommitted work |
|
||||
| `pane_closed` | After closed | Cleanup resources, analytics, notifications |
|
||||
|
||||
### Worktree Lifecycle Hooks
|
||||
|
||||
| Hook | When | Common Use Cases |
|
||||
|------|------|------------------|
|
||||
| `before_worktree_remove` | Before worktree removal | Archive worktree, save artifacts |
|
||||
| `worktree_removed` | After worktree removed | Cleanup external references |
|
||||
|
||||
### Merge Lifecycle Hooks
|
||||
|
||||
| Hook | When | Common Use Cases |
|
||||
|------|------|------------------|
|
||||
| `pre_merge` | Before merge operation | Run final tests, create backups |
|
||||
| `post_merge` | After successful merge | Deploy, close issues, notify team |
|
||||
|
||||
### Interactive Hooks (with HTTP callbacks)
|
||||
|
||||
| Hook | When | Common Use Cases |
|
||||
|------|------|------------------|
|
||||
| `run_test` | When tests triggered | Run test suite, report status via HTTP |
|
||||
| `run_dev` | When dev server triggered | Start dev server, create tunnel, report URL |
|
||||
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Always Available
|
||||
```bash
|
||||
DMUX_ROOT="/path/to/project" # Project root directory
|
||||
DMUX_SERVER_PORT="3142" # HTTP server port
|
||||
```
|
||||
|
||||
### Pane Context (most hooks)
|
||||
```bash
|
||||
DMUX_PANE_ID="dmux-1234567890" # dmux pane identifier
|
||||
DMUX_SLUG="fix-auth-bug" # Branch/worktree name
|
||||
DMUX_PROMPT="Fix authentication bug" # User's prompt
|
||||
DMUX_AGENT="claude" # Agent type (registry id, e.g. claude, codex, opencode)
|
||||
DMUX_TMUX_PANE_ID="%38" # tmux pane ID
|
||||
```
|
||||
|
||||
### Worktree Context
|
||||
```bash
|
||||
DMUX_WORKTREE_PATH="/path/.dmux/worktrees/fix-auth-bug"
|
||||
DMUX_BRANCH="fix-auth-bug" # Same as slug
|
||||
```
|
||||
|
||||
### Bootstrap Progress Context
|
||||
```bash
|
||||
DMUX_PROGRESS="1" # Set when output is shown in the new pane setup UI
|
||||
DMUX_STATUS_PREFIX="DMUX_STATUS:" # Prefix for clean status messages
|
||||
```
|
||||
|
||||
`worktree_created` can emit progress while it runs. Any stdout/stderr line is shown in the setup UI; lines prefixed with `$DMUX_STATUS_PREFIX` are displayed without the prefix.
|
||||
|
||||
```bash
|
||||
status() {
|
||||
if [ "${DMUX_PROGRESS:-0}" = "1" ]; then
|
||||
echo "${DMUX_STATUS_PREFIX:-DMUX_STATUS:} $*"
|
||||
else
|
||||
echo "[Hook] $*"
|
||||
fi
|
||||
}
|
||||
```
|
||||
|
||||
### Merge Context
|
||||
```bash
|
||||
DMUX_TARGET_BRANCH="main" # Branch being merged into
|
||||
```
|
||||
|
||||
## HTTP Callback API
|
||||
|
||||
Interactive hooks (`run_test` and `run_dev`) can update dmux UI via HTTP.
|
||||
|
||||
### Update Test Status
|
||||
```bash
|
||||
curl -X PUT "http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/test" -H "Content-Type: application/json" -d '{"status": "running", "output": "optional test output"}'
|
||||
|
||||
# Status values: "running" | "passed" | "failed"
|
||||
```
|
||||
|
||||
### Update Dev Server
|
||||
```bash
|
||||
curl -X PUT "http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/dev" -H "Content-Type: application/json" -d '{"status": "running", "url": "http://localhost:3000"}'
|
||||
|
||||
# Status values: "running" | "stopped"
|
||||
# url: Can be localhost or tunnel URL (ngrok, cloudflared, etc.)
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Install Dependencies
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .dmux-hooks/worktree_created
|
||||
|
||||
cd "$DMUX_WORKTREE_PATH"
|
||||
|
||||
status() {
|
||||
if [ "${DMUX_PROGRESS:-0}" = "1" ]; then
|
||||
echo "${DMUX_STATUS_PREFIX:-DMUX_STATUS:} $*"
|
||||
else
|
||||
echo "[Hook] $*"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -f "pnpm-lock.yaml" ]; then
|
||||
status "Installing dependencies with pnpm"
|
||||
pnpm install --prefer-offline
|
||||
elif [ -f "package-lock.json" ]; then
|
||||
status "Installing dependencies with npm"
|
||||
npm install
|
||||
elif [ -f "yarn.lock" ]; then
|
||||
status "Installing dependencies with yarn"
|
||||
yarn install
|
||||
elif [ -f "Gemfile" ]; then
|
||||
status "Installing gems"
|
||||
bundle install
|
||||
elif [ -f "requirements.txt" ]; then
|
||||
status "Installing Python dependencies"
|
||||
pip install -r requirements.txt
|
||||
elif [ -f "Cargo.toml" ]; then
|
||||
status "Building Rust project"
|
||||
cargo build
|
||||
fi
|
||||
```
|
||||
|
||||
### Pattern 2: Copy Configuration
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .dmux-hooks/worktree_created
|
||||
|
||||
# Copy environment file
|
||||
if [ -f "$DMUX_ROOT/.env.local" ]; then
|
||||
cp "$DMUX_ROOT/.env.local" "$DMUX_WORKTREE_PATH/.env.local"
|
||||
fi
|
||||
|
||||
# Copy other config files
|
||||
for file in .env.development .npmrc .yarnrc; do
|
||||
if [ -f "$DMUX_ROOT/$file" ]; then
|
||||
cp "$DMUX_ROOT/$file" "$DMUX_WORKTREE_PATH/$file"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### Pattern 3: Run Tests with Status Updates
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .dmux-hooks/run_test
|
||||
|
||||
set -e
|
||||
cd "$DMUX_WORKTREE_PATH"
|
||||
API="http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/test"
|
||||
|
||||
# Update: starting
|
||||
curl -s -X PUT "$API" -H "Content-Type: application/json" -d '{"status": "running"}' > /dev/null
|
||||
|
||||
# Run tests and capture output
|
||||
OUTPUT_FILE="/tmp/dmux-test-$DMUX_PANE_ID.txt"
|
||||
if pnpm test > "$OUTPUT_FILE" 2>&1; then
|
||||
STATUS="passed"
|
||||
else
|
||||
STATUS="failed"
|
||||
fi
|
||||
|
||||
# Get output (truncate if too long)
|
||||
OUTPUT=$(head -c 5000 "$OUTPUT_FILE")
|
||||
|
||||
# Update: complete
|
||||
curl -s -X PUT "$API" -H "Content-Type: application/json" -d "$(jq -n --arg status "$STATUS" --arg output "$OUTPUT" '{status: $status, output: $output}')" > /dev/null
|
||||
|
||||
rm -f "$OUTPUT_FILE"
|
||||
```
|
||||
|
||||
### Pattern 4: Dev Server with Tunnel
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .dmux-hooks/run_dev
|
||||
|
||||
set -e
|
||||
cd "$DMUX_WORKTREE_PATH"
|
||||
API="http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/dev"
|
||||
|
||||
# Start dev server in background
|
||||
LOG_FILE="/tmp/dmux-dev-$DMUX_PANE_ID.log"
|
||||
pnpm dev > "$LOG_FILE" 2>&1 &
|
||||
DEV_PID=$!
|
||||
|
||||
# Wait for server to start
|
||||
sleep 5
|
||||
|
||||
# Detect port from logs
|
||||
PORT=$(grep -oP 'localhost:Kd+' "$LOG_FILE" | head -1)
|
||||
[ -z "$PORT" ] && PORT=3000
|
||||
|
||||
# Optional: Create tunnel with cloudflared
|
||||
if command -v cloudflared &> /dev/null; then
|
||||
TUNNEL=$(cloudflared tunnel --url "http://localhost:$PORT" 2>&1 | grep -oP 'https://[a-z0-9-]+.trycloudflare.com' | head -1)
|
||||
URL="${TUNNEL:-http://localhost:$PORT}"
|
||||
else
|
||||
URL="http://localhost:$PORT"
|
||||
fi
|
||||
|
||||
# Report status
|
||||
curl -s -X PUT "$API" -H "Content-Type: application/json" -d "{"status": "running", "url": "$URL"}" > /dev/null
|
||||
|
||||
echo "[Hook] Dev server running at $URL (PID: $DEV_PID)"
|
||||
```
|
||||
|
||||
### Pattern 5: Post-Merge Deployment
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .dmux-hooks/post_merge
|
||||
|
||||
set -e
|
||||
cd "$DMUX_ROOT"
|
||||
|
||||
# Only deploy from main/master
|
||||
if [ "$DMUX_TARGET_BRANCH" != "main" ] && [ "$DMUX_TARGET_BRANCH" != "master" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Push to remote
|
||||
git push origin "$DMUX_TARGET_BRANCH"
|
||||
|
||||
# Trigger deployment (example: Vercel)
|
||||
if [ -n "$VERCEL_TOKEN" ]; then
|
||||
curl -s -X POST "https://api.vercel.com/v1/deployments" -H "Authorization: Bearer $VERCEL_TOKEN" -H "Content-Type: application/json" -d '{"name": "my-project"}' > /dev/null
|
||||
fi
|
||||
|
||||
# Close GitHub issue if prompt contains #123
|
||||
ISSUE=$(echo "$DMUX_PROMPT" | grep -oP '#Kd+' | head -1)
|
||||
if [ -n "$ISSUE" ] && command -v gh &> /dev/null; then
|
||||
gh issue close "$ISSUE" -c "Resolved in $DMUX_SLUG, merged to $DMUX_TARGET_BRANCH" 2>/dev/null || true
|
||||
fi
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always start with shebang**: `#!/bin/bash`
|
||||
2. **Set error handling**: `set -e` (exit on error)
|
||||
3. **Make executable**: `chmod +x .dmux-hooks/hook_name`
|
||||
4. **Background long operations**: Append `&` to avoid blocking
|
||||
5. **Check for required tools**: `command -v tool &> /dev/null`
|
||||
6. **Log for debugging**: `echo "[Hook] message" >> "$DMUX_ROOT/.dmux/hooks.log"`
|
||||
7. **Handle missing vars gracefully**: `[ -z "$VAR" ] && exit 0`
|
||||
8. **Use silent curl**: `curl -s` to avoid noise in logs
|
||||
9. **Clean up temp files**: Remove files in `/tmp/`
|
||||
10. **Test before committing**: Run hooks manually with mock env vars
|
||||
|
||||
## Testing Hooks
|
||||
|
||||
### Manual Testing
|
||||
```bash
|
||||
# 1. Set environment variables
|
||||
export DMUX_ROOT="$(pwd)"
|
||||
export DMUX_PANE_ID="test-pane"
|
||||
export DMUX_SLUG="test-branch"
|
||||
export DMUX_WORKTREE_PATH="$(pwd)"
|
||||
export DMUX_SERVER_PORT="3142"
|
||||
export DMUX_AGENT="claude"
|
||||
export DMUX_PROMPT="Test prompt"
|
||||
|
||||
# 2. Run hook directly
|
||||
./.dmux-hooks/worktree_created
|
||||
|
||||
# 3. Check exit code
|
||||
echo $? # Should be 0 for success
|
||||
```
|
||||
|
||||
### Syntax Check
|
||||
```bash
|
||||
# Check for syntax errors without running
|
||||
bash -n ./.dmux-hooks/worktree_created
|
||||
```
|
||||
|
||||
### Shellcheck (if available)
|
||||
```bash
|
||||
shellcheck ./.dmux-hooks/worktree_created
|
||||
```
|
||||
|
||||
## Project Context Analysis
|
||||
|
||||
Before creating hooks, analyze these files in the project:
|
||||
|
||||
### Package Manager Detection
|
||||
```bash
|
||||
# Check which package manager is used
|
||||
if [ -f "pnpm-lock.yaml" ]; then
|
||||
# Use: pnpm install, pnpm test, pnpm dev
|
||||
elif [ -f "package-lock.json" ]; then
|
||||
# Use: npm install, npm test, npm run dev
|
||||
elif [ -f "yarn.lock" ]; then
|
||||
# Use: yarn install, yarn test, yarn dev
|
||||
fi
|
||||
```
|
||||
|
||||
### Test Command Discovery
|
||||
```bash
|
||||
# Read package.json to find test command
|
||||
cat package.json | grep '"test"'
|
||||
# Or with jq:
|
||||
jq -r '.scripts.test' package.json
|
||||
```
|
||||
|
||||
### Dev Command Discovery
|
||||
```bash
|
||||
# Read package.json to find dev command
|
||||
cat package.json | grep '"dev"'
|
||||
# Or with jq:
|
||||
jq -r '.scripts.dev' package.json
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
# Check for .env files to copy
|
||||
ls -la | grep '.env'
|
||||
```
|
||||
|
||||
### Build System
|
||||
```bash
|
||||
# Detect build system
|
||||
if [ -f "vite.config.ts" ]; then
|
||||
# Vite project
|
||||
elif [ -f "next.config.js" ]; then
|
||||
# Next.js project
|
||||
elif [ -f "nuxt.config.ts" ]; then
|
||||
# Nuxt project
|
||||
fi
|
||||
```
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
❌ **Blocking operations**: `sleep 60` (blocks dmux)
|
||||
✅ **Background long tasks**: `slow_operation &`
|
||||
|
||||
❌ **Hardcoded paths**: `/Users/me/project`
|
||||
✅ **Use variables**: `"$DMUX_ROOT"`
|
||||
|
||||
❌ **Assuming tools exist**: `pnpm install`
|
||||
✅ **Check first**: `command -v pnpm && pnpm install`
|
||||
|
||||
❌ **No error handling**: Script fails silently
|
||||
✅ **Set error mode**: `set -e` or check exit codes
|
||||
|
||||
❌ **Forgetting executable bit**: Hook won't run
|
||||
✅ **Make executable**: `chmod +x`
|
||||
|
||||
❌ **Noisy output**: Clutters dmux logs
|
||||
✅ **Silent operations**: `curl -s`, `> /dev/null 2>&1`
|
||||
|
||||
❌ **Not testing**: Deploy and hope
|
||||
✅ **Test manually**: Run with mock env vars first
|
||||
|
||||
## Debugging
|
||||
|
||||
If a hook isn't working:
|
||||
|
||||
1. **Check if file exists**: `ls -la .dmux-hooks/`
|
||||
2. **Check permissions**: Should show `x` in `rwxr-xr-x`
|
||||
3. **Check syntax**: `bash -n .dmux-hooks/hook_name`
|
||||
4. **Test manually**: Set env vars and run
|
||||
5. **Check logs**: dmux logs to stderr with `[Hooks]` prefix
|
||||
6. **Simplify**: Remove complex parts, test basic version
|
||||
7. **Check tool availability**: `command -v required_tool`
|
||||
|
||||
### Debug Mode
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Add to top of hook for debugging
|
||||
set -x # Print each command before executing
|
||||
set -e # Exit on error
|
||||
|
||||
# Your hook logic here
|
||||
```
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
When creating a new hook:
|
||||
|
||||
- [ ] Create file in `.dmux-hooks/`
|
||||
- [ ] Add shebang: `#!/bin/bash`
|
||||
- [ ] Make executable: `chmod +x`
|
||||
- [ ] Add `set -e` for error handling
|
||||
- [ ] Use environment variables (never hardcode paths)
|
||||
- [ ] Keep blocking hooks chatty with status output
|
||||
- [ ] Background long operations with `&` only when the hook does not gate the operation
|
||||
- [ ] Check for required tools before using
|
||||
- [ ] Test manually with mock env vars
|
||||
- [ ] Add comments explaining what it does
|
||||
- [ ] Commit to version control
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Full documentation**: See `HOOKS.md` in project root
|
||||
- **Claude-specific tips**: See `CLAUDE.md` in `.dmux-hooks/`
|
||||
- **Examples**: Check `.dmux-hooks/examples/` directory
|
||||
- **dmux API**: See `API.md` for REST endpoints
|
||||
|
||||
---
|
||||
|
||||
*This documentation was auto-generated from dmux source code.*
|
||||
*Version: 2026-05-25*
|
||||
@@ -0,0 +1,445 @@
|
||||
# dmux Hooks System - Agent Reference
|
||||
|
||||
**Auto-generated documentation for AI agents**
|
||||
|
||||
This document contains everything an AI agent needs to create, modify, and understand dmux hooks. It is automatically generated from the dmux source code and embedded in the binary.
|
||||
|
||||
## What You're Working On
|
||||
|
||||
You are editing hooks for **dmux**, a tmux pane manager that creates AI-powered development workflows. Each pane runs in its own git worktree with an AI agent.
|
||||
|
||||
## Your Goal
|
||||
|
||||
Create executable bash scripts in `.dmux-hooks/` that run automatically at key lifecycle events.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Create a hook file**: `touch .dmux-hooks/worktree_created`
|
||||
2. **Make it executable**: `chmod +x .dmux-hooks/worktree_created`
|
||||
3. **Add shebang**: Start with `#!/bin/bash`
|
||||
4. **Use environment variables**: Access `$DMUX_ROOT`, `$DMUX_WORKTREE_PATH`, etc.
|
||||
5. **Test it**: Set env vars manually and run the script
|
||||
|
||||
## Hook Execution Model
|
||||
|
||||
- **Mostly non-blocking**: Most hooks run in background (detached processes)
|
||||
- **Bootstrap-gated**: `worktree_created` blocks agent launch so setup can finish first, with no fixed timeout
|
||||
- **Live bootstrap output**: During `worktree_created`, stdout/stderr is streamed into the new pane's setup UI
|
||||
- **Failure behavior**: Background hook errors are logged; gated hooks can abort the operation
|
||||
- **Environment-based**: All context passed via environment variables
|
||||
- **Version controlled**: Hooks in `.dmux-hooks/` are shared with team
|
||||
- **Priority resolution**: `.dmux-hooks/` → `.dmux/hooks/` → `~/.dmux/hooks/`
|
||||
|
||||
## Available Hooks
|
||||
|
||||
### Pane Lifecycle Hooks
|
||||
|
||||
| Hook | When | Common Use Cases |
|
||||
|------|------|------------------|
|
||||
| `before_pane_create` | Before pane creation | Validation, notifications, pre-flight checks |
|
||||
| `pane_created` | After pane, before worktree | Configure tmux settings, prepare environment |
|
||||
| `worktree_created` | After worktree creation, before agent launch | Install deps, copy configs, setup git |
|
||||
| `before_pane_close` | Before closing | Save state, backup uncommitted work |
|
||||
| `pane_closed` | After closed | Cleanup resources, analytics, notifications |
|
||||
|
||||
### Worktree Lifecycle Hooks
|
||||
|
||||
| Hook | When | Common Use Cases |
|
||||
|------|------|------------------|
|
||||
| `before_worktree_remove` | Before worktree removal | Archive worktree, save artifacts |
|
||||
| `worktree_removed` | After worktree removed | Cleanup external references |
|
||||
|
||||
### Merge Lifecycle Hooks
|
||||
|
||||
| Hook | When | Common Use Cases |
|
||||
|------|------|------------------|
|
||||
| `pre_merge` | Before merge operation | Run final tests, create backups |
|
||||
| `post_merge` | After successful merge | Deploy, close issues, notify team |
|
||||
|
||||
### Interactive Hooks (with HTTP callbacks)
|
||||
|
||||
| Hook | When | Common Use Cases |
|
||||
|------|------|------------------|
|
||||
| `run_test` | When tests triggered | Run test suite, report status via HTTP |
|
||||
| `run_dev` | When dev server triggered | Start dev server, create tunnel, report URL |
|
||||
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Always Available
|
||||
```bash
|
||||
DMUX_ROOT="/path/to/project" # Project root directory
|
||||
DMUX_SERVER_PORT="3142" # HTTP server port
|
||||
```
|
||||
|
||||
### Pane Context (most hooks)
|
||||
```bash
|
||||
DMUX_PANE_ID="dmux-1234567890" # dmux pane identifier
|
||||
DMUX_SLUG="fix-auth-bug" # Branch/worktree name
|
||||
DMUX_PROMPT="Fix authentication bug" # User's prompt
|
||||
DMUX_AGENT="claude" # Agent type (registry id, e.g. claude, codex, opencode)
|
||||
DMUX_TMUX_PANE_ID="%38" # tmux pane ID
|
||||
```
|
||||
|
||||
### Worktree Context
|
||||
```bash
|
||||
DMUX_WORKTREE_PATH="/path/.dmux/worktrees/fix-auth-bug"
|
||||
DMUX_BRANCH="fix-auth-bug" # Same as slug
|
||||
```
|
||||
|
||||
### Bootstrap Progress Context
|
||||
```bash
|
||||
DMUX_PROGRESS="1" # Set when output is shown in the new pane setup UI
|
||||
DMUX_STATUS_PREFIX="DMUX_STATUS:" # Prefix for clean status messages
|
||||
```
|
||||
|
||||
`worktree_created` can emit progress while it runs. Any stdout/stderr line is shown in the setup UI; lines prefixed with `$DMUX_STATUS_PREFIX` are displayed without the prefix.
|
||||
|
||||
```bash
|
||||
status() {
|
||||
if [ "${DMUX_PROGRESS:-0}" = "1" ]; then
|
||||
echo "${DMUX_STATUS_PREFIX:-DMUX_STATUS:} $*"
|
||||
else
|
||||
echo "[Hook] $*"
|
||||
fi
|
||||
}
|
||||
```
|
||||
|
||||
### Merge Context
|
||||
```bash
|
||||
DMUX_TARGET_BRANCH="main" # Branch being merged into
|
||||
```
|
||||
|
||||
## HTTP Callback API
|
||||
|
||||
Interactive hooks (`run_test` and `run_dev`) can update dmux UI via HTTP.
|
||||
|
||||
### Update Test Status
|
||||
```bash
|
||||
curl -X PUT "http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/test" -H "Content-Type: application/json" -d '{"status": "running", "output": "optional test output"}'
|
||||
|
||||
# Status values: "running" | "passed" | "failed"
|
||||
```
|
||||
|
||||
### Update Dev Server
|
||||
```bash
|
||||
curl -X PUT "http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/dev" -H "Content-Type: application/json" -d '{"status": "running", "url": "http://localhost:3000"}'
|
||||
|
||||
# Status values: "running" | "stopped"
|
||||
# url: Can be localhost or tunnel URL (ngrok, cloudflared, etc.)
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Install Dependencies
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .dmux-hooks/worktree_created
|
||||
|
||||
cd "$DMUX_WORKTREE_PATH"
|
||||
|
||||
status() {
|
||||
if [ "${DMUX_PROGRESS:-0}" = "1" ]; then
|
||||
echo "${DMUX_STATUS_PREFIX:-DMUX_STATUS:} $*"
|
||||
else
|
||||
echo "[Hook] $*"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -f "pnpm-lock.yaml" ]; then
|
||||
status "Installing dependencies with pnpm"
|
||||
pnpm install --prefer-offline
|
||||
elif [ -f "package-lock.json" ]; then
|
||||
status "Installing dependencies with npm"
|
||||
npm install
|
||||
elif [ -f "yarn.lock" ]; then
|
||||
status "Installing dependencies with yarn"
|
||||
yarn install
|
||||
elif [ -f "Gemfile" ]; then
|
||||
status "Installing gems"
|
||||
bundle install
|
||||
elif [ -f "requirements.txt" ]; then
|
||||
status "Installing Python dependencies"
|
||||
pip install -r requirements.txt
|
||||
elif [ -f "Cargo.toml" ]; then
|
||||
status "Building Rust project"
|
||||
cargo build
|
||||
fi
|
||||
```
|
||||
|
||||
### Pattern 2: Copy Configuration
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .dmux-hooks/worktree_created
|
||||
|
||||
# Copy environment file
|
||||
if [ -f "$DMUX_ROOT/.env.local" ]; then
|
||||
cp "$DMUX_ROOT/.env.local" "$DMUX_WORKTREE_PATH/.env.local"
|
||||
fi
|
||||
|
||||
# Copy other config files
|
||||
for file in .env.development .npmrc .yarnrc; do
|
||||
if [ -f "$DMUX_ROOT/$file" ]; then
|
||||
cp "$DMUX_ROOT/$file" "$DMUX_WORKTREE_PATH/$file"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### Pattern 3: Run Tests with Status Updates
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .dmux-hooks/run_test
|
||||
|
||||
set -e
|
||||
cd "$DMUX_WORKTREE_PATH"
|
||||
API="http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/test"
|
||||
|
||||
# Update: starting
|
||||
curl -s -X PUT "$API" -H "Content-Type: application/json" -d '{"status": "running"}' > /dev/null
|
||||
|
||||
# Run tests and capture output
|
||||
OUTPUT_FILE="/tmp/dmux-test-$DMUX_PANE_ID.txt"
|
||||
if pnpm test > "$OUTPUT_FILE" 2>&1; then
|
||||
STATUS="passed"
|
||||
else
|
||||
STATUS="failed"
|
||||
fi
|
||||
|
||||
# Get output (truncate if too long)
|
||||
OUTPUT=$(head -c 5000 "$OUTPUT_FILE")
|
||||
|
||||
# Update: complete
|
||||
curl -s -X PUT "$API" -H "Content-Type: application/json" -d "$(jq -n --arg status "$STATUS" --arg output "$OUTPUT" '{status: $status, output: $output}')" > /dev/null
|
||||
|
||||
rm -f "$OUTPUT_FILE"
|
||||
```
|
||||
|
||||
### Pattern 4: Dev Server with Tunnel
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .dmux-hooks/run_dev
|
||||
|
||||
set -e
|
||||
cd "$DMUX_WORKTREE_PATH"
|
||||
API="http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/dev"
|
||||
|
||||
# Start dev server in background
|
||||
LOG_FILE="/tmp/dmux-dev-$DMUX_PANE_ID.log"
|
||||
pnpm dev > "$LOG_FILE" 2>&1 &
|
||||
DEV_PID=$!
|
||||
|
||||
# Wait for server to start
|
||||
sleep 5
|
||||
|
||||
# Detect port from logs
|
||||
PORT=$(grep -oP 'localhost:Kd+' "$LOG_FILE" | head -1)
|
||||
[ -z "$PORT" ] && PORT=3000
|
||||
|
||||
# Optional: Create tunnel with cloudflared
|
||||
if command -v cloudflared &> /dev/null; then
|
||||
TUNNEL=$(cloudflared tunnel --url "http://localhost:$PORT" 2>&1 | grep -oP 'https://[a-z0-9-]+.trycloudflare.com' | head -1)
|
||||
URL="${TUNNEL:-http://localhost:$PORT}"
|
||||
else
|
||||
URL="http://localhost:$PORT"
|
||||
fi
|
||||
|
||||
# Report status
|
||||
curl -s -X PUT "$API" -H "Content-Type: application/json" -d "{"status": "running", "url": "$URL"}" > /dev/null
|
||||
|
||||
echo "[Hook] Dev server running at $URL (PID: $DEV_PID)"
|
||||
```
|
||||
|
||||
### Pattern 5: Post-Merge Deployment
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .dmux-hooks/post_merge
|
||||
|
||||
set -e
|
||||
cd "$DMUX_ROOT"
|
||||
|
||||
# Only deploy from main/master
|
||||
if [ "$DMUX_TARGET_BRANCH" != "main" ] && [ "$DMUX_TARGET_BRANCH" != "master" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Push to remote
|
||||
git push origin "$DMUX_TARGET_BRANCH"
|
||||
|
||||
# Trigger deployment (example: Vercel)
|
||||
if [ -n "$VERCEL_TOKEN" ]; then
|
||||
curl -s -X POST "https://api.vercel.com/v1/deployments" -H "Authorization: Bearer $VERCEL_TOKEN" -H "Content-Type: application/json" -d '{"name": "my-project"}' > /dev/null
|
||||
fi
|
||||
|
||||
# Close GitHub issue if prompt contains #123
|
||||
ISSUE=$(echo "$DMUX_PROMPT" | grep -oP '#Kd+' | head -1)
|
||||
if [ -n "$ISSUE" ] && command -v gh &> /dev/null; then
|
||||
gh issue close "$ISSUE" -c "Resolved in $DMUX_SLUG, merged to $DMUX_TARGET_BRANCH" 2>/dev/null || true
|
||||
fi
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always start with shebang**: `#!/bin/bash`
|
||||
2. **Set error handling**: `set -e` (exit on error)
|
||||
3. **Make executable**: `chmod +x .dmux-hooks/hook_name`
|
||||
4. **Background long operations**: Append `&` to avoid blocking
|
||||
5. **Check for required tools**: `command -v tool &> /dev/null`
|
||||
6. **Log for debugging**: `echo "[Hook] message" >> "$DMUX_ROOT/.dmux/hooks.log"`
|
||||
7. **Handle missing vars gracefully**: `[ -z "$VAR" ] && exit 0`
|
||||
8. **Use silent curl**: `curl -s` to avoid noise in logs
|
||||
9. **Clean up temp files**: Remove files in `/tmp/`
|
||||
10. **Test before committing**: Run hooks manually with mock env vars
|
||||
|
||||
## Testing Hooks
|
||||
|
||||
### Manual Testing
|
||||
```bash
|
||||
# 1. Set environment variables
|
||||
export DMUX_ROOT="$(pwd)"
|
||||
export DMUX_PANE_ID="test-pane"
|
||||
export DMUX_SLUG="test-branch"
|
||||
export DMUX_WORKTREE_PATH="$(pwd)"
|
||||
export DMUX_SERVER_PORT="3142"
|
||||
export DMUX_AGENT="claude"
|
||||
export DMUX_PROMPT="Test prompt"
|
||||
|
||||
# 2. Run hook directly
|
||||
./.dmux-hooks/worktree_created
|
||||
|
||||
# 3. Check exit code
|
||||
echo $? # Should be 0 for success
|
||||
```
|
||||
|
||||
### Syntax Check
|
||||
```bash
|
||||
# Check for syntax errors without running
|
||||
bash -n ./.dmux-hooks/worktree_created
|
||||
```
|
||||
|
||||
### Shellcheck (if available)
|
||||
```bash
|
||||
shellcheck ./.dmux-hooks/worktree_created
|
||||
```
|
||||
|
||||
## Project Context Analysis
|
||||
|
||||
Before creating hooks, analyze these files in the project:
|
||||
|
||||
### Package Manager Detection
|
||||
```bash
|
||||
# Check which package manager is used
|
||||
if [ -f "pnpm-lock.yaml" ]; then
|
||||
# Use: pnpm install, pnpm test, pnpm dev
|
||||
elif [ -f "package-lock.json" ]; then
|
||||
# Use: npm install, npm test, npm run dev
|
||||
elif [ -f "yarn.lock" ]; then
|
||||
# Use: yarn install, yarn test, yarn dev
|
||||
fi
|
||||
```
|
||||
|
||||
### Test Command Discovery
|
||||
```bash
|
||||
# Read package.json to find test command
|
||||
cat package.json | grep '"test"'
|
||||
# Or with jq:
|
||||
jq -r '.scripts.test' package.json
|
||||
```
|
||||
|
||||
### Dev Command Discovery
|
||||
```bash
|
||||
# Read package.json to find dev command
|
||||
cat package.json | grep '"dev"'
|
||||
# Or with jq:
|
||||
jq -r '.scripts.dev' package.json
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
# Check for .env files to copy
|
||||
ls -la | grep '.env'
|
||||
```
|
||||
|
||||
### Build System
|
||||
```bash
|
||||
# Detect build system
|
||||
if [ -f "vite.config.ts" ]; then
|
||||
# Vite project
|
||||
elif [ -f "next.config.js" ]; then
|
||||
# Next.js project
|
||||
elif [ -f "nuxt.config.ts" ]; then
|
||||
# Nuxt project
|
||||
fi
|
||||
```
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
❌ **Blocking operations**: `sleep 60` (blocks dmux)
|
||||
✅ **Background long tasks**: `slow_operation &`
|
||||
|
||||
❌ **Hardcoded paths**: `/Users/me/project`
|
||||
✅ **Use variables**: `"$DMUX_ROOT"`
|
||||
|
||||
❌ **Assuming tools exist**: `pnpm install`
|
||||
✅ **Check first**: `command -v pnpm && pnpm install`
|
||||
|
||||
❌ **No error handling**: Script fails silently
|
||||
✅ **Set error mode**: `set -e` or check exit codes
|
||||
|
||||
❌ **Forgetting executable bit**: Hook won't run
|
||||
✅ **Make executable**: `chmod +x`
|
||||
|
||||
❌ **Noisy output**: Clutters dmux logs
|
||||
✅ **Silent operations**: `curl -s`, `> /dev/null 2>&1`
|
||||
|
||||
❌ **Not testing**: Deploy and hope
|
||||
✅ **Test manually**: Run with mock env vars first
|
||||
|
||||
## Debugging
|
||||
|
||||
If a hook isn't working:
|
||||
|
||||
1. **Check if file exists**: `ls -la .dmux-hooks/`
|
||||
2. **Check permissions**: Should show `x` in `rwxr-xr-x`
|
||||
3. **Check syntax**: `bash -n .dmux-hooks/hook_name`
|
||||
4. **Test manually**: Set env vars and run
|
||||
5. **Check logs**: dmux logs to stderr with `[Hooks]` prefix
|
||||
6. **Simplify**: Remove complex parts, test basic version
|
||||
7. **Check tool availability**: `command -v required_tool`
|
||||
|
||||
### Debug Mode
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Add to top of hook for debugging
|
||||
set -x # Print each command before executing
|
||||
set -e # Exit on error
|
||||
|
||||
# Your hook logic here
|
||||
```
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
When creating a new hook:
|
||||
|
||||
- [ ] Create file in `.dmux-hooks/`
|
||||
- [ ] Add shebang: `#!/bin/bash`
|
||||
- [ ] Make executable: `chmod +x`
|
||||
- [ ] Add `set -e` for error handling
|
||||
- [ ] Use environment variables (never hardcode paths)
|
||||
- [ ] Keep blocking hooks chatty with status output
|
||||
- [ ] Background long operations with `&` only when the hook does not gate the operation
|
||||
- [ ] Check for required tools before using
|
||||
- [ ] Test manually with mock env vars
|
||||
- [ ] Add comments explaining what it does
|
||||
- [ ] Commit to version control
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Full documentation**: See `HOOKS.md` in project root
|
||||
- **Claude-specific tips**: See `CLAUDE.md` in `.dmux-hooks/`
|
||||
- **Examples**: Check `.dmux-hooks/examples/` directory
|
||||
- **dmux API**: See `API.md` for REST endpoints
|
||||
|
||||
---
|
||||
|
||||
*This documentation was auto-generated from dmux source code.*
|
||||
*Version: 2026-05-25*
|
||||
@@ -0,0 +1,53 @@
|
||||
# dmux Hooks
|
||||
|
||||
This directory contains hooks that run automatically at key lifecycle events in dmux.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Read the documentation**:
|
||||
- `AGENTS.md` - Complete reference (for any AI agent)
|
||||
- `CLAUDE.md` - Same content (Claude Code looks for this filename)
|
||||
|
||||
2. **Check examples**:
|
||||
- `examples/` directory contains starter templates
|
||||
|
||||
3. **Create a hook**:
|
||||
```bash
|
||||
touch worktree_created
|
||||
chmod +x worktree_created
|
||||
nano worktree_created
|
||||
```
|
||||
|
||||
4. **Test it**:
|
||||
```bash
|
||||
export DMUX_ROOT="$(pwd)"
|
||||
export DMUX_WORKTREE_PATH="$(pwd)"
|
||||
./worktree_created
|
||||
```
|
||||
|
||||
## Available Hooks
|
||||
|
||||
- `before_pane_create` - Before pane creation
|
||||
- `pane_created` - After pane created
|
||||
- `worktree_created` - After worktree setup
|
||||
- `before_pane_close` - Before closing
|
||||
- `pane_closed` - After closed
|
||||
- `before_worktree_remove` - Before worktree removal
|
||||
- `worktree_removed` - After worktree removed
|
||||
- `pre_merge` - Before merge
|
||||
- `post_merge` - After merge
|
||||
- `run_test` - When running tests
|
||||
- `run_dev` - When starting dev server
|
||||
|
||||
## Documentation
|
||||
|
||||
See `AGENTS.md` or `CLAUDE.md` for complete documentation including:
|
||||
- Environment variables
|
||||
- HTTP callback API
|
||||
- Common patterns
|
||||
- Best practices
|
||||
- Testing strategies
|
||||
|
||||
## Note
|
||||
|
||||
This directory is **version controlled**. Hooks you create here will be shared with your team.
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
# Example: post_merge hook
|
||||
#
|
||||
# This hook runs after a successful merge into the target branch.
|
||||
# Use it to trigger deployments, close issues, notify teams, etc.
|
||||
|
||||
set -e
|
||||
|
||||
echo "[Hook] Post-merge processing for $DMUX_SLUG → $DMUX_TARGET_BRANCH"
|
||||
|
||||
cd "$DMUX_ROOT"
|
||||
|
||||
# Push to remote if merging to main/master
|
||||
if [ "$DMUX_TARGET_BRANCH" = "main" ] || [ "$DMUX_TARGET_BRANCH" = "master" ]; then
|
||||
echo "[Hook] Pushing to origin/$DMUX_TARGET_BRANCH"
|
||||
git push origin "$DMUX_TARGET_BRANCH"
|
||||
|
||||
# Optional: Trigger deployment
|
||||
# if [ -n "$VERCEL_TOKEN" ]; then
|
||||
# echo "[Hook] Triggering Vercel deployment..."
|
||||
# curl -X POST "https://api.vercel.com/v1/deployments" \
|
||||
# -H "Authorization: Bearer $VERCEL_TOKEN" \
|
||||
# -H "Content-Type: application/json" \
|
||||
# -d '{
|
||||
# "name": "my-project",
|
||||
# "gitSource": {
|
||||
# "type": "github",
|
||||
# "ref": "main"
|
||||
# }
|
||||
# }'
|
||||
# fi
|
||||
fi
|
||||
|
||||
# Close related GitHub issue (if prompt contains #123 format)
|
||||
ISSUE_NUM=$(echo "$DMUX_PROMPT" | grep -oP '#\K\d+' | head -1)
|
||||
if [ -n "$ISSUE_NUM" ]; then
|
||||
echo "[Hook] Closing GitHub issue #$ISSUE_NUM"
|
||||
if command -v gh &> /dev/null; then
|
||||
gh issue close "$ISSUE_NUM" \
|
||||
-c "Resolved in branch $DMUX_SLUG, merged to $DMUX_TARGET_BRANCH" \
|
||||
2>/dev/null || echo "[Hook] Warning: Failed to close issue (maybe already closed?)"
|
||||
else
|
||||
echo "[Hook] GitHub CLI (gh) not found, skipping issue close"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Send notification to Slack
|
||||
# if [ -n "$SLACK_WEBHOOK" ]; then
|
||||
# echo "[Hook] Sending Slack notification"
|
||||
# curl -s -X POST "$SLACK_WEBHOOK" \
|
||||
# -H "Content-Type: application/json" \
|
||||
# -d "{
|
||||
# \"text\": \"Merged: $DMUX_SLUG → $DMUX_TARGET_BRANCH\",
|
||||
# \"blocks\": [
|
||||
# {
|
||||
# \"type\": \"section\",
|
||||
# \"text\": {
|
||||
# \"type\": \"mrkdwn\",
|
||||
# \"text\": \"*Branch Merged* :rocket:\n\n*From:* \`$DMUX_SLUG\`\n*To:* \`$DMUX_TARGET_BRANCH\`\n*Task:* $DMUX_PROMPT\"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# }" > /dev/null
|
||||
# fi
|
||||
|
||||
echo "[Hook] Post-merge processing complete"
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
# Example: run_dev hook
|
||||
#
|
||||
# This hook starts a dev server and optionally creates a tunnel for sharing.
|
||||
# It reports the server URL back to dmux via the HTTP API.
|
||||
|
||||
set -e
|
||||
|
||||
echo "[Hook] Starting dev server for $DMUX_SLUG"
|
||||
|
||||
cd "$DMUX_WORKTREE_PATH"
|
||||
API_URL="http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/dev"
|
||||
|
||||
# Update status: starting
|
||||
curl -s -X PUT "$API_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "running"}' > /dev/null
|
||||
|
||||
# Start dev server in background
|
||||
# Adjust the command for your project (pnpm dev, npm run dev, vite, etc.)
|
||||
LOG_FILE="/tmp/dmux-dev-$DMUX_PANE_ID.log"
|
||||
pnpm dev > "$LOG_FILE" 2>&1 &
|
||||
DEV_PID=$!
|
||||
|
||||
# Wait for server to be ready
|
||||
echo "[Hook] Waiting for dev server to start..."
|
||||
sleep 5
|
||||
|
||||
# Detect port from log output
|
||||
# Adjust the grep pattern for your dev server's output format
|
||||
PORT=$(grep -oP '(?<=localhost:)\d+' "$LOG_FILE" | head -1)
|
||||
|
||||
if [ -z "$PORT" ]; then
|
||||
echo "[Hook] Warning: Could not detect port from logs, using default 3000"
|
||||
PORT=3000
|
||||
fi
|
||||
|
||||
LOCAL_URL="http://localhost:$PORT"
|
||||
echo "[Hook] Dev server running at $LOCAL_URL"
|
||||
|
||||
# Optional: Create a public tunnel (uncomment to enable)
|
||||
# Requires ngrok, cloudflared, or another tunneling tool
|
||||
|
||||
# Example with cloudflared:
|
||||
# TUNNEL_URL=$(cloudflared tunnel --url "$LOCAL_URL" 2>&1 | \
|
||||
# grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' | head -1)
|
||||
|
||||
# Example with ngrok:
|
||||
# TUNNEL_URL=$(ngrok http $PORT --log=stdout 2>&1 | \
|
||||
# grep -oP 'url=https://[^"]+' | head -1 | cut -d= -f2)
|
||||
|
||||
# For now, just use local URL (uncomment tunnel code above to enable)
|
||||
FINAL_URL="$LOCAL_URL"
|
||||
|
||||
# Report status back to dmux
|
||||
curl -s -X PUT "$API_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"status\": \"running\", \"url\": \"$FINAL_URL\"}" > /dev/null
|
||||
|
||||
echo "[Hook] Dev server ready at: $FINAL_URL"
|
||||
echo "[Hook] Dev server PID: $DEV_PID"
|
||||
echo "[Hook] Log file: $LOG_FILE"
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# Example: run_test hook
|
||||
#
|
||||
# This hook runs tests and reports the status back to dmux via the HTTP API.
|
||||
# Status updates appear in real-time in the dmux UI.
|
||||
|
||||
set -e
|
||||
|
||||
echo "[Hook] Running tests for $DMUX_SLUG"
|
||||
|
||||
cd "$DMUX_WORKTREE_PATH"
|
||||
API_URL="http://localhost:$DMUX_SERVER_PORT/api/panes/$DMUX_PANE_ID/test"
|
||||
|
||||
# Update status: running
|
||||
curl -s -X PUT "$API_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "running"}' > /dev/null
|
||||
|
||||
echo "[Hook] Running test suite..."
|
||||
|
||||
# Capture test output
|
||||
OUTPUT_FILE="/tmp/dmux-test-$DMUX_PANE_ID.txt"
|
||||
|
||||
# Run tests (adjust command for your project)
|
||||
# Examples:
|
||||
# - pnpm test
|
||||
# - npm test
|
||||
# - vitest run
|
||||
# - jest
|
||||
# - pytest
|
||||
# - cargo test
|
||||
if pnpm test > "$OUTPUT_FILE" 2>&1; then
|
||||
STATUS="passed"
|
||||
echo "[Hook] Tests passed ✓"
|
||||
else
|
||||
STATUS="failed"
|
||||
echo "[Hook] Tests failed ✗"
|
||||
fi
|
||||
|
||||
# Get output (truncate if too long)
|
||||
OUTPUT=$(head -c 5000 "$OUTPUT_FILE")
|
||||
|
||||
# Report results back to dmux
|
||||
curl -s -X PUT "$API_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg status "$STATUS" \
|
||||
--arg output "$OUTPUT" \
|
||||
'{status: $status, output: $output}')" > /dev/null
|
||||
|
||||
# Cleanup
|
||||
rm -f "$OUTPUT_FILE"
|
||||
|
||||
echo "[Hook] Test results reported to dmux"
|
||||
|
||||
# Exit with test status
|
||||
if [ "$STATUS" = "passed" ]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Example: worktree_created hook
|
||||
#
|
||||
# This hook runs after a new worktree is created and before the agent launches.
|
||||
# Use it to set up the worktree environment (install deps, copy configs, etc.)
|
||||
# Stdout/stderr is streamed into the new pane's setup UI while this hook runs.
|
||||
# dmux waits for this hook without a fixed timeout.
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
status() {
|
||||
if [ "${DMUX_PROGRESS:-0}" = "1" ]; then
|
||||
echo "${DMUX_STATUS_PREFIX:-DMUX_STATUS:} $*"
|
||||
else
|
||||
echo "[Hook] $*"
|
||||
fi
|
||||
}
|
||||
|
||||
status "Setting up worktree: $DMUX_SLUG"
|
||||
|
||||
cd "$DMUX_WORKTREE_PATH"
|
||||
|
||||
# Install dependencies before the agent launches.
|
||||
if [ -f "pnpm-lock.yaml" ]; then
|
||||
status "Installing dependencies with pnpm"
|
||||
pnpm install --prefer-offline
|
||||
elif [ -f "package-lock.json" ]; then
|
||||
status "Installing dependencies with npm"
|
||||
npm install
|
||||
elif [ -f "yarn.lock" ]; then
|
||||
status "Installing dependencies with yarn"
|
||||
yarn install
|
||||
fi
|
||||
|
||||
# Copy environment file if it exists
|
||||
if [ -f "$DMUX_ROOT/.env.local" ]; then
|
||||
status "Copying .env.local"
|
||||
cp "$DMUX_ROOT/.env.local" "$DMUX_WORKTREE_PATH/.env.local"
|
||||
fi
|
||||
|
||||
# Keep existing git author identity.
|
||||
# Do not set git user.name/user.email in this hook.
|
||||
|
||||
# Create a log entry
|
||||
echo "[$(date)] Created worktree: $DMUX_SLUG | Agent: $DMUX_AGENT | Prompt: $DMUX_PROMPT" \
|
||||
>> "$DMUX_ROOT/.dmux/worktree_history.log"
|
||||
|
||||
status "Worktree setup complete"
|
||||
@@ -1,16 +1,30 @@
|
||||
.git
|
||||
.github
|
||||
node_modules
|
||||
web/node_modules
|
||||
web/.next
|
||||
web/build
|
||||
web/out
|
||||
dist
|
||||
tmp
|
||||
logs
|
||||
upload
|
||||
*.log
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
config.yaml
|
||||
.env
|
||||
.env.*
|
||||
docker-compose*.yml
|
||||
|
||||
bin/
|
||||
build/
|
||||
dist/
|
||||
data/
|
||||
logs/
|
||||
uploads/
|
||||
s3_cache/
|
||||
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
frontend/out/
|
||||
frontend/build/
|
||||
frontend/disk/
|
||||
frontend/.env
|
||||
frontend/next-env.d.ts
|
||||
frontend/*.tsbuildinfo
|
||||
frontend/package-lock.json
|
||||
|
||||
internal/router/dist/
|
||||
internal/router/root/dist/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{json,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{js,ts,css,html,jsx,tsx,vue}]
|
||||
indent_size = 2
|
||||
@@ -0,0 +1,102 @@
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# openflare — 环境变量配置模板
|
||||
# 复制此文件为 .env 并填入实际值: cp .env.example .env
|
||||
# 环境变量优先级高于 config.yaml / config.docker.yaml
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ─── Docker Compose 服务端口映射 ───────────────────────────────────────────────
|
||||
APP_PORT=3000
|
||||
POSTGRES_PORT=5432
|
||||
REDIS_PORT=6379
|
||||
JAEGER_UI_PORT=16686
|
||||
JAEGER_OTLP_GRPC_PORT=4317
|
||||
JAEGER_OTLP_HTTP_PORT=4318
|
||||
|
||||
# ─── PostgreSQL 容器配置(仅 docker-compose 使用)────────────────────────────
|
||||
POSTGRES_DB=openflare
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
|
||||
# ─── 时区 ─────────────────────────────────────────────────────────────────────
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# ─── 应用配置 ──────────────────────────────────────────────────────────────────
|
||||
APP_NAME=openflare
|
||||
APP_ENV=production
|
||||
APP_ADDR=:3000
|
||||
APP_NODE_ID=1
|
||||
APP_API_PREFIX=/api
|
||||
# APP_GRACEFUL_SHUTDOWN_TIMEOUT=30
|
||||
APP_SESSION_COOKIE_NAME=openflare_session_id
|
||||
APP_SESSION_SECRET=change-me-to-a-random-string-in-production
|
||||
# APP_SESSION_DOMAIN=
|
||||
APP_SESSION_AGE=86400
|
||||
APP_SESSION_HTTP_ONLY=true
|
||||
# HTTPS 部署时设为 true,HTTP 环境必须为 false
|
||||
APP_SESSION_SECURE=true
|
||||
|
||||
# ─── 数据库 ────────────────────────────────────────────────────────────────────
|
||||
# 设置 DB_HOST 后自动启用 PostgreSQL,也可通过 DB_ENABLED 显式控制
|
||||
# DB_ENABLED=false 时使用 SQLite 作为后备数据库
|
||||
DB_ENABLED=true
|
||||
# SQLITE_PATH=./data/openflare.db
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_NAME=openflare
|
||||
DB_SSL_MODE=disable
|
||||
DB_TIMEZONE=Asia/Shanghai
|
||||
# DB_LOG_LEVEL=info
|
||||
# DB_MAX_IDLE_CONN=16
|
||||
# DB_MAX_OPEN_CONN=128
|
||||
|
||||
# ─── Redis ─────────────────────────────────────────────────────────────────────
|
||||
# 设置 REDIS_ADDR 后自动启用,也可通过 REDIS_ENABLED 显式控制
|
||||
REDIS_ENABLED=true
|
||||
REDIS_ADDR=redis:6379
|
||||
# REDIS_USERNAME=
|
||||
# REDIS_PASSWORD=
|
||||
# REDIS_DB=0
|
||||
REDIS_KEY_PREFIX=openflare:
|
||||
# REDIS_POOL_SIZE=100
|
||||
|
||||
# ─── ClickHouse(可选,默认关闭)──────────────────────────────────────────
|
||||
# 设置 CLICKHOUSE_HOST 后自动启用,也可显式控制
|
||||
# CLICKHOUSE_ENABLED=false
|
||||
# CLICKHOUSE_HOST=clickhouse:9000
|
||||
# CLICKHOUSE_USERNAME=default
|
||||
# CLICKHOUSE_PASSWORD=
|
||||
# CLICKHOUSE_NAME=openflare
|
||||
|
||||
# ─── 日志 ──────────────────────────────────────────────────────────────────────
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=console
|
||||
LOG_OUTPUT=stdout
|
||||
|
||||
# ─── OpenTelemetry ─────────────────────────────────────────────────────────────
|
||||
# docker-compose 默认将 Trace 发往 Jaeger All-in-One: http://jaeger:4317
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
|
||||
OTEL_EXPORTER_OTLP_INSECURE=true
|
||||
# 设为 0 关闭 tracing;本地 Jaeger 调试建议设为 1.0
|
||||
OTEL_SAMPLING_RATE=1.0
|
||||
# 全局 Tracer 命名空间,默认为 github.com/Rain-kl/OpenFlare
|
||||
# OTEL_TRACER_NAME=github.com/Rain-kl/OpenFlare
|
||||
|
||||
# ─── S3 兼容存储(可选,默认关闭)──────────────────────────────────────────
|
||||
# S3_ENABLED=false
|
||||
# S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
|
||||
# S3_REGION=auto
|
||||
# S3_BUCKET=<bucket-name>
|
||||
# S3_ACCESS_KEY_ID=<access-key>
|
||||
# S3_SECRET_ACCESS_KEY=<secret-key>
|
||||
# S3_PATH_STYLE=false
|
||||
# S3_CDN_URL=
|
||||
|
||||
# ─── Worker ────────────────────────────────────────────────────────────────────
|
||||
# WORKER_CONCURRENCY=20
|
||||
# WORKER_STRICT_PRIORITY=false
|
||||
|
||||
# ─── Scheduler ─────────────────────────────────────────────────────────────────
|
||||
# 未设置时默认为 @daily
|
||||
# SCHEDULER_CLEANUP_CRON=0 */2 * * *
|
||||
@@ -0,0 +1,90 @@
|
||||
name: 使用时的错误报告
|
||||
description: 某些事情不按照预期工作。
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您花时间填写此 Bug 报告!
|
||||
|
||||
- **提交错误报告前**:请检查 [已有 Issues](https://github.com/Rain-kl/Wavelet/issues) 列表,了解是否有类似问题被报告。如果不确定,请进行搜索,这有助于我们高效地专注于改进项目。
|
||||
- type: checkboxes
|
||||
id: issue-check
|
||||
attributes:
|
||||
label: 检查现有问题
|
||||
description: 确认您在提交新报告之前已经检查了现有报告。
|
||||
options:
|
||||
- label: 我已经搜索了现有问题和讨论。
|
||||
required: true
|
||||
- label: 我正在使用 wavelet 的最新版本或当前部署实例。
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: 发生了什么?
|
||||
description: 请详细描述您正在进行的操作,您期待看到什么,实际发生了什么。
|
||||
placeholder: 请告诉我们您看到了什么!
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: 如何重现此 Bug?
|
||||
description: 请提供详细的步骤来重现此 Bug。
|
||||
placeholder: |
|
||||
1. 在此环境中...
|
||||
2. 使用此配置...
|
||||
3. 运行 '...'
|
||||
4. 看到错误...
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: 在哪些浏览器中出现问题?
|
||||
multiple: true
|
||||
options:
|
||||
- Firefox
|
||||
- Chrome
|
||||
- Safari
|
||||
- Microsoft Edge
|
||||
- Other (请在“其他信息”中说明)
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: other-info
|
||||
attributes:
|
||||
label: 任何其他信息
|
||||
description: 您有任何其他关于此报告的信息吗?
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: 确认
|
||||
description: 确保已满足以下先决条件。
|
||||
options:
|
||||
- label: 我已阅读并遵循了 `README.md` 中的所有说明。
|
||||
required: true
|
||||
- label: 我正在使用 Rain-kl/Wavelet 的最新版本。
|
||||
required: true
|
||||
- label: 我已提供我能够提供的尽可能多的相关日志,屏幕截图等。
|
||||
required: true
|
||||
- label: |
|
||||
我已详细记录了精确、按顺序且无歧义的逐步重现说明。我的步骤:
|
||||
- 从正在执行的操作开始,
|
||||
- 指定进入了什么页面,
|
||||
- 列出访问的 URL、用户输入(包括所需的示例值/电子邮件/密码),
|
||||
- 描述所有已启用或更改的选项和开关,
|
||||
- 包含任何可能的浏览器控制台日志,
|
||||
- 识别每个阶段的预期和实际结果,
|
||||
- 确保任何有合理技能的用户都可以遵循并遇到相同的问题。
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## 注意
|
||||
如果 Bug 报告不完整或不遵循说明,则可能不会得到处理。请确保您已遵循所有 **README.md** 指南,并提供所有必要信息以便我们重现该问题。
|
||||
感谢您为 wavelet 做出贡献!
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,79 @@
|
||||
name: 新功能建议
|
||||
description: 请求新的功能或对现有功能进行改进。
|
||||
title: "feature: "
|
||||
labels: ["enhancement"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您花时间填写此功能请求!
|
||||
|
||||
- **提交功能请求前**:请检查 [已有 Issues](https://github.com/Rain-kl/Wavelet/issues) 列表和讨论区,了解是否有类似功能已被讨论或请求。这有助于我们避免重复工作,并高效地专注于改进项目。
|
||||
- type: checkboxes
|
||||
id: check-existing
|
||||
attributes:
|
||||
label: 检查现有问题和讨论
|
||||
description: 确认您在提交新请求之前已经检查了现有报告和讨论。
|
||||
options:
|
||||
- label: 我已经搜索了现有问题和讨论。
|
||||
required: true
|
||||
- label: 我正在使用 wavelet 的最新版本或当前部署实例。
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: 你希望添加什么功能或改进什么?
|
||||
description: 请详细描述您希望添加的功能或进行的改进。
|
||||
placeholder: 我希望可以...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: why-needed
|
||||
attributes:
|
||||
label: 为什么需要此功能?
|
||||
description: 请说明此功能解决了什么问题,或提供了什么价值。请提供具体的用例和场景,帮助我们理解其重要性。
|
||||
placeholder: |
|
||||
目前我遇到...
|
||||
如果有了此功能,我可以...
|
||||
这将为用户带来...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: 建议的解决方案(可选)
|
||||
description: 如果您对如何实现此功能有任何想法,请在此处描述。这可以包括用户界面草图、API 设想、技术方案等。
|
||||
placeholder: |
|
||||
我设想此功能可以通过以下方式实现:
|
||||
1. ...
|
||||
2. ...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: other-info
|
||||
attributes:
|
||||
label: 任何其他信息
|
||||
description: 您有任何其他关于此报告的信息吗?例如,您目前如何解决这个问题,或者其他类似项目的实现方式等。
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: 确认
|
||||
description: 确保已满足以下先决条件。
|
||||
options:
|
||||
- label: 我已阅读并遵循了 `README.md` 中的所有说明。
|
||||
required: true
|
||||
- label: 我正在使用 Rain-kl/Wavelet 的最新版本。
|
||||
required: true
|
||||
- label: 我已提供我能够提供的尽可能多的相关信息,包括用例和场景。
|
||||
required: true
|
||||
- label: 我理解功能请求的实现取决于项目优先级和资源。
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## 注意
|
||||
如果功能请求不完整或不遵循说明,则可能不会得到处理。请确保您已提供所有必要信息以便我们理解您的建议。
|
||||
感谢您为 wavelet 做出贡献!
|
||||
@@ -0,0 +1,31 @@
|
||||
## 基础规范
|
||||
|
||||
- 在任何情况都使用简体中文
|
||||
- 你是一个专业的代码助手,专门为 wavelet 项目提供代码编写和优化服务
|
||||
- 严格遵循项目的代码规范和最佳实践,确保代码质量和一致性
|
||||
- 保持代码简洁、可读、高效
|
||||
- 优先考虑项目的可维护性、性能和安全性,避免引入不必要的复杂性
|
||||
- 注释和上一行代码之间保留一行空格
|
||||
- 编写代码前仔细分析需求,确保改动有实际价值和意义
|
||||
- 避免仅修改格式、注释或无影响力的拼写错误
|
||||
- 重构代码时必须带来可维护性或功能上的实质提升
|
||||
- 新增功能时考虑向后兼容性和 API 稳定性
|
||||
- 遵循项目的 Apache2.0 许可证要求
|
||||
- 遵循语义化版本控制规范
|
||||
- 新增异步任务时使用项目技能 `.agent/new-async-task/SKILL.md`
|
||||
|
||||
## 后端规范
|
||||
|
||||
- 后端开发使用 Go 语言,所有接口需要符合 Restful 风格
|
||||
- 数据库使用 PostgreSQL 作为主存储,Redis 作为缓存和会话存储
|
||||
- Go 代码遵循 gofmt 标准格式,使用 snake_case 命名数据库字段
|
||||
- 所有 API 接口必须编写完整的 Swagger 文档
|
||||
- API 响应格式统一为 {"error_msg": "", "data": {}} 结构
|
||||
- 分页数据返回 {"error_msg": "", "data": {"total": 0, "results": []}} 格式
|
||||
- 数据库设计禁止使用外键,但必须保留对应字段的索引
|
||||
|
||||
## 前端规范
|
||||
|
||||
- TypeScript 代码严禁使用 any 类型,优先使用 unknown 进行类型安全处理
|
||||
- 组件按功能分类:公共组件放在 components/common,UI 组件放在 components/ui
|
||||
- 自定义图标统一放置在 components/icons 目录,常规图标使用 Lucide 库
|
||||
@@ -0,0 +1,3 @@
|
||||
- 如果有其他代码文件,忽略 Swagger 变更和版本号变更,只需要关注其他代码文件的变更
|
||||
- 需要符合 Github 的提交规范,使用 <type>(<scope>): <subject> 格式
|
||||
- Commit Message 必须有 Scope 信息
|
||||
@@ -0,0 +1,25 @@
|
||||
**例行检查**
|
||||
|
||||
<!-- 请在下面的 [ ] 中删除空格并打 x ,表示已完成相关检查 -->
|
||||
|
||||
- [ ] 我已阅读并理解 [贡献者公约](https://github.com/Rain-kl/Wavelet/blob/main/CODE_OF_CONDUCT.md)
|
||||
- [ ] 我已阅读并同意 [贡献者许可协议 (CLA)](https://github.com/Rain-kl/Wavelet/blob/main/CLA.md),确认我的贡献将根据项目的 Apache2.0 许可证进行许可
|
||||
- [ ] 我知晓如果此 PR 并不做出实质性更改,或可被认为是*为了PR被合并而提交PR*的,则可能不会被合并
|
||||
|
||||
**关联信息**
|
||||
|
||||
<!--
|
||||
如此 PR 解决了一个 Issue, 请在下方填写
|
||||
resolves #<issue_number>,例如:
|
||||
resolves #1234
|
||||
-->
|
||||
|
||||
<!-- 若以上均没有,请删除此节 -->
|
||||
|
||||
**变更内容**
|
||||
|
||||
<!-- 请在下方简要描述此 PR 的变更内容 -->
|
||||
|
||||
**变更原因**
|
||||
|
||||
<!-- 请在下方简要描述此 PR 的变更原因 -->
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
name: Build Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Image version/tag to publish, for example v1.0.0-beta"
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||
cache-from: type=gha,scope=docker-server-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-server-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /tmp/server-digests
|
||||
touch "/tmp/server-digests/${DIGEST#sha256:}"
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-digests-${{ matrix.arch }}
|
||||
path: /tmp/server-digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-name: ${{ env.IMAGE }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
merge:
|
||||
name: Merge multi-arch manifest
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/server-digests
|
||||
pattern: server-digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/server-digests
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
references=()
|
||||
for digest in *; do
|
||||
references+=("${IMAGE}@sha256:${digest}")
|
||||
done
|
||||
|
||||
if [ ${#references[@]} -eq 0 ]; then
|
||||
echo "No digests found in /tmp/server-digests" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then
|
||||
FLOATING_TAG="beta"
|
||||
else
|
||||
FLOATING_TAG="latest"
|
||||
fi
|
||||
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:${FLOATING_TAG}" \
|
||||
"${references[@]}"
|
||||
|
||||
- name: Inspect image
|
||||
run: docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- name: Trigger webhook
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }}
|
||||
run: |
|
||||
if [ -n "$WEBHOOK_URL" ]; then
|
||||
curl -fsSL "$WEBHOOK_URL"
|
||||
else
|
||||
echo "Webhook URL is not set, skipping."
|
||||
fi
|
||||
@@ -0,0 +1,270 @@
|
||||
name: Build Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version/tag to build, for example v1.0.0-beta"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
APP_NAME: wavelet
|
||||
GO_MAIN: ./main.go
|
||||
GO_BUILD_TAGS: embed_frontend
|
||||
GO_LDFLAGS: -s -w
|
||||
NODE_VERSION: "22"
|
||||
PNPM_VERSION: "10.10.0"
|
||||
FRONTEND_DIR: frontend
|
||||
FRONTEND_BUILD_COMMAND: pnpm build:embed
|
||||
FRONTEND_OUT_DIR: frontend/out
|
||||
EMBED_DIST_DIR: internal/router/root/dist
|
||||
EXTRA_FILES: |
|
||||
LICENSE
|
||||
README.md
|
||||
README_zh.md
|
||||
config.example.yaml
|
||||
DEPLOYMENT_zh.md
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
prepare-message:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
commit_msg: ${{ steps.trans.outputs.commit_msg }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Prepare Commit Message
|
||||
id: trans
|
||||
shell: bash
|
||||
run: |
|
||||
msg=$(git log -1 --pretty=%B)
|
||||
pip install deep-translator > /dev/null 2>&1 || true
|
||||
|
||||
export COMMIT_MSG="$msg"
|
||||
echo "commit_msg<<EOF" >> "$GITHUB_OUTPUT"
|
||||
if [ -f "scripts/translate_commit.py" ]; then
|
||||
python3 scripts/translate_commit.py >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Translation script not found, using raw message"
|
||||
echo "$msg" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
create-release:
|
||||
name: Create Release
|
||||
needs: prepare-message
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.metadata.outputs.version }}
|
||||
version_without_v: ${{ steps.metadata.outputs.version_without_v }}
|
||||
build_date: ${{ steps.metadata.outputs.build_date }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Set release metadata
|
||||
id: metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
input_version="${INPUT_VERSION//[[:space:]]/}"
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
version="$GITHUB_REF_NAME"
|
||||
elif [[ -n "$input_version" ]]; then
|
||||
version="$input_version"
|
||||
else
|
||||
echo "workflow_dispatch requires a version input" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version=$version"
|
||||
echo "version_without_v=${version#v}"
|
||||
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.metadata.outputs.version }}
|
||||
name: ${{ steps.metadata.outputs.version }}
|
||||
body: ${{ needs.prepare-message.outputs.commit_msg }}
|
||||
prerelease: ${{ contains(steps.metadata.outputs.version, 'alpha') || contains(steps.metadata.outputs.version, 'beta') || contains(steps.metadata.outputs.version, 'rc') }}
|
||||
|
||||
build-frontend:
|
||||
name: Build Embedded Frontend
|
||||
runs-on: ubuntu-latest
|
||||
needs: create-release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: ${{ env.FRONTEND_DIR }}/pnpm-lock.yaml
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ${{ env.FRONTEND_DIR }}
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build frontend
|
||||
env:
|
||||
NEXT_PUBLIC_APP_VERSION: ${{ needs.create-release.outputs.version }}
|
||||
NEXT_PUBLIC_APP_BUILD_DATE: ${{ needs.create-release.outputs.build_date }}
|
||||
run: ${{ env.FRONTEND_BUILD_COMMAND }}
|
||||
working-directory: ${{ env.FRONTEND_DIR }}
|
||||
|
||||
- name: Prepare embed directory
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf "$EMBED_DIST_DIR"
|
||||
mkdir -p "$(dirname "$EMBED_DIST_DIR")"
|
||||
cp -R "$FRONTEND_OUT_DIR" "$EMBED_DIST_DIR"
|
||||
|
||||
- name: Upload embedded frontend
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: embedded-frontend
|
||||
path: ${{ env.EMBED_DIST_DIR }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-binaries:
|
||||
name: Build ${{ matrix.goos }}/${{ matrix.goarch }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- create-release
|
||||
- build-frontend
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
archive: tar.gz
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
archive: tar.gz
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
archive: tar.gz
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
archive: tar.gz
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
archive: zip
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download embedded frontend
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: embedded-frontend
|
||||
path: ${{ env.EMBED_DIST_DIR }}
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: "0"
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
BUILD_DATE: ${{ needs.create-release.outputs.build_date }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p dist
|
||||
binary_name="$APP_NAME"
|
||||
if [[ "$GOOS" == "windows" ]]; then
|
||||
binary_name="${binary_name}.exe"
|
||||
fi
|
||||
|
||||
ldflags="$GO_LDFLAGS -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=$VERSION -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=$BUILD_DATE"
|
||||
build_args=(
|
||||
-trimpath
|
||||
-ldflags "$ldflags"
|
||||
-o "dist/$binary_name"
|
||||
)
|
||||
|
||||
if [[ -n "$GO_BUILD_TAGS" ]]; then
|
||||
build_args=(-tags "$GO_BUILD_TAGS" "${build_args[@]}")
|
||||
fi
|
||||
|
||||
go build "${build_args[@]}" "$GO_MAIN"
|
||||
|
||||
- name: Package artifact
|
||||
id: package
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ARCHIVE_FORMAT: ${{ matrix.archive }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
package_name="${APP_NAME}_${VERSION}_${GOOS}_${GOARCH}"
|
||||
staging_dir="dist/$package_name"
|
||||
mkdir -p "$staging_dir"
|
||||
|
||||
if [[ "$GOOS" == "windows" ]]; then
|
||||
cp "dist/${APP_NAME}.exe" "$staging_dir/"
|
||||
else
|
||||
cp "dist/${APP_NAME}" "$staging_dir/"
|
||||
fi
|
||||
|
||||
while IFS= read -r extra_file; do
|
||||
[[ -z "$extra_file" ]] && continue
|
||||
if [[ -e "$extra_file" ]]; then
|
||||
cp -R "$extra_file" "$staging_dir/"
|
||||
fi
|
||||
done <<< "$EXTRA_FILES"
|
||||
|
||||
if [[ "$ARCHIVE_FORMAT" == "zip" ]]; then
|
||||
(cd dist && zip -r "${package_name}.zip" "$package_name")
|
||||
artifact="dist/${package_name}.zip"
|
||||
else
|
||||
tar -C dist -czf "dist/${package_name}.tar.gz" "$package_name"
|
||||
artifact="dist/${package_name}.tar.gz"
|
||||
fi
|
||||
|
||||
echo "artifact=$artifact" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload release artifact
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.create-release.outputs.version }}
|
||||
files: ${{ steps.package.outputs.artifact }}
|
||||
@@ -0,0 +1,96 @@
|
||||
name: Cleanup Prerelease
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve version metadata
|
||||
id: version
|
||||
run: |
|
||||
SHOULD_RUN=true
|
||||
VERSION="all-prerelease-tags"
|
||||
|
||||
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$VERSION" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then
|
||||
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Delete prerelease, dangling, and unbound releases/tags
|
||||
if: steps.version.outputs.should_run == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Fetch all tags from remote to ensure full synchronization
|
||||
git fetch --tags --force
|
||||
|
||||
# Get all local/remote git tags starting with 'v'
|
||||
mapfile -t GIT_TAGS < <(git tag --list 'v*' | sort -V)
|
||||
|
||||
# Get all GitHub releases (tags associated with releases)
|
||||
mapfile -t GH_RELEASES < <(gh release list --limit 1000 --json tagName --jq '.[].tagName' 2>/dev/null || true)
|
||||
|
||||
# Helper function to check array containment
|
||||
contains_element() {
|
||||
local e match="$1"
|
||||
shift
|
||||
for e; do [[ "$e" == "$match" ]] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
DELETED_TAGS=0
|
||||
DELETED_RELEASES=0
|
||||
|
||||
echo "=== Phase 1: Checking and cleaning Git tags ==="
|
||||
for TAG in "${GIT_TAGS[@]}"; do
|
||||
if [[ "$TAG" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then
|
||||
# Formal release tag
|
||||
if ! contains_element "$TAG" "${GH_RELEASES[@]}"; then
|
||||
echo "Delete formal tag not bound to any GitHub release: $TAG"
|
||||
git push origin --delete "refs/tags/$TAG" || true
|
||||
git tag -d "$TAG" || true
|
||||
DELETED_TAGS=$((DELETED_TAGS + 1))
|
||||
else
|
||||
echo "Keep formal release tag (bound to release): $TAG"
|
||||
fi
|
||||
else
|
||||
# Prerelease tag
|
||||
if contains_element "$TAG" "${GH_RELEASES[@]}"; then
|
||||
echo "Delete prerelease release: $TAG"
|
||||
gh release delete "$TAG" --yes || true
|
||||
DELETED_RELEASES=$((DELETED_RELEASES + 1))
|
||||
fi
|
||||
|
||||
echo "Delete prerelease tag: $TAG"
|
||||
git push origin --delete "refs/tags/$TAG" || true
|
||||
git tag -d "$TAG" || true
|
||||
DELETED_TAGS=$((DELETED_TAGS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=== Phase 2: Checking and cleaning dangling GitHub releases ==="
|
||||
for REL_TAG in "${GH_RELEASES[@]}"; do
|
||||
if ! contains_element "$REL_TAG" "${GIT_TAGS[@]}"; then
|
||||
echo "Delete GitHub release not bound to any Git tag: $REL_TAG"
|
||||
gh release delete "$REL_TAG" --yes || true
|
||||
DELETED_RELEASES=$((DELETED_RELEASES + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=== Summary ==="
|
||||
echo "Successfully deleted $DELETED_TAGS tag(s) and $DELETED_RELEASES release(s)."
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Close Ticket
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
jobs:
|
||||
close_ticket:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
days-before-issue-stale: 14
|
||||
days-before-issue-close: 14
|
||||
stale-issue-message: "此 issue 长期无活动,将在 14 天后自动关闭。如需继续讨论请回复"
|
||||
close-issue-message: "此 issue 因长期无活动已自动关闭,如有需要请重新开启"
|
||||
days-before-pr-stale: 14
|
||||
days-before-pr-close: 14
|
||||
stale-pr-message: "此 PR 长期无活动,将在 14 天后自动关闭。如需继续讨论请回复"
|
||||
close-pr-message: "此 PR 因长期无活动已自动关闭,如有需要请重新开启"
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ "*" ]
|
||||
push:
|
||||
branches:
|
||||
- "dev"
|
||||
- "main"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: go
|
||||
build-mode: autobuild
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -0,0 +1,48 @@
|
||||
name: "Copilot Setup Steps"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
jobs:
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.10.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install JavaScript dependencies
|
||||
working-directory: frontend
|
||||
run: pnpm install
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
check-latest: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
go mod download
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.6
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
name: ESLint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ "*" ]
|
||||
push:
|
||||
branches:
|
||||
- "dev"
|
||||
- "main"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.10.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: pnpm install
|
||||
|
||||
- name: Run ESLint
|
||||
working-directory: frontend
|
||||
run: npx eslint . --max-warnings 0
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Check PR Template Checklist
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
jobs:
|
||||
check-pr-template:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: check all checklist items are checked
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// get the pull request body
|
||||
const prBody = context.payload.pull_request.body || '';
|
||||
|
||||
// regex to match all checklist items in the template
|
||||
// matches lines like: - [ ] ... or - [x] ...
|
||||
const checklistRegex = /^- \[( |x|X)\] .+$/gm;
|
||||
const matches = prBody.match(checklistRegex) || [];
|
||||
|
||||
// check if any checklist item is not checked
|
||||
const unchecked = matches.filter(line => line.startsWith('- [ ]'));
|
||||
|
||||
// if any unchecked, fail the workflow
|
||||
if (unchecked.length > 0) {
|
||||
core.setFailed(`PR checklist 未全部勾选,请确保所有 checklist 项都已勾选。未勾选项如下:\n${unchecked.join('\n')}`);
|
||||
} else {
|
||||
console.log('all checklist items are checked.');
|
||||
}
|
||||
|
||||
@@ -1,2 +1,61 @@
|
||||
# IDE
|
||||
.idea/*
|
||||
.idea
|
||||
!.idea/icon.png
|
||||
.vscode
|
||||
.pnpm-store/
|
||||
|
||||
# logs
|
||||
/logs/
|
||||
*.log
|
||||
|
||||
# config
|
||||
config.yaml
|
||||
.env
|
||||
|
||||
# sqlite
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# frontend
|
||||
frontend/build
|
||||
frontend/disk
|
||||
frontend/node_modules/*
|
||||
frontend/.next/*
|
||||
frontend/next-env.d.ts
|
||||
frontend/package-lock.json
|
||||
frontend/.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
|
||||
# os
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# build
|
||||
/build/
|
||||
/bin/
|
||||
/dist/
|
||||
|
||||
# go workspace
|
||||
go.work
|
||||
go.work.sum
|
||||
main
|
||||
|
||||
# upload
|
||||
uploads/*
|
||||
|
||||
s3_cache
|
||||
/frontend/.next/
|
||||
/data/
|
||||
/postgres-data/
|
||||
/internal/router/dist/
|
||||
/frontend/out/
|
||||
/.idea/
|
||||
/uploads/
|
||||
/*-source/
|
||||
/.cache/
|
||||
/internal/router/root/dist/
|
||||
.dmux/
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: false
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
# 基础检查
|
||||
- govet
|
||||
- staticcheck
|
||||
- errcheck
|
||||
- ineffassign
|
||||
- unused
|
||||
|
||||
# 代码坏味道
|
||||
- dupl # 重复代码
|
||||
- mnd # 魔法数字
|
||||
- goconst # 不必要的字符串常量
|
||||
- cyclop # 包/函数复杂度
|
||||
- nestif # if 嵌套太深
|
||||
- maintidx # 维护性指数
|
||||
- revive # 风格/命名/坏味道
|
||||
- gocritic # 各类代码问题
|
||||
- funlen # 函数过长
|
||||
|
||||
- gosec # 安全问题检查
|
||||
- bodyclose # HTTP response body 没有正确关闭
|
||||
- noctx # 没有传递 context.Context
|
||||
- contextcheck # 其他检查
|
||||
- sqlclosecheck # SQL rows 没有正确关闭
|
||||
- unconvert # 不必要的类型转换
|
||||
- nilerr # 函数返回 nil 错误
|
||||
|
||||
settings:
|
||||
dupl:
|
||||
threshold: 120
|
||||
|
||||
cyclop:
|
||||
max-complexity: 20
|
||||
package-average: 10
|
||||
|
||||
nestif:
|
||||
min-complexity: 5
|
||||
|
||||
funlen:
|
||||
lines: 200
|
||||
statements: 100
|
||||
|
||||
mnd:
|
||||
checks:
|
||||
- argument
|
||||
- condition
|
||||
- return
|
||||
@@ -0,0 +1,331 @@
|
||||
# AGENTS.md — 项目AI助手工作操作手册
|
||||
|
||||
本文件面向 AI 开发助手,定义其职责与操作规范。
|
||||
|
||||
## Git 提交规范指南
|
||||
|
||||
### 提交信息基本格式
|
||||
|
||||
每次提交更改时,应当使用以下提交格式:
|
||||
|
||||
```text
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
```
|
||||
|
||||
* **Type**: 提交类型(例如 `feat`, `fix`, `refactor`, `perf`, `docs`, `chore` 等)。
|
||||
* **Scope** (可选): 影响的范围(例如 `api`, `frontend`, `auth`, `mcp` 等)。
|
||||
* **Subject**: 简短的一句话描述变更。
|
||||
* **Body** (可选): 详细的说明,多行叙述。
|
||||
|
||||
## 务必阅读匹配的 Skill
|
||||
|
||||
| Skill | 何时使用 |
|
||||
| :--- | :--- |
|
||||
| `new-api` | 添加或修改自定义业务 API、Handler、服务层逻辑、自定义路由注册 |
|
||||
| `new-async-task` | 添加或修改 Asynq 任务、定时任务、TaskHandler、任务元数据 |
|
||||
| `new-setting` | 添加或修改系统/业务/公开设置、`/admin/system` 参数或 `/admin/settings` 图形化设置 |
|
||||
| `database-migration` | 数据库表结构变更、goose SQL 迁移、seed 数据 |
|
||||
| `file-upload` | 业务上传文件、Worker 程序化摄取、`upload.Ingest` 策略选型、文件访问与 `w_uploads` / 统计排查 |
|
||||
| `push-notification` | 系统通知推送事件、统一触发器投递、带消息推送的业务功能 |
|
||||
| `release-guide` | 根据自上一正式版本 Tag 以来的提交整理 Version Bump 提交信息以触发双语 Release |
|
||||
| `shadcn` | 添加、修改或组合 shadcn/ui 组件 |
|
||||
|
||||
|
||||
## 严格遵循事项 (Guardrails)
|
||||
|
||||
- 切勿删除 `frontend/node_modules`
|
||||
- 保持 `internal/util/` 绝对纯净且不引入任何框架。禁止从 `internal/util/` 及其子包中导入 Gin、GORM、sessions 等 HTTP/Web/数据库相关框架包(例如,Web 会话选项已收敛至 `internal/apps/oauth/session.go`)。
|
||||
- 编写测试用例时,禁止使用硬编码的相对路径(如 `"uploads/test_cache"`)在源码目录下创建临时测试目录,必须统一使用 Go 内置的 `t.TempDir()` 以避免污染源码目录。
|
||||
- 所有 HTTP 路由仅在 `internal/router/router.go` 中注册。
|
||||
- 当 API Handler 发生变化时,更新 Swagger 文档(运行 `make swagger`)。
|
||||
- 在完成代码开发后必须运行 `make code-check`, 并修复报错。
|
||||
- 需要缓存或文件管理能力时,必须复用现有平台实现,禁止在业务包中自行创建缓存目录、直接管理缓存文件或重复封装存储后端。
|
||||
- 文件摄取必须通过 `upload.Ingest`(`upload.PolicyCreate` / `PolicyDedupNewRecord` / `PolicyResolveExisting`);删除必须通过 `upload.Remove` 或 `upload.RemoveOwned`。禁止业务模块直接调用 `repository.CreateUpload` / `repository.SoftDeleteUpload`,禁止 `db.Create(&model.Upload{})` 旁路写 `w_uploads`。
|
||||
- 禁止在 `init()` 中注册跨模块集成(任务 Handler、推送内置事件、域事件监听器、任务完成钩子)。统一通过 `internal/bootstrap` 在 `internal/cmd` 入口显式装配。
|
||||
- `internal/router/router.go` 的 `Serve()` 仅负责 HTTP 路由与中间件,禁止在其中执行 `SyncEvents`、`InitLogWriter` 等进程级运行时初始化。
|
||||
- 核心业务模块(如 `oauth`、`user`)禁止直接 `import` `internal/apps/admin/push` 或 `custom_events` 触发通知;应通过 `internal/listener` 发射域事件,由 push 模块在 bootstrap 阶段订阅。
|
||||
- 编写依赖任务注册或推送事件同步的测试时,必须在测试 setup 中显式调用 `bootstrap.RegisterTasks()`、`bootstrap.RegisterPushDomainEvents()` 等,不得依赖 `init()` 副作用。
|
||||
- API 错误响应必须通过 `response.Abort*` 中断请求,由 `ErrorHandlerMiddleware` 统一写出 JSON;禁止 `c.JSON(http.StatusOK, response.Err(...))` 及 Handler 直接 `c.JSON(status, response.Err(...))`。
|
||||
|
||||
## 项目介绍
|
||||
|
||||
### 技术栈
|
||||
|
||||
- 后端:Go 1.25+、Gin、GORM、PostgreSQL、可选 ClickHouse、Redis、Asynq、Cobra、Viper、Swaggo、OpenTelemetry、Zap、AWS SDK v2、Snowflake IDs。
|
||||
- 前端:Next.js App Router、TypeScript、Tailwind CSS、pnpm、shadcn/ui。
|
||||
|
||||
### 目录结构与平台能力
|
||||
|
||||
顶层目录:
|
||||
|
||||
- `main.go`:程序入口,委派给 `internal/cmd`。
|
||||
- `config.example.yaml`:已提交的配置模板。在添加配置字段时保持更新。
|
||||
- `config.yaml`:本地运行时的配置文件。不要将其作为已提交的源码提交。
|
||||
- `docker/`:集成的、仅前端的和仅后端的 Dockerfile。
|
||||
- `docs/`:自动生成的 Swagger 文档。请勿手动编辑生成的文件。
|
||||
- `frontend/`:Next.js 应用。
|
||||
- `internal/`:私有 Go 后端代码。
|
||||
- `pkg/`:公共 Go 库/工具包(留作扩展或存放不依赖特定业务的通用代码)。
|
||||
- `scripts/`:本地和 CI 辅助脚本。
|
||||
- `support-files/`:部署 and 数据库辅助文件。
|
||||
- `bin/`:本地编译生成的二进制可执行文件。
|
||||
- `data/`:本地运行时数据文件目录(如 PostgreSQL、Redis 数据等)。
|
||||
- `uploads/`:本地文件上传存储目录。
|
||||
|
||||
后端目录:
|
||||
|
||||
- `internal/cmd/`:用于 API、worker、scheduler、root init 的 Cobra 命令。进程启动时在此调用 `bootstrap.Register*` 与 `bootstrap.Init`,再启动 router / worker / scheduler。
|
||||
- `internal/bootstrap/`:应用装配根(composition root)。集中注册任务 Handler、推送域事件订阅、任务完成监听器,并执行 `SyncEvents`、ClickHouse 访问日志写入等进程级初始化;所有注册函数使用 `sync.Once` 保证幂等。
|
||||
- `internal/config/`:Viper 加载和配置结构体。运行时代码应使用 `config.Config.<Section>.<Field>`。
|
||||
- `internal/router/`:唯一的 HTTP 路由注册点。
|
||||
- `internal/apps/`:按功能(Feature-based)组织的 HTTP Handler、中间件、内部服务与模块逻辑。移除全局 service 层,模块内部业务逻辑(如验证码业务逻辑管理器 `internal/apps/cap/manager.go`)均收敛于各自模块中;管理端模块位于 `internal/apps/admin/`。
|
||||
- `internal/apps/upload/`:上传记录、文件访问控制、本地/S3 文件响应、下载及图片 WebP 压缩。业务应复用 `upload.Ingest` / `upload.Remove` 与 `GET /f/:id` 文件服务,不直接操作底层 storage 或旁路写 `w_uploads`。
|
||||
- `internal/model/`:GORM 实体和模型级业务方法。
|
||||
- `internal/db/`:PostgreSQL、Redis、ClickHouse、GORM 日志、ID 生成和 goose SQL 迁移的布线。
|
||||
- `internal/diskcache/`:平台级磁盘字节缓存,通过 `diskcache.GetGlobalCache()` 提供 TTL、最大空间限制、LRU 淘汰、清空、状态统计和配置热更新。写入时使用 `DefaultExpiration`(全局默认 TTL)、正数 `time.Duration`(业务 TTL)或 `NoExpiration`(无 TTL,仍受空间限制和 LRU 淘汰)。
|
||||
- `internal/storage/`:S3 兼容对象存储适配,提供对象上传、读取、删除、CDN/代理读取及远端对象本地缓存。
|
||||
- `internal/task/`:Asynq 任务框架;参见 `new-async-task` 了解变更。
|
||||
- `internal/common/`:共享的通用模型及响应(如 `internal/common/response`)、绑定(bind)、常量以及通用错误。
|
||||
- `internal/util/`:纯底层工具包,无任何 HTTP/数据库框架依赖。
|
||||
- `internal/listener/`:域事件分发层。核心域(auth、user 等)在此定义并发射事件(如 `EmitAdminLoggedIn`);运维模块(push、webhook 等)在 bootstrap 阶段订阅,实现跨模块解耦。
|
||||
- `internal/otel_trace/`:链路追踪(tracing)助手。
|
||||
- `internal/testhelper/`:后端测试共享辅助能力。
|
||||
- `internal/buildinfo/`:暴露在发布/构建工作流中注入的元数据(如版本号、编译时间等)。
|
||||
|
||||
公共底层包 (`pkg/`):
|
||||
- `pkg/cache/disk/`:纯底层的通用本地磁盘缓存引擎。
|
||||
- `pkg/cap/`:底层的通用验证码验证和生成库。
|
||||
- `pkg/httppool/`:管理全局共享且经过优化的 HTTP 传输客户端及连接池,集成 OTel 链路追踪。
|
||||
- `pkg/logger/`:Zap 和 OTel 日志助手。
|
||||
- `pkg/push/`:推送渠道客户端集成(Lark/Telegram/Email)。
|
||||
- `pkg/mail/`:邮件发送客户端。
|
||||
- `pkg/trace/`:OpenTelemetry 链路追踪配置。
|
||||
- `pkg/util/`:纯底层无副作用的系统工具(Crypto/Password/UUID)。
|
||||
|
||||
前端目录:
|
||||
|
||||
- `frontend/app/`:App Router 页面、路由组、根布局、全局配置。
|
||||
- `frontend/components/ui/`:shadcn/ui 基础组件。
|
||||
- `frontend/components/common/`:跨页面的业务组件。
|
||||
- `frontend/components/layout/`:Header、Sidebar、Footer 等应用布局组件。
|
||||
- `frontend/components/auth/`、`home/`、`animate-ui/`、`providers/`:特定作用域的 UI 组件。
|
||||
- `frontend/lib/services/`:基于 `BaseService` 的类型化 API 服务,按业务域拆分并由 `services` 对象统一导出。
|
||||
- `frontend/contexts/`、`hooks/`、`lib/`、`types/`、`public/`:共享状态、Hook、客户端与实用工具、TypeScript 类型、静态资产。
|
||||
- `frontend/scripts/`:前端构建和维护脚本。
|
||||
- `frontend/.next/`、`frontend/out/`、`frontend/node_modules/`:本地生成或安装的产物,不作为业务源码编辑。
|
||||
|
||||
|
||||
## 开发要求
|
||||
|
||||
### 后端规则
|
||||
|
||||
命名规范:
|
||||
|
||||
- Go 包和文件使用小写蛇形命名(lowercase snake case):如 `auth_source`、`postgres_logger.go`。
|
||||
- 导出的 Go 标识符使用 PascalCase;未导出的标识符使用 camelCase。
|
||||
- 请求/响应结构体使用 camelCase 并带有后缀,例如 `listUsersRequest` 和 `listUsersResponse`。
|
||||
- 错误消息常量是 camelCase 字符串 `const`值,而不是包级别的 `error` 值。
|
||||
- YAML 配置键使用小写蛇形命名(lowercase snake case)。
|
||||
|
||||
Handler 规范:
|
||||
|
||||
- Handler 命名为 动词 + 名词,例如 `ListUsers`。
|
||||
- 使用 `ShouldBindQuery` 或 `ShouldBindJSON` 进行绑定。
|
||||
- 每个 HTTP API 都需要有完整的 Swagger 注释;在 API 变更后运行 `make swagger`。
|
||||
|
||||
#### API 响应信封(统一格式)
|
||||
|
||||
所有 JSON API 响应的外层结构**必须**为:
|
||||
|
||||
```json
|
||||
{ "error_msg": "", "data": ... }
|
||||
```
|
||||
|
||||
- 成功时:`error_msg` 为空字符串,`data` 承载业务载荷。
|
||||
- 失败时:`data` 为 `null`,`error_msg` 为用户可见的错误说明。
|
||||
- 分页响应在 `data` 下使用 `{ "total": 0, "results": [] }`。
|
||||
|
||||
#### 成功响应(唯一写法)
|
||||
|
||||
成功时**始终**使用 HTTP `200`,由 Handler 直接写出 JSON:
|
||||
|
||||
```go
|
||||
import (
|
||||
"net/http"
|
||||
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 有数据
|
||||
c.JSON(http.StatusOK, response.OK(data))
|
||||
|
||||
// 无数据(data 为 null)
|
||||
c.JSON(http.StatusOK, response.OKNil())
|
||||
```
|
||||
|
||||
#### 失败响应(中断请求,禁止直接写错误 JSON)
|
||||
|
||||
失败时**禁止**在 Handler / 中间件中直接调用 `c.JSON(..., response.Err(msg))`,也**禁止**用 HTTP `200` 携带非空 `error_msg` 表示失败。
|
||||
|
||||
统一通过 `internal/common/response` 的 **Abort 系列函数**中断请求。这些函数会将 `*response.APIError` 挂载到 Gin 的 `c.Errors` 链并 `c.Abort()`;请求结束后由全局 `response.ErrorHandlerMiddleware()`(在 `internal/router/middlewares.go` 中注册)统一写出 JSON,并记录到 OpenTelemetry Trace/Jaeger。
|
||||
|
||||
**推荐使用的便捷函数(优先于手写状态码):**
|
||||
|
||||
| 函数 | HTTP 状态码 | 典型场景 |
|
||||
|------|-------------|----------|
|
||||
| `response.AbortBadRequest(c, msg)` | 400 | 参数绑定失败、字段校验、业务规则拒绝(如密码错误、重复注册) |
|
||||
| `response.AbortUnauthorized(c, msg)` | 401 | 未登录、Session/Token 失效(`oauth.LoginRequired()`) |
|
||||
| `response.AbortForbidden(c, msg)` | 403 | 已登录但无权访问(如 Token 不允许访问的端点) |
|
||||
| `response.AbortNotFound(c, msg)` | 404 | 资源不存在;管理员中间件对非管理员隐藏端点时也使用此码 |
|
||||
| `response.AbortConflict(c, msg)` | 409 | 资源冲突(如唯一键重复) |
|
||||
| `response.AbortTooManyRequests(c, msg)` | 429 | 限流、频率限制 |
|
||||
| `response.AbortInternal(c, msg)` | 500 | 对用户返回通用提示;底层错误须先记录日志 |
|
||||
| `response.AbortWithError(c, code, msg)` | 自定义 | 上表未覆盖的状态码时使用 |
|
||||
|
||||
**标准 Handler 模板:**
|
||||
|
||||
```go
|
||||
func CreateWidget(c *gin.Context) {
|
||||
var req createWidgetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortBadRequest(c, errBindParamsFailed)
|
||||
return
|
||||
}
|
||||
|
||||
widget, err := createWidgetLogic(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
// 底层错误已记录日志时,向用户返回安全文案
|
||||
response.AbortBadRequest(c, err.Error()) // 或按语义选用 AbortConflict / AbortInternal 等
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(widget))
|
||||
}
|
||||
```
|
||||
|
||||
**中间件**与 Handler 遵循同一规则。参考 `oauth.LoginRequired()` → `AbortUnauthorized`,`admin.LoginAdminRequired()` → `AbortNotFound`,`cap.VerifyMiddleware` → `AbortUnauthorized`。
|
||||
|
||||
#### 错误消息定义
|
||||
|
||||
- 面向用户的错误文案定义为模块内 **camelCase 字符串常量**(放在 `errs.go`),例如 `errBindParamsFailed = "参数绑定失败"`。
|
||||
- Handler / 中间件向 Abort 函数传入这些常量或经校验的安全字符串;**禁止**将数据库驱动错误、堆栈信息等内部细节直接暴露给客户端。
|
||||
- `response.Err(msg)` 仅供 `ErrorHandlerMiddleware` 内部构造 JSON,**业务代码不得直接用于 `c.JSON`**。
|
||||
|
||||
#### `logics.go` 与 Handler 的分工
|
||||
|
||||
- `logics.go` 接受 `context.Context`,返回 `(result, error)` 或带状态的业务结果结构体(参考 `internal/apps/user/logics.go` 的 `LoginEmailVerificationResult`)。
|
||||
- `logics.go` **不得**依赖 `*gin.Context`,**不得**调用 `response.Abort*` 或 `c.JSON`。
|
||||
- Handler 负责:绑定参数 → 调用 logic → 将 logic 错误/状态映射为对应的 `Abort*` 或 `response.OK`。
|
||||
|
||||
#### 日志与内部错误
|
||||
|
||||
- 数据库、Redis、第三方 API、文件 I/O 等**运行时错误**:在 Handler 或 logic 边界用 `pkg/logger` 记录(带 `ctx`),再向用户返回安全的 `AbortInternal` 或语义匹配的业务错误常量。
|
||||
- 任何关键错误在被吞掉、转换为通用响应,或由后台 worker 忽略之前,都必须通过 `pkg/logger` 打印日志。
|
||||
- 禁止用 `_ = ...` 静默丢弃重要错误。如果某个错误因为 best-effort 操作或确认无害而需要忽略,必须添加简短注释说明原因。
|
||||
- 避免重复刷日志:在真正处理或抑制错误的边界记录一次,然后 `Abort*` 或成功返回。
|
||||
|
||||
#### 禁止写法(反模式)
|
||||
|
||||
```go
|
||||
// ❌ 禁止:HTTP 200 表示失败
|
||||
c.JSON(http.StatusOK, response.Err("密码错误"))
|
||||
|
||||
// ❌ 禁止:Handler 直接写错误 JSON,绕过 ErrorHandlerMiddleware 与 OTel 记录
|
||||
c.JSON(http.StatusBadRequest, response.Err("参数错误"))
|
||||
|
||||
// ❌ 禁止:gin.H 手写错误体
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error_msg": "...", "data": nil})
|
||||
|
||||
// ❌ 禁止:logics.go 中中断 HTTP 请求
|
||||
func doSomething(c *gin.Context) { response.AbortBadRequest(c, "...") }
|
||||
```
|
||||
|
||||
#### Swagger 注释约定
|
||||
|
||||
- `@Success 200` 的 `data` 使用具体类型或 `response.Any`。
|
||||
- 对每个可能返回的 Abort 状态码声明 `@Failure`,例如 `@Failure 400 {object} response.Any "参数错误"`、`@Failure 401 {object} response.Any "未登录"`。
|
||||
|
||||
路由与模块:
|
||||
|
||||
- 仅在 `internal/router/router.go` 中作为统一高层入口进行路由分发委派,不允许在 `router.go` 中直接挂载业务 Handler。
|
||||
- 关于所有的路由归属划分、接口开发隔离防线以及详细的注册和开发步骤,请直接阅读并严格遵循 [new-api](file:///Users/ryan/DEV/Go/OpenFlare/openflare-server/.claude/skills/new-api/SKILL.md) 技能。
|
||||
|
||||
应用装配与跨模块集成:
|
||||
|
||||
- 新增跨模块副作用(任务注册、推送订阅、后台监听器)时,在 `internal/bootstrap/bootstrap.go` 增加 `Register*` 函数,并在对应 `internal/cmd/*.go` 入口调用;参考现有 `RegisterAPI` / `RegisterWorker` / `RegisterAll` 分工。
|
||||
- `bootstrap.Init` 必须在 `RegisterPushDomainEvents()` 之后调用(API/`all` 模式),以确保 `SyncEvents` 能同步内置推送事件元数据。
|
||||
- Handler 与业务逻辑分离:HTTP Handler 负责绑定与响应;可复用逻辑放入 `logics.go`(接受 `context.Context`,不依赖 `*gin.Context`),便于 Worker 与单元测试复用。参考 `internal/apps/user/logics.go`。
|
||||
|
||||
中间件:
|
||||
|
||||
- 全局中间件属于路由设置:`gin.Recovery()`、`otelgin.Middleware()`、日志中间件 and session 中间件。
|
||||
- 对于登录路由组,使用 `oauth.LoginRequired()`。
|
||||
- 对于管理路由组,使用 `admin.LoginAdminRequired()`。
|
||||
|
||||
配置管理:
|
||||
|
||||
- 运行时代码从 `config.Config` 中读取配置,绝对不要直接从 `os.Getenv()` 中读取。
|
||||
- 当添加配置时,同时更新 `config.example.yaml` and `internal/config/model.go`。
|
||||
|
||||
数据库操作:
|
||||
|
||||
- 简单查询可以直接从 model 层使用 GORM。
|
||||
- 管理员代码应首选 `db.DB(ctx)` 以获得链路追踪感知的 DB 访问。
|
||||
- 不要在 Handler 中放置复杂的 SQL;将其移至 `internal/model/` 或模块内的业务服务层(如 `internal/apps/<module>/service.go` 或 `logics.go`)。
|
||||
- 在 `internal/db/migrator/goose/` 下使用 goose SQL 迁移;不要添加基于 GORM AutoMigrate 的 Schema 升级。
|
||||
- 不要创建物理数据库外键。改为关系字段添加显式索引。
|
||||
- 数据库默认值必须与 Go 模型零值(`nil`、`0`、`false`、`""`)匹配,以避免意外的插入。
|
||||
|
||||
### 前端规则
|
||||
|
||||
在进行任何 Next.js 工作之前,请在 `node_modules/next/dist/docs/` 中找到并阅读相关文档。您的训练数据已过时 —— 这些文档是唯一的真理来源。
|
||||
|
||||
请直接查看并参考项目提供的示例和 Demo 代码:[frontend/app/(main)/admin/demo](file:///Users/ryan/DEV/Go/OpenFlare/openflare-server/frontend/app/(main)/admin/demo)。
|
||||
|
||||
样式规范:
|
||||
|
||||
- shadcn/ui 基础组件应该使用它们的 `variant` 系统和全局 CSS 变量。当组件的变体(variant)应该拥有某种外观时,不要在业务 `className` 中硬编码颜色、背景或阴影。
|
||||
- 如果现有的变体不足以满足需求,请扩展 shadcn/ui 组件的变体,而不是硬编码一次性的颜色。
|
||||
|
||||
页面标题栏规范 (新人开发与重构必读):
|
||||
|
||||
- **容器与对齐机制**:
|
||||
- 标题容器统一使用 `flex items-center gap-2`。如果右侧有操作按钮(如“新增”、“刷新”),请使用 `justify-between` 布局让操作区与标题双向分布。
|
||||
- 为了确保所有页面在进入/切换时,顶部的呼吸感和视觉高度完全一致,页面最外层容器**必须**统一使用 `py-6 px-1` 或 `py-6` 进行上边距对齐。
|
||||
- **图标标准**:图标作为视觉辅助点缀,**必须**直接嵌套在标题容器中,直接使用 Lucide 图标组件,样式大小限制为 `size-5 text-primary`。**严禁**为图标包裹任何背景小卡片、圆角边框或额外的修饰容器。
|
||||
- **标题文字标准**:标题文字使用且仅使用 `h1 className="text-2xl font-semibold tracking-tight"`。不要自行定义字号、字量(如使用 `font-bold`)或添加任何渐变色,保持整个系统的字形规范化。
|
||||
- **Tabs 模块化与文件拆分规范**:凡是带有多个 Tab 页切换的复杂页面,**禁止**将所有 Tab 的渲染逻辑堆积在同一个主文件内。每个 Tab 的具体渲染内容必须单独拆分为独立的 React 组件文件(如 `tabs/events-tab.tsx`)。主页面文件应该仅用于导入子组件、注册 Tabs 触发器以及管理 Tab 的切换激活状态。这有利于防止单文件过大(避免单文件行数超过 600 行限制),并大幅度提高代码的可读性与编译维护效率。
|
||||
- **扁平化结构与避免冗余中间件**:为了消除无意义的“中间代理文件”,所有作为路由物理入口的 Tabs 状态维护、骨架及外层布局代码,**必须**直接定义在 Next.js 的 `app/` 页面文件(即 `page.tsx`)中。禁止在 `page.tsx` 中仅写一个单纯的 `<AnotherComponent />` 转发,而在外部新建一个同名中转容器。
|
||||
- **复杂度驱动的组件拆分规范**:组件的拆分不应局限于“跨页面复用”。当一个路由页面的复杂度变高时(如渲染逻辑膨胀、存在大型嵌套弹窗或多层状态管理,如单文件代码行数超过 600 行),必须主动将其拆分为子组件以维持单文件的高可读性与低耦合度。拆分时遵循就近原则:特定于该路由且不复用的子组件应放置在最邻近该路由的特征目录(Feature Folder,如 `components/` 局部文件夹)中;只有真正具备跨页面复用价值的通用业务/基础 UI 组件才应存放在全局 `components/` 共享目录下。
|
||||
- **最佳实践标杆案例(数据管理 `/admin/database`)**:
|
||||
该页面由于整合了“运行状态概览”、“物理表网格浏览器”、“磁盘缓存管理”和“SQL 交互控台”多个复杂大区块,重构前单文件接近 1000 行。
|
||||
重构后,主页面 `page.tsx` 仅做高级页面骨架与排版排布,维护全局刷新机制与终端视图切换;而“数据表浏览器 (`table-browser.tsx`)”、“缓存管理 (`cache-manager.tsx`)”与“SQL 终端 (`sql-console.tsx`)”等独立高状态密度区块均被抽离为局部子组件,存放在 `frontend/app/(main)/admin/database/components/`。这保证了代码结构层次清晰、单文件小巧好维护。所有复杂页面的新开发和重构必须遵循此模式。
|
||||
|
||||
页面宽度:
|
||||
|
||||
- 页面根容器必须支持全宽。使用 `w-full`。
|
||||
- 不要硬编码页面级的最大宽度,如 `max-w-6xl` 或 `max-w-4xl`;主布局(main layout)拥有正常/全宽的限制。
|
||||
|
||||
组件放置:
|
||||
|
||||
- 跨页面的业务组件属于 `frontend/components/common/`。
|
||||
- shadcn/ui 原生组件(primitives)属于 `frontend/components/ui/`。
|
||||
- 特定于路由/页面的组件放在最邻近的特征(feature)目录中。
|
||||
|
||||
服务类(Services):
|
||||
|
||||
- 前端 API 访问通过服务类和导出的 `services` 对象进行。
|
||||
- 新增服务结构如下:
|
||||
|
||||
```text
|
||||
frontend/lib/services/<service-name>/
|
||||
types.ts
|
||||
<service-name>.service.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
- 服务类继承 `BaseService`,定义 `basePath`,并暴露有类型的静态方法。
|
||||
- 在 `frontend/lib/services/index.ts` 中注册新服务。
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# 贡献指南
|
||||
|
||||
感谢您有兴趣为本项目做出贡献!我们欢迎各种形式的贡献,但请先阅读如下文档,以节省您和我们的时间。
|
||||
|
||||
当您在使用 Claude Code, Gemini CLI 等 Vibe-coding 工具时,推荐将此文档内容附加到上下文内。
|
||||
|
||||
## 我们不接受的更改
|
||||
|
||||
出于包括但不限于项目可持续性与可维护性考虑,我们不接受如下类型的更改。
|
||||
如果您提交的 PR 包含以下类型的更改,我们可能会包括但不限于忽略、关闭或要求您更改 PR 内容。
|
||||
|
||||
- 导致项目整体性能下降的更改;
|
||||
- 仅修改注释、空格、格式的小 PR;
|
||||
- 仅修正无影响力的拼写错误(typo)或代码注释,不提升可读性或准确性;
|
||||
- 重构已稳定工作的逻辑而不带来可维护性或功能上的实质提升;
|
||||
- 未经讨论的接口或 API 命名改动;
|
||||
|
||||
**请注意:判断标准不是改动大小,而是改动是否有实际作用。**
|
||||
|
||||
为提高协作效率,我们建议您在提交 PR 前,先通过 Issue 简要说明动机与背景。
|
||||
|
||||
|
||||
## 贡献步骤
|
||||
|
||||
1. **Fork 本仓库** 并创建您的分支(建议使用有意义的分支名)。
|
||||
2. **编写代码**,确保遵循项目的代码风格和最佳实践。
|
||||
3. **添加/更新测试**,确保您的更改不会破坏现有功能。
|
||||
4. **本地测试**,确认所有测试通过。
|
||||
5. **提交 Pull Request**,请详细描述您的更改内容和动机。
|
||||
|
||||
|
||||
## 代码规范
|
||||
|
||||
### 后端
|
||||
|
||||
**基础检查**
|
||||
|
||||
需要通过 CodeQL 扫描,较长的代码建议增加 Copilot 检查。
|
||||
|
||||
**API 文档**
|
||||
|
||||
所有接口需要写 Swagger 文档,提交前通过 make swagger 更新文档后再提交。
|
||||
|
||||
**响应格式**
|
||||
|
||||
```json
|
||||
# 响应数据最外层有两个字段,error_msg 和 data
|
||||
{
|
||||
"error_msg": "",
|
||||
"data": null
|
||||
}
|
||||
|
||||
# 如果是非列表数据
|
||||
{
|
||||
"error_msg": "",
|
||||
"data": {}
|
||||
}
|
||||
|
||||
# 如果是分页数据
|
||||
{
|
||||
"error_msg": "",
|
||||
"data": {
|
||||
"total": 0,
|
||||
"results": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**数据库**
|
||||
|
||||
- 禁止使用外键,但需要保留对应字段的索引;
|
||||
- 字段如有默认值,需要与 struct 默认值相同,如 nil,0,false,空字符串等,避免初始化时未填写或漏填写导致的数据异常。
|
||||
|
||||
### 前端
|
||||
|
||||
**基础检查**
|
||||
|
||||
代码需要通过 ESLint 检查和 CodeQL 扫描。
|
||||
|
||||
**类型安全**
|
||||
|
||||
- 禁止使用 `any` 类型,`any` 类型绕过了 TypeScript 的类型检查系统,会导致潜在的运行时错误;
|
||||
- `unknown` 是类型安全的 `any`,但必须立即进行类型断言或类型收窄;
|
||||
- `never` 类型表示永远不会发生的值类型,必须谨慎使用,并提供清晰的注释说明。
|
||||
|
||||
**组件规范**
|
||||
|
||||
- 组件应按功能分类
|
||||
- 公共组件放在 `components/common` 目录
|
||||
- ShadcnUI 组件放在 `components/ui` 目录
|
||||
- 自定义图标应放置在 `/components/icons/` 目录下以命名导出形式管理,对于常规的图标,我们使用 Lucide 库
|
||||
|
||||
**服务层**
|
||||
|
||||
服务层架构是前端与API交互的统一入口,基于以下原则:
|
||||
1. 关注点分离 - 每个服务负责一个业务领域
|
||||
2. 统一入口 - 通过services对象导出所有服务
|
||||
3. 类型安全 - 所有请求和响应有明确类型定义
|
||||
|
||||
|
||||
**如何新建接口服务**
|
||||
|
||||
1. **创建目录结构**:
|
||||
```
|
||||
/services/新服务名/
|
||||
- types.ts // 类型定义
|
||||
- 服务名.service.ts // 服务实现
|
||||
- index.ts // 导出服务
|
||||
```
|
||||
|
||||
2. **实现服务类**:
|
||||
```typescript
|
||||
// 新服务名/服务名.service.ts
|
||||
import {BaseService} from '../core/base.service';
|
||||
|
||||
export class 新服务类 extends BaseService {
|
||||
protected static readonly basePath = '/api/v1/路径';
|
||||
|
||||
static async 方法名(参数): Promise<返回类型> {
|
||||
return this.get<返回类型>('/endpoint');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **在services/index.ts注册**:
|
||||
```typescript
|
||||
import {新服务类} from './新服务名';
|
||||
|
||||
const services = {
|
||||
auth: AuthService,
|
||||
新服务名: 新服务类
|
||||
};
|
||||
```
|
||||
|
||||
**使用方法**
|
||||
|
||||
```typescript
|
||||
import services from '@/lib/services';
|
||||
|
||||
// 调用服务方法
|
||||
const 结果 = await services.新服务名.方法名(参数);
|
||||
```
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
Agents.md
|
||||
@@ -1,39 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
ARG VERSION=dev
|
||||
FROM node:20 AS web-builder
|
||||
ARG VERSION
|
||||
WORKDIR /build
|
||||
RUN corepack enable
|
||||
COPY openflare-server/web/package.json openflare-server/web/pnpm-lock.yaml ./
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
COPY openflare-server/web ./
|
||||
RUN --mount=type=cache,id=next-cache,target=/build/.next/cache \
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
|
||||
|
||||
FROM golang:1.25 AS go-builder
|
||||
ARG VERSION
|
||||
ENV GO111MODULE=on \
|
||||
CGO_ENABLED=0 \
|
||||
GOOS=linux
|
||||
WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
COPY openflare-server ./openflare-server
|
||||
COPY pkg ./pkg
|
||||
COPY --from=web-builder /build/build ./openflare-server/web/build
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
go build -trimpath \
|
||||
-ldflags "-s -w -X 'github.com/rain-kl/openflare/openflare-server/internal/common.Version=$VERSION'" \
|
||||
-o openflare ./openflare-server/cmd/server
|
||||
|
||||
FROM alpine:latest
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& update-ca-certificates 2>/dev/null || true
|
||||
ENV PORT=3000
|
||||
COPY --from=go-builder /build/openflare /openflare
|
||||
EXPOSE 3000
|
||||
WORKDIR /data
|
||||
ENTRYPOINT ["/openflare"]
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025 Arctel.net
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,80 @@
|
||||
.PHONY: swagger license license-check build-embedded build-test cross-build code-check
|
||||
|
||||
VERSION ?= dev
|
||||
BUILD_DATE ?= $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
MODULE := $(shell go list -m)
|
||||
|
||||
swagger:
|
||||
scripts/swagger.sh
|
||||
|
||||
license:
|
||||
scripts/update_go_license.sh
|
||||
|
||||
license-check:
|
||||
scripts/update_go_license.sh --check
|
||||
|
||||
build-embedded:
|
||||
@echo "==> Building embedded frontend version=$(VERSION) build_date=$(BUILD_DATE)..."
|
||||
cd frontend && \
|
||||
NEXT_PUBLIC_APP_VERSION="$(VERSION)" \
|
||||
NEXT_PUBLIC_APP_BUILD_DATE="$(BUILD_DATE)" \
|
||||
pnpm build:embed
|
||||
rm -rf internal/router/root/dist
|
||||
cp -R frontend/out internal/router/root/dist
|
||||
go build \
|
||||
-tags embed_frontend \
|
||||
-ldflags "-s -w -X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' -X '$(MODULE)/internal/buildinfo.BuildTime=$(BUILD_DATE)'" \
|
||||
-o bin/wavelet \
|
||||
main.go
|
||||
|
||||
code-check:
|
||||
golangci-lint run
|
||||
cd frontend && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
|
||||
|
||||
build-backend:
|
||||
@echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..."
|
||||
go build \
|
||||
-ldflags "-s -w -X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' -X '$(MODULE)/internal/buildinfo.BuildTime=$(BUILD_DATE)'" \
|
||||
-o bin/wavelet \
|
||||
main.go
|
||||
|
||||
build-frontend:
|
||||
@echo "==> Building frontend version=$(VERSION) build_date=$(BUILD_DATE)..."
|
||||
cd frontend && \
|
||||
NEXT_PUBLIC_APP_VERSION="$(VERSION)" \
|
||||
NEXT_PUBLIC_APP_BUILD_DATE="$(BUILD_DATE)" \
|
||||
pnpm build:embed
|
||||
|
||||
build-test:
|
||||
@echo "==> Running frontend and backend build tests in parallel..."
|
||||
@PIDS=""; \
|
||||
STATUS=0; \
|
||||
( cd frontend && pnpm build:embed 2>&1 | sed 's/^/[frontend] /' ) & PIDS="$$PIDS $$!"; \
|
||||
( go test ./... && go build -o /dev/null ./... 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
|
||||
for PID in $$PIDS; do \
|
||||
wait $$PID || STATUS=1; \
|
||||
done; \
|
||||
if [ $$STATUS -eq 0 ]; then \
|
||||
echo "==> All build tests passed."; \
|
||||
else \
|
||||
echo "==> Build test FAILED." >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
cross-build:
|
||||
@echo "==> Cross-compiling \
|
||||
$(if $(GOOS),$(GOOS),linux/darwin/windows) × \
|
||||
$(if $(GOARCH),$(GOARCH),amd64/arm64) \
|
||||
(version=$(or $(VERSION),dev))..."
|
||||
@mkdir -p bin
|
||||
docker build \
|
||||
--file docker/Dockerfile.cross \
|
||||
--target export \
|
||||
--build-arg VERSION=$(or $(VERSION),dev) \
|
||||
--build-arg BUILD_DATE="$(shell date -u +'%Y-%m-%dT%H:%M:%SZ')" \
|
||||
$(if $(GOOS),--build-arg TARGET_OS=$(GOOS)) \
|
||||
$(if $(GOARCH),--build-arg TARGET_ARCH=$(GOARCH)) \
|
||||
--output type=local,dest=./bin \
|
||||
.
|
||||
@echo "==> Done. Binaries written to ./bin/"
|
||||
@ls -lh bin/
|
||||
@@ -0,0 +1,9 @@
|
||||
Wavelet
|
||||
|
||||
This product includes software derived from LinuxDO Credit.
|
||||
|
||||
LinuxDO Credit:
|
||||
Copyright 2025 linux.do
|
||||
Licensed under the Apache License, Version 2.0.
|
||||
|
||||
This distribution includes modifications by Arctel.net.
|
||||
@@ -0,0 +1,352 @@
|
||||
# wavelet
|
||||
|
||||
🚀 A modern, production-ready full-stack boilerplate for building scalable web applications
|
||||
|
||||
[中文](./README_zh.md)
|
||||
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://golang.org/)
|
||||
[](https://nextjs.org/)
|
||||
[](https://reactjs.org/)
|
||||
|
||||
## 📖 Introduction
|
||||
|
||||
**wavelet** is a generic, production-ready full-stack boilerplate built with **Go (Gin + GORM)** on the backend and **Next.js (App Router + Shadcn UI)** on the frontend. It ships with everything you need to bootstrap a modern SaaS, internal tool, or developer platform — without the boilerplate headaches.
|
||||
|
||||
The project was designed from the ground up to be **framework-first and business-agnostic**: plug in your own domain logic while reusing the battle-tested infrastructure that comes out of the box.
|
||||
|
||||
### ✨ Key Features
|
||||
|
||||
- 🔐 **Multi-auth System** — Local password login/registration + pluggable OIDC/OAuth2 providers (supports multiple auth sources simultaneously)
|
||||
- 🗝️ **Personal Access Tokens** — API key management for programmatic access; supports `Authorization: Bearer` and `X-Access-Token` headers
|
||||
- 👤 **User Management** — Admin panel for listing, searching, filtering, enabling/disabling user accounts
|
||||
- ⚙️ **Dynamic System Config** — Key-value system configuration management with live reload, controllable from the admin UI
|
||||
- 📋 **Async Task Queue** — Background job processing with [Asynq](https://github.com/hibiken/asynq) (Redis-backed), including a scheduling dashboard
|
||||
- 📁 **S3 File Storage** — Unified file upload/download via S3-compatible APIs with local disk cache
|
||||
- 📊 **Observability** — Structured logging (Zap) + distributed tracing (OpenTelemetry)
|
||||
- 🎨 **Modern UI** — Responsive, dark-mode-ready design system built with Tailwind CSS 4 and Shadcn UI
|
||||
- 📖 **Built-in Documentation** — Integrated docs portal with usage guides, API reference, privacy policy, and terms of service
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ Backend │ │ Database │
|
||||
│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │
|
||||
│ │ │ │ │ │
|
||||
│ • React 19 │ │ • Gin HTTP Framework │ │ • PostgreSQL │
|
||||
│ • TypeScript │ │ • GORM ORM │ │ • Redis Cache │
|
||||
│ • Tailwind 4 │ │ • Multi-provider Auth │ │ │
|
||||
│ • Shadcn UI │ │ • AccessToken Middleware │ │ │
|
||||
│ │ │ • Asynq Task Queue │ │ │
|
||||
│ │ │ • OpenTelemetry Tracing │ │ │
|
||||
│ │ │ • Swagger API Docs │ │ │
|
||||
└─────────────────┘ └─────────────────────────────┘ └─────────────────┘
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ Multi-Process CLI │
|
||||
│ (Cobra + Viper) │
|
||||
│ • api (HTTP) │
|
||||
│ • worker (Queue) │
|
||||
│ • scheduler(Cron) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
### Backend
|
||||
- **[Go 1.25+](https://go.dev/doc)** — Primary language
|
||||
- **[Gin](https://github.com/gin-gonic/gin)** — HTTP web framework
|
||||
- **[GORM](https://github.com/go-gorm/gorm)** — ORM with PostgreSQL & ClickHouse support
|
||||
- **[Redis](https://github.com/redis/redis)** — Cache, session store, and task queue backend
|
||||
- **[Asynq](https://github.com/hibiken/asynq)** — Distributed task queue (Redis-backed)
|
||||
- **[Cobra + Viper](https://github.com/spf13/cobra)** — CLI entrypoint and configuration management
|
||||
- **[OpenTelemetry](https://opentelemetry.io)** — Distributed tracing and observability
|
||||
- **[Zap](https://github.com/uber-go/zap)** — Structured, high-performance logging
|
||||
- **[Swagger (Swaggo)](https://github.com/swaggo/swag)** — Auto-generated API documentation
|
||||
- **[AWS SDK v2](https://github.com/aws/aws-sdk-go-v2)** — S3-compatible file storage
|
||||
- **[Snowflake](https://github.com/bwmarrin/snowflake)** — Distributed ID generation
|
||||
|
||||
### Frontend
|
||||
- **[Next.js 16](https://github.com/vercel/next.js)** — React framework with App Router
|
||||
- **[React 19](https://github.com/facebook/react)** — UI library
|
||||
- **[TypeScript](https://github.com/microsoft/TypeScript)** — Type safety
|
||||
- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** — Utility-first styling
|
||||
- **[Shadcn UI](https://github.com/shadcn-ui/ui)** — Accessible, composable component library
|
||||
- **[Lucide Icons](https://github.com/lucide-icons/lucide)** — Icon library
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
- **Go** >= 1.25
|
||||
- **Node.js** >= 18.0
|
||||
- **PostgreSQL** >= 14
|
||||
- **Redis** >= 6.0
|
||||
- **pnpm** >= 8.0 (recommended)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Rain-kl/Wavelet.git refreshing
|
||||
cd refreshing
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
Edit `config.yaml` to configure your database and Redis. OIDC auth sources are configured at runtime in the admin settings page.
|
||||
|
||||
### 3. Initialize Database
|
||||
|
||||
```bash
|
||||
# Start local dependencies (PostgreSQL + Redis)
|
||||
docker compose up -d
|
||||
|
||||
# Optional: also start ClickHouse
|
||||
docker compose --profile clickhouse up -d
|
||||
|
||||
# If you use an external PostgreSQL instance instead of Docker, create the database manually
|
||||
createdb -h <host> -p 5432 -U postgres refreshing
|
||||
|
||||
# Database schema is auto-migrated on first startup
|
||||
```
|
||||
|
||||
### 4. Start the Backend
|
||||
|
||||
```bash
|
||||
# Install Go dependencies
|
||||
go mod tidy
|
||||
|
||||
# Generate Swagger API documentation
|
||||
make swagger
|
||||
|
||||
# Start the HTTP API server
|
||||
go run main.go api
|
||||
```
|
||||
|
||||
> The backend also supports separate `scheduler` and `worker` processes for async task processing:
|
||||
> ```bash
|
||||
> go run main.go scheduler # Cron job scheduler
|
||||
> go run main.go worker # Asynq task worker
|
||||
> ```
|
||||
|
||||
### 5. Start the Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start dev server (Turbopack)
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 6. Access the Application
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| Frontend | http://localhost:3000 |
|
||||
| Swagger API Docs | http://localhost:8000/swagger/index.html |
|
||||
| Health Check | http://localhost:8000/api/health |
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Key configuration options (see `config.example.yaml` for the full reference):
|
||||
|
||||
| Option | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `app.addr` | Backend listen address | `:8000` |
|
||||
| `database.host` | PostgreSQL host | `127.0.0.1` |
|
||||
| `database.database` | Database name | `refreshing` |
|
||||
| `redis.host` | Redis host | `127.0.0.1` |
|
||||
| `storage.endpoint` | S3-compatible endpoint | `s3.amazonaws.com` |
|
||||
|
||||
## 🔧 Development Guide
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# Run API server
|
||||
go run main.go api
|
||||
|
||||
# Run task scheduler
|
||||
go run main.go scheduler
|
||||
|
||||
# Run async worker
|
||||
go run main.go worker
|
||||
|
||||
# Regenerate Swagger docs (required after controller changes)
|
||||
make swagger
|
||||
|
||||
# Format & vet code
|
||||
make tidy
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Development mode (Turbopack)
|
||||
pnpm dev
|
||||
|
||||
# Production build
|
||||
pnpm build
|
||||
|
||||
# Start production server
|
||||
pnpm start
|
||||
|
||||
# Lint & format
|
||||
pnpm lint
|
||||
pnpm format
|
||||
```
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
wavelet/
|
||||
├── main.go # Entry point (delegates to internal/cmd)
|
||||
├── config.example.yaml # Configuration template
|
||||
├── Makefile # Common commands (swagger, tidy, license, cross-build)
|
||||
├── docker/ # Docker image build files (integrated/frontend/backend)
|
||||
├── docs/ # Swagger auto-generated docs
|
||||
├── frontend/ # Next.js frontend application
|
||||
│ ├── app/ # App Router pages
|
||||
│ ├── components/ # React components (ui, common, layout)
|
||||
│ ├── lib/services/ # API service layer
|
||||
│ └── types/ # TypeScript type definitions
|
||||
└── internal/ # Go backend (private)
|
||||
├── cmd/ # CLI commands (api, scheduler, worker)
|
||||
├── apps/ # Business modules (oauth, user, admin, upload)
|
||||
├── model/ # GORM entities and business methods
|
||||
├── router/ # HTTP route registration
|
||||
├── task/ # Async task definitions and workers
|
||||
├── db/ # Database and Redis initialization
|
||||
├── storage/ # S3 file storage abstraction
|
||||
└── common/ # Shared utilities and response helpers
|
||||
```
|
||||
|
||||
## 📚 API Documentation
|
||||
|
||||
Swagger API documentation is auto-generated and available once the backend is running:
|
||||
|
||||
```
|
||||
http://localhost:8000/swagger/index.html
|
||||
```
|
||||
|
||||
The built-in frontend docs portal at `/docs` includes:
|
||||
- **Usage Guide** — Step-by-step walkthrough for getting started
|
||||
- **API Reference** — Detailed interface documentation
|
||||
- **Privacy Policy** — Template privacy policy (customize as needed)
|
||||
- **Terms of Service** — Template terms of service
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
go test ./...
|
||||
|
||||
# Frontend lint
|
||||
cd frontend && pnpm lint
|
||||
```
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Cross-platform Binary
|
||||
|
||||
Build static binaries for all 6 targets (Linux / macOS / Windows × amd64 / arm64) with a single command.
|
||||
The compiled frontend is embedded in every binary — no separate deployment needed.
|
||||
|
||||
**Prerequisites:** Docker with BuildKit enabled (Docker 23+ defaults to on).
|
||||
|
||||
```bash
|
||||
# Build all 6 binaries → ./bin/
|
||||
make cross-build
|
||||
|
||||
# Stamp a release version
|
||||
make cross-build VERSION=v1.2.3
|
||||
|
||||
# Build only a specific OS (both architectures)
|
||||
make cross-build GOOS=linux
|
||||
make cross-build GOOS=darwin
|
||||
make cross-build GOOS=windows
|
||||
|
||||
# Build only a specific architecture (all OSes)
|
||||
make cross-build GOARCH=amd64
|
||||
make cross-build GOARCH=arm64
|
||||
|
||||
# Combine filters — single binary
|
||||
make cross-build GOOS=linux GOARCH=arm64
|
||||
make cross-build GOOS=darwin GOARCH=amd64 VERSION=v1.2.3
|
||||
```
|
||||
|
||||
Output files in `./bin/`:
|
||||
|
||||
| File | Platform |
|
||||
|------|----------|
|
||||
| `wavelet_linux_amd64` | Linux x86-64 |
|
||||
| `wavelet_linux_arm64` | Linux ARM64 |
|
||||
| `wavelet_darwin_amd64` | macOS Intel |
|
||||
| `wavelet_darwin_arm64` | macOS Apple Silicon |
|
||||
| `wavelet_windows_amd64.exe` | Windows x86-64 |
|
||||
| `wavelet_windows_arm64.exe` | Windows ARM64 |
|
||||
|
||||
> The version string is accessible at runtime via `wavelet --version`.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Build image
|
||||
docker build -t refreshing .
|
||||
|
||||
# Run (pass your config as a volume mount)
|
||||
docker run -d -p 8000:8000 \
|
||||
-v $(pwd)/config.yaml:/app/config.yaml \
|
||||
refreshing api
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
1. Build the frontend:
|
||||
```bash
|
||||
cd frontend && pnpm build
|
||||
```
|
||||
|
||||
2. Compile the backend:
|
||||
```bash
|
||||
go build -o refreshing main.go
|
||||
```
|
||||
|
||||
3. Configure `config.yaml` for production.
|
||||
|
||||
4. Start services:
|
||||
```bash
|
||||
./refreshing api # HTTP API
|
||||
./refreshing scheduler # Cron scheduler (optional)
|
||||
./refreshing worker # Task worker (optional)
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Please read the following before submitting code:
|
||||
|
||||
- [Contributing Guidelines](CONTRIBUTING.md)
|
||||
- [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||
- [Contributor License Agreement](CLA.md)
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/your-feature`)
|
||||
3. Commit your changes (`git commit -am 'Add your feature'`)
|
||||
4. Push to the branch (`git push origin feature/your-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the [Apache 2.0 License](LICENSE).
|
||||
@@ -0,0 +1,352 @@
|
||||
# wavelet
|
||||
|
||||
🚀 现代化、生产就绪的全栈应用脚手架
|
||||
|
||||
[English](./README.md)
|
||||
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://golang.org/)
|
||||
[](https://nextjs.org/)
|
||||
[](https://reactjs.org/)
|
||||
|
||||
## 📖 项目简介
|
||||
|
||||
**wavelet** 是一个通用型、生产就绪的现代全栈脚手架,后端采用 **Go(Gin + GORM)**,前端采用 **Next.js(App Router + Shadcn UI)**。项目开箱即用,内置构建现代 SaaS、内部工具或开发者平台所需的核心基础设施。
|
||||
|
||||
项目设计理念是 **框架优先、业务中立**:您可以在沿用经过实战检验的底层基础设施的同时,自由接入自己的业务逻辑。
|
||||
|
||||
### ✨ 主要特性
|
||||
|
||||
- 🔐 **多认证方式** — 本地账号密码登录/注册 + 可插拔 OIDC/OAuth2 认证源(支持同时配置多个认证源)
|
||||
- 🗝️ **个人访问令牌** — API Key 管理,支持程序化接口访问;兼容 `Authorization: Bearer` 和 `X-Access-Token` 请求头
|
||||
- 👤 **用户管理** — 管理后台提供用户列表、搜索筛选、启用/禁用账号等功能
|
||||
- ⚙️ **动态系统配置** — KV 系统配置管理,支持实时变更,可通过管理后台界面直接操作
|
||||
- 📋 **异步任务队列** — 基于 [Asynq](https://github.com/hibiken/asynq)(Redis 驱动)的后台任务处理系统,含任务调度面板
|
||||
- 📁 **S3 文件存储** — 通过 S3 兼容 API 统一处理文件上传/下载,支持本地磁盘缓存
|
||||
- 📊 **可观测性** — 结构化日志(Zap)+ 分布式链路追踪(OpenTelemetry)
|
||||
- 🎨 **现代化 UI** — 基于 Tailwind CSS 4 和 Shadcn UI 构建的响应式、支持深色模式的设计系统
|
||||
- 📖 **内置文档中心** — 集成文档门户,包含使用指南、接口文档、隐私政策和服务条款
|
||||
|
||||
## 🏗️ 架构概览
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐
|
||||
│ 前端 │ │ 后端 │ │ 数据库 │
|
||||
│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │
|
||||
│ │ │ │ │ │
|
||||
│ • React 19 │ │ • Gin HTTP 框架 │ │ • PostgreSQL │
|
||||
│ • TypeScript │ │ • GORM ORM │ │ • Redis 缓存 │
|
||||
│ • Tailwind 4 │ │ • 多认证源适配 │ │ │
|
||||
│ • Shadcn UI │ │ • AccessToken 中间件 │ │ │
|
||||
│ │ │ • Asynq 任务队列 │ │ │
|
||||
│ │ │ • OpenTelemetry 链路追踪 │ │ │
|
||||
│ │ │ • Swagger 接口文档 │ │ │
|
||||
└─────────────────┘ └─────────────────────────────┘ └─────────────────┘
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ 多进程 CLI 入口 │
|
||||
│ (Cobra + Viper) │
|
||||
│ • api (HTTP) │
|
||||
│ • worker (队列) │
|
||||
│ • scheduler(定时) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
### 后端
|
||||
- **[Go 1.25+](https://go.dev/doc)** — 主语言
|
||||
- **[Gin](https://github.com/gin-gonic/gin)** — HTTP Web 框架
|
||||
- **[GORM](https://github.com/go-gorm/gorm)** — ORM,支持 PostgreSQL 和 ClickHouse
|
||||
- **[Redis](https://github.com/redis/redis)** — 缓存、Session 存储、任务队列后端
|
||||
- **[Asynq](https://github.com/hibiken/asynq)** — 分布式任务队列(Redis 驱动)
|
||||
- **[Cobra + Viper](https://github.com/spf13/cobra)** — CLI 入口 + 配置管理
|
||||
- **[OpenTelemetry](https://opentelemetry.io)** — 分布式链路追踪与可观测性
|
||||
- **[Zap](https://github.com/uber-go/zap)** — 结构化高性能日志
|
||||
- **[Swagger (Swaggo)](https://github.com/swaggo/swag)** — 自动生成 API 文档
|
||||
- **[AWS SDK v2](https://github.com/aws/aws-sdk-go-v2)** — S3 兼容文件存储
|
||||
- **[Snowflake](https://github.com/bwmarrin/snowflake)** — 分布式 ID 生成
|
||||
|
||||
### 前端
|
||||
- **[Next.js 16](https://github.com/vercel/next.js)** — React 框架(App Router)
|
||||
- **[React 19](https://github.com/facebook/react)** — UI 库
|
||||
- **[TypeScript](https://github.com/microsoft/TypeScript)** — 类型安全
|
||||
- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** — 原子化 CSS 框架
|
||||
- **[Shadcn UI](https://github.com/shadcn-ui/ui)** — 可访问、可组合的组件库
|
||||
- **[Lucide Icons](https://github.com/lucide-icons/lucide)** — 图标库
|
||||
|
||||
## 📋 环境要求
|
||||
|
||||
- **Go** >= 1.25
|
||||
- **Node.js** >= 18.0
|
||||
- **PostgreSQL** >= 14
|
||||
- **Redis** >= 6.0
|
||||
- **pnpm** >= 8.0(推荐)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 克隆仓库
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Rain-kl/Wavelet.git refreshing
|
||||
cd refreshing
|
||||
```
|
||||
|
||||
### 2. 配置环境
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
编辑 `config.yaml`,配置数据库和 Redis。OIDC 认证源统一在管理后台的系统设置页面运行时配置。
|
||||
|
||||
### 3. 初始化数据库
|
||||
|
||||
```bash
|
||||
# 启动本地依赖服务(PostgreSQL + Redis)
|
||||
docker compose up -d
|
||||
|
||||
# 可选:同时启动 ClickHouse
|
||||
docker compose --profile clickhouse up -d
|
||||
|
||||
# 如果使用外部 PostgreSQL,而不是 Docker 内置服务,则手动创建数据库
|
||||
createdb -h <主机> -p 5432 -U postgres refreshing
|
||||
|
||||
# 数据库表结构在首次启动时自动迁移,无需手动执行
|
||||
```
|
||||
|
||||
### 4. 启动后端
|
||||
|
||||
```bash
|
||||
# 安装 Go 依赖
|
||||
go mod tidy
|
||||
|
||||
# 生成 Swagger 接口文档
|
||||
make swagger
|
||||
|
||||
# 启动 HTTP API 服务器
|
||||
go run main.go api
|
||||
```
|
||||
|
||||
> 后端也支持独立运行 `scheduler` 和 `worker` 进程来处理异步任务:
|
||||
> ```bash
|
||||
> go run main.go scheduler # 定时任务调度器
|
||||
> go run main.go worker # Asynq 任务处理工作进程
|
||||
> ```
|
||||
|
||||
### 5. 启动前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动开发服务器(Turbopack)
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 6. 访问应用
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| 前端界面 | http://localhost:3000 |
|
||||
| Swagger 接口文档 | http://localhost:8000/swagger/index.html |
|
||||
| 健康检查 | http://localhost:8000/api/health |
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
主要配置项(完整说明请参考 `config.example.yaml`):
|
||||
|
||||
| 配置项 | 说明 | 示例 |
|
||||
|--------|------|------|
|
||||
| `app.addr` | 后端监听地址 | `:8000` |
|
||||
| `database.host` | PostgreSQL 主机 | `127.0.0.1` |
|
||||
| `database.database` | 数据库名称 | `refreshing` |
|
||||
| `redis.host` | Redis 主机 | `127.0.0.1` |
|
||||
| `storage.endpoint` | S3 兼容存储端点 | `s3.amazonaws.com` |
|
||||
|
||||
## 🔧 开发指南
|
||||
|
||||
### 后端
|
||||
|
||||
```bash
|
||||
# 运行 API 服务器
|
||||
go run main.go api
|
||||
|
||||
# 运行定时任务调度器
|
||||
go run main.go scheduler
|
||||
|
||||
# 运行异步任务工作进程
|
||||
go run main.go worker
|
||||
|
||||
# 修改 Controller 后重新生成 Swagger 文档(必须执行)
|
||||
make swagger
|
||||
|
||||
# 代码格式化与检查
|
||||
make tidy
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# 开发模式(Turbopack)
|
||||
pnpm dev
|
||||
|
||||
# 构建生产版本
|
||||
pnpm build
|
||||
|
||||
# 启动生产服务器
|
||||
pnpm start
|
||||
|
||||
# 代码 Lint 和格式化
|
||||
pnpm lint
|
||||
pnpm format
|
||||
```
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
wavelet/
|
||||
├── main.go # 程序入口(委托给 internal/cmd)
|
||||
├── config.example.yaml # 配置模板
|
||||
├── Makefile # 常用命令(swagger、tidy、license、cross-build)
|
||||
├── docker/ # Docker 镜像构建文件(集成/前端/后端)
|
||||
├── docs/ # Swagger 自动生成文档
|
||||
├── frontend/ # Next.js 前端应用
|
||||
│ ├── app/ # App Router 页面
|
||||
│ ├── components/ # React 组件(ui、common、layout)
|
||||
│ ├── lib/services/ # API 服务层
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
└── internal/ # Go 后端(private)
|
||||
├── cmd/ # CLI 命令(api、scheduler、worker)
|
||||
├── apps/ # 业务模块(oauth、user、admin、upload)
|
||||
├── model/ # GORM 实体与业务方法
|
||||
├── router/ # HTTP 路由注册
|
||||
├── task/ # 异步任务定义与工作进程
|
||||
├── db/ # 数据库与 Redis 初始化
|
||||
├── storage/ # S3 文件存储抽象层
|
||||
└── common/ # 公共工具与响应封装
|
||||
```
|
||||
|
||||
## 📚 接口文档
|
||||
|
||||
Swagger 接口文档在后端启动后自动可用:
|
||||
|
||||
```
|
||||
http://localhost:8000/swagger/index.html
|
||||
```
|
||||
|
||||
前端文档中心(路径 `/docs`)内置以下内容:
|
||||
- **使用指南** — 分步入门教程
|
||||
- **接口文档** — 详细接口说明
|
||||
- **隐私政策** — 隐私政策模板(请按需自定义)
|
||||
- **服务条款** — 服务条款模板
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
```bash
|
||||
# 后端测试
|
||||
go test ./...
|
||||
|
||||
# 前端 Lint
|
||||
cd frontend && pnpm lint
|
||||
```
|
||||
|
||||
## 🚀 部署
|
||||
|
||||
### 跨平台二进制编译
|
||||
|
||||
一条命令构建全部 6 个平台的静态二进制文件(Linux / macOS / Windows × amd64 / arm64)。
|
||||
前端已内嵌到每个二进制文件中,无需单独部署。
|
||||
|
||||
**前提条件:** 已安装 Docker 且启用 BuildKit(Docker 23+ 默认开启)。
|
||||
|
||||
```bash
|
||||
# 构建全部 6 个二进制文件 → ./bin/
|
||||
make cross-build
|
||||
|
||||
# 指定版本号
|
||||
make cross-build VERSION=v1.2.3
|
||||
|
||||
# 只构建指定系统(两种架构均会构建)
|
||||
make cross-build GOOS=linux
|
||||
make cross-build GOOS=darwin
|
||||
make cross-build GOOS=windows
|
||||
|
||||
# 只构建指定架构(所有系统均会构建)
|
||||
make cross-build GOARCH=amd64
|
||||
make cross-build GOARCH=arm64
|
||||
|
||||
# 同时指定系统和架构 — 只生成单个文件
|
||||
make cross-build GOOS=linux GOARCH=arm64
|
||||
make cross-build GOOS=darwin GOARCH=amd64 VERSION=v1.2.3
|
||||
```
|
||||
|
||||
输出到 `./bin/` 目录:
|
||||
|
||||
| 文件名 | 平台 |
|
||||
|--------|------|
|
||||
| `wavelet_linux_amd64` | Linux x86-64 |
|
||||
| `wavelet_linux_arm64` | Linux ARM64 |
|
||||
| `wavelet_darwin_amd64` | macOS Intel |
|
||||
| `wavelet_darwin_arm64` | macOS Apple Silicon |
|
||||
| `wavelet_windows_amd64.exe` | Windows x86-64 |
|
||||
| `wavelet_windows_arm64.exe` | Windows ARM64 |
|
||||
|
||||
> 版本号可通过 `wavelet --version` 在运行时查看。
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t refreshing .
|
||||
|
||||
# 运行(通过卷挂载传入配置文件)
|
||||
docker run -d -p 8000:8000 \
|
||||
-v $(pwd)/config.yaml:/app/config.yaml \
|
||||
refreshing api
|
||||
```
|
||||
|
||||
### 生产环境
|
||||
|
||||
1. 构建前端资源:
|
||||
```bash
|
||||
cd frontend && pnpm build
|
||||
```
|
||||
|
||||
2. 编译后端程序:
|
||||
```bash
|
||||
go build -o refreshing main.go
|
||||
```
|
||||
|
||||
3. 配置生产环境的 `config.yaml`。
|
||||
|
||||
4. 启动服务:
|
||||
```bash
|
||||
./refreshing api # HTTP API
|
||||
./refreshing scheduler # 定时调度器(可选)
|
||||
./refreshing worker # 任务工作进程(可选)
|
||||
```
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
我们欢迎社区贡献!请在提交代码前阅读以下文档:
|
||||
|
||||
- [贡献指南](CONTRIBUTING.md)
|
||||
- [行为准则](CODE_OF_CONDUCT.md)
|
||||
- [贡献者许可协议](CLA.md)
|
||||
|
||||
### 贡献流程
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建特性分支 (`git checkout -b feature/your-feature`)
|
||||
3. 提交更改 (`git commit -am 'Add your feature'`)
|
||||
4. 推送到分支 (`git push origin feature/your-feature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目基于 [Apache 2.0 许可证](LICENSE) 开源。
|
||||
@@ -1,562 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rain-kl/openflare/openflare-server/internal/common"
|
||||
"github.com/rain-kl/openflare/openflare-server/internal/model"
|
||||
)
|
||||
|
||||
const retentionDays = 90
|
||||
|
||||
func main() {
|
||||
dsn := flag.String("dsn", envOr("DSN", "postgres://openflare:replace-with-strong-password@192.168.107.2:5432/openflare?sslmode=disable"), "PostgreSQL DSN")
|
||||
records := flag.Int("records", 1_000_000, "number of access log rows to seed (0 = skip seeding)")
|
||||
seedNode := flag.String("node-id", "bench-node", "node_id used for seeded rows")
|
||||
iterations := flag.Int("iterations", 10, "benchmark iterations per scenario")
|
||||
warmup := flag.Int("warmup", 2, "warmup iterations per scenario")
|
||||
page := flag.Int("page", 0, "page index for list benchmark")
|
||||
pageSize := flag.Int("page-size", 20, "page size for list benchmark")
|
||||
reset := flag.Bool("reset", false, "truncate node_access_logs shard tables before seeding")
|
||||
legacyIterations := flag.Int("legacy-iterations", 1, "iterations for legacy full-scan scenario (can be very slow)")
|
||||
skipLegacy := flag.Bool("skip-legacy", false, "skip legacy full-scan scenario")
|
||||
verifyOnly := flag.Bool("verify-only", false, "verify query correctness and exit")
|
||||
flag.Parse()
|
||||
|
||||
common.SQLDSN = *dsn
|
||||
common.SQLitePath = filepath.Join(os.TempDir(), "openflare-accesslogbench-missing.sqlite")
|
||||
if err := initBenchDB(*dsn); err != nil {
|
||||
log.Fatalf("init db: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("=== OpenFlare node_access_logs benchmark (PostgreSQL) ===")
|
||||
fmt.Printf("DSN: %s\n", redactDSN(*dsn))
|
||||
fmt.Printf("GOMAXPROCS=%d\n", runtime.GOMAXPROCS(0))
|
||||
|
||||
if *reset {
|
||||
if err := truncateAccessLogs(); err != nil {
|
||||
log.Fatalf("truncate access logs: %v", err)
|
||||
}
|
||||
fmt.Println("truncated node_access_logs shard tables")
|
||||
}
|
||||
|
||||
if *records > 0 {
|
||||
existing, err := countAllAccessLogs()
|
||||
if err != nil {
|
||||
log.Fatalf("count existing rows: %v", err)
|
||||
}
|
||||
if existing >= int64(*records) {
|
||||
fmt.Printf("existing rows=%d >= target=%d, skip seeding\n", existing, *records)
|
||||
} else {
|
||||
missing := *records - int(existing)
|
||||
fmt.Printf("seeding %d rows (existing=%d)...\n", missing, existing)
|
||||
if err := seedAccessLogs(*seedNode, missing); err != nil {
|
||||
log.Fatalf("seed access logs: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total, err := countAllAccessLogs()
|
||||
if err != nil {
|
||||
log.Fatalf("count rows after seed: %v", err)
|
||||
}
|
||||
fmt.Printf("total rows across shards: %d\n\n", total)
|
||||
|
||||
since := time.Now().UTC().Add(-retentionDays * 24 * time.Hour)
|
||||
if err := verifyQueryCorrectness(since, total); err != nil {
|
||||
log.Fatalf("correctness verification failed: %v", err)
|
||||
}
|
||||
fmt.Println("correctness verification: PASS")
|
||||
if *verifyOnly {
|
||||
return
|
||||
}
|
||||
|
||||
query := model.NodeAccessLogQuery{
|
||||
Since: since,
|
||||
Page: *page,
|
||||
PageSize: *pageSize,
|
||||
SortBy: "logged_at",
|
||||
SortOrder: "desc",
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
name: "paginated_list",
|
||||
run: func() error {
|
||||
_, err := model.ListNodeAccessLogs(query)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sql_count",
|
||||
run: func() error {
|
||||
_, _, err := model.CountNodeAccessLogs(query)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "api_list+count",
|
||||
run: func() error {
|
||||
if _, err := model.ListNodeAccessLogs(query); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := model.CountNodeAccessLogs(query)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fullscan_list_legacy",
|
||||
run: func() error {
|
||||
_, err := fullScanList(query)
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := printExplainPlans(since); err != nil {
|
||||
log.Printf("warn: explain analyze failed: %v", err)
|
||||
}
|
||||
|
||||
for _, item := range scenarios {
|
||||
if item.name == "fullscan_list_legacy" && *skipLegacy {
|
||||
fmt.Println("[fullscan_list_legacy] skipped")
|
||||
continue
|
||||
}
|
||||
iterationCount := *iterations
|
||||
if item.name == "fullscan_list_legacy" {
|
||||
iterationCount = *legacyIterations
|
||||
}
|
||||
result, err := runScenario(item, *warmup, iterationCount)
|
||||
if err != nil {
|
||||
log.Fatalf("scenario %s failed: %v", item.name, err)
|
||||
}
|
||||
printResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
type scenario struct {
|
||||
name string
|
||||
run func() error
|
||||
}
|
||||
|
||||
type benchResult struct {
|
||||
name string
|
||||
iterations int
|
||||
latencies []time.Duration
|
||||
allocBytes []uint64
|
||||
heapInUse []uint64
|
||||
maxHeapInUse uint64
|
||||
}
|
||||
|
||||
func runScenario(item scenario, warmup int, iterations int) (*benchResult, error) {
|
||||
for range warmup {
|
||||
if err := item.run(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
result := &benchResult{
|
||||
name: item.name,
|
||||
iterations: iterations,
|
||||
}
|
||||
var peakHeap uint64
|
||||
|
||||
for range iterations {
|
||||
runtime.GC()
|
||||
var before, after runtime.MemStats
|
||||
runtime.ReadMemStats(&before)
|
||||
|
||||
start := time.Now()
|
||||
if err := item.run(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
|
||||
runtime.ReadMemStats(&after)
|
||||
result.latencies = append(result.latencies, elapsed)
|
||||
result.allocBytes = append(result.allocBytes, after.TotalAlloc-before.TotalAlloc)
|
||||
result.heapInUse = append(result.heapInUse, after.HeapInuse)
|
||||
if after.HeapInuse > peakHeap {
|
||||
peakHeap = after.HeapInuse
|
||||
}
|
||||
}
|
||||
result.maxHeapInUse = peakHeap
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func printResult(result *benchResult) {
|
||||
latencies := append([]time.Duration(nil), result.latencies...)
|
||||
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
|
||||
|
||||
var allocSum uint64
|
||||
var heapSum uint64
|
||||
for index := range result.allocBytes {
|
||||
allocSum += result.allocBytes[index]
|
||||
heapSum += result.heapInUse[index]
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] iterations=%d\n", result.name, result.iterations)
|
||||
fmt.Printf(" latency: min=%s avg=%s p50=%s p95=%s max=%s\n",
|
||||
minDuration(latencies),
|
||||
avgDuration(latencies),
|
||||
percentile(latencies, 50),
|
||||
percentile(latencies, 95),
|
||||
maxDuration(latencies),
|
||||
)
|
||||
fmt.Printf(" alloc/op: avg=%s peak_heap=%s\n",
|
||||
humanBytes(allocSum/uint64(len(result.allocBytes))),
|
||||
humanBytes(result.maxHeapInUse),
|
||||
)
|
||||
fmt.Printf(" heap_inuse/op: avg=%s\n\n", humanBytes(heapSum/uint64(len(result.heapInUse))))
|
||||
}
|
||||
|
||||
func seedAccessLogs(nodeID string, total int) error {
|
||||
started := time.Now()
|
||||
perShard := total / 10
|
||||
remainder := total % 10
|
||||
seeded := 0
|
||||
now := time.Now().UTC()
|
||||
|
||||
for shard := range 10 {
|
||||
rows := perShard
|
||||
if shard < remainder {
|
||||
rows++
|
||||
}
|
||||
if rows == 0 {
|
||||
continue
|
||||
}
|
||||
table := fmt.Sprintf("node_access_logs_%02d", shard)
|
||||
sql := fmt.Sprintf(`
|
||||
INSERT INTO %s (id, node_id, logged_at, remote_addr, region, host, path, status_code, created_at)
|
||||
SELECT
|
||||
(gs * 10 + %d)::bigint AS id,
|
||||
$1 AS node_id,
|
||||
$2::timestamptz - (gs || ' minutes')::interval AS logged_at,
|
||||
('203.0.' || ((gs / 256) %% 256)::text || '.' || (gs %% 256)::text) AS remote_addr,
|
||||
'Benchland' AS region,
|
||||
('host-' || (gs %% 200)::text || '.example.com') AS host,
|
||||
('/api/v1/resource/' || gs::text) AS path,
|
||||
(200 + (gs %% 4))::bigint AS status_code,
|
||||
$2::timestamptz AS created_at
|
||||
FROM generate_series(0, $3 - 1) AS gs
|
||||
`, table, shard)
|
||||
if err := model.DB.Exec(sql, nodeID, now, rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
seeded += rows
|
||||
elapsed := time.Since(started)
|
||||
rate := float64(seeded) / elapsed.Seconds()
|
||||
fmt.Printf(" seeded %d/%d rows (%.0f rows/s, elapsed %s)\n", seeded, total, rate, elapsed.Round(time.Millisecond))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncateAccessLogs() error {
|
||||
for _, table := range observabilityShardTables() {
|
||||
if err := model.DB.Exec("TRUNCATE TABLE " + table).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func countAllAccessLogs() (int64, error) {
|
||||
var total int64
|
||||
for _, table := range observabilityShardTables() {
|
||||
var count int64
|
||||
if err := model.DB.Table(table).Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total += count
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func observabilityShardTables() []string {
|
||||
tables := make([]string, 0, 10)
|
||||
for index := range 10 {
|
||||
tables = append(tables, fmt.Sprintf("node_access_logs_%02d", index))
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
func verifyQueryCorrectness(since time.Time, tableTotal int64) error {
|
||||
fmt.Println("=== correctness verification ===")
|
||||
baseQuery := model.NodeAccessLogQuery{
|
||||
Since: since,
|
||||
SortBy: "logged_at",
|
||||
SortOrder: "desc",
|
||||
}
|
||||
|
||||
reference, err := fullScanList(baseQuery)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reference full scan failed: %w", err)
|
||||
}
|
||||
if int64(len(reference)) != tableTotal {
|
||||
return fmt.Errorf("reference row count %d != table total %d", len(reference), tableTotal)
|
||||
}
|
||||
fmt.Printf(" reference rows in retention window: %d\n", len(reference))
|
||||
|
||||
totalRecords, totalIPs, err := model.CountNodeAccessLogs(baseQuery)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CountNodeAccessLogs failed: %w", err)
|
||||
}
|
||||
if totalRecords != int64(len(reference)) {
|
||||
return fmt.Errorf("total_records=%d want reference=%d", totalRecords, len(reference))
|
||||
}
|
||||
referenceIPs := countUniqueIPs(reference)
|
||||
if totalIPs != referenceIPs {
|
||||
return fmt.Errorf("total_ip=%d want reference=%d", totalIPs, referenceIPs)
|
||||
}
|
||||
fmt.Printf(" count totals: total_record=%d total_ip=%d\n", totalRecords, totalIPs)
|
||||
|
||||
pages := []struct {
|
||||
page int
|
||||
pageSize int
|
||||
}{
|
||||
{0, 20},
|
||||
{1, 20},
|
||||
{49, 20},
|
||||
{100, 50},
|
||||
}
|
||||
for _, item := range pages {
|
||||
query := baseQuery
|
||||
query.Page = item.page
|
||||
query.PageSize = item.pageSize
|
||||
got, err := model.ListNodeAccessLogs(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ListNodeAccessLogs page=%d size=%d failed: %w", item.page, item.pageSize, err)
|
||||
}
|
||||
start := item.page * item.pageSize
|
||||
end := start + item.pageSize
|
||||
if start >= len(reference) {
|
||||
if len(got) != 0 {
|
||||
return fmt.Errorf("page=%d size=%d expected empty got %d", item.page, item.pageSize, len(got))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if end > len(reference) {
|
||||
end = len(reference)
|
||||
}
|
||||
want := reference[start:end]
|
||||
if !accessLogsEqual(got, want) {
|
||||
return fmt.Errorf("page=%d size=%d content mismatch (got %d want %d rows)", item.page, item.pageSize, len(got), len(want))
|
||||
}
|
||||
fmt.Printf(" page=%d size=%d: %d rows match reference\n", item.page, item.pageSize, len(got))
|
||||
}
|
||||
|
||||
filtered := model.NodeAccessLogQuery{
|
||||
NodeID: "bench-node",
|
||||
Since: since,
|
||||
SortBy: "status_code",
|
||||
SortOrder: "asc",
|
||||
Page: 2,
|
||||
PageSize: 15,
|
||||
}
|
||||
filteredReference, err := fullScanList(filtered)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filtered reference failed: %w", err)
|
||||
}
|
||||
filteredRows, err := model.ListNodeAccessLogs(filtered)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filtered ListNodeAccessLogs failed: %w", err)
|
||||
}
|
||||
start := filtered.Page * filtered.PageSize
|
||||
end := start + filtered.PageSize
|
||||
if end > len(filteredReference) {
|
||||
end = len(filteredReference)
|
||||
}
|
||||
if start >= len(filteredReference) {
|
||||
start = len(filteredReference)
|
||||
}
|
||||
if !accessLogsEqual(filteredRows, filteredReference[start:end]) {
|
||||
return fmt.Errorf("filtered page content mismatch")
|
||||
}
|
||||
filteredTotal, filteredIPs, err := model.CountNodeAccessLogs(filtered)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filtered CountNodeAccessLogs failed: %w", err)
|
||||
}
|
||||
if filteredTotal != int64(len(filteredReference)) {
|
||||
return fmt.Errorf("filtered total_records=%d want %d", filteredTotal, len(filteredReference))
|
||||
}
|
||||
if filteredIPs != countUniqueIPs(filteredReference) {
|
||||
return fmt.Errorf("filtered total_ip=%d want %d", filteredIPs, countUniqueIPs(filteredReference))
|
||||
}
|
||||
fmt.Printf(" filtered node_id=bench-node page=2 size=15: match (total_record=%d total_ip=%d)\n", filteredTotal, filteredIPs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func countUniqueIPs(logs []*model.NodeAccessLog) int64 {
|
||||
ips := make(map[string]struct{})
|
||||
for _, item := range logs {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if trimmed := strings.TrimSpace(item.RemoteAddr); trimmed != "" {
|
||||
ips[trimmed] = struct{}{}
|
||||
}
|
||||
}
|
||||
return int64(len(ips))
|
||||
}
|
||||
|
||||
func accessLogsEqual(left []*model.NodeAccessLog, right []*model.NodeAccessLog) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] == nil || right[index] == nil {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if left[index].ID != right[index].ID ||
|
||||
left[index].NodeID != right[index].NodeID ||
|
||||
!left[index].LoggedAt.Equal(right[index].LoggedAt) ||
|
||||
left[index].RemoteAddr != right[index].RemoteAddr ||
|
||||
left[index].Host != right[index].Host ||
|
||||
left[index].Path != right[index].Path ||
|
||||
left[index].StatusCode != right[index].StatusCode {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func printExplainPlans(since time.Time) error {
|
||||
fmt.Println("=== PostgreSQL EXPLAIN (one shard sample: node_access_logs_00) ===")
|
||||
queries := []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{
|
||||
name: "paginated_list",
|
||||
sql: "EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM node_access_logs_00 WHERE logged_at >= $1 ORDER BY logged_at DESC, id DESC LIMIT 20",
|
||||
},
|
||||
{
|
||||
name: "count_rows",
|
||||
sql: "EXPLAIN (ANALYZE, BUFFERS) SELECT COUNT(*) FROM node_access_logs_00 WHERE logged_at >= $1",
|
||||
},
|
||||
{
|
||||
name: "distinct_ip_union_all",
|
||||
sql: `EXPLAIN (ANALYZE, BUFFERS) SELECT COUNT(*) FROM (
|
||||
SELECT remote_addr FROM (
|
||||
SELECT TRIM(remote_addr) AS remote_addr FROM node_access_logs_00 WHERE logged_at >= $1 AND remote_addr <> ''
|
||||
UNION ALL
|
||||
SELECT TRIM(remote_addr) AS remote_addr FROM node_access_logs_01 WHERE logged_at >= $1 AND remote_addr <> ''
|
||||
) AS all_ips GROUP BY remote_addr
|
||||
) AS ips`,
|
||||
},
|
||||
}
|
||||
for _, item := range queries {
|
||||
rows, err := model.DB.Raw(item.sql, since).Rows()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("-- %s\n", item.name)
|
||||
for rows.Next() {
|
||||
var line string
|
||||
if err := rows.Scan(&line); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
fmt.Println(line)
|
||||
}
|
||||
rows.Close()
|
||||
fmt.Println()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fullScanList(query model.NodeAccessLogQuery) ([]*model.NodeAccessLog, error) {
|
||||
legacy := query
|
||||
legacy.PageSize = 0
|
||||
return model.ListNodeAccessLogs(legacy)
|
||||
}
|
||||
|
||||
func initBenchDB(dsn string) error {
|
||||
return model.InitBenchmarkDB(dsn)
|
||||
}
|
||||
|
||||
func envOr(key string, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func redactDSN(dsn string) string {
|
||||
if at := strings.Index(dsn, "@"); at > 0 {
|
||||
schemeEnd := strings.Index(dsn, "://")
|
||||
if schemeEnd >= 0 {
|
||||
return dsn[:schemeEnd+3] + "***@" + dsn[at+1:]
|
||||
}
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
func minDuration(values []time.Duration) time.Duration {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
func maxDuration(values []time.Duration) time.Duration {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
return values[len(values)-1]
|
||||
}
|
||||
|
||||
func avgDuration(values []time.Duration) time.Duration {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
var sum time.Duration
|
||||
for _, value := range values {
|
||||
sum += value
|
||||
}
|
||||
return sum / time.Duration(len(values))
|
||||
}
|
||||
|
||||
func percentile(values []time.Duration, p int) time.Duration {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
if p <= 0 {
|
||||
return values[0]
|
||||
}
|
||||
if p >= 100 {
|
||||
return values[len(values)-1]
|
||||
}
|
||||
index := (len(values)*p + 99) / 100
|
||||
if index <= 0 {
|
||||
index = 1
|
||||
}
|
||||
if index > len(values) {
|
||||
index = len(values)
|
||||
}
|
||||
return values[index-1]
|
||||
}
|
||||
|
||||
func humanBytes(value uint64) string {
|
||||
const unit = 1024
|
||||
if value < unit {
|
||||
return fmt.Sprintf("%d B", value)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for n := value / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(value)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package main
|
||||
|
||||
import server "github.com/rain-kl/openflare/openflare-server"
|
||||
|
||||
func main() {
|
||||
server.Run()
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
# openflare — Platform Config
|
||||
# Copy this file to config.yaml and fill in your values.
|
||||
# Fields marked with <...> are required; others have sensible defaults.
|
||||
|
||||
# ─── Application ────────────────────────────────────────────────────────────────
|
||||
app:
|
||||
app_name: "openflare"
|
||||
env: "development" # development | testing | production
|
||||
addr: ":3000"
|
||||
node_id: 1 # Snowflake node ID (0-1023). Must be unique per instance.
|
||||
graceful_shutdown_timeout: 30
|
||||
session_cookie_name: "openflare_session_id" # Change to something unique before deploy
|
||||
session_secret: "<uniq-random-string>" # Cannot be changed after first start
|
||||
session_domain: "" # e.g. ".yourdomain.com"
|
||||
session_age: 86400 # Session lifetime in seconds (default: 24h)
|
||||
session_secure: false
|
||||
session_http_only: false
|
||||
api_prefix: "/api"
|
||||
|
||||
# ─── PostgreSQL ─────────────────────────────────────────────────────────────────
|
||||
# Supports Standalone and Primary-Replica (read/write split) modes.
|
||||
database:
|
||||
enabled: true
|
||||
sqlite_path: "openflare.db" # PostgreSQL 禁用时使用此 SQLite 文件路径
|
||||
host: "127.0.0.1"
|
||||
port: 5432
|
||||
username: "postgres"
|
||||
password: "postgres"
|
||||
database: "openflare"
|
||||
max_idle_conn: 16
|
||||
max_open_conn: 128
|
||||
conn_max_lifetime: 1800
|
||||
conn_max_idle_time: 600
|
||||
log_level: "info" # error | warn | info | debug | silent
|
||||
ssl_mode: "disable"
|
||||
time_zone: "UTC"
|
||||
application_name: "openflare-server"
|
||||
prefer_simple_protocol: false
|
||||
search_path: "public"
|
||||
statement_cache_capacity: 256
|
||||
default_query_exec_mode: "cache_statement"
|
||||
slow_threshold: 200ms
|
||||
# Optional read replicas:
|
||||
# replicas:
|
||||
# - host: "replica1.db.internal"
|
||||
# port: 5432
|
||||
# - host: "replica2.db.internal"
|
||||
# port: 5432
|
||||
|
||||
# ─── Redis ──────────────────────────────────────────────────────────────────────
|
||||
# Supports Standalone, Sentinel (HA), and Cluster modes.
|
||||
redis:
|
||||
enabled: true
|
||||
addrs:
|
||||
- "127.0.0.1:6379"
|
||||
username: ""
|
||||
password: ""
|
||||
db: 0 # Ignored in Cluster mode
|
||||
cluster_mode: false # Set true to enable Cluster mode
|
||||
master_name: "" # Set non-empty to enable Sentinel mode
|
||||
key_prefix: "openflare:"
|
||||
pool_size: 100
|
||||
min_idle_conn: 10
|
||||
dial_timeout: 5
|
||||
read_timeout: 3
|
||||
write_timeout: 3
|
||||
max_retries: 3
|
||||
pool_timeout: 4
|
||||
conn_max_idle_time: 300
|
||||
|
||||
# ─── Logging ────────────────────────────────────────────────────────────────────
|
||||
log:
|
||||
level: "info" # debug | info | warn | error | fatal | panic
|
||||
format: "console" # console | json
|
||||
output: "stdout" # stdout | file
|
||||
file_path: "./logs/app.log"
|
||||
max_size: 100 # MB per log file
|
||||
max_age: 30 # Days to retain old log files
|
||||
max_backups: 10
|
||||
compress: true
|
||||
|
||||
|
||||
# ─── Async Task Worker ──────────────────────────────────────────────────────────
|
||||
worker:
|
||||
concurrency: 20
|
||||
strict_priority: false
|
||||
queues:
|
||||
- name: webhook
|
||||
priority: 10
|
||||
- name: whitelist_only
|
||||
priority: 5
|
||||
- name: default
|
||||
priority: 3
|
||||
|
||||
# ─── OpenTelemetry Tracing ──────────────────────────────────────────────────────
|
||||
otel:
|
||||
sampling_rate: 0.0 # Trace sampling rate (0.0 – 1.0)
|
||||
tracer_name: "github.com/Rain-kl/OpenFlare" # Global tracer instrumentation name
|
||||
|
||||
|
||||
# ─── ClickHouse (optional) ──────────────────────────────────────────────────────
|
||||
clickhouse:
|
||||
enabled: false
|
||||
hosts:
|
||||
- "127.0.0.1:9000"
|
||||
username: "default"
|
||||
password: ""
|
||||
database: "openflare"
|
||||
max_idle_conn: 10
|
||||
max_open_conn: 100
|
||||
conn_max_lifetime: 3600
|
||||
dial_timeout: 5
|
||||
block_buffer_size: 10
|
||||
@@ -1,35 +0,0 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: openflare
|
||||
POSTGRES_USER: openflare
|
||||
POSTGRES_PASSWORD: replace-with-strong-password
|
||||
volumes:
|
||||
- ./postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
openflare:
|
||||
build:
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
SESSION_SECRET: replace-with-random-string
|
||||
SQLITE_PATH: /data/openflare.db
|
||||
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
|
||||
GIN_MODE: release
|
||||
LOG_LEVEL: info
|
||||
|
||||
volumes:
|
||||
- ./openflare-data:/data
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
services:
|
||||
wavelet:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
args:
|
||||
VERSION: v0.9.9
|
||||
# image: ghcr.io/rain-kl/wavelet:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://jaeger:4317}
|
||||
OTEL_EXPORTER_OTLP_INSECURE: ${OTEL_EXPORTER_OTLP_INSECURE:-true}
|
||||
OTEL_SAMPLING_RATE: ${OTEL_SAMPLING_RATE:-1.0}
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- ./data/sqlite:/app/data
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
jaeger:
|
||||
condition: service_started
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-openflare}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- ./data/postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-openflare}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:8.0-alpine
|
||||
restart: unless-stopped
|
||||
command: ["valkey-server", "--appendonly", "yes"]
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- ./data/valkey:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
jaeger:
|
||||
image: jaegertracing/jaeger:${JAEGER_VERSION:-2.19.0}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ports:
|
||||
- "${JAEGER_UI_PORT:-16686}:16686"
|
||||
- "${JAEGER_OTLP_GRPC_PORT:-4317}:4317"
|
||||
- "${JAEGER_OTLP_HTTP_PORT:-4318}:4318"
|
||||
#
|
||||
# clickhouse:
|
||||
# image: clickhouse/clickhouse-server:25.3-alpine
|
||||
# restart: unless-stopped
|
||||
# profiles:
|
||||
# - clickhouse
|
||||
# environment:
|
||||
# CLICKHOUSE_DB: ${CLICKHOUSE_DB:-openflare}
|
||||
# CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default}
|
||||
# CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-123456}
|
||||
# CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
# TZ: ${TZ:-Asia/Shanghai}
|
||||
# ports:
|
||||
# - "${CLICKHOUSE_HTTP_PORT:-8123}:8123"
|
||||
# - "${CLICKHOUSE_NATIVE_PORT:-9000}:9000"
|
||||
# volumes:
|
||||
# - ./data/clickhouse_data:/var/lib/clickhouse
|
||||
# healthcheck:
|
||||
# test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
# start_period: 15s
|
||||
@@ -0,0 +1,68 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG GO_VERSION=1.25
|
||||
ARG NODE_VERSION=22
|
||||
ARG ALPINE_VERSION=3.23
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine AS frontend-builder
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_DATE=""
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_STANDALONE_EXPORT=true
|
||||
ENV NEXT_PUBLIC_APP_VERSION=${VERSION}
|
||||
ENV NEXT_PUBLIC_APP_BUILD_DATE=${BUILD_DATE}
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
|
||||
|
||||
WORKDIR /workspace/frontend
|
||||
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY frontend/ ./
|
||||
|
||||
RUN pnpm build:embed
|
||||
|
||||
FROM golang:${GO_VERSION}-alpine AS backend-builder
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_DATE=""
|
||||
|
||||
RUN apk add --no-cache ca-certificates git
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend-builder /workspace/frontend/out ./internal/router/root/dist
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-tags embed_frontend \
|
||||
-trimpath \
|
||||
-ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=${BUILD_DATE}" \
|
||||
-o /out/wavelet \
|
||||
./main.go
|
||||
|
||||
FROM alpine:${ALPINE_VERSION}
|
||||
|
||||
ARG TZ=Asia/Shanghai
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata postgresql-client && \
|
||||
cp /usr/share/zoneinfo/${TZ} /etc/localtime && \
|
||||
echo "${TZ}" > /etc/timezone
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=backend-builder /out/wavelet ./wavelet
|
||||
COPY docs ./docs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["./wavelet"]
|
||||
CMD ["all"]
|
||||
@@ -0,0 +1,42 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG GO_VERSION=1.25
|
||||
ARG ALPINE_VERSION=3.23
|
||||
|
||||
FROM golang:${GO_VERSION}-alpine AS builder
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_DATE=""
|
||||
|
||||
RUN apk add --no-cache ca-certificates git
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=${BUILD_DATE}" \
|
||||
-o /out/wavelet \
|
||||
./main.go
|
||||
|
||||
FROM alpine:${ALPINE_VERSION}
|
||||
|
||||
ARG TZ=Asia/Shanghai
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata postgresql-client && \
|
||||
cp /usr/share/zoneinfo/${TZ} /etc/localtime && \
|
||||
echo "${TZ}" > /etc/timezone
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /out/wavelet ./wavelet
|
||||
COPY docs ./docs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["./wavelet"]
|
||||
CMD ["api"]
|
||||
@@ -0,0 +1,108 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Cross-platform build workflow — produces up to 6 static binaries under ./bin/
|
||||
# Each binary embeds the compiled frontend and is stamped with VERSION.
|
||||
#
|
||||
# Build args:
|
||||
# VERSION — version string injected at link time (default: dev)
|
||||
# TARGET_OS — restrict to one OS: linux | darwin | windows (default: all)
|
||||
# TARGET_ARCH — restrict to one arch: amd64 | arm64 (default: all)
|
||||
#
|
||||
# Usage:
|
||||
# docker build -f docker/Dockerfile.cross --output ./bin .
|
||||
# docker build -f docker/Dockerfile.cross \
|
||||
# --build-arg VERSION=v1.2.3 --output ./bin .
|
||||
# docker build -f docker/Dockerfile.cross \
|
||||
# --build-arg TARGET_OS=linux --build-arg TARGET_ARCH=arm64 \
|
||||
# --output ./bin .
|
||||
#
|
||||
# Or via Makefile:
|
||||
# make cross-build
|
||||
# make cross-build VERSION=v1.2.3
|
||||
# make cross-build GOOS=linux GOARCH=arm64
|
||||
# make cross-build GOOS=darwin
|
||||
|
||||
ARG GO_VERSION=1.25
|
||||
ARG NODE_VERSION=22
|
||||
ARG ALPINE_VERSION=3.23
|
||||
|
||||
# ── Stage 1: Build frontend (static export for embedding) ────────────────────
|
||||
FROM node:${NODE_VERSION}-alpine AS frontend-builder
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_DATE=""
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_STANDALONE_EXPORT=true
|
||||
ENV NEXT_PUBLIC_APP_VERSION=${VERSION}
|
||||
ENV NEXT_PUBLIC_APP_BUILD_DATE=${BUILD_DATE}
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
|
||||
|
||||
WORKDIR /workspace/frontend
|
||||
|
||||
# Cache dependency layer
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY frontend/ ./
|
||||
|
||||
RUN pnpm build:embed
|
||||
|
||||
# ── Stage 2: Cross-compile Go backend ──────────────────────────────────────
|
||||
FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_DATE=""
|
||||
# Empty string means "build all"; set to a specific value to filter.
|
||||
ARG TARGET_OS=
|
||||
ARG TARGET_ARCH=
|
||||
|
||||
# git — needed by go mod download for VCS-stamped modules
|
||||
# ca-certificates — needed for HTTPS during module download
|
||||
RUN apk add --no-cache ca-certificates git
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Cache dependency layer
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Overlay the compiled frontend into the embed path
|
||||
COPY --from=frontend-builder /workspace/frontend/out ./internal/router/root/dist
|
||||
|
||||
# Build matrix: GOOS × GOARCH
|
||||
# CGO_ENABLED=0 — fully static, no libc dependency, required for cross-compilation.
|
||||
# -trimpath — strip local filesystem paths from the binary.
|
||||
# -s -w — omit symbol table and DWARF debug info (smaller output).
|
||||
# -X — stamp version string at link time.
|
||||
# TARGET_OS / TARGET_ARCH — when non-empty, skip non-matching combinations.
|
||||
RUN set -e; \
|
||||
mkdir -p /out; \
|
||||
FILTER_OS="${TARGET_OS}"; \
|
||||
FILTER_ARCH="${TARGET_ARCH}"; \
|
||||
for GOOS in linux darwin windows; do \
|
||||
[ -n "$FILTER_OS" ] && [ "$GOOS" != "$FILTER_OS" ] && continue; \
|
||||
for GOARCH in amd64 arm64; do \
|
||||
[ -n "$FILTER_ARCH" ] && [ "$GOARCH" != "$FILTER_ARCH" ] && continue; \
|
||||
EXT=""; \
|
||||
[ "$GOOS" = "windows" ] && EXT=".exe"; \
|
||||
OUTPUT="/out/wavelet_${GOOS}_${GOARCH}${EXT}"; \
|
||||
echo "==> Building ${OUTPUT} (version=${VERSION})..."; \
|
||||
CGO_ENABLED=0 GOOS=${GOOS} GOARCH=${GOARCH} \
|
||||
go build \
|
||||
-tags embed_frontend \
|
||||
-trimpath \
|
||||
-ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=${BUILD_DATE}" \
|
||||
-o "${OUTPUT}" \
|
||||
./main.go; \
|
||||
done; \
|
||||
done
|
||||
|
||||
# ── Export stage ─────────────────────────────────────────────────────────────
|
||||
# `docker build --output ./bin .` copies everything from /out to the host.
|
||||
FROM scratch AS export
|
||||
COPY --from=builder /out/ /
|
||||
@@ -0,0 +1,53 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG NODE_VERSION=22
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine AS base
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_PUBLIC_WAVELET_BACKEND_URL=https://build-placeholder.invalid
|
||||
ENV WAVELET_SESSION_COOKIE_NAME=__WAVELET_SESSION_COOKIE_NAME__
|
||||
ENV WAVELET_RATE_LIMIT_ENABLED=__WAVELET_RATE_LIMIT_ENABLED__
|
||||
ENV WAVELET_BACKEND_URL=https://build-placeholder-2.invalid
|
||||
|
||||
ARG VERSION=""
|
||||
ARG BUILD_DATE=""
|
||||
|
||||
ENV NEXT_PUBLIC_APP_VERSION=${VERSION}
|
||||
ENV NEXT_PUBLIC_APP_BUILD_DATE=${BUILD_DATE}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY frontend/ ./
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
RUN grep -rl \
|
||||
-e "https://build-placeholder.invalid" \
|
||||
-e "https://build-placeholder-2.invalid" \
|
||||
-e "__WAVELET_SESSION_COOKIE_NAME__" \
|
||||
-e "__WAVELET_RATE_LIMIT_ENABLED__" \
|
||||
/app/.next > /app/.replace.files
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app ./
|
||||
|
||||
EXPOSE 3010
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
CMD ["pnpm", "start"]
|
||||
@@ -0,0 +1,335 @@
|
||||
# wavelet 部署指南
|
||||
|
||||
本文档详细介绍了 **wavelet** 脚手架系统在不同业务阶段的部署方案,涵盖从**最小化单机部署**到**最大化高可用分布式部署**的全生命周期架构。
|
||||
|
||||
---
|
||||
|
||||
## 一、 系统组件概览
|
||||
|
||||
在部署系统前,请了解各运行组件及其角色:
|
||||
|
||||
| 组件名称 | 运行命令/形式 | 职责说明 | 必选/可选 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **HTTP API 服务** | `bin/wavelet api` | 接收并处理前端及第三方的 RESTful API 请求 | **必选** |
|
||||
| **异步任务工作进程** | `bin/wavelet worker` | 消费并处理异步队列任务(如邮件发送、清理上传文件等) | **必选** |
|
||||
| **定时任务调度器** | `bin/wavelet scheduler` | 定时向 Redis 队列下发 Cron 任务(仅负责触发,不负责执行) | **必选** |
|
||||
| **前端服务 (Node.js)** | `pnpm start` | 提供 React/Next.js 页面服务(在分离部署时使用) | 分离模式必选 |
|
||||
| **PostgreSQL** | 关系型主数据库 | 存储用户、系统配置、认证源、任务执行记录等核心数据 | **必选** |
|
||||
| **Redis** | 缓存与消息队列中间件 | 存储 Session 会话、临时缓存以及 Asynq 异步任务队列数据 | **必选** |
|
||||
| **ClickHouse** | 分析型数据库 | 存储历史数据同步或进行高性能分析 | 可选 |
|
||||
| **对象存储 (S3)** | 兼容 S3 的云存储/私有云 | 存放用户上传的静态文件、图片等 | 可选 |
|
||||
|
||||
---
|
||||
|
||||
## 二、 部署配置准备
|
||||
|
||||
系统在启动前会从当前目录加载 `config.yaml` 配置文件。
|
||||
生产环境部署前,请复制 `config.example.yaml` 为 `config.yaml`,并至少确认以下关键参数的配置:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
env: "production" # 生产环境标识
|
||||
addr: ":8000" # API 服务监听端口
|
||||
session_secret: "prod-random-secret" # 极其重要的加密密钥,首发启动后不可更改
|
||||
session_domain: ".yourdomain.com" # 跨域共享 Session 时需配置
|
||||
|
||||
database:
|
||||
host: "db.yourdomain.com"
|
||||
port: 5432
|
||||
username: "postgres"
|
||||
password: "YOUR_DB_PASSWORD"
|
||||
database: "refreshing"
|
||||
|
||||
redis:
|
||||
addrs:
|
||||
- "redis.yourdomain.com:6379"
|
||||
password: "YOUR_REDIS_PASSWORD"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、 方案一:最小部署 — 单机嵌入式极简版 (推荐)
|
||||
|
||||
此部署方案将**前端静态网页全部直接打入 Go 后端二进制文件中**,极大地简化了部署运维,是中小型应用、内部系统、SaaS 早期阶段的首选。
|
||||
|
||||
### 📊 架构设计
|
||||
- **服务载体**:单台云服务器 (1核2G 即可)。
|
||||
- **依赖服务**:在一台机器上启动轻量级 PostgreSQL 与 Redis(可采用 Docker 部署)。
|
||||
- **进程管理**:在一台机器上直接拉起打包好的 Go 单文件,并分别运行 `api`、`worker`、`scheduler` 进程。
|
||||
- **前端托管**:Go 服务直接在 8000 端口承载前端的所有页面,不需要额外配置 Node.js 生产服务器。
|
||||
|
||||
### 🛠️ 步骤说明
|
||||
|
||||
#### 1. 单机依赖服务初始化 (使用 Docker Compose)
|
||||
在机器上准备以下 `docker-compose.yml` 快速启动 PostgreSQL 和 Redis:
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: refreshing-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: YOUR_DB_PASSWORD
|
||||
POSTGRES_DB: refreshing
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- ./data/pg:/var/lib/postgresql/data
|
||||
restart: always
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:8.0-alpine
|
||||
container_name: refreshing-redis
|
||||
command: valkey-server --requirepass YOUR_REDIS_PASSWORD
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
restart: always
|
||||
```
|
||||
执行命令启动:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### 2. 前后端一键嵌入式打包
|
||||
在开发或编译机上,运行编译指令:
|
||||
```bash
|
||||
make build-embedded
|
||||
```
|
||||
该命令会自动完成前端的静态编译导出 (`frontend/out`)、复制到 Go 后端目录,最后使用 `-tags embed_frontend` 生成后端单文件:
|
||||
- 产物路径:`bin/wavelet`
|
||||
|
||||
#### 3. 进程管理 (使用 Systemd)
|
||||
将 `bin/wavelet` 拷贝到生产服务器 `/usr/local/bin/wavelet`,并为 `api`、`worker` 和 `scheduler` 配置 Systemd 管理服务。
|
||||
|
||||
新建 API 进程服务文件 `/etc/systemd/system/wavelet-api.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Refreshing API Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/app
|
||||
ExecStart=/usr/local/bin/wavelet api
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
同理,新建 Worker 服务 `/etc/systemd/system/wavelet-worker.service`(将命令改为 `wavelet worker`),以及 Scheduler 服务 `/etc/systemd/system/wavelet-scheduler.service`(将命令改为 `wavelet scheduler`)。
|
||||
|
||||
启动并启用所有服务:
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now refreshing-api refreshing-worker refreshing-scheduler
|
||||
```
|
||||
|
||||
#### 4. 配置 Nginx 证书
|
||||
配置 Nginx 作为反向代理并启用 HTTPS 证书:
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.crt;
|
||||
ssl_certificate_key /path/to/cert.key;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、 方案二:标准部署 — 前后端物理分离架构
|
||||
|
||||
此方案中前端与后端彻底解耦。前端采用 SSR/ISR (Next.js Node 服务) 运行,后端采用独立的 API 服务运行。
|
||||
|
||||
### 📊 架构设计
|
||||
- **前端部署**:单独部署到 Node.js 托管环境(如多台前端机器或 Vercel/Cloudflare Pages)。
|
||||
- **后端部署**:多台后端云服务器,统一指向云数据库 RDS 与云缓存 Redis。
|
||||
- **通信方式**:前后端通过 Nginx 规则路由或独立域名(如 `app.yourdomain.com` 访问前端,`api.yourdomain.com` 访问后端)进行跨域通信。
|
||||
|
||||
### 🛠️ 步骤说明
|
||||
|
||||
#### 1. 部署后端 Go 服务
|
||||
1. 编译后端:
|
||||
```bash
|
||||
go build -o bin/wavelet main.go
|
||||
```
|
||||
2. 在后端服务器上,同样使用 Systemd 或 Docker 守护启动 `wavelet api`、`wavelet worker` 和 `wavelet scheduler`。
|
||||
3. 配置后端 Nginx 将客户端 API 请求(如 `/api/...`)反向代理至后端绑定的端口(如 `:8000`)。
|
||||
|
||||
#### 2. 部署前端 Next.js 服务
|
||||
1. 前端服务器环境确保已安装 Node.js 和 pnpm。
|
||||
2. 安装依赖并编译生产版本:
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
3. 使用 PM2 守护前端 Node.js 服务运行。新建 `ecosystem.config.js`:
|
||||
```javascript
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'refreshing-frontend',
|
||||
script: 'node_modules/next/dist/bin/next',
|
||||
args: 'start -p 3000',
|
||||
instances: 'max',
|
||||
exec_mode: 'cluster',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
WAVELET_BACKEND_URL: 'https://api.yourdomain.com'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
启动前端服务:
|
||||
```bash
|
||||
pm2 start ecosystem.config.js
|
||||
```
|
||||
|
||||
#### 3. 跨域与 Cookie 说明
|
||||
- 若前后端使用**不同子域名**部署(例如 `app.yourdomain.com` 和 `api.yourdomain.com`),必须在 `config.yaml` 中将 `app.session_domain` 显式设置为顶级域名(`.yourdomain.com`),以确保 Session Cookie 可以在子域间顺利透传。
|
||||
- 在跨域状态下,前端请求必须配置 `withCredentials: true`,API 端的跨域中间件(`corsMiddleware`)会自动将该域添加至允许源中。
|
||||
|
||||
---
|
||||
|
||||
## 五、 方案三:最大部署 — 企业级高可用分布式架构 (Max)
|
||||
|
||||
当系统面临高并发流量、海量后台任务或极高的可用性要求时,需要将所有组件拆分为无状态水平扩容,并引入高可用的云基础设施。
|
||||
|
||||
### 📊 架构设计图
|
||||
```
|
||||
┌────────────────────────┐
|
||||
│ 域名 / 负载均衡器 │
|
||||
│ (SLB / Cloudflare) │
|
||||
└──────────┬─────────────┘
|
||||
│
|
||||
┌──────────────────┴──────────────────┐
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ 前端集群 │ │ 后端 API 集群 │
|
||||
│ (Next.js Node) │ │ (Go 无状态实例) │
|
||||
│ [弹性扩容 / 8台+] │ │ [弹性扩容 / 8台+] │
|
||||
└─────────────────────┘ └──────────┬──────────┘
|
||||
│
|
||||
┌────────────────────────────────────────┼────────────────────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
|
||||
│ 异步 Worker 集群 │ │ 定时 Scheduler │ │ S3 对象存储集群 │
|
||||
│ (多节点并发处理) │ │ (主备模式,限单节点)│ │(R2/MinIO/AWS S3) │
|
||||
└─────────┬─────────┘ └─────────┬─────────┘ └───────────────────┘
|
||||
│ │
|
||||
└───────────────────┬────────────────────┘
|
||||
│
|
||||
┌───────────────────┴────────────────────┐
|
||||
▼ ▼
|
||||
┌───────────────────────────────────┐ ┌───────────────────────────────────┐
|
||||
│ Redis 哨兵/集群 │ │ PG 主从读写分离集群 │
|
||||
│ (高可用缓存/Asynq 队列) │ │ (RDS Primary-Replica) │
|
||||
└───────────────────────────────────┘ └───────────────────────────────────┘
|
||||
```
|
||||
|
||||
### ⚙️ 最大部署配置要点
|
||||
|
||||
#### 1. 数据库高可用 (主从读写分离)
|
||||
在 `config.yaml` 中配置 `database` 的主库写与从库读:
|
||||
```yaml
|
||||
database:
|
||||
enabled: true
|
||||
host: "pg-primary.yourdomain.com" # 主库地址(写)
|
||||
port: 5432
|
||||
username: "postgres"
|
||||
password: "YOUR_DB_PASSWORD"
|
||||
database: "refreshing"
|
||||
# 配置读写分离只读副本(GORM 自动轮询读,支持配置多个从库)
|
||||
replicas:
|
||||
- host: "pg-replica-1.yourdomain.com"
|
||||
port: 5432
|
||||
username: "postgres"
|
||||
password: "YOUR_DB_PASSWORD"
|
||||
- host: "pg-replica-2.yourdomain.com"
|
||||
port: 5432
|
||||
username: "postgres"
|
||||
password: "YOUR_DB_PASSWORD"
|
||||
```
|
||||
|
||||
#### 2. Redis 高可用 (哨兵/Sentinel 或集群)
|
||||
- **Sentinel 哨兵模式**:通过配置 `redis.master_name` 启用,SDK 会自动监视 Master 的主备切换。
|
||||
- **Cluster 集群模式**:将 `redis.cluster_mode` 设为 `true`,并提供所有集群节点的 `addrs`。
|
||||
```yaml
|
||||
redis:
|
||||
addrs:
|
||||
- "redis-node-1.yourdomain.com:6379"
|
||||
- "redis-node-2.yourdomain.com:6379"
|
||||
- "redis-node-3.yourdomain.com:6379"
|
||||
cluster_mode: true
|
||||
```
|
||||
|
||||
#### 3. 对象存储与缓存分离 (S3 + Local Cache)
|
||||
高可用集群下,本地文件系统不再可共享。文件存储必须启用 S3 兼容服务,并在多节点间开启本地高速磁盘缓存加速读取:
|
||||
```yaml
|
||||
s3:
|
||||
enabled: true
|
||||
endpoint: "https://your-r2-or-s3-id.r2.cloudflarestorage.com"
|
||||
region: "auto"
|
||||
bucket: "refreshing-assets"
|
||||
access_key_id: "YOUR_S3_KEY"
|
||||
secret_access_key: "YOUR_S3_SECRET"
|
||||
local_cache:
|
||||
enabled: true # 开启本地磁盘缓存
|
||||
cache_dir: "/data/s3_cache" # 本地高性能 SSD 挂载点
|
||||
```
|
||||
|
||||
#### 4. 后端进程横向拆分部署
|
||||
- **API 集群**:启动数十个甚至上百个 `wavelet api` 无状态容器。它们可以通过负载均衡器直接挂载,支持随时弹性缩容扩容。
|
||||
- **Worker 集群**:启动多个 `wavelet worker` 容器。因为 `Asynq` 基于 Redis 分布式处理,多个 Worker 进程可以安全地同时运行并竞抢同一队列的异步任务,自动保障任务的并发吞吐能力。
|
||||
- **Scheduler 独占**:**【注意】** 为避免重复触发定时 Cron 任务,`wavelet scheduler` 定时调度器进程**同一时间应仅运行单个活跃实例**(主备高可用可以通过容器平台的单实例保障或 K8s Job 机制来限制实例数为 1)。
|
||||
|
||||
#### 5. ClickHouse 高并发同步
|
||||
在大数据量、高频支付结算场景下,开启 ClickHouse 以接收系统的历史数据同步,通过定时器把 PostgreSQL 的压力转移到 ClickHouse 列式存储中。
|
||||
```yaml
|
||||
clickhouse:
|
||||
enabled: true
|
||||
hosts:
|
||||
- "ch-node-1.yourdomain.com:9000"
|
||||
- "ch-node-2.yourdomain.com:9000"
|
||||
```
|
||||
|
||||
#### 6. OpenTelemetry 分布式链路追踪
|
||||
最大部署架构必须引入链路追踪(Jaeger 或 OTel Collector)以便排查节点间请求延迟或网络问题。
|
||||
在生产环境,通过配置 OTel 将 Span 发送至公共日志分析平台。
|
||||
```yaml
|
||||
otel:
|
||||
sampling_rate: 0.05 # 开启 5% 的流量追踪采样率以减少开销
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、 部署方案对比与选择建议
|
||||
|
||||
| 指标维度 | 方案一:最小单机嵌入版 | 方案二:标准前后端分离版 | 方案三:最大高可用分布式版 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **支持流量/并发** | 1,000 ~ 5,000 QPS (视机器性能) | 5,000 ~ 20,000 QPS | 20,000 ~ 100,000+ QPS (无限扩展) |
|
||||
| **服务器数量** | 1 台 | 3 ~ 5 台 | 10 台以上集群 |
|
||||
| **运维复杂度** | 极简 (只需部署一个程序) | 中等 (需维护 Node 和 Go 两套环境) | 较高 (K8s/多组件集群维护) |
|
||||
| **适合场景** | 个人项目、内部系统、SaaS 早期起步 | 正常线上运营项目、有中等规模团队 | 大型企业级应用、高并发核心交易系统 |
|
||||
@@ -0,0 +1,501 @@
|
||||
# Wavelet 系统性能分析与优化建议
|
||||
|
||||
> 分析日期:2026-06-17
|
||||
> 范围:Go 后端 + Next.js 前端
|
||||
> 目标:识别可能在生产环境真实出现的性能问题,并给出高 ROI 优化路线
|
||||
|
||||
**状态图例**:`✅ 已完成` · `🔶 部分完成` · `⬜ 待做`
|
||||
|
||||
| 修复批次 | 范围 | 状态 |
|
||||
|----------|------|------|
|
||||
| P0 后端 #1–#4 | WebP 锁、文件路径缓存、增量统计、复合索引 | ✅ |
|
||||
| P0 前端 #6–#7 | 认证并行化、日志虚拟化 | ✅ |
|
||||
| P1 #9 | 公共配置 Redis 列表缓存 | ✅ |
|
||||
| P1 参数中心 | 系统配置 Otter RAM 缓存 + 统一失效 + 多节点 pub/sub | ✅ |
|
||||
| P1 CAPTCHA | 运行时配置快照 + 批量加载 + pub/sub 失效 | ✅ |
|
||||
| P0 前端 #12–#19 | dynamic 分割、React Query、登录并行、Tooltip、lazy、barrel 收窄 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [架构概览与核心瓶颈](#架构概览与核心瓶颈)
|
||||
- [Critical — 高概率生产问题](#critical--高概率生产问题)
|
||||
- [Medium — 中等风险](#medium--中等风险)
|
||||
- [高价值优化路线图](#高价值优化路线图)
|
||||
- [已做得好的设计](#已做得好的设计)
|
||||
- [场景风险矩阵](#场景风险矩阵)
|
||||
- [优先行动清单](#优先行动清单)
|
||||
|
||||
---
|
||||
|
||||
## 架构概览与核心瓶颈
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph frontend["前端 (Static Export)"]
|
||||
A[HTML 静态壳] --> B[Hydrate]
|
||||
B --> C["UserProvider.getUserInfo()"]
|
||||
C --> D[页面数据请求]
|
||||
D --> E[渲染]
|
||||
end
|
||||
|
||||
subgraph backend["后端热点路径"]
|
||||
F["/f/{id}?quality=..."] --> G[DB 查 upload]
|
||||
G --> H[迁移状态 DB 查询]
|
||||
H --> I[白名单 Redis/DB]
|
||||
I --> J{WebP 缓存命中?}
|
||||
J -->|否| K["全量读文件 + 编码 + 磁盘缓存(全局锁)"]
|
||||
J -->|是| L[返回]
|
||||
end
|
||||
|
||||
C -.->|已解除阻塞| D
|
||||
```
|
||||
|
||||
**参数中心读路径**(`SystemConfig.GetByKey`):
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
R[业务调用 GetByKey] --> A{RAM 命中?}
|
||||
A -->|是| Z[返回]
|
||||
A -->|否| B{Redis HGET 命中?}
|
||||
B -->|是| C[写入 RAM]
|
||||
C --> Z
|
||||
B -->|否| D[查 PostgreSQL]
|
||||
D --> E[回写 Redis + RAM]
|
||||
E --> Z
|
||||
|
||||
W[管理员 Create/Update] --> F[写 DB]
|
||||
F --> G["InvalidateSystemConfigCache(key)"]
|
||||
G --> H[清本机 RAM + Redis field]
|
||||
G --> I[pub/sub 通知其他节点清 RAM]
|
||||
```
|
||||
|
||||
当前最大的结构性问题(2026-06-17 更新):
|
||||
|
||||
1. **前端**:~~全局认证瀑布流~~ ✅ 已改为 layout 即时渲染 + 子页面 `RequireAuth` 自行处理未登录态;~~Admin 重模块无 `dynamic()` 分割~~ ✅ database/logs/settings 已懒加载子模块。其余路由 `page.tsx` 仍为 `"use client"`(静态导出下 RSC 收益有限,待逐步薄壳化)。
|
||||
2. **后端**:文件服务路径(`/f/{id}`)仍是最高频热点;~~磁盘缓存全局互斥锁~~ ✅ 已改为 `RWMutex` + `singleflight`,但 WebP miss 仍在请求线程内同步编码,部署预热与异步回退原图尚未落地。
|
||||
3. **参数中心**:~~`GetByKey` 每次直打 Redis~~ ✅ 已加 Otter v2 进程内缓存(`pkg/cache/ram`),读路径为 RAM → Redis → DB;管理员写配置后统一失效 RAM + Redis,并通过 pub/sub 同步多节点本地缓存。
|
||||
|
||||
---
|
||||
|
||||
## Critical — 高概率生产问题
|
||||
|
||||
### 1. 图片 WebP 服务:请求路径阻塞 + 全局锁串行化 `🔶 部分完成`
|
||||
|
||||
**涉及文件**:
|
||||
|
||||
- `internal/apps/upload/file_server.go`
|
||||
- `pkg/cache/disk/cache.go`
|
||||
|
||||
**问题描述**:
|
||||
|
||||
缓存未命中时,在 HTTP 请求 goroutine 内执行:
|
||||
|
||||
1. `io.ReadAll` 将原始文件全量读入内存
|
||||
2. 进程内 WebP 解码 + 编码
|
||||
3. 写入磁盘缓存
|
||||
|
||||
同时,磁盘缓存 `Get`/`Set` 使用**全局 `sync.Mutex`**,所有并发图片请求在缓存层完全串行。
|
||||
|
||||
```go
|
||||
// file_server.go — 缓存 miss 时的重操作
|
||||
origBytes, err := getOriginalFileBytes(ctx, upload) // io.ReadAll
|
||||
webpBytes, err = CompressImageToWebP(bytes.NewReader(origBytes), quality)
|
||||
cache.Set(cacheKey, webpBytes, diskcache.NoExpiration)
|
||||
|
||||
// pkg/cache/disk/cache.go — 全局互斥锁
|
||||
func (c *Cache) Get(key string) ([]byte, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**生产表现**:
|
||||
|
||||
- 首次访问或缓存淘汰后,P99 延迟从几十毫秒飙升到数秒
|
||||
- 并发图片请求形成「隐形队列」
|
||||
- 大文件全量读入带来内存尖峰,可能触发 OOM 或 GC 停顿
|
||||
|
||||
**优化价值**:⭐⭐⭐⭐⭐
|
||||
|
||||
**建议**:
|
||||
|
||||
- [x] ✅ 磁盘缓存改用 `RWMutex`,读路径不互斥 — `pkg/cache/disk/cache.go`
|
||||
- [x] ✅ 对同一 cache key 使用 `singleflight` 合并并发 miss — `internal/apps/upload/file_server.go`
|
||||
- [ ] 部署后强制执行 `upload:warm_image_cache` 异步预热任务
|
||||
- [ ] 考虑 miss 时先返回原图,后台异步生成 WebP
|
||||
|
||||
---
|
||||
|
||||
### 2. 文件访问路径:每次请求多次 DB/Redis 查询 `✅ 已完成`
|
||||
|
||||
**涉及文件**:
|
||||
|
||||
- `internal/apps/upload/storage_ops.go`
|
||||
- `internal/apps/upload/file_server.go`
|
||||
|
||||
**问题描述**:
|
||||
|
||||
存储迁移状态**无进程内缓存**,每次文件操作都查询 `w_task_executions`:
|
||||
|
||||
```go
|
||||
// storage_ops.go
|
||||
func StorageReadOnly(ctx context.Context) bool {
|
||||
execution, ok, err := latestStorageMigrationExecution(ctx)
|
||||
// ...
|
||||
}
|
||||
|
||||
func backendForStoredDriver(ctx context.Context, driver storage.Driver) (storage.Backend, error) {
|
||||
// 可能再次调用 currentMigrationTargetConfig → 又一次相同 DB 查询
|
||||
}
|
||||
```
|
||||
|
||||
公开文件白名单每次走 Redis/DB:
|
||||
|
||||
```go
|
||||
// file_server.go
|
||||
func isFilePublic(ctx context.Context, uploadType string) bool {
|
||||
sc.GetByKey(ctx, model.ConfigKeyFileAccessWhitelist)
|
||||
// JSON 解析 + 遍历
|
||||
}
|
||||
```
|
||||
|
||||
对比:`storage.Active()` 已有 5 秒内存缓存 + Redis pub/sub 失效机制,迁移状态却未复用该模式。
|
||||
|
||||
**生产表现**:
|
||||
|
||||
- 每个 `/f/{id}` 请求额外 2–4 次 DB/Redis 往返
|
||||
- 图片站/CDN 场景下 QPS 放大后 PostgreSQL 连接池压力明显
|
||||
|
||||
**优化价值**:⭐⭐⭐⭐⭐
|
||||
|
||||
**建议**:
|
||||
|
||||
- [x] ✅ 为 `StorageReadOnly` / `latestStorageMigrationExecution` 增加 5s TTL 进程内缓存 — `internal/apps/upload/access_cache.go`
|
||||
- [x] ✅ 配置变更或迁移状态变化时通过 Redis pub/sub 失效 — `access_cache.go` + `system_config/routers.go`
|
||||
- [x] ✅ `file_access_whitelist` 增加进程内缓存,复用 `GetByKey` 的失效机制 — `access_cache.go`
|
||||
|
||||
---
|
||||
|
||||
### 3. Admin 文件统计:无界全表扫描 `✅ 已完成`
|
||||
|
||||
**涉及文件**:`internal/apps/upload/stats.go`
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```go
|
||||
err = db.DB(ctx).Model(&model.Upload{}).
|
||||
Select("extension, mime_type, file_size").
|
||||
Where("status != ?", model.UploadStatusDeleted).
|
||||
Scan(&fileRaws).Error
|
||||
// 然后在 Go 中遍历全量结果做分类统计
|
||||
```
|
||||
|
||||
**生产表现**:
|
||||
|
||||
- 10 万+ 文件时,管理端「文件统计」接口耗时数秒
|
||||
- 占用数百 MB 内存,可能拖垮 admin API
|
||||
|
||||
**优化价值**:⭐⭐⭐⭐
|
||||
|
||||
**建议**:
|
||||
|
||||
- [ ] 改为 SQL `GROUP BY` + `CASE WHEN` 聚合(未采用)
|
||||
- [x] ✅ 维护增量统计表,上传/删除时更新计数 — `w_upload_stats` + `stats_counter.go` + `GetFileStats` 读统计表
|
||||
|
||||
---
|
||||
|
||||
### 4. `w_uploads` 索引缺口 `✅ 已完成`
|
||||
|
||||
**涉及文件**:`internal/db/migrator/goose/postgres/202606090001_initial_schema.sql`
|
||||
|
||||
**当前索引**:`user_id`, `file_path`, `hash`, `type`
|
||||
|
||||
**缺失的高频查询索引**:
|
||||
|
||||
| 查询场景 | 建议索引 |
|
||||
|----------|----------|
|
||||
| 清理任务 `status + created_at` | `(status, created_at)` |
|
||||
| 存储迁移 `storage_driver + status` | `(storage_driver, status)` |
|
||||
| 秒传去重 `hash + file_size + status` | `(hash, file_size, status)` |
|
||||
|
||||
**生产表现**:
|
||||
|
||||
- 数据量增长后,清理 worker、迁移任务、上传去重退化为顺序扫描
|
||||
- 后台任务积压,admin 操作变慢
|
||||
|
||||
**优化价值**:⭐⭐⭐⭐
|
||||
|
||||
**建议**:
|
||||
|
||||
- [x] ✅ 通过 goose migration 新增上述复合索引(PostgreSQL + SQLite 双方言)— `202606170001_add_upload_composite_indexes.sql`
|
||||
|
||||
---
|
||||
|
||||
### 5. 批量 ZIP 下载:无上限 + 同步阻塞
|
||||
|
||||
**涉及文件**:`internal/apps/upload/routers.go` — `BatchDownloadFiles`
|
||||
|
||||
**问题描述**:
|
||||
|
||||
- `req.IDs` 无数量上限
|
||||
- 在请求 goroutine 内串行打开每个文件并 `io.Copy` 到 ZIP
|
||||
- 远端 S3 场景下单个文件就可能耗时数秒
|
||||
|
||||
**生产表现**:
|
||||
|
||||
- 网关超时、连接耗尽
|
||||
- Admin 批量下载操作卡死
|
||||
|
||||
**优化价值**:⭐⭐⭐⭐
|
||||
|
||||
**建议**:
|
||||
|
||||
- [ ] 限制单次批量数量(如 max 50)
|
||||
- [ ] 或改为 Asynq 后台任务生成 ZIP,前端轮询下载链接
|
||||
|
||||
---
|
||||
|
||||
### 6. 前端全局认证瀑布流 `✅ 已完成`
|
||||
|
||||
**涉及文件**:
|
||||
|
||||
- `frontend/contexts/user-context.tsx`
|
||||
- `frontend/app/(main)/layout.tsx`
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```tsx
|
||||
// user-context.tsx — 挂载时获取用户
|
||||
useEffect(() => {
|
||||
fetchUser()
|
||||
}, [fetchUser])
|
||||
|
||||
// layout.tsx — 阻塞所有子页面渲染
|
||||
if (loading || !user) {
|
||||
return <LoadingPage text="登录状态" badgeText="Auth" />
|
||||
}
|
||||
```
|
||||
|
||||
**生产表现**:
|
||||
|
||||
- 每次进入 `/home`、`/files`、`/admin/*` 都先等 `getUserInfo`(约 200–800ms)
|
||||
- 页面级数据请求无法并行启动,TTI 被硬性拉长
|
||||
|
||||
**优化价值**:⭐⭐⭐⭐⭐
|
||||
|
||||
**建议**:
|
||||
|
||||
- [x] ✅ Layout 不阻塞渲染,子页面自行处理未登录状态 — `layout.tsx` + `RequireAuth` / `RequireAdminAuth`
|
||||
- [ ] 或 Server Component 通过 cookie 预取 session,消除客户端首屏等待
|
||||
- [x] ✅ `/login`、`/register` 跳过 `getUserInfo` — `user-context.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 7. 实时日志面板:2000 行 DOM 无虚拟化 `✅ 已完成`
|
||||
|
||||
**涉及文件**:`frontend/components/common/admin/app-logs.tsx`
|
||||
|
||||
**问题描述**:
|
||||
|
||||
- 日志上限 2000 行(内存有界,但 DOM 无界)
|
||||
- 每行渲染完整 `<div>`,无虚拟滚动
|
||||
- `@tanstack/react-virtual` 已在 `package.json` 但未使用
|
||||
|
||||
**生产表现**:
|
||||
|
||||
- 管理员开着日志 Tab 时 CPU/内存持续升高
|
||||
- 滚动卡顿,长时间运行拖慢整台机器
|
||||
|
||||
**优化价值**:⭐⭐⭐⭐
|
||||
|
||||
**建议**:
|
||||
|
||||
- [x] ✅ 使用 `useVirtualizer` 只渲染可视区域行 — `app-logs.tsx`
|
||||
- [x] ✅ 行组件 `React.memo` 避免无效重渲染 — `LogLine`
|
||||
|
||||
---
|
||||
|
||||
## Medium — 中等风险
|
||||
|
||||
| # | 问题 | 位置 | 影响 |
|
||||
|---|------|------|------|
|
||||
| 1 | ~~公共配置接口无 Redis 缓存~~ ✅ | `internal/model/system_configs.go` — `ListVisibleSystemConfigs` | ~~每次前端启动/登录直查 PostgreSQL~~ → Redis 列表缓存 + Create/Update 时失效 |
|
||||
| 2 | ~~CAPTCHA 每次 5 次独立 `GetByKey`~~ ✅ | `internal/apps/cap/runtime_settings.go` | ~~登录高峰 5× 配置读取~~ → `CurrentSettings` 快照一次加载 6 个 key,`Generate`/`Redeem`/中间件零 `GetByKey` |
|
||||
| 3 | ~~系统配置单 key 无进程内缓存~~ ✅ | `system_config_cache.go`, `pkg/cache/ram` | ~~热路径重复 Redis HGET~~ → Otter RAM + 写后 `InvalidateSystemConfigCache` + pub/sub |
|
||||
| 4 | OIDC 每次 `oidc.NewProvider` 无缓存 | `internal/apps/oauth/sources.go:164` | 登录发起/回调多一次外部 HTTP |
|
||||
| 5 | CORS 每次跨域查 `server_address` 配置 `🔶` | `internal/router/middlewares.go:75` | 预检请求仍每次调用 `GetByKey`,但 `server_address` 已受益于 RAM 缓存 |
|
||||
| 6 | 推送通知无界 goroutine + 逐 target DB 查询 | `internal/apps/admin/push/events.go:102` | 通知风暴时 goroutine/DB 双压 |
|
||||
| 7 | 上传清理:每文件一个事务 | `internal/apps/upload/cleanup.go` | 大量 pending 文件时 commit 风暴 |
|
||||
| 8 | ClickHouse 风控:每请求 `json.Marshal` 全部 headers | `internal/apps/risk_control/middleware.go:58` | 高 QPS 时 CPU 开销(写入本身已异步批处理) |
|
||||
| 9 | 存储迁移日志大量写 Redis | `internal/apps/upload/storage_migration_task.go` | 迁移期间 Redis CPU/内存压力 |
|
||||
| 10 | 存储迁移后二次 SHA 全量读取验证 | `storage_migration_task.go` | 迁移期间对象 I/O 翻倍 |
|
||||
| 11 | Admin 状态页 5s 轮询 | `frontend/components/common/admin/status.tsx` | Tab 常驻时持续打后端 |
|
||||
| 12 | 路由切换 500ms fade 动画 | `frontend/app/(main)/layout.tsx:53-60` | 即使数据已缓存,感知仍慢 |
|
||||
| 13 | ~~无 `next/dynamic` 代码分割~~ ✅ | `database/`, `logs/`, `settings/` page-client | Admin 重模块拆分为独立 chunk |
|
||||
| 14 | 19/24 个 `page.tsx` 为 `"use client"` `🔶` | 各路由 | database/logs/settings 已薄壳化;其余待迁移 |
|
||||
| 15 | ~~Admin 部分页面用 `useEffect` 而非 React Query~~ ✅ | `access-logs.tsx`, `task-executions.tsx` | 列表/详情走 React Query 缓存去重 |
|
||||
| 16 | ~~登录页 OIDC sources 等待 public config~~ ✅ | `login-form.tsx` | public config 与 auth sources 并行请求 |
|
||||
| 17 | ~~Users 表每行嵌套 3 个 `TooltipProvider`~~ ✅ | `admin/users/page.tsx` | 表格外层单一 Provider |
|
||||
| 18 | ~~缩略图用原生 `<img>` 无 lazy loading~~ ✅ | `file-list.tsx`, `file-manager.tsx` | `loading="lazy"` + `decoding="async"` |
|
||||
| 19 | ~~`@/lib/services` barrel 导入~~ ✅ | 全前端消费侧 | 改为 `@/lib/services/<module>` 直接导入 |
|
||||
| 20 | SQLite 模式无连接池调优 | `internal/db/postgres.go` | 默认 SQLite 写锁瓶颈 |
|
||||
| 21 | Session Redis 仅用第一个地址 | `internal/router/router.go` | Sentinel/Cluster 场景不一致 |
|
||||
|
||||
---
|
||||
|
||||
## 高价值优化路线图
|
||||
|
||||
### P0 — 立即做(1–2 周,收益最大)
|
||||
|
||||
| # | 优化项 | 涉及模块 | 预期收益 | 复杂度 | 状态 |
|
||||
|---|--------|----------|----------|--------|------|
|
||||
| 1 | WebP:`singleflight` + `RWMutex` + 强制预热 | `file_server.go`, `pkg/cache/disk/` | 图片 P99 ↓ 80%+,并发吞吐 ↑ 5–10x | 中 | 🔶 锁与去重已完成,预热待做 |
|
||||
| 2 | 缓存 `StorageReadOnly` / 迁移状态 | `access_cache.go` | 每文件请求减少 1–3 次 DB | 低 | ✅ |
|
||||
| 3 | 内存缓存 `file_access_whitelist` | `access_cache.go` | 每公开文件请求减少 1 次 Redis | 低 | ✅ |
|
||||
| 4 | `GetFileStats` 增量统计表 | `stats.go`, `w_upload_stats` | Admin 统计从 O(n) → O(1) | 低 | ✅ |
|
||||
| 5 | 新增 `w_uploads` 复合索引 | goose migration | 清理/迁移/秒传全面加速 | 低 | ✅ |
|
||||
| 6 | 前端日志虚拟化 | `app-logs.tsx` | Admin 日志 Tab 流畅度质变 | 低 | ✅ |
|
||||
| 7 | Admin 重模块 `dynamic()` 懒加载 | `database/page-client.tsx`, `logs/page-client.tsx`, `settings/page-client.tsx` | 首包 JS ↓ 150–300KB | 低 | ✅ |
|
||||
|
||||
### P1 — 短期(2–4 周)
|
||||
|
||||
| # | 优化项 | 预期收益 | 状态 |
|
||||
|---|--------|----------|------|
|
||||
| 8 | 认证并行化:layout 不阻塞 / Server 预取 session | TTI ↓ 200–800ms | 🔶 客户端并行化已完成,RSC 预取待做 |
|
||||
| 9 | `ListVisibleSystemConfigs` 加 Redis 缓存 | 前端冷启动加速 | ✅ |
|
||||
| 10 | 系统配置 Otter RAM 缓存 + 统一失效 | 热路径 `GetByKey` 零 Redis RTT(命中后) | ✅ |
|
||||
| 11 | CAPTCHA 运行时配置快照 | 验证码路径配置读取 → O(1) 快照 | ✅ |
|
||||
| 12 | OIDC Provider/JWKS 进程内缓存(TTL 1h) | 登录延迟 ↓ 100–500ms | ⬜ |
|
||||
| 13 | 批量下载限制(max 50)或异步任务 | 消除网关超时风险 | ⬜ |
|
||||
| 14 | Admin `useEffect` 数据获取迁移到 React Query | 去重、缓存、后台刷新 | 🔶 access-logs / task-executions 已完成 |
|
||||
| 15 | 登录页并行请求 public config + auth sources | 登录页 ↓ 100–300ms | ✅ |
|
||||
| 16 | 状态轮询在 `document.hidden` 时暂停 | 降低后台 + 客户端负载 | ⬜ |
|
||||
|
||||
### P2 — 中期架构演进
|
||||
|
||||
| # | 优化项 | 预期收益 |
|
||||
|---|--------|----------|
|
||||
| 16 | 批量 ZIP 改为 Asynq 后台任务 | 彻底解耦长耗时操作 |
|
||||
| 17 | 存储迁移日志降噪 + 跳过已验证文件二次 SHA | 迁移期间 Redis/I/O ↓ 50% |
|
||||
| 18 | 推送通知 target 批量解析(`WHERE id IN ?`) | 通知风暴 DB 查询 ↓ N 倍 |
|
||||
| 19 | 上传清理改为批量 UPDATE + 异步存储删除 | 减少 DB commit 频率 |
|
||||
| 20 | 路由动画 0.5s → 0.15s 或纯 CSS | 导航感知速度 ↑ |
|
||||
| 21 | ~~服务导入收窄(直接 import 具体 Service)~~ ✅ | 每路由 bundle ↓ 10–30KB |
|
||||
| 22 | Admin 路由级 `loading.tsx` + Suspense | 渐进式渲染体验 |
|
||||
| 23 | ~~缩略图 `loading="lazy"` + 固定尺寸~~ ✅ | 文件管理页初始 paint 加速 |
|
||||
|
||||
---
|
||||
|
||||
## 已做得好的设计
|
||||
|
||||
以下设计说明团队已有性能意识,优化应在此基础上增量改进,**不必重复造轮子**:
|
||||
|
||||
| # | 设计 | 位置 |
|
||||
|---|------|------|
|
||||
| 1 | 系统配置三层缓存 RAM → Redis → DB | `pkg/cache/ram`, `system_config_cache.go`, `GetByKey` |
|
||||
| 2 | 系统配置统一失效 + 多节点 pub/sub | `InvalidateSystemConfigCache`, `InvalidateAllSystemConfigCaches` |
|
||||
| 3 | Storage Backend 单例 + 5s TTL + pub/sub 失效 | `internal/storage/storage.go` — `Active()` |
|
||||
| 4 | 推送事件/渠道 24h Redis 缓存 + GORM hook 失效 | `internal/model/push_event.go`, `push_channel.go` |
|
||||
| 5 | 风控日志异步批写 ClickHouse(1 万缓冲 + 1000 条/1s + 429 背压) | `internal/apps/risk_control/` |
|
||||
| 6 | HTTP 连接池统一(`httppool` + OTel) | `pkg/httppool/` |
|
||||
| 7 | DB/Redis 连接池显式配置 | `config.yaml`, `internal/db/` |
|
||||
| 8 | 游标分批处理(`id > ? LIMIT n`) | `cleanup.go`, image warmup |
|
||||
| 9 | 存储迁移并发上限 `errgroup.SetLimit(10)` | `storage_migration_task.go` |
|
||||
| 10 | 邮件/推送走 Asynq,不在 HTTP 路径同步发送 | `user/logics.go`, `push/events.go` |
|
||||
| 11 | 文件服务 ETag/304 + 原图 `DataFromReader` 流式返回 | `file_server.go` |
|
||||
| 12 | 无 GORM `Preload` 滥用 | 全项目 |
|
||||
| 13 | 前端 API 请求去重(`pendingRequests` Map) | `frontend/lib/services/core/api-client.ts` |
|
||||
| 14 | React Query 全局 30s `staleTime` | `frontend/components/providers/query-provider.tsx` |
|
||||
| 15 | React Compiler 已启用 | `frontend/next.config.ts` |
|
||||
| 16 | 读副本支持(`dbresolver`) | `internal/db/postgres.go` |
|
||||
| 17 | 任务执行日志 Redis 缓冲 + 批量回写 | `internal/model/task_execution.go` |
|
||||
| 18 | 公共配置列表 Redis 缓存 + 写后失效 | `ListVisibleSystemConfigs`, `InvalidateVisibleSystemConfigsCache` |
|
||||
| 19 | 上传文件统计增量表 `w_upload_stats` | `stats_counter.go`, 上传/删除 hook |
|
||||
| 20 | 文件访问路径进程内缓存 + pub/sub | `internal/apps/upload/access_cache.go` |
|
||||
| 21 | 磁盘缓存读路径 `RWMutex` + WebP `singleflight` | `pkg/cache/disk/cache.go`, `file_server.go` |
|
||||
| 22 | 前端认证非阻塞 + 页面级鉴权 | `use-auth-redirect.ts`, `require-auth.tsx` |
|
||||
| 23 | Admin 实时日志虚拟滚动 | `frontend/components/common/admin/app-logs.tsx` |
|
||||
| 24 | CAPTCHA 运行时配置快照 + 批量加载 | `runtime_settings.go`, `ListSystemConfigsByKeys` |
|
||||
|
||||
---
|
||||
|
||||
## 场景风险矩阵
|
||||
|
||||
| 场景 | 最可能爆的点 | 对应优先级 |
|
||||
|------|-------------|-----------|
|
||||
| 图片站 / 公开相册 | WebP miss(锁/白名单已优化) | P0 #1 预热待做 |
|
||||
| 文件量 10 万+ | 清理慢(统计/索引已优化) | P2 #19 清理批量化 |
|
||||
| 管理端日常使用 | ~~大 bundle~~(dynamic 分割 + barrel 收窄已落地) | P2 #22 路由 loading.tsx |
|
||||
| 存储迁移进行中 | Redis 日志风暴 | P2 #17 |
|
||||
| 登录高峰 | OIDC discovery 无缓存 | P1 #12 OIDC |
|
||||
| 多租户 / 跨域前端 | CORS 仍每次调 `GetByKey`(`server_address` 已 RAM 缓存) | 可选 CORS 快照 |
|
||||
| 参数热更新 | 多节点 RAM 一致性 | ✅ `system:config_invalidation` pub/sub |
|
||||
| 批量文件操作 | ZIP 同步打包无上限 | P0 #5, P1 #12 |
|
||||
|
||||
---
|
||||
|
||||
## 优先行动清单
|
||||
|
||||
如果只选 **3 件事** 先做(预计用户感知延迟降低 50–70%):
|
||||
|
||||
1. ~~**WebP 路径解耦**~~ ✅ `singleflight` + `RWMutex` 已落地;**下一步**:部署后预热 + miss 异步回退原图
|
||||
2. ~~**文件路径查询缓存**~~ ✅ 迁移状态 + 白名单进程内缓存已落地
|
||||
3. ~~**前端认证与首屏并行化**~~ ✅ 全局 auth gate 已移除;~~Admin `dynamic()` 代码分割~~ ✅ 已落地;**下一步**:其余 Admin 路由薄壳化 + `loading.tsx`
|
||||
|
||||
### 实施检查清单
|
||||
|
||||
```
|
||||
P0 后端
|
||||
[x] disk cache RWMutex + singleflight ✅ 2026-06-17
|
||||
[x] StorageReadOnly 5s 缓存 + pub/sub 失效 ✅ 2026-06-17
|
||||
[x] file_access_whitelist 进程内缓存 ✅ 2026-06-17
|
||||
[x] GetFileStats 增量统计表 (w_upload_stats) ✅ 2026-06-17
|
||||
[x] w_uploads 复合索引 migration ✅ 2026-06-17
|
||||
[ ] 批量下载数量上限
|
||||
[ ] WebP 部署预热 + miss 异步回退原图
|
||||
|
||||
P0 前端
|
||||
[x] app-logs.tsx 虚拟滚动 ✅ 2026-06-17
|
||||
[x] SQLConsole / Settings Tabs / Logs Tabs dynamic import ✅ 2026-06-17
|
||||
[x] 认证 gate 并行化 ✅ 2026-06-17
|
||||
[x] 登录页 public config + auth sources 并行 ✅ 2026-06-17
|
||||
[x] access-logs / task-executions → React Query ✅ 2026-06-17
|
||||
[x] Users TooltipProvider 合并 ✅ 2026-06-17
|
||||
[x] 缩略图 loading="lazy" ✅ 2026-06-17
|
||||
[x] @/lib/services barrel 导入收窄 ✅ 2026-06-17
|
||||
|
||||
P1
|
||||
[x] ListVisibleSystemConfigs Redis 缓存 ✅ 2026-06-17
|
||||
[x] 系统配置 Otter RAM 缓存 + 统一失效 + pub/sub ✅ 2026-06-17
|
||||
[x] CAPTCHA 运行时配置快照 ✅ 2026-06-17
|
||||
[ ] OIDC Provider 缓存
|
||||
[ ] Admin useEffect → React Query 统一(database overview 等待)
|
||||
[ ] 状态轮询 visibility 感知
|
||||
[ ] Server Component session 预取
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录:关键代码路径索引
|
||||
|
||||
| 路径 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| 图片服务 | `internal/apps/upload/file_server.go` | `/f/{id}` 热点 |
|
||||
| 磁盘缓存 | `pkg/cache/disk/cache.go` | ✅ RWMutex 读路径 |
|
||||
| 迁移/白名单缓存 | `internal/apps/upload/access_cache.go` | ✅ 5s TTL + pub/sub |
|
||||
| 文件统计 | `internal/apps/upload/stats.go` | ✅ 读 `w_upload_stats` |
|
||||
| 公共配置列表 | `internal/model/system_configs.go` | ✅ Redis 列表缓存 |
|
||||
| RAM 缓存封装 | `pkg/cache/ram/cache.go` | ✅ Otter v2 薄封装 |
|
||||
| 系统配置缓存 | `internal/model/system_config_cache.go` | ✅ RAM + 失效 + pub/sub |
|
||||
| 参数失效 API | `InvalidateSystemConfigCache` | ✅ 清 RAM + Redis field |
|
||||
| CAPTCHA 快照 | `internal/apps/cap/runtime_settings.go` | ✅ `CurrentSettings` + pub/sub |
|
||||
| 批量下载 | `internal/apps/upload/routers.go` | 同步 ZIP |
|
||||
| 上传索引 | `internal/db/migrator/goose/*202606170001*.sql` | ✅ 复合索引已加 |
|
||||
| 认证 gate | `frontend/app/(main)/layout.tsx` | ✅ 即时渲染 + `useAuthRedirect` |
|
||||
| 页面鉴权 | `frontend/components/auth/require-auth.tsx` | ✅ 子页面按需拦截 |
|
||||
| 用户上下文 | `frontend/contexts/user-context.tsx` | ✅ 登录/注册页跳过 fetch |
|
||||
| 实时日志 | `frontend/components/common/admin/app-logs.tsx` | ✅ `useVirtualizer` |
|
||||
| API 去重 | `frontend/lib/services/core/api-client.ts` | 已有,可复用模式 |
|
||||
+15216
-1852
File diff suppressed because it is too large
Load Diff
+15215
-1855
File diff suppressed because it is too large
Load Diff
+9736
-1506
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
# 前端 URL
|
||||
WAVELET_FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# 后端 URL
|
||||
WAVELET_BACKEND_URL=http://localhost:3000
|
||||
|
||||
# Session Cookie
|
||||
WAVELET_SESSION_COOKIE_NAME=openflare_session_id
|
||||
|
||||
# 环境
|
||||
NODE_ENV=production
|
||||
@@ -0,0 +1,50 @@
|
||||
# Wavelet Frontend
|
||||
|
||||
Modern frontend application for the Wavelet scaffold framework.
|
||||
|
||||
[中文](./README_zh.md) | English
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: [Next.js 16](https://nextjs.org/)
|
||||
- **Styling**: [Tailwind CSS 4](https://tailwindcss.com/)
|
||||
- **UI Components**: [Radix UI](https://www.radix-ui.com/)
|
||||
- **Icons**: [Lucide React](https://lucide.dev/)
|
||||
- **Package Manager**: [pnpm](https://pnpm.io/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (Latest LTS recommended)
|
||||
- pnpm
|
||||
|
||||
### Installation
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. Run the development server:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) (or the port shown in your terminal) with your browser to see the result.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `app/`: Next.js App Router pages and layouts
|
||||
- `components/`: Reusable UI components
|
||||
- `ui/`: Base UI components (buttons, inputs, etc.)
|
||||
- `common/`: Shared business components
|
||||
- `lib/`: Utility functions and service definitions
|
||||
- `public/`: Static assets
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm dev`: Start development server with Turbopack
|
||||
- `pnpm build`: Build the application for production
|
||||
- `pnpm start`: Start production server
|
||||
- `pnpm lint`: Run ESLint
|
||||
@@ -0,0 +1,50 @@
|
||||
# Wavelet Frontend
|
||||
|
||||
Wavelet 脚手架系统的现代化前端应用。
|
||||
|
||||
[English](./README.md) | 中文
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: [Next.js 16](https://nextjs.org/)
|
||||
- **样式**: [Tailwind CSS 4](https://tailwindcss.com/)
|
||||
- **UI 组件**: [Radix UI](https://www.radix-ui.com/)
|
||||
- **图标**: [Lucide React](https://lucide.dev/)
|
||||
- **包管理器**: [pnpm](https://pnpm.io/)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Node.js (建议使用最新的 LTS 版本)
|
||||
- pnpm
|
||||
|
||||
### 安装
|
||||
|
||||
1. 安装依赖:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. 运行开发服务器:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
在浏览器中打开 [http://localhost:3000](http://localhost:3000) (或终端中显示的端口) 查看结果。
|
||||
|
||||
## 项目结构
|
||||
|
||||
- `app/`: Next.js App Router 页面和布局
|
||||
- `components/`: 可复用 UI 组件
|
||||
- `ui/`: 基础 UI 组件 (按钮, 输入框等)
|
||||
- `common/`: 共享业务组件
|
||||
- `lib/`: 工具函数和服务定义
|
||||
- `public/`: 静态资源
|
||||
|
||||
## 脚本
|
||||
|
||||
- `pnpm dev`: 启动开发服务器 (使用 Turbopack)
|
||||
- `pnpm build`: 构建生产版本
|
||||
- `pnpm start`: 启动生产服务器
|
||||
- `pnpm lint`: 运行 ESLint
|
||||
@@ -0,0 +1,10 @@
|
||||
import {Suspense} from "react"
|
||||
import {LoginPage} from "@/components/auth/login-page"
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {Suspense} from "react"
|
||||
import {RegisterPage} from "@/components/auth/register-page"
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<RegisterPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {LegalPageLayout} from "@/components/common/docs/legal-page-layout";
|
||||
import {apiSections, DOCS_LAST_UPDATED} from "@/components/common/docs/api";
|
||||
|
||||
export default function ApiDocPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="API 接口文档"
|
||||
lastUpdated={DOCS_LAST_UPDATED}
|
||||
sections={apiSections}
|
||||
description={
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
OpenFlare 提供简单、强大的 API 接口,支持多种编程语言和开发环境。通过标准化的 RESTful 接口,您可以轻松地与后端服务进行集成与二次开发。
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {LegalPageLayout} from "@/components/common/docs/legal-page-layout";
|
||||
import {howToUseSections} from "@/components/common/docs/how-to-use";
|
||||
import {DOCS_LAST_UPDATED} from "@/components/common/docs/api";
|
||||
|
||||
export default function HowToUsePage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="平台使用指南"
|
||||
lastUpdated={DOCS_LAST_UPDATED}
|
||||
sections={howToUseSections}
|
||||
description={
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
欢迎使用 OpenFlare 管理平台。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {LegalPageLayout} from "@/components/common/docs/legal-page-layout"
|
||||
import {privacySections} from "@/components/common/docs/privacy"
|
||||
import {TERMS_LAST_UPDATED} from "@/components/common/docs/terms"
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="隐私政策 (Privacy)"
|
||||
lastUpdated={TERMS_LAST_UPDATED}
|
||||
sections={privacySections}
|
||||
description={
|
||||
<span>
|
||||
我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息,
|
||||
<br className="hidden md:block" />
|
||||
以及您享有的相关权利。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {LegalPageLayout} from "@/components/common/docs/legal-page-layout"
|
||||
import {TERMS_LAST_UPDATED, termsSections} from "@/components/common/docs/terms"
|
||||
|
||||
export default function TermsOfServicePage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="服务协议 (Terms)"
|
||||
lastUpdated={TERMS_LAST_UPDATED}
|
||||
sections={termsSections}
|
||||
description={
|
||||
<span>
|
||||
为了保障您的合法权益,请您仔细阅读以下条款。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import {Search} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import type {AccessLogTab, SearchDraft} from "./access-log-utils"
|
||||
import {PAGE_SIZE_OPTIONS} from "./access-log-utils"
|
||||
|
||||
interface AccessLogFiltersProps {
|
||||
tab: AccessLogTab
|
||||
draft: SearchDraft
|
||||
pageSize: number
|
||||
onDraftChange: (draft: SearchDraft) => void
|
||||
onPageSizeChange: (pageSize: number) => void
|
||||
onSearch: () => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export function AccessLogFilters({
|
||||
tab,
|
||||
draft,
|
||||
pageSize,
|
||||
onDraftChange,
|
||||
onPageSizeChange,
|
||||
onSearch,
|
||||
onReset,
|
||||
}: AccessLogFiltersProps) {
|
||||
const showPath = tab === "list" || tab === "folds"
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">节点 ID</p>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={draft.nodeId}
|
||||
onChange={(e) =>
|
||||
onDraftChange({ ...draft, nodeId: e.target.value })
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSearch()
|
||||
}}
|
||||
placeholder="按 node_id 搜索"
|
||||
className="pl-8 h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">来源 IP</p>
|
||||
<Input
|
||||
value={draft.remoteAddr}
|
||||
onChange={(e) =>
|
||||
onDraftChange({ ...draft, remoteAddr: e.target.value })
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSearch()
|
||||
}}
|
||||
placeholder="按 IP 搜索"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">访问域名</p>
|
||||
<Input
|
||||
value={draft.host}
|
||||
onChange={(e) => onDraftChange({ ...draft, host: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSearch()
|
||||
}}
|
||||
placeholder="按域名搜索"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{showPath ? (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">请求路径</p>
|
||||
<Input
|
||||
value={draft.path}
|
||||
onChange={(e) => onDraftChange({ ...draft, path: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSearch()
|
||||
}}
|
||||
placeholder="按路径搜索"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-1.5 w-full sm:max-w-[180px]">
|
||||
<p className="text-xs font-medium text-muted-foreground">每页条数</p>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => onPageSizeChange(Number.parseInt(value, 10))}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{option} 条
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={onSearch}>
|
||||
筛选
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onReset}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export type AccessLogTab = "list" | "folds" | "ip-summary" | "ip-trend"
|
||||
|
||||
export type SearchDraft = {
|
||||
nodeId: string
|
||||
remoteAddr: string
|
||||
host: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200]
|
||||
|
||||
export const DETAIL_SORT_OPTIONS = [
|
||||
{ value: "logged_at:desc", label: "时间从新到旧" },
|
||||
{ value: "logged_at:asc", label: "时间从旧到新" },
|
||||
{ value: "status_code:desc", label: "状态码从高到低" },
|
||||
{ value: "status_code:asc", label: "状态码从低到高" },
|
||||
{ value: "remote_addr:asc", label: "IP 正序" },
|
||||
{ value: "remote_addr:desc", label: "IP 倒序" },
|
||||
]
|
||||
|
||||
export const FOLD_SORT_OPTIONS = [
|
||||
{ value: "bucket_started_at:desc", label: "时间桶从新到旧" },
|
||||
{ value: "bucket_started_at:asc", label: "时间桶从旧到新" },
|
||||
{ value: "request_count:desc", label: "访问次数从高到低" },
|
||||
{ value: "request_count:asc", label: "访问次数从低到高" },
|
||||
]
|
||||
|
||||
export const IP_SORT_OPTIONS = [
|
||||
{ value: "total_requests:desc", label: "总访问次数从高到低" },
|
||||
{ value: "total_requests:asc", label: "总访问次数从低到高" },
|
||||
{ value: "recent_requests:desc", label: "3 小时访问次数从高到低" },
|
||||
{ value: "last_seen_at:desc", label: "最后访问时间从新到旧" },
|
||||
]
|
||||
|
||||
export function parseSortValue(value: string) {
|
||||
const [sortBy = "logged_at", sortOrder = "desc"] = value.split(":")
|
||||
return {
|
||||
sortBy,
|
||||
sortOrder: sortOrder === "asc" ? ("asc" as const) : ("desc" as const),
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCompactNumber(value: number) {
|
||||
return new Intl.NumberFormat("zh-CN", {
|
||||
notation: value >= 10000 ? "compact" : "standard",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client"
|
||||
|
||||
import {useState} from "react"
|
||||
import {Loader2} from "lucide-react"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
|
||||
const CLEANUP_PRESETS = [3, 7, 30]
|
||||
|
||||
interface CleanupDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (retentionDays: number) => void
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export function CleanupDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
loading,
|
||||
}: CleanupDialogProps) {
|
||||
const [mode, setMode] = useState<string>("7")
|
||||
const [customDays, setCustomDays] = useState("14")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleConfirm = () => {
|
||||
const retentionDays =
|
||||
mode === "custom"
|
||||
? Number.parseInt(customDays, 10)
|
||||
: Number.parseInt(mode, 10)
|
||||
|
||||
if (!Number.isFinite(retentionDays) || retentionDays < 1) {
|
||||
setError("保留天数必须大于 0")
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
onConfirm(retentionDays)
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>清理访问日志</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除早于指定保留天数的访问日志记录,操作不可恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>保留策略</Label>
|
||||
<Select value={mode} onValueChange={setMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CLEANUP_PRESETS.map((days) => (
|
||||
<SelectItem key={days} value={String(days)}>
|
||||
保留最近 {days} 天
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">自定义天数</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{mode === "custom" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="customDays">自定义保留天数</Label>
|
||||
<Input
|
||||
id="customDays"
|
||||
type="number"
|
||||
min={1}
|
||||
value={customDays}
|
||||
onChange={(e) => setCustomDays(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={handleConfirm} disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-1" />
|
||||
清理中...
|
||||
</>
|
||||
) : (
|
||||
"确认清理"
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
"use client"
|
||||
|
||||
import {useCallback, useEffect, useMemo, useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from "recharts"
|
||||
import {RefreshCw, ScrollText, Trash2} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent,} from "@/components/ui/chart"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import {AccessLogService} from "@/lib/services/openflare"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
|
||||
import {AccessLogFilters} from "./components/access-log-filters"
|
||||
import {
|
||||
type AccessLogTab,
|
||||
DETAIL_SORT_OPTIONS,
|
||||
FOLD_SORT_OPTIONS,
|
||||
formatCompactNumber,
|
||||
IP_SORT_OPTIONS,
|
||||
parseSortValue,
|
||||
type SearchDraft,
|
||||
} from "./components/access-log-utils"
|
||||
import {CleanupDialog} from "./components/cleanup-dialog"
|
||||
|
||||
const trendChartConfig = {
|
||||
requests: { label: "请求数", color: "hsl(var(--primary))" },
|
||||
} satisfies ChartConfig
|
||||
|
||||
const emptyDraft: SearchDraft = {
|
||||
nodeId: "",
|
||||
remoteAddr: "",
|
||||
host: "",
|
||||
path: "",
|
||||
}
|
||||
|
||||
function PaginationBar({
|
||||
page,
|
||||
hasMore,
|
||||
loading,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
page: number
|
||||
hasMore: boolean
|
||||
loading: boolean
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-dashed">
|
||||
<p className="text-xs text-muted-foreground">当前第 {page + 1} 页</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={loading || page <= 0} onClick={onPrev}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={loading || !hasMore} onClick={onNext}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AccessLogsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [tab, setTab] = useState<AccessLogTab>("list")
|
||||
const [draft, setDraft] = useState<SearchDraft>(emptyDraft)
|
||||
const [filters, setFilters] = useState<SearchDraft>(emptyDraft)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [page, setPage] = useState(0)
|
||||
const [detailSort, setDetailSort] = useState("logged_at:desc")
|
||||
const [foldSort, setFoldSort] = useState("bucket_started_at:desc")
|
||||
const [ipSort, setIpSort] = useState("total_requests:desc")
|
||||
const [foldMinutes, setFoldMinutes] = useState<3 | 5>(3)
|
||||
const [trendIp, setTrendIp] = useState("")
|
||||
const [appliedTrendIp, setAppliedTrendIp] = useState("")
|
||||
const [cleanupOpen, setCleanupOpen] = useState(false)
|
||||
|
||||
const detailSortState = parseSortValue(detailSort)
|
||||
const foldSortState = parseSortValue(foldSort)
|
||||
const ipSortState = parseSortValue(ipSort)
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["openflare", "access-logs", "list", filters, page, pageSize, detailSort],
|
||||
queryFn: () =>
|
||||
AccessLogService.list({
|
||||
node_id: filters.nodeId || undefined,
|
||||
remote_addr: filters.remoteAddr || undefined,
|
||||
host: filters.host || undefined,
|
||||
path: filters.path || undefined,
|
||||
p: page,
|
||||
page_size: pageSize,
|
||||
sort_by: detailSortState.sortBy,
|
||||
sort_order: detailSortState.sortOrder,
|
||||
}),
|
||||
enabled: tab === "list",
|
||||
})
|
||||
|
||||
const foldsQuery = useQuery({
|
||||
queryKey: ["openflare", "access-logs", "folds", filters, page, pageSize, foldSort, foldMinutes],
|
||||
queryFn: () =>
|
||||
AccessLogService.listFolds({
|
||||
node_id: filters.nodeId || undefined,
|
||||
remote_addr: filters.remoteAddr || undefined,
|
||||
host: filters.host || undefined,
|
||||
path: filters.path || undefined,
|
||||
p: page,
|
||||
page_size: pageSize,
|
||||
sort_by: foldSortState.sortBy,
|
||||
sort_order: foldSortState.sortOrder,
|
||||
fold_minutes: foldMinutes,
|
||||
}),
|
||||
enabled: tab === "folds",
|
||||
})
|
||||
|
||||
const ipSummaryQuery = useQuery({
|
||||
queryKey: ["openflare", "access-logs", "ip-summary", filters, page, pageSize, ipSort],
|
||||
queryFn: () =>
|
||||
AccessLogService.listIPSummaries({
|
||||
node_id: filters.nodeId || undefined,
|
||||
remote_addr: filters.remoteAddr || undefined,
|
||||
host: filters.host || undefined,
|
||||
p: page,
|
||||
page_size: pageSize,
|
||||
sort_by: ipSortState.sortBy,
|
||||
sort_order: ipSortState.sortOrder,
|
||||
}),
|
||||
enabled: tab === "ip-summary",
|
||||
})
|
||||
|
||||
const ipTrendQuery = useQuery({
|
||||
queryKey: ["openflare", "access-logs", "ip-trend", filters, appliedTrendIp],
|
||||
queryFn: () =>
|
||||
AccessLogService.getIPTrend({
|
||||
node_id: filters.nodeId || undefined,
|
||||
remote_addr: appliedTrendIp,
|
||||
host: filters.host || undefined,
|
||||
hours: 24,
|
||||
bucket_minutes: 30,
|
||||
}),
|
||||
enabled: tab === "ip-trend" && appliedTrendIp !== "",
|
||||
})
|
||||
|
||||
const cleanupMutation = useMutation({
|
||||
mutationFn: (retentionDays: number) =>
|
||||
AccessLogService.cleanup({ retention_days: retentionDays }),
|
||||
onSuccess: async (result) => {
|
||||
toast.success(`已清理 ${result.deleted_count} 条日志`)
|
||||
setCleanupOpen(false)
|
||||
await queryClient.invalidateQueries({ queryKey: ["openflare", "access-logs"] })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "清理失败")
|
||||
},
|
||||
})
|
||||
|
||||
const activeSummary = useMemo(() => {
|
||||
if (tab === "list" && listQuery.data) {
|
||||
return {
|
||||
totalRecord: listQuery.data.total_record,
|
||||
totalIp: listQuery.data.total_ip,
|
||||
}
|
||||
}
|
||||
if (tab === "folds" && foldsQuery.data) {
|
||||
return {
|
||||
totalRecord: foldsQuery.data.total_record,
|
||||
totalIp: foldsQuery.data.total_ip,
|
||||
}
|
||||
}
|
||||
if (tab === "ip-summary" && ipSummaryQuery.data) {
|
||||
return {
|
||||
totalRecord: 0,
|
||||
totalIp: ipSummaryQuery.data.total_ip,
|
||||
}
|
||||
}
|
||||
return { totalRecord: 0, totalIp: 0 }
|
||||
}, [tab, listQuery.data, foldsQuery.data, ipSummaryQuery.data])
|
||||
|
||||
const trendChartData = useMemo(() => {
|
||||
return (ipTrendQuery.data?.points ?? []).map((point) => ({
|
||||
label: formatDateTime(point.bucket_started_at).slice(5),
|
||||
requests: point.request_count,
|
||||
}))
|
||||
}, [ipTrendQuery.data?.points])
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setFilters({
|
||||
nodeId: draft.nodeId.trim(),
|
||||
remoteAddr: draft.remoteAddr.trim(),
|
||||
host: draft.host.trim(),
|
||||
path: draft.path.trim(),
|
||||
})
|
||||
setPage(0)
|
||||
}, [draft])
|
||||
|
||||
const handleReset = () => {
|
||||
setDraft(emptyDraft)
|
||||
setFilters(emptyDraft)
|
||||
setPage(0)
|
||||
setTrendIp("")
|
||||
setAppliedTrendIp("")
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0)
|
||||
}, [tab, pageSize])
|
||||
|
||||
const refreshActive = () => {
|
||||
if (tab === "list") void listQuery.refetch()
|
||||
if (tab === "folds") void foldsQuery.refetch()
|
||||
if (tab === "ip-summary") void ipSummaryQuery.refetch()
|
||||
if (tab === "ip-trend") void ipTrendQuery.refetch()
|
||||
}
|
||||
|
||||
const isFetching =
|
||||
listQuery.isFetching ||
|
||||
foldsQuery.isFetching ||
|
||||
ipSummaryQuery.isFetching ||
|
||||
ipTrendQuery.isFetching
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">访问日志</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
按节点、IP、域名与路径检索,支持时间折叠、IP 汇总与趋势分析。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={refreshActive} disabled={isFetching}>
|
||||
<RefreshCw className={`size-3.5 mr-1 ${isFetching ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setCleanupOpen(true)}>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
清理日志
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: "访问记录", value: formatCompactNumber(activeSummary.totalRecord) },
|
||||
{ label: "来源 IP", value: formatCompactNumber(activeSummary.totalIp) },
|
||||
{
|
||||
label: "当前视图",
|
||||
value:
|
||||
tab === "list"
|
||||
? "明细日志"
|
||||
: tab === "folds"
|
||||
? "时间折叠"
|
||||
: tab === "ip-summary"
|
||||
? "IP 汇总"
|
||||
: "IP 趋势",
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-lg border border-dashed px-4 py-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as AccessLogTab)}>
|
||||
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-4">
|
||||
<TabsTrigger value="list">明细列表</TabsTrigger>
|
||||
<TabsTrigger value="folds">时间折叠</TabsTrigger>
|
||||
<TabsTrigger value="ip-summary">IP 汇总</TabsTrigger>
|
||||
<TabsTrigger value="ip-trend">IP 趋势</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="mt-4 rounded-lg border border-dashed bg-background p-4">
|
||||
<AccessLogFilters
|
||||
tab={tab}
|
||||
draft={draft}
|
||||
pageSize={pageSize}
|
||||
onDraftChange={setDraft}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSearch={handleSearch}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsContent value="list" className="mt-4">
|
||||
<div className="rounded-lg border border-dashed overflow-hidden bg-background">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dashed">
|
||||
<p className="text-sm font-medium">明细日志</p>
|
||||
<Select value={detailSort} onValueChange={setDetailSort}>
|
||||
<SelectTrigger className="h-8 w-44 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DETAIL_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{listQuery.isError ? (
|
||||
<div className="p-4">
|
||||
<ErrorInline
|
||||
message={listQuery.error instanceof Error ? listQuery.error.message : "加载失败"}
|
||||
onRetry={() => void listQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : listQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (listQuery.data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title="暂无访问日志" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow className="border-dashed hover:bg-transparent">
|
||||
<TableHead className="text-xs">时间</TableHead>
|
||||
<TableHead className="text-xs">节点</TableHead>
|
||||
<TableHead className="text-xs">IP</TableHead>
|
||||
<TableHead className="text-xs">域名</TableHead>
|
||||
<TableHead className="text-xs">路径</TableHead>
|
||||
<TableHead className="text-xs">状态码</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(listQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id} className="border-dashed">
|
||||
<TableCell className="text-xs">{formatDateTime(item.logged_at)}</TableCell>
|
||||
<TableCell className="text-xs">{item.node_name || item.node_id}</TableCell>
|
||||
<TableCell className="text-xs font-mono">{item.remote_addr}</TableCell>
|
||||
<TableCell className="text-xs">{item.host}</TableCell>
|
||||
<TableCell className="text-xs max-w-48 truncate">{item.path}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{item.status_code}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<PaginationBar
|
||||
page={page}
|
||||
hasMore={listQuery.data?.has_more ?? false}
|
||||
loading={listQuery.isFetching}
|
||||
onPrev={() => setPage((p) => Math.max(0, p - 1))}
|
||||
onNext={() => setPage((p) => p + 1)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="folds" className="mt-4">
|
||||
<div className="rounded-lg border border-dashed overflow-hidden bg-background">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-3 border-b border-dashed">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">时间折叠</p>
|
||||
<Select
|
||||
value={String(foldMinutes)}
|
||||
onValueChange={(value) => setFoldMinutes(Number(value) as 3 | 5)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">3 分钟桶</SelectItem>
|
||||
<SelectItem value="5">5 分钟桶</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={foldSort} onValueChange={setFoldSort}>
|
||||
<SelectTrigger className="h-8 w-44 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FOLD_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{foldsQuery.isError ? (
|
||||
<div className="p-4">
|
||||
<ErrorInline
|
||||
message={
|
||||
foldsQuery.error instanceof Error
|
||||
? foldsQuery.error.message
|
||||
: "加载失败"
|
||||
}
|
||||
onRetry={() => void foldsQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : foldsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (foldsQuery.data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title="暂无折叠数据" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow className="border-dashed hover:bg-transparent">
|
||||
<TableHead className="text-xs">时间桶</TableHead>
|
||||
<TableHead className="text-xs">请求数</TableHead>
|
||||
<TableHead className="text-xs">独立 IP</TableHead>
|
||||
<TableHead className="text-xs">独立域名</TableHead>
|
||||
<TableHead className="text-xs">2xx</TableHead>
|
||||
<TableHead className="text-xs">4xx</TableHead>
|
||||
<TableHead className="text-xs">5xx</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(foldsQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.bucket_started_at} className="border-dashed">
|
||||
<TableCell className="text-xs">
|
||||
{formatDateTime(item.bucket_started_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{item.request_count}</TableCell>
|
||||
<TableCell className="text-xs">{item.unique_ip_count}</TableCell>
|
||||
<TableCell className="text-xs">{item.unique_host_count}</TableCell>
|
||||
<TableCell className="text-xs">{item.success_count}</TableCell>
|
||||
<TableCell className="text-xs">{item.client_error_count}</TableCell>
|
||||
<TableCell className="text-xs">{item.server_error_count}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<PaginationBar
|
||||
page={page}
|
||||
hasMore={foldsQuery.data?.has_more ?? false}
|
||||
loading={foldsQuery.isFetching}
|
||||
onPrev={() => setPage((p) => Math.max(0, p - 1))}
|
||||
onNext={() => setPage((p) => p + 1)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ip-summary" className="mt-4">
|
||||
<div className="rounded-lg border border-dashed overflow-hidden bg-background">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dashed">
|
||||
<p className="text-sm font-medium">IP 汇总</p>
|
||||
<Select value={ipSort} onValueChange={setIpSort}>
|
||||
<SelectTrigger className="h-8 w-52 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{IP_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{ipSummaryQuery.isError ? (
|
||||
<div className="p-4">
|
||||
<ErrorInline
|
||||
message={
|
||||
ipSummaryQuery.error instanceof Error
|
||||
? ipSummaryQuery.error.message
|
||||
: "加载失败"
|
||||
}
|
||||
onRetry={() => void ipSummaryQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : ipSummaryQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (ipSummaryQuery.data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title="暂无 IP 汇总数据" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow className="border-dashed hover:bg-transparent">
|
||||
<TableHead className="text-xs">IP</TableHead>
|
||||
<TableHead className="text-xs">总请求数</TableHead>
|
||||
<TableHead className="text-xs">近 3 小时</TableHead>
|
||||
<TableHead className="text-xs">最后访问</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(ipSummaryQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.remote_addr} className="border-dashed">
|
||||
<TableCell className="text-xs font-mono">{item.remote_addr}</TableCell>
|
||||
<TableCell className="text-xs">{item.total_requests}</TableCell>
|
||||
<TableCell className="text-xs">{item.recent_requests}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{formatDateTime(item.last_seen_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<PaginationBar
|
||||
page={page}
|
||||
hasMore={ipSummaryQuery.data?.has_more ?? false}
|
||||
loading={ipSummaryQuery.isFetching}
|
||||
onPrev={() => setPage((p) => Math.max(0, p - 1))}
|
||||
onNext={() => setPage((p) => p + 1)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ip-trend" className="mt-4 space-y-4">
|
||||
<div className="rounded-lg border border-dashed bg-background p-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">趋势 IP</p>
|
||||
<input
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-xs"
|
||||
value={trendIp}
|
||||
onChange={(e) => setTrendIp(e.target.value)}
|
||||
placeholder="输入要分析趋势的 IP 地址"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAppliedTrendIp(trendIp.trim())}
|
||||
disabled={!trendIp.trim()}
|
||||
>
|
||||
查看趋势
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dashed overflow-hidden bg-background p-4">
|
||||
{!appliedTrendIp ? (
|
||||
<EmptyStateWithBorder description="请输入 IP 地址后查看 24 小时访问趋势。" />
|
||||
) : ipTrendQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : ipTrendQuery.isError ? (
|
||||
<ErrorInline
|
||||
message={
|
||||
ipTrendQuery.error instanceof Error
|
||||
? ipTrendQuery.error.message
|
||||
: "加载失败"
|
||||
}
|
||||
onRetry={() => void ipTrendQuery.refetch()}
|
||||
/>
|
||||
) : trendChartData.length === 0 ? (
|
||||
<EmptyStateWithBorder description="该 IP 在选定时间范围内没有访问记录。" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">
|
||||
{appliedTrendIp} · 近 {ipTrendQuery.data?.hours ?? 24} 小时
|
||||
</p>
|
||||
<ChartContainer config={trendChartConfig} className="h-64 w-full">
|
||||
<AreaChart data={trendChartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="label" tickLine={false} axisLine={false} fontSize={10} />
|
||||
<YAxis tickLine={false} axisLine={false} fontSize={10} width={40} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="requests"
|
||||
stroke="var(--color-requests)"
|
||||
fill="var(--color-requests)"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<CleanupDialog
|
||||
open={cleanupOpen}
|
||||
onOpenChange={setCleanupOpen}
|
||||
onConfirm={(days) => cleanupMutation.mutate(days)}
|
||||
loading={cleanupMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {HardDrive, RefreshCw, Trash2} from "lucide-react"
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import type {CacheStatus} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
|
||||
/**
|
||||
* 格式化字节大小
|
||||
*/
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes"
|
||||
const k = 1024
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
|
||||
}
|
||||
|
||||
interface CacheManagerProps {
|
||||
refreshTrigger: number
|
||||
}
|
||||
|
||||
export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null)
|
||||
const [loadingCache, setLoadingCache] = useState<boolean>(true)
|
||||
const [savingConfig, setSavingConfig] = useState<boolean>(false)
|
||||
const [clearingCache, setClearingCache] = useState<boolean>(false)
|
||||
const [showClearConfirm, setShowClearConfirm] = useState<boolean>(false)
|
||||
|
||||
// 策略配置表单状态
|
||||
const [maxSizeMB, setMaxSizeMB] = useState<string>("100")
|
||||
const [ttlMinutes, setTtlMinutes] = useState<string>("60")
|
||||
const [lruEnabled, setLruEnabled] = useState<boolean>(true)
|
||||
|
||||
// 获取磁盘缓存状态
|
||||
const fetchCacheStatus = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setLoadingCache(true)
|
||||
try {
|
||||
const data = await services.adminCache.getCacheStatus()
|
||||
setCacheStatus(data)
|
||||
setMaxSizeMB(data.max_size_mb.toString())
|
||||
setTtlMinutes(data.ttl_minutes.toString())
|
||||
setLruEnabled(data.lru_enabled)
|
||||
} catch (err) {
|
||||
toast.error("获取磁盘缓存状态失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingCache(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 保存配置
|
||||
const handleSaveConfig = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const size = parseInt(maxSizeMB, 10)
|
||||
const ttl = parseInt(ttlMinutes, 10)
|
||||
if (isNaN(size) || size < 1) {
|
||||
toast.error("保存失败", { description: "最大容量限制必须是大于等于 1 的整数" })
|
||||
return
|
||||
}
|
||||
if (isNaN(ttl) || ttl < 0) {
|
||||
toast.error("保存失败", { description: "默认过期时间必须是大于等于 0 的整数" })
|
||||
return
|
||||
}
|
||||
setSavingConfig(true)
|
||||
try {
|
||||
await services.adminCache.updateCacheConfig({
|
||||
max_size_mb: size,
|
||||
ttl_minutes: ttl,
|
||||
lru_enabled: lruEnabled,
|
||||
})
|
||||
toast.success("保存成功", { description: "磁盘缓存策略已热更新" })
|
||||
await fetchCacheStatus(true)
|
||||
} catch (err) {
|
||||
toast.error("保存配置失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setSavingConfig(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空磁盘缓存数据
|
||||
const handleClearCache = async () => {
|
||||
setClearingCache(true)
|
||||
try {
|
||||
await services.adminCache.clearCache()
|
||||
toast.success("清空成功", { description: "缓存数据已全部清除" })
|
||||
setShowClearConfirm(false)
|
||||
await fetchCacheStatus(true)
|
||||
} catch (err) {
|
||||
toast.error("清空缓存失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setClearingCache(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化拉取
|
||||
useEffect(() => {
|
||||
fetchCacheStatus()
|
||||
}, [fetchCacheStatus])
|
||||
|
||||
// 当外部刷新触发器改变时自动刷新
|
||||
useEffect(() => {
|
||||
fetchCacheStatus(true)
|
||||
}, [refreshTrigger, fetchCacheStatus])
|
||||
|
||||
return (
|
||||
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-3 border-b border-dashed flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="size-4 text-primary animate-pulse" />
|
||||
<CardTitle className="text-sm font-semibold">缓存管理</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-[11px]">管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略</CardDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 gap-1.5 text-xs self-start md:self-auto"
|
||||
onClick={() => fetchCacheStatus(true)}
|
||||
disabled={loadingCache}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${loadingCache ? "animate-spin" : ""}`} />
|
||||
刷新状态
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{loadingCache && !cacheStatus ? (
|
||||
<div className="space-y-3 py-6">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : cacheStatus ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
{/* 左边:状态区 (2/5 cols) */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">运行状态</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* 已占空间 */}
|
||||
<div className="p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300">
|
||||
<p className="text-[10px] text-muted-foreground font-medium mb-1">已用空间</p>
|
||||
<p className="text-xl font-bold tracking-tight text-foreground">
|
||||
{formatBytes(cacheStatus.total_size)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Key数量 */}
|
||||
<div className="p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300">
|
||||
<p className="text-[10px] text-muted-foreground font-medium mb-1">缓存键数量</p>
|
||||
<p className="text-xl font-bold tracking-tight text-foreground">
|
||||
{formatNumber(cacheStatus.keys_count)} <span className="text-xs text-muted-foreground font-normal">个文件</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 存储路径 */}
|
||||
<div className="p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300">
|
||||
<p className="text-[10px] text-muted-foreground font-medium mb-1.5">缓存基准目录</p>
|
||||
<code className="text-xs font-mono bg-muted/60 px-2 py-1 rounded-md block truncate" title={cacheStatus.base_path}>
|
||||
{cacheStatus.base_path}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-xs font-medium w-full"
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
>
|
||||
立即清空缓存
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 右边:配置区 (3/5 cols) */}
|
||||
<div className="lg:col-span-3 border-t lg:border-t-0 lg:border-l border-border/40 pt-6 lg:pt-0 lg:pl-6 space-y-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">策略配置</h4>
|
||||
<form onSubmit={handleSaveConfig} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="maxSizeMB" className="text-xs font-medium">最大容量限制 (MB)</Label>
|
||||
<Input
|
||||
id="maxSizeMB"
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxSizeMB}
|
||||
onChange={(e) => setMaxSizeMB(e.target.value)}
|
||||
className="h-8 text-xs bg-background/50 border-border/40"
|
||||
placeholder="例如 100"
|
||||
required
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground">当总大小超出该值时,自动触发淘汰算法</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ttlMinutes" className="text-xs font-medium">生存时间限制 (分钟)</Label>
|
||||
<Input
|
||||
id="ttlMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
value={ttlMinutes}
|
||||
onChange={(e) => setTtlMinutes(e.target.value)}
|
||||
className="h-8 text-xs bg-background/50 border-border/40"
|
||||
placeholder="例如 60,0 表示永不过期"
|
||||
required
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground">缓存项的最长存活时间,超期后将失效被清理</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between p-4 rounded-xl border border-border/40 bg-background/20">
|
||||
<div className="space-y-1 pr-4">
|
||||
<Label htmlFor="lruEnabled" className="text-xs font-semibold block cursor-pointer">
|
||||
启用 LRU 淘汰机制
|
||||
</Label>
|
||||
<span className="text-[10px] text-muted-foreground block">
|
||||
在到达最大容量限制时,自动移除最久未被访问的缓存项。关闭该功能仅清理过期项。
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="lruEnabled"
|
||||
checked={lruEnabled}
|
||||
onCheckedChange={setLruEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={savingConfig}
|
||||
className="h-8 text-xs px-4"
|
||||
>
|
||||
{savingConfig ? "正在保存..." : "保存设置"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<HardDrive className="size-8 opacity-45 mb-2" />
|
||||
<span className="text-xs">未加载到缓存状态信息</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* 清除缓存确认对话框 */}
|
||||
<Dialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold flex items-center gap-2 text-destructive">
|
||||
<Trash2 className="size-4" />
|
||||
确认清空所有缓存?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground pt-1">
|
||||
该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为 0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 justify-end mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
disabled={clearingCache}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={handleClearCache}
|
||||
disabled={clearingCache}
|
||||
>
|
||||
{clearingCache ? "清理中..." : "确认清空"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useRef, useState} from "react"
|
||||
import {useTheme} from "next-themes"
|
||||
import CodeMirror from "@uiw/react-codemirror"
|
||||
import {sql} from "@codemirror/lang-sql"
|
||||
import {toast} from "sonner"
|
||||
import {ArrowLeft, Play, RefreshCw, Terminal, Trash2,} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import type {ExecuteSQLResponse} from "@/lib/services/db-manage"
|
||||
import {DbManageService} from "@/lib/services/db-manage"
|
||||
|
||||
/**
|
||||
* 格式化表格单元格内容
|
||||
*/
|
||||
const formatCellValue = (val: unknown): string => {
|
||||
if (val === null || val === undefined) return "-"
|
||||
if (typeof val === "boolean") return val ? "true" : "false"
|
||||
if (typeof val === "object") {
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
} catch {
|
||||
return "[Object]"
|
||||
}
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
interface SQLConsoleProps {
|
||||
dbType?: string // "postgres" | "sqlite"
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
// SQL 控制台状态
|
||||
const [sqlQuery, setSqlQuery] = useState<string>("")
|
||||
const [executingSQL, setExecutingSQL] = useState<boolean>(false)
|
||||
const [sqlResult, setSqlResult] = useState<ExecuteSQLResponse | null>(null)
|
||||
const [sqlError, setSqlError] = useState<string | null>(null)
|
||||
|
||||
// 拖拽与主题状态
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [editorHeight, setEditorHeight] = useState<number>(240)
|
||||
const { resolvedTheme } = useTheme()
|
||||
const cmTheme = resolvedTheme === "dark" ? "dark" : "light"
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const startY = e.clientY
|
||||
const startHeight = editorHeight
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!containerRef.current) return
|
||||
const deltaY = moveEvent.clientY - startY
|
||||
const containerHeight = containerRef.current.getBoundingClientRect().height
|
||||
const newHeight = startHeight + deltaY
|
||||
|
||||
// Limit editor height between 80px and containerHeight - 80px
|
||||
if (newHeight > 80 && newHeight < containerHeight - 80) {
|
||||
setEditorHeight(newHeight)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
// 执行 SQL 查询
|
||||
const handleExecuteSQL = async () => {
|
||||
if (!sqlQuery.trim()) return
|
||||
setExecutingSQL(true)
|
||||
setSqlResult(null)
|
||||
setSqlError(null)
|
||||
try {
|
||||
const result = await DbManageService.executeSQL(sqlQuery)
|
||||
setSqlResult(result)
|
||||
toast.success("SQL 执行成功")
|
||||
} catch (err) {
|
||||
setSqlError(err instanceof Error ? err.message : "未知执行错误")
|
||||
toast.error("SQL 执行失败")
|
||||
} finally {
|
||||
setExecutingSQL(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Preset SQL 选择器
|
||||
const handlePresetSQLChange = (query: string) => {
|
||||
setSqlQuery(query)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSqlResult(null)
|
||||
setSqlError(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6 w-full">
|
||||
{/* 顶部控制与标题 */}
|
||||
<div className="flex items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">SQL 查询终端</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{dbType === "postgres" ? "PostgreSQL Connected" : "SQLite Connected"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类似于 VS Code 的单个整体编辑器+结果区域,固定高度,有分水岭拖拽调整大小 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full border border-border/40 bg-card/60 backdrop-blur-md rounded-lg overflow-hidden flex flex-col shadow-sm h-[calc(100vh-140px)] min-h-[500px]"
|
||||
>
|
||||
{/* 顶部编辑工具栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0 gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-4 text-primary" />
|
||||
<span className="text-xs font-semibold">SQL 编辑器</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* 快速模板 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">快速模板:</span>
|
||||
<Select onValueChange={handlePresetSQLChange}>
|
||||
<SelectTrigger className="h-7 w-[180px] text-[11px] bg-background">
|
||||
<SelectValue placeholder="选择预设 SQL" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SELECT * FROM users LIMIT 10;">查询用户 (SELECT users)</SelectItem>
|
||||
<SelectItem value="SELECT * FROM uploads LIMIT 10;">查询文件 (SELECT uploads)</SelectItem>
|
||||
<SelectItem value="SELECT * FROM task_executions ORDER BY created_at DESC LIMIT 10;">查询任务流水 (SELECT task_executions)</SelectItem>
|
||||
<SelectItem value="SELECT sqlite_version();">SQLite 版本 (SQLite only)</SelectItem>
|
||||
<SelectItem value="SELECT version();">PostgreSQL 版本 (Postgres only)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 控制按钮 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSqlQuery("")}
|
||||
title="清空编辑器"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3 gap-1 text-[11px]"
|
||||
onClick={handleExecuteSQL}
|
||||
disabled={executingSQL || !sqlQuery.trim()}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{executingSQL ? "运行中..." : "运行"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上半区:编辑器输入框 */}
|
||||
<div
|
||||
style={{ height: editorHeight }}
|
||||
className="w-full overflow-hidden relative min-h-[100px] bg-background"
|
||||
>
|
||||
<CodeMirror
|
||||
value={sqlQuery}
|
||||
height="100%"
|
||||
extensions={[sql()]}
|
||||
theme={cmTheme}
|
||||
onChange={(value) => setSqlQuery(value)}
|
||||
className="h-full text-xs font-mono"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 拖动分割线 */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="h-1.5 bg-border/60 hover:bg-primary/50 cursor-row-resize transition-colors flex items-center justify-center shrink-0 select-none z-10"
|
||||
title="拖动调整大小"
|
||||
>
|
||||
<div className="w-8 h-1 rounded bg-muted-foreground/30" />
|
||||
</div>
|
||||
|
||||
{/* 下半区:查看结果 (可滑动查看) */}
|
||||
<div className="flex-1 min-h-[100px] flex flex-col overflow-hidden bg-muted/10">
|
||||
{/* 结果栏工具提示 */}
|
||||
<div className="flex items-center justify-between px-4 py-1.5 border-b bg-muted/20 shrink-0 text-[11px] text-muted-foreground font-mono">
|
||||
<span className="font-semibold">执行输出</span>
|
||||
{sqlResult && (
|
||||
<span>
|
||||
类型: {sqlResult.type.toUpperCase()} | 耗时: {sqlResult.execution_time_ms} ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 滚动结果内容 */}
|
||||
<div className="flex-1 overflow-auto p-4 min-h-0">
|
||||
{executingSQL && (
|
||||
<div className="flex flex-col items-center justify-center h-full py-10 space-y-2">
|
||||
<RefreshCw className="size-5 text-primary animate-spin" />
|
||||
<span className="text-xs text-muted-foreground">正在在数据库执行查询,请稍候...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sqlError && (
|
||||
<div className="bg-destructive/10 border border-destructive/20 text-destructive font-mono text-xs p-4 rounded-lg overflow-auto h-full max-h-[300px]">
|
||||
<p className="font-semibold mb-1">SQL 执行报错 (Error):</p>
|
||||
<pre className="whitespace-pre-wrap">{sqlError}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sqlResult && (
|
||||
<div className="h-full flex flex-col">
|
||||
{sqlResult.type === "select" && sqlResult.columns && sqlResult.columns.length > 0 && (
|
||||
<div className="border rounded-md overflow-auto max-w-full max-h-full bg-background relative flex-1 min-h-0">
|
||||
<Table className="text-xs">
|
||||
<TableHeader className="bg-muted/40 font-semibold sticky top-0 z-10">
|
||||
<TableRow>
|
||||
{sqlResult.columns.map((col) => (
|
||||
<TableHead key={col} className="font-semibold text-foreground py-2">{col}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sqlResult.results && sqlResult.results.length > 0 ? (
|
||||
sqlResult.results.map((row, rIndex) => (
|
||||
<TableRow key={rIndex} className="hover:bg-muted/10">
|
||||
{sqlResult.columns!.map((col) => (
|
||||
<TableCell key={col} className="font-mono py-1.5">
|
||||
<span className="truncate max-w-[240px] block" title={formatCellValue(row[col])}>
|
||||
{formatCellValue(row[col])}
|
||||
</span>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={sqlResult.columns.length} className="text-center py-10 text-muted-foreground">
|
||||
查询结果为空
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sqlResult.type === "exec" && (
|
||||
<div className="bg-primary/5 border border-primary/10 text-primary-foreground font-mono text-xs p-6 rounded-lg text-center my-auto">
|
||||
<p className="text-foreground text-sm font-semibold">SQL 执行成功</p>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
受影响行数: {sqlResult.affected_rows} 行,耗时 {sqlResult.execution_time_ms} 毫秒。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!executingSQL && !sqlResult && !sqlError && (
|
||||
<div className="flex flex-col items-center justify-center h-full py-10 text-muted-foreground opacity-60">
|
||||
<Terminal className="size-8 mb-2" />
|
||||
<span className="text-xs">编辑器就绪,请在上方编写 SQL 并运行查看结果</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Layers, RefreshCw} from "lucide-react"
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import type {TableDataResponse} from "@/lib/services/db-manage"
|
||||
import {DbManageService} from "@/lib/services/db-manage"
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化表格单元格内容
|
||||
*/
|
||||
const formatCellValue = (val: unknown): string => {
|
||||
if (val === null || val === undefined) return "-"
|
||||
if (typeof val === "boolean") return val ? "true" : "false"
|
||||
if (typeof val === "object") {
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
} catch {
|
||||
return "[Object]"
|
||||
}
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
interface TableBrowserProps {
|
||||
tables: string[]
|
||||
loadingTables: boolean
|
||||
refreshTrigger: number
|
||||
}
|
||||
|
||||
export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBrowserProps) {
|
||||
const [selectedTable, setSelectedTable] = useState<string>("")
|
||||
const [tableData, setTableData] = useState<TableDataResponse | null>(null)
|
||||
const [page, setPage] = useState<number>(1)
|
||||
const [loadingData, setLoadingData] = useState<boolean>(false)
|
||||
const pageSize = 10
|
||||
|
||||
// 获取具体表数据
|
||||
const fetchTableData = useCallback(async (tableName: string, targetPage: number, size: number) => {
|
||||
if (!tableName) return
|
||||
setLoadingData(true)
|
||||
try {
|
||||
const data = await DbManageService.getTableData({
|
||||
table: tableName,
|
||||
page: targetPage,
|
||||
pageSize: size,
|
||||
})
|
||||
setTableData(data)
|
||||
} catch (err) {
|
||||
toast.error(`获取数据表 ${tableName} 数据失败`, {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingData(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 默认选择第一张表
|
||||
useEffect(() => {
|
||||
if (tables.length > 0 && !selectedTable) {
|
||||
setSelectedTable(tables[0])
|
||||
}
|
||||
}, [tables, selectedTable])
|
||||
|
||||
// 当选中的表、页码或外部刷新触发器改变时获取数据
|
||||
useEffect(() => {
|
||||
if (selectedTable) {
|
||||
fetchTableData(selectedTable, page, pageSize)
|
||||
}
|
||||
}, [selectedTable, page, pageSize, refreshTrigger, fetchTableData])
|
||||
|
||||
// 处理页码改变
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
|
||||
// 处理表切换
|
||||
const handleTableChange = (value: string) => {
|
||||
setSelectedTable(value)
|
||||
setPage(1) // 切换表重置为第一页
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-3 border-b border-dashed flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">数据表浏览器</CardTitle>
|
||||
<CardDescription className="text-[11px]">浏览数据库中的物理数据表详情及内容</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{loadingTables ? (
|
||||
<Skeleton className="h-8 w-48" />
|
||||
) : (
|
||||
<Select value={selectedTable} onValueChange={handleTableChange}>
|
||||
<SelectTrigger className="h-8 w-[200px] text-xs bg-background border-border/40">
|
||||
<SelectValue placeholder="选择数据表" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-xs font-mono">{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{loadingData && !tableData ? (
|
||||
<div className="space-y-3 py-6">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : tableData && tableData.columns.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{/* 数据表格区域 */}
|
||||
<div className="border rounded-md overflow-x-auto max-w-full bg-background/50 relative">
|
||||
{loadingData && (
|
||||
<div className="absolute inset-0 bg-background/40 backdrop-blur-[1px] flex items-center justify-center z-10">
|
||||
<RefreshCw className="size-5 text-primary animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<Table className="text-xs">
|
||||
<TableHeader className="bg-muted/40 font-semibold sticky top-0">
|
||||
<TableRow>
|
||||
{tableData.columns.map((col) => (
|
||||
<TableHead key={col} className="font-semibold text-foreground py-2.5">{col}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tableData.results && tableData.results.length > 0 ? (
|
||||
tableData.results.map((row, rIndex) => (
|
||||
<TableRow key={rIndex} className="hover:bg-muted/10">
|
||||
{tableData.columns.map((col) => (
|
||||
<TableCell key={col} className="font-mono py-2">
|
||||
<span className="truncate max-w-[200px] block" title={formatCellValue(row[col])}>
|
||||
{formatCellValue(row[col])}
|
||||
</span>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={tableData.columns.length} className="text-center py-10 text-muted-foreground">
|
||||
表中无记录数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页控制 */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground flex-wrap gap-2 pt-2 border-t border-dashed">
|
||||
<div>
|
||||
总行数: <span className="font-mono text-foreground font-semibold">{formatNumber(tableData.total)}</span> 条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page <= 1 || loadingData}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-xs px-2 font-mono">
|
||||
第 {page} / {Math.max(1, Math.ceil(tableData.total / pageSize))} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page >= Math.ceil(tableData.total / pageSize) || loadingData}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<Layers className="size-8 opacity-45 mb-2" />
|
||||
<span className="text-xs">未选择数据表或表结构无法加载</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {
|
||||
Activity,
|
||||
Cpu,
|
||||
Database,
|
||||
Download,
|
||||
FileText,
|
||||
HardDrive,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import services from "@/lib/services"
|
||||
import type {DBOverview} from "@/lib/services/db-manage"
|
||||
|
||||
const sectionFallback = (
|
||||
<div className="h-48 animate-pulse rounded-lg border border-border/40 bg-muted/20" />
|
||||
)
|
||||
|
||||
const TableBrowser = dynamic(
|
||||
() => import("./components/table-browser").then((mod) => mod.TableBrowser),
|
||||
{ loading: () => sectionFallback },
|
||||
)
|
||||
|
||||
const CacheManager = dynamic(
|
||||
() => import("./components/cache-manager").then((mod) => mod.CacheManager),
|
||||
{ loading: () => sectionFallback },
|
||||
)
|
||||
|
||||
const SQLConsole = dynamic(
|
||||
() => import("./components/sql-console").then((mod) => mod.SQLConsole),
|
||||
{ ssr: false },
|
||||
)
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
||||
}
|
||||
|
||||
export function DatabasePageClient() {
|
||||
// 核心状态
|
||||
const [overview, setOverview] = useState<DBOverview | null>(null)
|
||||
const [tables, setTables] = useState<string[]>([])
|
||||
const [loadingOverview, setLoadingOverview] = useState<boolean>(true)
|
||||
const [loadingTables, setLoadingTables] = useState<boolean>(true)
|
||||
const [exporting, setExporting] = useState<boolean>(false)
|
||||
|
||||
// 协调子组件刷新与视图切换状态
|
||||
const [showConsole, setShowConsole] = useState<boolean>(false)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState<number>(0)
|
||||
|
||||
// 1. 获取运行概览
|
||||
const fetchOverview = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setLoadingOverview(true)
|
||||
try {
|
||||
const data = await services.dbManage.getOverview()
|
||||
setOverview(data)
|
||||
} catch (err) {
|
||||
toast.error("获取数据库概览失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingOverview(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 2. 获取表列表
|
||||
const fetchTables = useCallback(async () => {
|
||||
setLoadingTables(true)
|
||||
try {
|
||||
const data = await services.dbManage.listTables()
|
||||
setTables(data)
|
||||
} catch (err) {
|
||||
toast.error("获取数据库数据表列表失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingTables(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 3. 协调刷新
|
||||
const handleRefreshAll = () => {
|
||||
fetchOverview()
|
||||
fetchTables()
|
||||
setRefreshTrigger((prev) => prev + 1)
|
||||
}
|
||||
|
||||
// 4. 导出数据库备份
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const { blob, filename } = await services.dbManage.exportDatabase()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success("数据库导出成功", { description: `已下载归档文件: ${filename}` })
|
||||
} catch (err) {
|
||||
toast.error("数据库导出失败", {
|
||||
description: err instanceof Error ? err.message : "导出异常",
|
||||
})
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化加载
|
||||
useEffect(() => {
|
||||
fetchOverview()
|
||||
fetchTables()
|
||||
}, [fetchOverview, fetchTables])
|
||||
|
||||
// 渲染概览卡片骨架
|
||||
const renderOverviewSkeleton = () => (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} className="border-border/40 bg-card/50">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// 渲染 SQL 查询控台全屏视图
|
||||
if (showConsole) {
|
||||
return (
|
||||
<SQLConsole
|
||||
dbType={overview?.type}
|
||||
onClose={() => setShowConsole(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 正常页面排版布局
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6 w-full">
|
||||
{/* 顶部控制与标题 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between pb-4 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">数据管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 gap-1.5 text-xs self-start sm:self-auto"
|
||||
onClick={handleRefreshAll}
|
||||
disabled={loadingOverview}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${loadingOverview ? "animate-spin" : ""}`} />
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 1. 概览指标卡片组 */}
|
||||
{loadingOverview ? (
|
||||
renderOverviewSkeleton()
|
||||
) : (
|
||||
overview && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{/* 卡片1: 数据库类型 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">数据库类型</CardDescription>
|
||||
<Server className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold uppercase">{overview.type}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片2: 数据库版本 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300 col-span-1 md:col-span-2 lg:col-span-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">版本信息</CardDescription>
|
||||
<Cpu className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xs font-semibold truncate" title={overview.version}>
|
||||
{overview.version.split(" ").slice(0, 2).join(" ")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片3: 数据库名称 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">名称/路径</CardDescription>
|
||||
<Database className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xs font-semibold truncate" title={overview.name}>
|
||||
{overview.name.substring(overview.name.lastIndexOf("/") + 1)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片4: 数据库大小 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">数据库大小</CardDescription>
|
||||
<HardDrive className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">{overview.size}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片5: 表数量 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">物理数据表</CardDescription>
|
||||
<Layers className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">{formatNumber(overview.table_count)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片6: 连接数 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">活跃连接数</CardDescription>
|
||||
<Activity className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">{formatNumber(overview.connections)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 2. 数据表浏览器区块 */}
|
||||
<TableBrowser
|
||||
tables={tables}
|
||||
loadingTables={loadingTables}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
|
||||
{/* 3. 缓存管理区块 */}
|
||||
<CacheManager
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
|
||||
{/* 4. 底部功能卡片区 */}
|
||||
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-3 border-b border-dashed">
|
||||
<CardTitle className="text-sm font-semibold">功能区</CardTitle>
|
||||
<CardDescription className="text-[11px]">数据库导出及自定义高级 SQL 执行终端</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 功能一: 数据库导出 */}
|
||||
<div className="flex items-center justify-between p-4 border border-dashed rounded-lg hover:bg-muted/20 transition-colors duration-150">
|
||||
<div className="space-y-1 pr-4">
|
||||
<p className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Download className="size-4 text-primary" />
|
||||
数据库备份导出
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL 导出为打包的 .sql 文本文件)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs gap-1"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${exporting ? "animate-spin" : ""}`} />
|
||||
{exporting ? "正在准备..." : "开始导出"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 功能二: SQL 查询终端 */}
|
||||
<div className="flex items-center justify-between p-4 border border-dashed rounded-lg hover:bg-muted/20 transition-colors duration-150">
|
||||
<div className="space-y-1 pr-4">
|
||||
<p className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Terminal className="size-4 text-primary" />
|
||||
SQL 查询控台
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
打开在线 SQL 控制终端,执行原生的 SQL 进行数据筛选、更新、调试或性能优化
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1 bg-primary text-primary-foreground hover:bg-primary/95"
|
||||
onClick={() => {
|
||||
setShowConsole(true)
|
||||
}}
|
||||
>
|
||||
<FileText className="size-3.5" />
|
||||
进入控台
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {DatabasePageClient} from "./page-client"
|
||||
|
||||
export default function DatabasePage() {
|
||||
return <DatabasePageClient />
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {Code} from "lucide-react"
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
|
||||
// 引入模块化拆分后的 Tab 子组件
|
||||
import {DashboardTab} from "./tabs/dashboard-tab"
|
||||
import {TableTab} from "./tabs/table-tab"
|
||||
import {ControlsTab} from "./tabs/controls-tab"
|
||||
|
||||
export default function HeaderDemoPage() {
|
||||
const [activeTab, setActiveTab] = React.useState("dashboard")
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
{/* 1. 标准页面标题 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">开发规范示例</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 说明区域 */}
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="size-4 text-primary" />
|
||||
<CardTitle className="text-base font-semibold">规范说明</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
此页面展示了后台 Sidebar 关联页面的标准标题栏布局,供开发参考。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">核心规范细节与编写原则:</h3>
|
||||
<ul className="list-disc pl-4 text-xs text-muted-foreground space-y-1.5">
|
||||
<li><strong>结构专注</strong>:页面标题区必须保持绝对干净。请不要在标题下方放置任何描述段落或小字副标题,让页面在视线进入时保持极简与信息纯粹。</li>
|
||||
<li><strong>容器与上边距对齐</strong>:使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">flex items-center gap-2</code> 作为标题行基础容器(不含下边框),外层统一使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">py-6 px-1</code> (或 <code className="bg-muted px-1 py-0.5 rounded font-mono">py-6</code>) 进行上边距的视觉对齐,确保切换菜单时顶部高度感一致。</li>
|
||||
<li><strong>图标呈现</strong>:直接将 Lucide 图标组件嵌套于标题容器中,应用 <code className="bg-muted px-1 py-0.5 rounded font-mono">size-5 text-primary</code> 样式。禁止为图标包裹任何背景色小卡片、圆角或装饰性边框。</li>
|
||||
<li><strong>文本样式标准</strong>:文本使用且仅使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">{'h1 className="text-2xl font-semibold tracking-tight"'}</code>,禁用字重加粗(如 <code className="bg-muted px-1 py-0.5 rounded font-mono">font-bold</code>)或任何渐变色,保证全站字体一致性。</li>
|
||||
<li><strong>Tabs 模块化拆分</strong>:凡是带有多个 Tab 页切换的页面,**禁止**将所有 Tab 的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的 Content 必须单独拆分为独立组件文件(如 <code className="bg-muted px-1 py-0.5 rounded font-mono">tabs/events-tab.tsx</code> 或就近放在 <code className="bg-muted px-1 py-0.5 rounded font-mono">components/</code> 下),主页面文件只负责维护当前 Tab 的激活状态及外层骨架布局。</li>
|
||||
<li><strong>复杂度驱动的区块拆分</strong>:不仅是 Tabs 切换,当一个页面代码行数超过 600 行时,必须将高密度区块拆分为子组件。路由专属、不复用的子组件应放在对应路由的特征目录中(如 <code className="bg-muted px-1 py-0.5 rounded font-mono">app/(main)/admin/database/components/</code>),只有真正跨页面复用的组件才进入全局 <code className="bg-muted px-1 py-0.5 rounded font-mono">components/common/</code>。典型标杆案例参考“数据管理” (<code className="bg-muted px-1 py-0.5 rounded font-mono">/admin/database</code>) 的 <code className="bg-muted px-1 py-0.5 rounded font-mono">table-browser.tsx</code>、<code className="bg-muted px-1 py-0.5 rounded font-mono">cache-manager.tsx</code> 与 <code className="bg-muted px-1 py-0.5 rounded font-mono">sql-console.tsx</code>。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs 切换 */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
|
||||
<TabsTrigger value="dashboard" className="px-0 pb-2 text-xs font-semibold">
|
||||
仪表盘与指标卡片
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="table" className="px-0 pb-2 text-xs font-semibold">
|
||||
数据表格规范
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="controls" className="px-0 pb-2 text-xs font-semibold">
|
||||
输入控件与表单
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="dashboard" className="focus-visible:outline-none">
|
||||
<DashboardTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="table" className="focus-visible:outline-none">
|
||||
<TableTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="controls" className="focus-visible:outline-none">
|
||||
<ControlsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {Code} from "lucide-react"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {Checkbox} from "@/components/ui/checkbox"
|
||||
import {Button} from "@/components/ui/button"
|
||||
|
||||
export function ControlsTab() {
|
||||
const [formData, setFormData] = React.useState({
|
||||
name: "",
|
||||
category: "database",
|
||||
allowNotify: true,
|
||||
channel: "all",
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 控件展示 */}
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">表单与输入框交互演示</CardTitle>
|
||||
<CardDescription>包含按钮变体、下拉选择、多选框及基础输入框的标准样式</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 输入框 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="task-name" className="text-xs font-medium text-foreground">任务名称</Label>
|
||||
<Input
|
||||
id="task-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="输入要创建的任务描述..."
|
||||
className="h-8 text-xs shadow-none bg-background focus-visible:ring-1"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">任务名称将用于后台执行记录的可读性显示</p>
|
||||
</div>
|
||||
|
||||
{/* 下拉框 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-foreground">任务分类</Label>
|
||||
<Select
|
||||
value={formData.category}
|
||||
onValueChange={(val) => setFormData(prev => ({ ...prev, category: val }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs shadow-none bg-background focus:ring-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="database">数据库运维</SelectItem>
|
||||
<SelectItem value="sync">数据批量同步</SelectItem>
|
||||
<SelectItem value="cleanup">过期磁盘清理</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 多选框 Checkbox */}
|
||||
<div className="flex items-start gap-2.5 p-3 bg-muted/30 border border-dashed rounded-lg">
|
||||
<Checkbox
|
||||
id="allow-notify"
|
||||
checked={formData.allowNotify}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, allowNotify: checked === true }))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="grid gap-1">
|
||||
<label
|
||||
htmlFor="allow-notify"
|
||||
className="text-xs font-medium text-foreground cursor-pointer select-none leading-none"
|
||||
>
|
||||
允许触发异步通知推送
|
||||
</label>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
勾选后,在当前后台任务顺利执行结束时,会关联派发多渠道通知推送。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮变体 */}
|
||||
<div className="pt-2">
|
||||
<div className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">按钮样式对齐:</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" className="h-8 text-xs shadow-none">
|
||||
主要操作 (Primary)
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="h-8 text-xs shadow-none">
|
||||
次要操作 (Secondary)
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 border-dashed text-xs shadow-none">
|
||||
辅助线框 (Outline)
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-xs hover:bg-muted">
|
||||
幽灵按钮 (Ghost)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 控件设计规范说明 */}
|
||||
<Card className="border-dashed shadow-none bg-muted/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<Code className="size-4 text-primary" />
|
||||
表单设计规范
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-xs text-muted-foreground">
|
||||
<div className="space-y-2 leading-relaxed">
|
||||
<p className="font-semibold text-foreground text-[11px]">1. 统一控件高度与投影:</p>
|
||||
<p>
|
||||
输入框 (<code className="bg-muted px-1 py-0.5 rounded font-mono">Input</code>) 与下拉选择器 (<code className="bg-muted px-1 py-0.5 rounded font-mono">SelectTrigger</code>) 的高度应统一限制为小号高度 <code className="bg-muted px-1 py-0.5 rounded font-mono">h-8</code>,字体大小使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">text-xs</code>,并使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">shadow-none</code> 去除原生的重叠阴影。
|
||||
</p>
|
||||
|
||||
<p className="font-semibold text-foreground text-[11px] pt-1">2. Checkbox 与文本的对齐:</p>
|
||||
<p>
|
||||
Checkbox 应在左侧垂直偏上对齐,使用容器 <code className="bg-muted px-1 py-0.5 rounded font-mono">flex items-start gap-2.5</code> 排列。Checkbox 组件本身添加 <code className="bg-muted px-1 py-0.5 rounded font-mono">mt-0.5</code>,从而在多行描述文本的情况下依然保持良好的顶对齐,避免出现居中对齐导致的凌乱感。
|
||||
</p>
|
||||
|
||||
<p className="font-semibold text-foreground text-[11px] pt-1">3. 按钮使用策略:</p>
|
||||
<p>
|
||||
普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要 <code className="bg-muted px-1 py-0.5 rounded font-mono">{'variant="secondary"'}</code> 按钮,小号高度统一采用 <code className="bg-muted px-1 py-0.5 rounded font-mono">h-8 text-xs</code>。线框按钮必须带有 <code className="bg-muted px-1 py-0.5 rounded font-mono">border-dashed shadow-none</code> 类。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-background/80 p-3 rounded border border-border/40 font-mono text-[10px] space-y-2 overflow-x-auto">
|
||||
<div>{`// 1. Label 与 Input 对齐`}</div>
|
||||
<div>{`<div className="space-y-1.5">
|
||||
<Label htmlFor="id" className="text-xs font-medium text-foreground">标题</Label>
|
||||
<Input id="id" className="h-8 text-xs shadow-none" />
|
||||
</div>`}</div>
|
||||
<div className="pt-2">{`// 2. Checkbox 顶对齐`}</div>
|
||||
<div>{`<div className="flex items-start gap-2.5">
|
||||
<Checkbox id="c" className="mt-0.5" />
|
||||
<div className="grid gap-1">
|
||||
<label htmlFor="c" className="text-xs font-medium leading-none">标题</label>
|
||||
<p className="text-[10px] text-muted-foreground">多行说明内容</p>
|
||||
</div>
|
||||
</div>`}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {Code, Cpu, Database, HardDrive} from "lucide-react"
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Progress} from "@/components/ui/progress"
|
||||
|
||||
export function DashboardTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* CPU */}
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">容器 CPU 使用率</span>
|
||||
<Cpu className="size-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-2xl font-semibold tracking-tight">42.8 %</div>
|
||||
<Progress value={42.8} className="h-1.5" />
|
||||
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<span className="size-1.5 rounded-full bg-emerald-500 inline-block animate-pulse" />
|
||||
容器运行正常,负载平稳
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Storage */}
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">存储已用容量</span>
|
||||
<HardDrive className="size-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-2xl font-semibold tracking-tight">72.4 GB <span className="text-xs text-muted-foreground">/ 100 GB</span></div>
|
||||
<Progress value={72.4} className="h-1.5" />
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
数据已同步远端 S3 存储桶,支持自动过期
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* DB Overview */}
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">物理数据库</span>
|
||||
<Database className="size-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-2xl font-semibold tracking-tight">PostgreSQL <span className="text-xs font-normal text-muted-foreground">(v16.2)</span></div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div>活跃连接: <span className="font-semibold text-foreground">12</span></div>
|
||||
<div>总表数量: <span className="font-semibold text-foreground">34</span></div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
链接池自动维护中,最大连接限制为 100
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 指标卡片规范与代码 */}
|
||||
<Card className="border-dashed shadow-none bg-muted/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<Code className="size-4 text-primary" />
|
||||
指标卡片 & 仪表盘设计规范
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ul className="list-disc pl-4 text-xs text-muted-foreground space-y-1.5">
|
||||
<li>指标卡片统一采用 <code className="bg-muted px-1 py-0.5 rounded font-mono">border-dashed shadow-none</code> 作为边框和投影。</li>
|
||||
<li>头部使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">flex flex-row items-center justify-between pb-2</code> 布局,左侧为标题小字,右侧为直接呈现的 Lucide 图标。</li>
|
||||
<li>数值强调使用且仅使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">text-2xl font-semibold tracking-tight</code> 展示。</li>
|
||||
<li>进度条使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">h-1.5</code> 细条高度,不喧宾夺主。</li>
|
||||
<li>底部的次要说明信息必须统一使用 <code className="bg-muted px-1 py-0.5 rounded font-mono">text-[10px] text-muted-foreground</code> 等辅助类。</li>
|
||||
</ul>
|
||||
<pre className="text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed">
|
||||
{`<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">指标标题</span>
|
||||
<Cpu className="size-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-2xl font-semibold tracking-tight">42.8 %</div>
|
||||
<Progress value={42.8} className="h-1.5" />
|
||||
<p className="text-[10px] text-muted-foreground">状态说明小字</p>
|
||||
</CardContent>
|
||||
</Card>`}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {Code, Edit, ListFilter, Plus, Search, Trash2} from "lucide-react"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
|
||||
export function TableTab() {
|
||||
const mockTableData = [
|
||||
{ id: "TX-1002", name: "用户信息批量同步导出", type: "数据同步", status: "success", duration: "1.2s", time: "2026-06-15 14:22" },
|
||||
{ id: "TX-1003", name: "系统数据库每周自动备份归档", type: "数据库运维", status: "running", duration: "在执行中", time: "2026-06-15 15:00" },
|
||||
{ id: "TX-1004", name: "远端S3过期缓存对象自动清理任务", type: "磁盘清理", status: "failed", duration: "0.4s", time: "2026-06-15 10:05" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 表格控制工具栏 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-2.5 top-2.5 size-3 text-muted-foreground" />
|
||||
<Input placeholder="输入任务名称搜索..." className="h-8 pl-8 text-xs w-full shadow-none border-dashed bg-background" />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-8 border-dashed text-xs shadow-none">
|
||||
<ListFilter className="size-3 mr-1" />
|
||||
过滤
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" className="h-8 text-xs shadow-none">
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
新建任务
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 数据表格本身 */}
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
|
||||
<Table className="w-full caption-bottom text-sm min-w-full">
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow className="border-dashed hover:bg-transparent">
|
||||
<TableHead className="w-[100px] text-xs font-semibold">任务编号</TableHead>
|
||||
<TableHead className="text-xs font-semibold">执行任务名称</TableHead>
|
||||
<TableHead className="w-[120px] text-xs font-semibold">任务类型</TableHead>
|
||||
<TableHead className="w-[100px] text-xs font-semibold">状态</TableHead>
|
||||
<TableHead className="w-[100px] text-xs font-semibold">执行耗时</TableHead>
|
||||
<TableHead className="w-[150px] text-xs font-semibold">触发时间</TableHead>
|
||||
<TableHead className="w-[80px] text-xs font-semibold text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mockTableData.map((row) => (
|
||||
<TableRow key={row.id} className="border-dashed hover:bg-muted/10 transition-colors">
|
||||
<TableCell className="font-mono text-xs font-semibold">{row.id}</TableCell>
|
||||
<TableCell className="text-xs font-medium text-foreground">{row.name}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{row.type}</TableCell>
|
||||
<TableCell>
|
||||
{row.status === "success" && (
|
||||
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2 font-medium">
|
||||
<span className="size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0" />
|
||||
成功
|
||||
</Badge>
|
||||
)}
|
||||
{row.status === "running" && (
|
||||
<Badge variant="outline" className="text-[10px] bg-blue-500/10 border-blue-500/20 text-blue-600 rounded-full py-0 px-2 font-medium">
|
||||
<span className="size-1 bg-blue-500 rounded-full mr-1.5 shrink-0 animate-pulse" />
|
||||
运行中
|
||||
</Badge>
|
||||
)}
|
||||
{row.status === "failed" && (
|
||||
<Badge variant="outline" className="text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2 font-medium">
|
||||
<span className="size-1 bg-destructive rounded-full mr-1.5 shrink-0" />
|
||||
失败
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground font-mono">{row.duration}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{row.time}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 rounded hover:bg-muted text-muted-foreground">
|
||||
<Edit className="size-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 rounded hover:bg-destructive/10 text-destructive">
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 表格规范指引 */}
|
||||
<Card className="border-dashed shadow-none bg-muted/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<Code className="size-4 text-primary" />
|
||||
表格设计规范与代码模板
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ul className="list-disc pl-4 text-xs text-muted-foreground space-y-1.5">
|
||||
<li>表格容器必须统一使用带有虚线边框的容器,即类名:<code className="bg-muted px-1 py-0.5 rounded font-mono">border border-dashed shadow-none rounded-lg overflow-hidden bg-background</code>。</li>
|
||||
<li>表头背景设为 <code className="bg-muted px-1 py-0.5 rounded font-mono">bg-muted/40</code>。表头表项字号统一为小字粗体 <code className="bg-muted px-1 py-0.5 rounded font-mono">text-xs font-semibold</code>。</li>
|
||||
<li>单元格行与列之间使用虚线分割(<code className="bg-muted px-1 py-0.5 rounded font-mono">border-dashed</code>)。表格行悬浮交互采用温和过渡色 <code className="bg-muted px-1 py-0.5 rounded font-mono">hover:bg-muted/10 transition-colors</code>。</li>
|
||||
<li>编码、主键或唯一 ID 类字段一律采用等宽字体呈现:<code className="bg-muted px-1 py-0.5 rounded font-mono">font-mono text-xs</code>。</li>
|
||||
<li>状态标签(Badge)尽量圆角化(<code className="bg-muted px-1 py-0.5 rounded font-mono">rounded-full py-0 px-2</code>),并以浅背景配以各自对应的图标圆点,以实现现代、高对比度的视觉质感。</li>
|
||||
</ul>
|
||||
<pre className="text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed">
|
||||
{`<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow className="border-dashed hover:bg-transparent">
|
||||
<TableHead className="text-xs font-semibold">编号</TableHead>
|
||||
<TableHead className="text-xs font-semibold">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className="border-dashed hover:bg-muted/10 transition-colors">
|
||||
<TableCell className="font-mono text-xs">ID-1001</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2">
|
||||
<span className="size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0" />
|
||||
成功
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>`}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {AnimatePresence, motion} from "motion/react"
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileImage,
|
||||
FileText,
|
||||
FileVideo,
|
||||
Loader2,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Checkbox} from "@/components/ui/checkbox"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet"
|
||||
import {FileImagePreview} from "@/components/common/file-image-preview"
|
||||
import services, {formatFileSize} from "@/lib/services"
|
||||
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
|
||||
|
||||
/* ─── 工具函数 ─────────────────────────────────────────── */
|
||||
|
||||
function getFileIcon(mimeType: string, className = "size-8") {
|
||||
if (mimeType.startsWith("image/")) return <FileImage className={`${className} text-blue-400`} />
|
||||
if (mimeType.startsWith("video/")) return <FileVideo className={`${className} text-purple-400`} />
|
||||
if (mimeType.startsWith("audio/")) return <FileAudio className={`${className} text-green-400`} />
|
||||
if (mimeType.includes("zip") || mimeType.includes("tar") || mimeType.includes("gzip"))
|
||||
return <FileArchive className={`${className} text-amber-400`} />
|
||||
return <FileText className={`${className} text-slate-400`} />
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export function FileList() {
|
||||
const queryClient = useQueryClient()
|
||||
const [keyword, setKeyword] = React.useState("")
|
||||
const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
|
||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set())
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(null)
|
||||
const [detailTarget, setDetailTarget] = React.useState<UploadRecord | null>(null)
|
||||
const [page, setPage] = React.useState(1)
|
||||
const pageSize = 15 // 表格视图下 15 条更紧凑
|
||||
|
||||
// 搜索防抖
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedKeyword(keyword)
|
||||
setPage(1)
|
||||
}, 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [keyword])
|
||||
|
||||
// 文件列表查询
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["files", "all", page, pageSize, debouncedKeyword],
|
||||
queryFn: () => services.adminUpload.listUploads(page, pageSize, debouncedKeyword || undefined),
|
||||
})
|
||||
|
||||
const storageDriverQuery = useQuery({
|
||||
queryKey: ["admin", "storage-config", "driver"],
|
||||
queryFn: async () => {
|
||||
const record = await services.adminSystemConfig.getSystemConfig("storage_config")
|
||||
const cfg = JSON.parse(record.value) as {driver?: string}
|
||||
return cfg.driver ?? "local"
|
||||
},
|
||||
})
|
||||
|
||||
const files = listQuery.data?.items ?? []
|
||||
const total = listQuery.data?.total ?? 0
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
const isAllSelected = files.length > 0 && files.every((f) => selectedIds.has(f.id))
|
||||
const isSomeSelected = files.length > 0 && files.some((f) => selectedIds.has(f.id)) && !isAllSelected
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (isAllSelected) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
files.forEach((f) => next.delete(f.id))
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
files.forEach((f) => next.add(f.id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 删除单文件
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => services.adminUpload.deleteFile(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["files", "my"] })
|
||||
void queryClient.invalidateQueries({ queryKey: ["files", "stats"] })
|
||||
toast.success("文件已删除")
|
||||
setDeleteTarget(null)
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message || "删除失败"),
|
||||
})
|
||||
|
||||
// 批量 ZIP 下载
|
||||
const batchDownloadMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => services.adminUpload.batchDownload(ids),
|
||||
onSuccess: (blob) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "batch_download.zip"
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success("批量下载已开始")
|
||||
},
|
||||
onError: () => toast.error("批量下载失败"),
|
||||
})
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearSelection = () => setSelectedIds(new Set())
|
||||
|
||||
const selectAll = () => setSelectedIds(new Set(files.map((f) => f.id)))
|
||||
|
||||
const handleDownload = (file: UploadRecord) => {
|
||||
const url = services.adminUpload.getDownloadUrl(file.id)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = file.file_name
|
||||
a.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索文件名..."
|
||||
className="pl-8 h-8 text-xs border-dashed rounded-lg focus-visible:ring-0"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
{keyword && (
|
||||
<button
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setKeyword("")}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{files.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs h-8 border border-dashed text-muted-foreground"
|
||||
onClick={selectedIds.size === files.length ? clearSelection : selectAll}
|
||||
>
|
||||
{selectedIds.size === files.length ? "取消全选" : "全选本页"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 打包下载等批量操作 */}
|
||||
<div className="flex items-center gap-2 ml-auto shrink-0">
|
||||
<AnimatePresence>
|
||||
{selectedIds.size > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs px-2.5">
|
||||
已选 {selectedIds.size} 个
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-dashed text-xs h-8"
|
||||
onClick={() => batchDownloadMutation.mutate([...selectedIds])}
|
||||
disabled={batchDownloadMutation.isPending}
|
||||
>
|
||||
{batchDownloadMutation.isPending ? (
|
||||
<Loader2 className="size-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<FileArchive className="size-3.5 mr-1" />
|
||||
)}
|
||||
打包下载
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs h-8 px-2"
|
||||
onClick={clearSelection}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{total > 0 && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">共 {total} 个文件</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文件列表 Table */}
|
||||
{listQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="size-6 animate-spin text-sky-500" />
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4 text-muted-foreground">
|
||||
<Upload className="size-12 text-muted-foreground/30" />
|
||||
<p className="text-sm">
|
||||
{debouncedKeyword ? "没有匹配的文件" : "您还没有上传任何文件"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-dashed rounded-xl bg-card overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-dashed">
|
||||
<TableHead className="w-[50px] pl-4 py-3">
|
||||
<Checkbox
|
||||
checked={isAllSelected || (isSomeSelected ? "indeterminate" : false)}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] py-3">预览</TableHead>
|
||||
<TableHead className="w-[180px] py-3">ID</TableHead>
|
||||
<TableHead className="py-3">文件名</TableHead>
|
||||
<TableHead className="max-w-[200px] truncate py-3">路径</TableHead>
|
||||
<TableHead className="w-[100px] py-3">业务类别</TableHead>
|
||||
<TableHead className="w-[125px] py-3">MIME类型</TableHead>
|
||||
<TableHead className="w-[100px] py-3">大小</TableHead>
|
||||
<TableHead className="w-[150px] py-3">上传时间</TableHead>
|
||||
<TableHead className="w-[120px] text-right pr-4 py-3">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.map((file) => {
|
||||
const isSelected = selectedIds.has(file.id)
|
||||
return (
|
||||
<TableRow
|
||||
key={file.id}
|
||||
className={`border-dashed hover:bg-muted/30 transition-colors ${
|
||||
isSelected ? "bg-sky-500/5 hover:bg-sky-500/10" : ""
|
||||
}`}
|
||||
>
|
||||
<TableCell className="pl-4 py-3">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelect(file.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="flex items-center justify-center size-9 rounded-lg bg-muted/40 overflow-hidden border">
|
||||
{file.mime_type.startsWith("image/") ? (
|
||||
<FileImagePreview
|
||||
fileId={file.id}
|
||||
alt={file.file_name}
|
||||
quality="low"
|
||||
variant="compact"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
getFileIcon(file.mime_type, "size-4.5")
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-3 font-mono text-[11px] text-muted-foreground select-all">
|
||||
{file.id}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 font-medium max-w-[180px] truncate text-xs" title={file.file_name}>
|
||||
{file.file_name}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 font-mono text-[11px] max-w-[200px] truncate text-muted-foreground select-all" title={file.file_path}>
|
||||
{file.file_path}
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<Badge variant="secondary" className="text-[10px] py-0 px-1.5 font-normal rounded-md">
|
||||
{file.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-[11px] text-muted-foreground truncate max-w-[125px]" title={file.mime_type}>
|
||||
{file.mime_type}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-xs">
|
||||
{formatFileSize(file.file_size)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-xs text-muted-foreground">
|
||||
{formatDate(file.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-right pr-4">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
title="查看详情"
|
||||
onClick={() => setDetailTarget(file)}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md hover:bg-muted"
|
||||
title="下载"
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
title="删除"
|
||||
onClick={() => setDeleteTarget(file)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-dashed text-xs h-7 px-3"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-dashed text-xs h-7 px-3"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情查看 Sheet */}
|
||||
<Sheet open={!!detailTarget} onOpenChange={(open) => !open && setDetailTarget(null)}>
|
||||
<SheetContent className="w-full sm:max-w-md overflow-y-auto p-6">
|
||||
<SheetHeader className="border-b pb-4 px-0">
|
||||
<SheetTitle>文件详情</SheetTitle>
|
||||
<SheetDescription>查看文件的完整元数据与配置属性。</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{detailTarget && (
|
||||
<div className="space-y-6 py-4">
|
||||
{/* 大图/格式预览 */}
|
||||
<div className="flex items-center justify-center h-48 rounded-xl bg-muted/30 border border-dashed overflow-hidden p-2">
|
||||
{detailTarget.mime_type.startsWith("image/") ? (
|
||||
<FileImagePreview
|
||||
fileId={detailTarget.id}
|
||||
alt={detailTarget.file_name}
|
||||
quality="low"
|
||||
className="max-h-full max-w-full object-contain rounded-lg shadow-sm"
|
||||
fallbackClassName="min-h-32 w-full rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{getFileIcon(detailTarget.mime_type, "size-14")}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase">
|
||||
{detailTarget.extension} 文件
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 元数据列表 */}
|
||||
<div className="space-y-4 text-xs">
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">文件 ID</span>
|
||||
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">文件名</span>
|
||||
<span className="col-span-2 break-all font-medium text-foreground/90">{detailTarget.file_name}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">存储路径</span>
|
||||
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.file_path}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">大小</span>
|
||||
<span className="col-span-2 text-foreground/90">
|
||||
{formatFileSize(detailTarget.file_size)} ({detailTarget.file_size.toLocaleString()} 字节)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">MIME 类型</span>
|
||||
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.mime_type}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">文件后缀</span>
|
||||
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.extension}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">Hash (SHA-256)</span>
|
||||
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.hash}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">业务类型</span>
|
||||
<span className="col-span-2">
|
||||
<Badge variant="secondary" className="text-[10px] py-0 px-2 rounded-md font-normal">
|
||||
{detailTarget.type}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">存储驱动</span>
|
||||
<span className="col-span-2 font-mono text-foreground/90">{storageDriverQuery.data ?? "local"}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">上传者 ID</span>
|
||||
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.user_id}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
|
||||
<span className="text-muted-foreground font-medium">上传时间</span>
|
||||
<span className="col-span-2 text-foreground/90">{formatDate(detailTarget.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{detailTarget.metadata && Object.keys(detailTarget.metadata).length > 0 && (
|
||||
<div className="space-y-1.5 pt-2">
|
||||
<span className="text-muted-foreground font-medium block">额外元数据</span>
|
||||
<pre className="p-3 bg-muted/40 border rounded-lg text-[10px] font-mono overflow-x-auto text-foreground/90 max-h-40 overflow-y-auto">
|
||||
{JSON.stringify(detailTarget.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* 删除确认 Dialog */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除文件{" "}
|
||||
<span className="font-semibold text-foreground">「{deleteTarget?.file_name}」</span>{" "}
|
||||
吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {Database, Files, HardDrive, Info, Loader2, TrendingUp, Upload,} from "lucide-react"
|
||||
import {Area, AreaChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis,} from "recharts"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import services, {formatFileSize} from "@/lib/services"
|
||||
|
||||
const categoryMap: Record<string, string> = {
|
||||
"图片": "images",
|
||||
"视频": "videos",
|
||||
"音频": "audio",
|
||||
"文档": "documents",
|
||||
"压缩包": "archives",
|
||||
"其他": "others",
|
||||
}
|
||||
|
||||
const statsChartConfig = {
|
||||
images: {
|
||||
label: "图片",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
videos: {
|
||||
label: "视频",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
audio: {
|
||||
label: "音频",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
documents: {
|
||||
label: "文档",
|
||||
color: "hsl(var(--chart-4))",
|
||||
},
|
||||
archives: {
|
||||
label: "压缩包",
|
||||
color: "hsl(var(--chart-5))",
|
||||
},
|
||||
others: {
|
||||
label: "其他",
|
||||
color: "hsl(var(--chart-6))",
|
||||
},
|
||||
count: {
|
||||
label: "新增文件数",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
size: {
|
||||
label: "新增大小",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function FileStats() {
|
||||
const [trendMetric, setTrendMetric] = React.useState<"count" | "size">("count")
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["files", "stats"],
|
||||
queryFn: () => services.adminUpload.getFileStats(),
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
})
|
||||
|
||||
const stats = statsQuery.data
|
||||
|
||||
const trendData = React.useMemo(() => {
|
||||
if (!stats?.trend) return []
|
||||
return stats.trend.map((item) => ({
|
||||
...item,
|
||||
formattedDate: item.date.substring(5), // YYYY-MM-DD -> MM-DD
|
||||
value: trendMetric === "count" ? item.count : item.size,
|
||||
}))
|
||||
}, [stats?.trend, trendMetric])
|
||||
|
||||
const categoryCountData = React.useMemo(() => {
|
||||
if (!stats?.categories) return []
|
||||
return stats.categories
|
||||
.filter((c) => c.count > 0)
|
||||
.map((c) => {
|
||||
const key = categoryMap[c.name] || "others"
|
||||
return {
|
||||
name: key,
|
||||
value: c.count,
|
||||
fill: `var(--color-${key})`,
|
||||
}
|
||||
})
|
||||
}, [stats?.categories])
|
||||
|
||||
const categorySizeData = React.useMemo(() => {
|
||||
if (!stats?.categories) return []
|
||||
return stats.categories
|
||||
.filter((c) => c.size > 0)
|
||||
.map((c) => {
|
||||
const key = categoryMap[c.name] || "others"
|
||||
return {
|
||||
name: key,
|
||||
value: c.size,
|
||||
fill: `var(--color-${key})`,
|
||||
}
|
||||
})
|
||||
}, [stats?.categories])
|
||||
|
||||
const maxStats = React.useMemo(() => {
|
||||
if (!stats?.categories || stats.categories.length === 0) {
|
||||
return { maxCountName: "无", maxCount: 0, maxSizeName: "无", maxSize: 0 }
|
||||
}
|
||||
let maxCountName = "无"
|
||||
let maxCount = 0
|
||||
let maxSizeName = "无"
|
||||
let maxSize = 0
|
||||
|
||||
stats.categories.forEach((cat) => {
|
||||
if (cat.count > maxCount) {
|
||||
maxCount = cat.count
|
||||
maxCountName = cat.name
|
||||
}
|
||||
if (cat.size > maxSize) {
|
||||
maxSize = cat.size
|
||||
maxSizeName = cat.name
|
||||
}
|
||||
})
|
||||
|
||||
return { maxCountName, maxCount, maxSizeName, maxSize }
|
||||
}, [stats?.categories])
|
||||
|
||||
const trendSummary = React.useMemo(() => {
|
||||
if (!stats?.trend) return { count: 0, size: 0 }
|
||||
return stats.trend.reduce(
|
||||
(acc, curr) => ({
|
||||
count: acc.count + curr.count,
|
||||
size: acc.size + curr.size,
|
||||
}),
|
||||
{ count: 0, size: 0 }
|
||||
)
|
||||
}, [stats?.trend])
|
||||
|
||||
if (statsQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<Loader2 className="size-8 animate-spin text-sky-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stats || stats.total_count === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 border border-dashed rounded-xl bg-card text-muted-foreground gap-4">
|
||||
<Upload className="size-14 text-muted-foreground/25" />
|
||||
<div className="text-center space-y-1">
|
||||
<p className="font-medium">暂无上传数据</p>
|
||||
<p className="text-xs text-muted-foreground">上传文件后即可在此查看容量与文件分布分析。</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 四个指标卡片 */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">总文件数</CardTitle>
|
||||
<Files className="size-4 text-sky-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono text-sky-500">
|
||||
{stats.total_count.toLocaleString()}{" "}
|
||||
<span className="text-xs font-normal text-muted-foreground">个</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">您上传的可用文件数量汇总</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">占用存储容量</CardTitle>
|
||||
<Database className="size-4 text-emerald-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono text-emerald-500">{formatFileSize(stats.total_size)}</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">文件实际在磁盘/S3中占用的总空间</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">近 7 天新增文件</CardTitle>
|
||||
<TrendingUp className="size-4 text-indigo-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono text-indigo-500">
|
||||
+{trendSummary.count.toLocaleString()}{" "}
|
||||
<span className="text-xs font-normal text-muted-foreground">个</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传成功的文件总数</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/25 border-border/40 hover:shadow-md transition-shadow">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-xs font-semibold tracking-tight text-muted-foreground">近 7 天新增大小</CardTitle>
|
||||
<HardDrive className="size-4 text-amber-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono text-amber-500">+{formatFileSize(trendSummary.size)}</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传所消耗的存储带宽</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 7 天新增趋势折线图 */}
|
||||
<Card className="bg-card/20 border-border/40">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-bold">最近 7 天新增趋势</CardTitle>
|
||||
<CardDescription className="text-xs">按日统计近一周期内的上传波动</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 border rounded-lg p-0.5 bg-muted/30">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={trendMetric === "count" ? "secondary" : "ghost"}
|
||||
className="h-7 text-xs px-2.5 rounded-md"
|
||||
onClick={() => setTrendMetric("count")}
|
||||
>
|
||||
新增数量 (个)
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={trendMetric === "size" ? "secondary" : "ghost"}
|
||||
className="h-7 text-xs px-2.5 rounded-md"
|
||||
onClick={() => setTrendMetric("size")}
|
||||
>
|
||||
新增大小 (MB)
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2 pr-4 pt-2">
|
||||
<div className="h-[280px] w-full">
|
||||
<ChartContainer config={statsChartConfig} className="w-full h-full">
|
||||
<AreaChart data={trendData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-count)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--color-count)" stopOpacity={0.01} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorSize" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-size)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--color-size)" stopOpacity={0.01} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border)/40)" />
|
||||
<XAxis
|
||||
dataKey="formattedDate"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
tickFormatter={(v) => (trendMetric === "size" ? formatFileSize(v) : v.toString())}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
formatter={(value) => {
|
||||
const label = trendMetric === "count" ? "新增文件数" : "新增大小"
|
||||
const formattedValue =
|
||||
trendMetric === "size" ? formatFileSize(Number(value)) : `${value} 个`
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full animate-pulse"
|
||||
style={{
|
||||
backgroundColor:
|
||||
trendMetric === "count" ? "var(--color-count)" : "var(--color-size)",
|
||||
}}
|
||||
/>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
|
||||
{formattedValue}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={trendMetric === "count" ? "var(--color-count)" : "var(--color-size)"}
|
||||
strokeWidth={2.5}
|
||||
fillOpacity={1}
|
||||
fill={trendMetric === "count" ? "url(#colorCount)" : "url(#colorSize)"}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 两个饼图分布 */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* 饼图 1: 格式数量分布 */}
|
||||
<Card className="bg-card/20 border-border/40 flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-bold">文件格式数量分布</CardTitle>
|
||||
<CardDescription className="text-xs">各种格式类型的文件个数占比</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col justify-between gap-4">
|
||||
{categoryCountData.length === 0 ? (
|
||||
<div className="h-[240px] flex items-center justify-center text-xs text-muted-foreground">
|
||||
暂无分类数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[240px] w-full">
|
||||
<ChartContainer config={statsChartConfig} className="w-full h-full">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categoryCountData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{categoryCountData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
formatter={(value, name) => {
|
||||
const configObj = statsChartConfig[name as keyof typeof statsChartConfig]
|
||||
const label = configObj?.label || name
|
||||
const color = configObj?.color || "hsl(var(--muted))"
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
|
||||
{value} 个
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent nameKey="name" />}
|
||||
className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-sky-500/5 border border-sky-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-sky-600 dark:text-sky-400">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<p className="leading-normal">
|
||||
文件类型中数量最多的是{" "}
|
||||
<strong className="font-bold">「{maxStats.maxCountName}」</strong>,累计上传了{" "}
|
||||
<strong className="font-mono font-bold">{maxStats.maxCount}</strong> 个文件。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 饼图 2: 格式容量分布 */}
|
||||
<Card className="bg-card/20 border-border/40 flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-bold">文件占用容量分布</CardTitle>
|
||||
<CardDescription className="text-xs">各种格式类型的文件大小占比</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col justify-between gap-4">
|
||||
{categorySizeData.length === 0 ? (
|
||||
<div className="h-[240px] flex items-center justify-center text-xs text-muted-foreground">
|
||||
暂无容量数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[240px] w-full">
|
||||
<ChartContainer config={statsChartConfig} className="w-full h-full">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categorySizeData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{categorySizeData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
formatter={(value, name) => {
|
||||
const configObj = statsChartConfig[name as keyof typeof statsChartConfig]
|
||||
const label = configObj?.label || name
|
||||
const color = configObj?.color || "hsl(var(--muted))"
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
|
||||
{formatFileSize(Number(value))}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent nameKey="name" />}
|
||||
className="flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4"
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-emerald-500/5 border border-emerald-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<p className="leading-normal">
|
||||
文件类型中占用存储最大的是{" "}
|
||||
<strong className="font-bold">「{maxStats.maxSizeName}」</strong>,共消耗了{" "}
|
||||
<strong className="font-mono font-bold">{formatFileSize(maxStats.maxSize)}</strong>{" "}
|
||||
存储容量。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {Database, Loader2, Play, Save} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Field, FieldDescription, FieldGroup, FieldLabel} from "@/components/ui/field"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Progress} from "@/components/ui/progress"
|
||||
import {Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import services from "@/lib/services"
|
||||
import type {
|
||||
ObjectStorageConfig,
|
||||
StorageConfig,
|
||||
StorageDriver,
|
||||
TaskExecution,
|
||||
TaskExecutionStatus
|
||||
} from "@/lib/services/admin/types"
|
||||
|
||||
const storageConfigKey = "storage_config"
|
||||
const storageMigrationTaskType = "storage_migration"
|
||||
|
||||
const driverLabels: Record<StorageDriver, string> = {
|
||||
local: "本地文件系统",
|
||||
s3: "AWS S3",
|
||||
r2: "Cloudflare R2",
|
||||
minio: "MinIO",
|
||||
oss: "阿里云 OSS",
|
||||
webdav: "WebDAV",
|
||||
}
|
||||
|
||||
const emptyObjectConfig: ObjectStorageConfig = {
|
||||
endpoint: "",
|
||||
region: "",
|
||||
bucket: "",
|
||||
access_key_id: "",
|
||||
secret_access_key: "",
|
||||
path_style: false,
|
||||
key_prefix: "",
|
||||
cdn_url: "",
|
||||
}
|
||||
|
||||
function normalizeConfig(config: StorageConfig): StorageConfig {
|
||||
const s3 = {...emptyObjectConfig, ...config.s3}
|
||||
const r2 = {...emptyObjectConfig, ...config.r2}
|
||||
const minio = {...emptyObjectConfig, ...config.minio}
|
||||
const oss = {...emptyObjectConfig, ...config.oss}
|
||||
const webdav = {...config.webdav}
|
||||
return {
|
||||
...config,
|
||||
local: {root: config.local?.root || "."},
|
||||
s3: {...s3, region: s3.region || "us-east-1"},
|
||||
r2: {...r2, region: r2.region || "auto"},
|
||||
minio: {...minio, region: minio.region || "us-east-1", path_style: config.minio?.path_style ?? true},
|
||||
oss,
|
||||
webdav: {
|
||||
endpoint: webdav.endpoint || "",
|
||||
username: webdav.username || "",
|
||||
password: webdav.password || "",
|
||||
base_path: webdav.base_path || "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StorageMigrationView = {
|
||||
state: "idle" | TaskExecutionStatus
|
||||
id?: string
|
||||
task_id?: string
|
||||
source_driver?: StorageDriver
|
||||
target_driver?: StorageDriver
|
||||
error?: string
|
||||
}
|
||||
|
||||
type StorageMigrationPayload = {
|
||||
target: StorageConfig
|
||||
}
|
||||
|
||||
function parseMigrationPayload(payload: string): StorageConfig | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as Partial<StorageMigrationPayload>
|
||||
return parsed.target ? normalizeConfig(parsed.target) : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function driverLabel(driver?: StorageDriver): string {
|
||||
return driver ? driverLabels[driver] : "未知"
|
||||
}
|
||||
|
||||
function latestMigration(executions: TaskExecution[], current?: StorageConfig): StorageMigrationView {
|
||||
const execution = executions[0]
|
||||
if (!execution || execution.status === "succeeded") {
|
||||
return {state: "idle"}
|
||||
}
|
||||
const target = parseMigrationPayload(execution.payload)
|
||||
return {
|
||||
state: execution.status,
|
||||
id: execution.id,
|
||||
task_id: execution.task_id,
|
||||
source_driver: current?.driver,
|
||||
target_driver: target?.driver,
|
||||
error: execution.error_message,
|
||||
}
|
||||
}
|
||||
|
||||
export function StorageConfigTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [config, setConfig] = React.useState<StorageConfig | null>(null)
|
||||
const query = useQuery({
|
||||
queryKey: ["admin", "storage-config"],
|
||||
queryFn: async () => {
|
||||
const [configRecord, executions] = await Promise.all([
|
||||
services.adminSystemConfig.getSystemConfig(storageConfigKey),
|
||||
services.adminTask.listTaskExecutions({task_type: storageMigrationTaskType, page: 1, page_size: 1}),
|
||||
])
|
||||
const current = normalizeConfig(JSON.parse(configRecord.value) as StorageConfig)
|
||||
const migration = latestMigration(executions.items, current)
|
||||
return {
|
||||
config: current,
|
||||
migration,
|
||||
}
|
||||
},
|
||||
refetchInterval: (result) => {
|
||||
const state = result.state.data?.migration.state
|
||||
return state === "pending" || state === "running" ? 2000 : false
|
||||
},
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (query.data?.config) {
|
||||
setConfig((current) => {
|
||||
const normalized = normalizeConfig(query.data.config)
|
||||
if (current) {
|
||||
return {
|
||||
...normalized,
|
||||
driver: current.driver,
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
})
|
||||
}
|
||||
}, [query.data?.config])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (value: StorageConfig) => {
|
||||
await services.adminSystemConfig.updateSystemConfig(storageConfigKey, {
|
||||
value: JSON.stringify(value),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("存储配置已保存")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存存储配置失败")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
},
|
||||
})
|
||||
|
||||
const migrateMutation = useMutation({
|
||||
mutationFn: async (value: StorageConfig) => {
|
||||
const payload: StorageMigrationPayload = {target: value}
|
||||
return services.adminTask.dispatchTask({
|
||||
task_type: storageMigrationTaskType,
|
||||
payload: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("存储迁移任务已下发")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "下发存储迁移任务失败")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
},
|
||||
})
|
||||
const runMutation = useMutation({
|
||||
mutationFn: (executionID: string) => services.adminTask.retryTaskExecution(executionID),
|
||||
onSuccess: () => {
|
||||
toast.success("存储迁移任务已重新下发")
|
||||
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "运行存储迁移失败"),
|
||||
})
|
||||
|
||||
if (query.isPending || !config) {
|
||||
return <div className="flex justify-center py-20"><Loader2 className="animate-spin" /></div>
|
||||
}
|
||||
|
||||
const migration = query.data?.migration
|
||||
const isReadOnly = migration ? migration.state !== "idle" : false
|
||||
const isFormDisabled =
|
||||
(migration ? (migration.state === "pending" || migration.state === "running") : false) ||
|
||||
saveMutation.isPending ||
|
||||
migrateMutation.isPending
|
||||
|
||||
const updateObject = (driver: "s3" | "r2" | "minio" | "oss", patch: Partial<ObjectStorageConfig>) => {
|
||||
setConfig((current) => current ? {
|
||||
...current,
|
||||
[driver]: {...current[driver], ...patch},
|
||||
} : current)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{isReadOnly && migration && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
存储维护模式
|
||||
<Badge variant={migration.state === "failed" ? "destructive" : "secondary"}>
|
||||
{migration.state}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{driverLabel(migration.source_driver)} → {driverLabel(migration.target_driver)}。迁移期间文件只允许读取,禁止上传、删除和清理。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<Progress value={migration.state === "succeeded" ? 100 : undefined} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
迁移进度请查看任务执行日志:{migration.task_id || "尚未下发"}
|
||||
</p>
|
||||
{migration.error && <p className="text-sm text-destructive">{migration.error}</p>}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={migration.state !== "failed" || !migration.id || runMutation.isPending}
|
||||
onClick={() => migration.id && runMutation.mutate(migration.id)}
|
||||
>
|
||||
{runMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Play data-icon="inline-start" />}
|
||||
重试迁移
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-indigo-500/10 p-1.5 text-indigo-500">
|
||||
<Database className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">文件存储</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
默认使用本地存储。配置系统文件的存储媒介,切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<FieldGroup className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field className="flex flex-col gap-1.5 md:col-span-2">
|
||||
<FieldLabel className="text-xs font-semibold">存储类型</FieldLabel>
|
||||
<Select
|
||||
value={config.driver}
|
||||
disabled={isFormDisabled}
|
||||
onValueChange={(value) => setConfig({...config, driver: value as StorageDriver})}
|
||||
>
|
||||
<SelectTrigger className="w-full border-dashed bg-card text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{(Object.keys(driverLabels) as StorageDriver[]).map((driver) => (
|
||||
<SelectItem key={driver} value={driver}>{driverLabels[driver]}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{config.driver === "local" && (
|
||||
<div className="md:col-span-2">
|
||||
<TextField
|
||||
label="根目录"
|
||||
value={config.local.root}
|
||||
placeholder="."
|
||||
onChange={(root) => setConfig({...config, local: {root}})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(config.driver === "s3" || config.driver === "r2" || config.driver === "minio" || config.driver === "oss") && (
|
||||
<ObjectFields
|
||||
driver={config.driver as "s3" | "r2" | "minio" | "oss"}
|
||||
value={config[config.driver as "s3" | "r2" | "minio" | "oss"]}
|
||||
onChange={(patch) => updateObject(config.driver as "s3" | "r2" | "minio" | "oss", patch)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.driver === "webdav" && (
|
||||
<>
|
||||
<div className="md:col-span-2">
|
||||
<TextField label="服务地址" value={config.webdav.endpoint} placeholder="https://dav.example.com" onChange={(endpoint) => setConfig({...config, webdav: {...config.webdav, endpoint}})} />
|
||||
</div>
|
||||
<TextField label="用户名" value={config.webdav.username} onChange={(username) => setConfig({...config, webdav: {...config.webdav, username}})} />
|
||||
<TextField label="密码" type="password" value={config.webdav.password} onChange={(password) => setConfig({...config, webdav: {...config.webdav, password}})} />
|
||||
<div className="md:col-span-2">
|
||||
<TextField label="基础路径" value={config.webdav.base_path} placeholder="openflare" onChange={(base_path) => setConfig({...config, webdav: {...config.webdav, base_path}})} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end gap-2 flex-col sm:flex-row items-end sm:items-center border-t border-dashed pt-4 mt-6">
|
||||
{config.driver !== query.data?.config.driver && (
|
||||
<span className="text-xs text-amber-500 mr-auto text-left max-w-md">
|
||||
⚠️ 您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。
|
||||
</span>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => saveMutation.mutate(config)}
|
||||
className="border-dashed"
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Save data-icon="inline-start" />}
|
||||
保存配置
|
||||
</Button>
|
||||
{config.driver !== query.data?.config.driver && (
|
||||
<Button
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => migrateMutation.mutate(config)}
|
||||
>
|
||||
{migrateMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Play data-icon="inline-start" />}
|
||||
开始迁移
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ObjectFields({
|
||||
driver,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
driver: "s3" | "r2" | "minio" | "oss"
|
||||
value: ObjectStorageConfig
|
||||
onChange: (patch: Partial<ObjectStorageConfig>) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{driver === "r2" && (
|
||||
<div className="md:col-span-2">
|
||||
<TextField label="Account ID" value={value.account_id || ""} onChange={(account_id) => onChange({account_id})} />
|
||||
</div>
|
||||
)}
|
||||
{driver !== "s3" && (
|
||||
<div className="md:col-span-2">
|
||||
<TextField label="Endpoint" value={value.endpoint} placeholder="https://..." onChange={(endpoint) => onChange({endpoint})} />
|
||||
</div>
|
||||
)}
|
||||
<TextField label="Region" value={value.region} onChange={(region) => onChange({region})} />
|
||||
<TextField label="Bucket" value={value.bucket} onChange={(bucket) => onChange({bucket})} />
|
||||
<TextField label="Access Key ID" value={value.access_key_id} onChange={(access_key_id) => onChange({access_key_id})} />
|
||||
<TextField label="Secret Access Key" type="password" value={value.secret_access_key} onChange={(secret_access_key) => onChange({secret_access_key})} />
|
||||
<TextField label="对象前缀" value={value.key_prefix} placeholder="uploads" onChange={(key_prefix) => onChange({key_prefix})} />
|
||||
<TextField label="CDN 地址" value={value.cdn_url} placeholder="https://cdn.example.com" onChange={(cdn_url) => onChange({cdn_url})} />
|
||||
{(driver === "s3" || driver === "minio") && (
|
||||
<div className="md:col-span-2">
|
||||
<Field orientation="horizontal" className="flex items-center justify-between border border-dashed rounded-lg p-4 bg-muted/30">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<FieldLabel className="text-xs font-semibold cursor-pointer">Path Style</FieldLabel>
|
||||
<FieldDescription className="text-[11px] leading-relaxed text-muted-foreground">MinIO 等自托管 S3 通常需要开启。</FieldDescription>
|
||||
</div>
|
||||
<Switch checked={value.path_style} onCheckedChange={(path_style) => onChange({path_style})} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TextField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
type?: React.HTMLInputTypeAttribute
|
||||
}) {
|
||||
return (
|
||||
<Field className="flex flex-col gap-1.5">
|
||||
<FieldLabel className="text-xs font-semibold">{label}</FieldLabel>
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="border-dashed bg-card text-xs"
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {motion} from "motion/react"
|
||||
import {FolderOpen} from "lucide-react"
|
||||
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import {FileStats} from "./components/file-stats"
|
||||
import {FileList} from "./components/file-list"
|
||||
import {StorageConfigTab} from "./components/storage-config-tab"
|
||||
|
||||
export default function FilesPage() {
|
||||
const [activeTab, setActiveTab] = React.useState("stats")
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
className="flex w-full flex-col gap-6 py-6"
|
||||
>
|
||||
{/* 顶部标题区 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">存储管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
|
||||
<TabsTrigger value="stats" className="px-0 pb-2 text-xs font-semibold">
|
||||
文件存储信息
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="list" className="px-0 pb-2 text-xs font-semibold">
|
||||
文件列表
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="storage" className="px-0 pb-2 text-xs font-semibold">
|
||||
存储配置
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ──────── TAB 1: 统计看板 ──────── */}
|
||||
<TabsContent value="stats" className="outline-hidden">
|
||||
{activeTab === "stats" ? <FileStats /> : null}
|
||||
</TabsContent>
|
||||
|
||||
{/* ──────── TAB 2: 文件列表 ──────── */}
|
||||
<TabsContent value="list" className="outline-hidden">
|
||||
{activeTab === "list" ? <FileList /> : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="storage" className="outline-hidden">
|
||||
{activeTab === "storage" ? <StorageConfigTab /> : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import {AdminUsersProvider} from "@/contexts/admin-users-context"
|
||||
import {RequireAdminAuth} from "@/components/auth/require-auth"
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<RequireAdminAuth>
|
||||
<AdminUsersProvider>
|
||||
{children}
|
||||
</AdminUsersProvider>
|
||||
</RequireAdminAuth>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {toast} from "sonner"
|
||||
import {ChevronDown, Edit2, Layers, Loader2, Plus, Trash2,} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Checkbox} from "@/components/ui/checkbox"
|
||||
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"
|
||||
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
|
||||
|
||||
import services from "@/lib/services"
|
||||
import type {CreatePushEventRequest, PushEvent, UpdatePushEventRequest,} from "@/lib/services/push"
|
||||
import {PushService} from "@/lib/services/push"
|
||||
|
||||
export function EventsTab() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// --- 获取所有自定义消息通道 ---
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels"],
|
||||
queryFn: () => PushService.listChannels(),
|
||||
})
|
||||
|
||||
const availableChannels = React.useMemo(() => {
|
||||
const customChannels = (channelsQuery.data ?? [])
|
||||
.filter(c => c.enabled)
|
||||
.map(c => c.name)
|
||||
return ["email", ...customChannels]
|
||||
}, [channelsQuery.data])
|
||||
|
||||
// --- 获取通知事件 ---
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ["admin", "push-events"],
|
||||
queryFn: () => PushService.listEvents(),
|
||||
})
|
||||
|
||||
const builtInEventsQuery = useQuery({
|
||||
queryKey: ["admin", "push-builtin-events"],
|
||||
queryFn: () => PushService.listBuiltInEvents(),
|
||||
})
|
||||
|
||||
// --- 获取所有系统可调度任务类型 ---
|
||||
const taskTypesQuery = useQuery({
|
||||
queryKey: ["admin", "task-types"],
|
||||
queryFn: () => services.adminTask.getTaskTypes(),
|
||||
})
|
||||
|
||||
// --- 修改保存事件 Mutation ---
|
||||
const updateEventMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => PushService.updateEvent(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success("事件更新成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
setEditEventOpen(false)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("事件更新失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const toggleEventMutation = useMutation({
|
||||
mutationFn: (id: number) => PushService.toggleEvent(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("操作失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const createEventMutation = useMutation({
|
||||
mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data),
|
||||
onSuccess: () => {
|
||||
toast.success("事件创建成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
setCreateEventOpen(false)
|
||||
setNewEventKey("")
|
||||
setNewEventType("builtin")
|
||||
setNewEventTaskType("")
|
||||
setNewEventChannels([])
|
||||
setNewEventEnabled(true)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("事件创建失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteEventMutation = useMutation({
|
||||
mutationFn: (id: number) => PushService.deleteEvent(id),
|
||||
onSuccess: () => {
|
||||
toast.success("配置删除成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("配置删除失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
// --- 事件编辑对话框状态 ---
|
||||
const [editEventOpen, setEditEventOpen] = React.useState(false)
|
||||
const [selectedEvent, setSelectedEvent] = React.useState<PushEvent | null>(null)
|
||||
const [eventChannels, setEventChannels] = React.useState<string[]>([])
|
||||
const [eventTargets, setEventTargets] = React.useState("")
|
||||
const [eventTemplate, setEventTemplate] = React.useState("")
|
||||
|
||||
// 事件创建对话框状态
|
||||
const [createEventOpen, setCreateEventOpen] = React.useState(false)
|
||||
const [newEventType, setNewEventType] = React.useState<"builtin" | "task">("builtin")
|
||||
const [newEventKey, setNewEventKey] = React.useState("")
|
||||
const [newEventTaskType, setNewEventTaskType] = React.useState("")
|
||||
const [newEventChannels, setNewEventChannels] = React.useState<string[]>([])
|
||||
const [newEventTargets, setNewEventTargets] = React.useState("")
|
||||
const [newEventTemplate, setNewEventTemplate] = React.useState("")
|
||||
const [newEventEnabled, setNewEventEnabled] = React.useState(true)
|
||||
|
||||
const availableBuiltInEvents = React.useMemo(() => {
|
||||
const configuredKeys = new Set((eventsQuery.data ?? []).map(e => e.event_key))
|
||||
return (builtInEventsQuery.data ?? []).filter(e => !configuredKeys.has(e.key))
|
||||
}, [builtInEventsQuery.data, eventsQuery.data])
|
||||
|
||||
const handleEditEventClick = (event: PushEvent) => {
|
||||
setSelectedEvent(event)
|
||||
setEventChannels(event.channels)
|
||||
setEventTargets((event.targets ?? []).join(", "))
|
||||
setEventTemplate(event.template)
|
||||
setEditEventOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveEvent = () => {
|
||||
if (!selectedEvent) return
|
||||
|
||||
try {
|
||||
JSON.parse(eventTemplate)
|
||||
} catch {
|
||||
toast.error("消息模板不是合法的 JSON 格式")
|
||||
return
|
||||
}
|
||||
|
||||
const targets = eventTargets
|
||||
.split(",")
|
||||
.map(t => t.trim())
|
||||
.filter(t => t !== "")
|
||||
|
||||
updateEventMutation.mutate({
|
||||
id: selectedEvent.id,
|
||||
data: {
|
||||
channels: eventChannels,
|
||||
targets,
|
||||
template: eventTemplate,
|
||||
enabled: selectedEvent.enabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreateEventClick = () => {
|
||||
setNewEventKey("")
|
||||
setNewEventType("builtin")
|
||||
setNewEventTaskType("")
|
||||
setNewEventChannels([])
|
||||
setNewEventTargets("")
|
||||
setNewEventTemplate("")
|
||||
setNewEventEnabled(true)
|
||||
setCreateEventOpen(true)
|
||||
}
|
||||
|
||||
const handleNewEventKeyChange = (key: string) => {
|
||||
setNewEventKey(key)
|
||||
const ev = availableBuiltInEvents.find(e => e.key === key)
|
||||
if (ev) {
|
||||
setNewEventTemplate(JSON.stringify(ev.default_template, null, 2))
|
||||
} else {
|
||||
setNewEventTemplate("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewEventTaskTypeChange = (taskType: string) => {
|
||||
setNewEventTaskType(taskType)
|
||||
const taskMeta = (taskTypesQuery.data ?? []).find(t => t.asynq_task === taskType)
|
||||
if (taskMeta) {
|
||||
const defaultTemplate = {
|
||||
title: `任务完成: ${taskMeta.name}`,
|
||||
content: `异步任务 {{task_name}} 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。`,
|
||||
level: "INFO",
|
||||
}
|
||||
setNewEventTemplate(JSON.stringify(defaultTemplate, null, 2))
|
||||
} else {
|
||||
setNewEventTemplate("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateEvent = () => {
|
||||
if (newEventType === "builtin" && !newEventKey) {
|
||||
toast.error("请选择系统事件")
|
||||
return
|
||||
}
|
||||
if (newEventType === "task" && !newEventTaskType) {
|
||||
toast.error("请选择异步任务")
|
||||
return
|
||||
}
|
||||
|
||||
if (newEventTemplate) {
|
||||
try {
|
||||
JSON.parse(newEventTemplate)
|
||||
} catch {
|
||||
toast.error("内容渲染模板不是合法的 JSON 格式")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const targets = newEventTargets
|
||||
.split(",")
|
||||
.map(t => t.trim())
|
||||
.filter(t => t !== "")
|
||||
|
||||
createEventMutation.mutate({
|
||||
event_key: newEventType === "builtin" ? newEventKey : undefined,
|
||||
task_type: newEventType === "task" ? newEventTaskType : undefined,
|
||||
channels: newEventChannels,
|
||||
targets: targets.length > 0 ? targets : undefined,
|
||||
template: newEventTemplate || undefined,
|
||||
enabled: newEventEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleCreateEventClick} className="text-xs">
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
新增通知事件
|
||||
</Button>
|
||||
</div>
|
||||
{eventsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Layers} description="加载通知事件中..." />
|
||||
) : eventsQuery.isError ? (
|
||||
<div className="p-8 border border-dashed rounded-xl bg-card">
|
||||
<ErrorInline error={eventsQuery.error} onRetry={() => eventsQuery.refetch()} className="justify-center" />
|
||||
</div>
|
||||
) : (eventsQuery.data ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Layers} description="暂无通知配置事件,请点击右上角新增" />
|
||||
) : (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<Table className="w-full caption-bottom text-sm min-w-full">
|
||||
<TableHeader className="sticky top-0 z-20 bg-background">
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="w-[80px] whitespace-nowrap py-2 h-8">ID</TableHead>
|
||||
<TableHead className="w-[180px] whitespace-nowrap py-2 h-8">通知事件</TableHead>
|
||||
<TableHead className="w-[200px] whitespace-nowrap py-2 h-8">关联渠道</TableHead>
|
||||
<TableHead className="whitespace-nowrap py-2 h-8">推送目标</TableHead>
|
||||
<TableHead className="w-[80px] text-center whitespace-nowrap py-2 h-8">状态</TableHead>
|
||||
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(eventsQuery.data ?? []).map((event) => (
|
||||
<TableRow
|
||||
key={event.id}
|
||||
className="border-dashed hover:bg-muted/30 cursor-pointer group"
|
||||
onClick={() => handleEditEventClick(event)}
|
||||
>
|
||||
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{event.id}</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-[11px] leading-tight" title={event.name}>{event.name}</span>
|
||||
{event.task_type && (
|
||||
<Badge variant="outline" className="text-[8px] h-3.5 px-1 bg-blue-50/50 text-blue-600 border-blue-200">
|
||||
任务
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono leading-tight">{event.event_key}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(event.channels ?? []).length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground italic">未指定渠道</span>
|
||||
) : (
|
||||
event.channels.map(ch => (
|
||||
<Badge key={ch} variant="secondary" className="text-[10px] py-0 px-1.5 h-4.5">
|
||||
{ch === "email" ? "邮件" : ch}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(!event.targets || event.targets.length === 0) ? (
|
||||
<span className="text-muted-foreground text-[10px] font-mono">-</span>
|
||||
) : (
|
||||
event.targets.map(t => (
|
||||
<Badge key={t} variant="outline" className="text-[10px] max-w-[150px] truncate py-0 px-1.5 h-4.5">
|
||||
{t}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={event.enabled}
|
||||
onCheckedChange={() => toggleEventMutation.mutate(event.id)}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleEditEventClick(event)}
|
||||
>
|
||||
<Edit2 className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
配置
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
disabled={deleteEventMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm("确定要删除该通知事件配置吗?")) {
|
||||
deleteEventMutation.mutate(event.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
删除
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== 对话框:新增事件 ==================== */}
|
||||
<Dialog open={createEventOpen} onOpenChange={setCreateEventOpen}>
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增通知事件</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择系统内置的事件并绑定推送渠道,创建后即可开始生效
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">事件类型</Label>
|
||||
<div className="flex gap-4 p-1.5 border rounded-md bg-muted/20">
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer font-medium">
|
||||
<input
|
||||
type="radio"
|
||||
name="eventType"
|
||||
checked={newEventType === "builtin"}
|
||||
onChange={() => {
|
||||
setNewEventType("builtin")
|
||||
setNewEventTaskType("")
|
||||
setNewEventTemplate("")
|
||||
}}
|
||||
className="scale-90"
|
||||
/>
|
||||
<span>系统内置事件</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer font-medium">
|
||||
<input
|
||||
type="radio"
|
||||
name="eventType"
|
||||
checked={newEventType === "task"}
|
||||
onChange={() => {
|
||||
setNewEventType("task")
|
||||
setNewEventKey("")
|
||||
setNewEventTemplate("")
|
||||
}}
|
||||
className="scale-90"
|
||||
/>
|
||||
<span>任务完成事件</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newEventType === "builtin" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">系统事件</Label>
|
||||
{builtInEventsQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>加载系统事件中...</span>
|
||||
</div>
|
||||
) : availableBuiltInEvents.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20">
|
||||
所有内置事件都已配置,没有可新增的事件。
|
||||
</div>
|
||||
) : (
|
||||
<Select value={newEventKey} onValueChange={handleNewEventKeyChange}>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="请选择系统事件" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableBuiltInEvents.map(ev => (
|
||||
<SelectItem key={ev.key} value={ev.key} className="text-xs">
|
||||
{ev.name} ({ev.key})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">系统异步任务</Label>
|
||||
{taskTypesQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>加载异步任务中...</span>
|
||||
</div>
|
||||
) : (taskTypesQuery.data ?? []).length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20">
|
||||
暂无可用的系统任务。
|
||||
</div>
|
||||
) : (
|
||||
<Select value={newEventTaskType} onValueChange={handleNewEventTaskTypeChange}>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="请选择异步任务" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(taskTypesQuery.data ?? []).map(taskMeta => (
|
||||
<SelectItem key={taskMeta.asynq_task} value={taskMeta.asynq_task} className="text-xs">
|
||||
{taskMeta.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newEventType === "builtin" && newEventKey && (
|
||||
<div className="text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1">
|
||||
<span className="font-semibold text-foreground">事件说明:</span>
|
||||
<span>
|
||||
{availableBuiltInEvents.find(e => e.key === newEventKey)?.description || "无描述信息"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newEventType === "task" && newEventTaskType && (
|
||||
<div className="text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1">
|
||||
<span className="font-semibold text-foreground">任务说明:</span>
|
||||
<span>
|
||||
{(taskTypesQuery.data ?? []).find(t => t.asynq_task === newEventTaskType)?.description || "无描述信息"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送渠道 (可多选)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between text-xs h-9 font-normal">
|
||||
{newEventChannels.length > 0
|
||||
? newEventChannels
|
||||
.map(ch => {
|
||||
if (ch === "email") return "邮件"
|
||||
return ch
|
||||
})
|
||||
.join(", ")
|
||||
: "选择已配置推送渠道"}
|
||||
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-3" align="start">
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{availableChannels.map(ch => (
|
||||
<label key={ch} className="flex items-center gap-2 text-xs font-medium cursor-pointer p-1.5 hover:bg-muted rounded transition-colors">
|
||||
<Checkbox
|
||||
checked={newEventChannels.includes(ch)}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setNewEventChannels([...newEventChannels, ch])
|
||||
} else {
|
||||
setNewEventChannels(newEventChannels.filter(c => c !== ch))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{ch === "email" ? "邮件推送" : ch}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{availableChannels.length === 0 && (
|
||||
<div className="text-[11px] text-muted-foreground italic p-1">
|
||||
暂无可用渠道,请先在“通道管理与设置”中创建或启用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送目标 (仅邮件等渠道需要)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com"
|
||||
value={newEventTargets}
|
||||
onChange={e => setNewEventTargets(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label>
|
||||
<span className="text-[10px] text-muted-foreground font-mono flex items-center">
|
||||
{newEventType === "task"
|
||||
? "支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}"
|
||||
: "支持变量:{{user.username}}, {{ip}}, {{time}}"}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={newEventTemplate}
|
||||
onChange={e => setNewEventTemplate(e.target.value)}
|
||||
rows={6}
|
||||
className="text-xs font-mono"
|
||||
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/10">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs font-semibold">启用状态</Label>
|
||||
<div className="text-[10px] text-muted-foreground">创建后是否立即开始接收此事件的通知</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={newEventEnabled}
|
||||
onCheckedChange={setNewEventEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setCreateEventOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={createEventMutation.isPending || (newEventType === "builtin" && availableBuiltInEvents.length === 0)}
|
||||
onClick={handleCreateEvent}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{createEventMutation.isPending && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
启用保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ==================== 对话框:编辑事件 ==================== */}
|
||||
<Dialog open={editEventOpen} onOpenChange={setEditEventOpen}>
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>事件通知渠道配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
自定义本事件触发时,需要异步推送的渠道以及接收人邮箱等配置
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedEvent && (
|
||||
<div className="space-y-5 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">事件名称</Label>
|
||||
<Input value={selectedEvent.name} disabled className="text-xs h-9 bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">事件键 (Key)</Label>
|
||||
<Input value={selectedEvent.event_key} disabled className="text-xs h-9 font-mono bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEvent.task_type && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">关联异步任务</Label>
|
||||
<Input
|
||||
value={`${(taskTypesQuery.data ?? []).find(t => t.asynq_task === selectedEvent.task_type)?.name || selectedEvent.task_type} (${selectedEvent.task_type})`}
|
||||
disabled
|
||||
className="text-xs h-9 bg-muted"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送渠道 (可多选)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between text-xs h-9 font-normal">
|
||||
{eventChannels.length > 0
|
||||
? eventChannels
|
||||
.map(ch => {
|
||||
if (ch === "email") return "邮件"
|
||||
return ch
|
||||
})
|
||||
.join(", ")
|
||||
: "选择已配置推送渠道"}
|
||||
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-3" align="start">
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{availableChannels.map(ch => (
|
||||
<label key={ch} className="flex items-center gap-2 text-xs font-medium cursor-pointer p-1.5 hover:bg-muted rounded transition-colors">
|
||||
<Checkbox
|
||||
checked={eventChannels.includes(ch)}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setEventChannels([...eventChannels, ch])
|
||||
} else {
|
||||
setEventChannels(eventChannels.filter(c => c !== ch))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{ch === "email" ? "邮件推送" : ch}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{availableChannels.length === 0 && (
|
||||
<div className="text-[11px] text-muted-foreground italic p-1">
|
||||
暂无可用渠道,请先在“通道管理与设置”中创建并启用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送目标 (仅邮件等渠道需要)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com"
|
||||
value={eventTargets}
|
||||
onChange={e => setEventTargets(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label>
|
||||
<span className="text-[10px] text-muted-foreground font-mono flex items-center">
|
||||
{selectedEvent.task_type
|
||||
? "支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}"
|
||||
: "支持变量:{{user.username}}, {{ip}}, {{time}}"}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={eventTemplate}
|
||||
onChange={e => setEventTemplate(e.target.value)}
|
||||
rows={6}
|
||||
className="text-xs font-mono"
|
||||
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditEventOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={updateEventMutation.isPending}
|
||||
onClick={handleSaveEvent}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{updateEventMutation.isPending && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
保存修改
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {ChevronLeft, ChevronRight, History, RefreshCw, Search,} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
import type {PushHistory} from "@/lib/services/push"
|
||||
import {PushService} from "@/lib/services/push"
|
||||
|
||||
function getLevelBadgeVariant(level: string): "outline" | "secondary" | "destructive" | "default" {
|
||||
switch (level) {
|
||||
case "CRITICAL":
|
||||
return "destructive"
|
||||
case "IMPORTANT":
|
||||
return "default"
|
||||
default:
|
||||
return "outline"
|
||||
}
|
||||
}
|
||||
|
||||
export function HistoriesTab() {
|
||||
const [historyPage, setHistoryPage] = React.useState(1)
|
||||
const [historySearch, setHistorySearch] = React.useState("")
|
||||
const [historyStatus, setHistoryStatus] = React.useState("all")
|
||||
|
||||
const [detailOpen, setDetailOpen] = React.useState(false)
|
||||
const [selectedHistory, setSelectedHistory] = React.useState<PushHistory | null>(null)
|
||||
|
||||
const handleRowClick = (hist: PushHistory) => {
|
||||
setSelectedHistory(hist)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
const historiesQuery = useQuery({
|
||||
queryKey: ["admin", "push-histories", historyPage, historySearch, historyStatus],
|
||||
queryFn: () =>
|
||||
PushService.listHistories({
|
||||
page: historyPage,
|
||||
page_size: 10,
|
||||
event_key: historySearch || undefined,
|
||||
status: historyStatus === "all" ? undefined : historyStatus,
|
||||
}),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="输入事件键过滤..."
|
||||
className="pl-8 text-xs h-9"
|
||||
value={historySearch}
|
||||
onChange={e => {
|
||||
setHistorySearch(e.target.value)
|
||||
setHistoryPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[150px]">
|
||||
<Select
|
||||
value={historyStatus}
|
||||
onValueChange={val => {
|
||||
setHistoryStatus(val)
|
||||
setHistoryPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="发送状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" className="text-xs">全部状态</SelectItem>
|
||||
<SelectItem value="success" className="text-xs">发送成功</SelectItem>
|
||||
<SelectItem value="failed" className="text-xs">发送失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={() => historiesQuery.refetch()}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", historiesQuery.isFetching && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="text-xs font-semibold">事件</TableHead>
|
||||
<TableHead className="text-xs font-semibold">渠道</TableHead>
|
||||
<TableHead className="text-xs font-semibold">目标</TableHead>
|
||||
<TableHead className="text-xs font-semibold">标题</TableHead>
|
||||
<TableHead className="text-xs font-semibold">等级</TableHead>
|
||||
<TableHead className="text-xs font-semibold">状态</TableHead>
|
||||
<TableHead className="text-xs font-semibold">时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{historiesQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32">
|
||||
<LoadingStateWithBorder icon={History} description="加载推送历史记录中..." className="border-0 shadow-none" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : historiesQuery.isError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32">
|
||||
<ErrorInline error={historiesQuery.error} onRetry={() => historiesQuery.refetch()} className="justify-center" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (historiesQuery.data?.results ?? []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32 text-center text-xs text-muted-foreground">
|
||||
无推送历史数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
(historiesQuery.data?.results ?? []).map(hist => (
|
||||
<TableRow
|
||||
key={hist.id}
|
||||
className="hover:bg-muted/10 cursor-pointer transition-colors"
|
||||
onClick={() => handleRowClick(hist)}
|
||||
>
|
||||
<TableCell className="text-xs font-mono font-medium">{hist.event_key}</TableCell>
|
||||
<TableCell className="text-xs uppercase font-semibold text-muted-foreground">
|
||||
{hist.channel === "email" ? "邮件" : hist.channel}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground max-w-[120px] truncate" title={hist.target}>
|
||||
{hist.target}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs max-w-[180px] truncate" title={hist.content}>{hist.title}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge variant={getLevelBadgeVariant(hist.level)} className="text-[10px] font-semibold">
|
||||
{hist.level}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge
|
||||
variant={hist.status === "success" ? "secondary" : "destructive"}
|
||||
className="text-[10px] font-semibold"
|
||||
>
|
||||
{hist.status === "success" ? "成功" : "失败"}
|
||||
</Badge>
|
||||
{hist.status !== "success" && hist.error_msg && (
|
||||
<div className="text-[10px] text-muted-foreground font-mono truncate max-w-[180px] mt-1" title={hist.error_msg}>
|
||||
{hist.error_msg}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{new Date(hist.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{historiesQuery.data && historiesQuery.data.total > 0 && (
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground">
|
||||
<span>共 {historiesQuery.data.total} 条历史记录</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={historyPage === 1}
|
||||
onClick={() => setHistoryPage(p => Math.max(1, p - 1))}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs font-medium px-2">{historyPage}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={historyPage * 10 >= historiesQuery.data.total}
|
||||
onClick={() => setHistoryPage(p => p + 1)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== 对话框:推送详情 ==================== */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<History className="size-5 text-primary" />
|
||||
推送通知详情
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
查看该条通知发送的详细审计信息与内容
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedHistory && (
|
||||
<div className="space-y-4 py-4 text-xs">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">事件标识</span>
|
||||
<div className="font-mono bg-muted/40 p-2 rounded border">{selectedHistory.event_key}</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">发送渠道</span>
|
||||
<div className="bg-muted/40 p-2 rounded border uppercase font-medium">
|
||||
{selectedHistory.channel === "email" ? "邮件推送" : selectedHistory.channel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">推送目标</span>
|
||||
<div className="font-mono bg-muted/40 p-2 rounded border truncate" title={selectedHistory.target}>
|
||||
{selectedHistory.target}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">发送时间</span>
|
||||
<div className="bg-muted/40 p-2 rounded border">
|
||||
{new Date(selectedHistory.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">通知等级</span>
|
||||
<div>
|
||||
<Badge variant={getLevelBadgeVariant(selectedHistory.level)} className="text-[10px] font-semibold py-0.5 px-2">
|
||||
{selectedHistory.level}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">发送状态</span>
|
||||
<div>
|
||||
<Badge
|
||||
variant={selectedHistory.status === "success" ? "secondary" : "destructive"}
|
||||
className="text-[10px] font-semibold py-0.5 px-2"
|
||||
>
|
||||
{selectedHistory.status === "success" ? "成功" : "失败"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedHistory.status === "failed" && selectedHistory.error_msg && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-destructive block">失败原因</span>
|
||||
<div className="font-mono text-destructive bg-destructive/10 p-2.5 rounded border border-destructive/20 whitespace-pre-wrap break-all">
|
||||
{selectedHistory.error_msg}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">通知标题</span>
|
||||
<div className="bg-muted/30 p-2.5 rounded border font-medium text-[13px]">
|
||||
{selectedHistory.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-semibold text-muted-foreground block">通知内容</span>
|
||||
<div className="bg-muted/30 p-3 rounded border whitespace-pre-wrap break-all leading-relaxed font-sans text-xs">
|
||||
{selectedHistory.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setDetailOpen(false)} className="h-9 text-xs">
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {toast} from "sonner"
|
||||
|
||||
import {Edit2, Loader2, Play, Plus, Settings, Trash2,} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
|
||||
import type {ChannelDefinition, CreateChannelRequest, PushChannel, UpdateChannelRequest} from "@/lib/services/push"
|
||||
import {PushService} from "@/lib/services/push"
|
||||
|
||||
export function SettingsTab() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// --- 获取所有自定义消息通道 ---
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels"],
|
||||
queryFn: () => PushService.listChannels(),
|
||||
})
|
||||
|
||||
// --- 获取动态通道表单字段定义 ---
|
||||
const definitionsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels-definitions"],
|
||||
queryFn: () => PushService.listChannelDefinitions(),
|
||||
})
|
||||
|
||||
|
||||
// --- 消息通道 CRUD Mutations ---
|
||||
const createChannelMutation = useMutation({
|
||||
mutationFn: (data: CreateChannelRequest) => PushService.createChannel(data),
|
||||
onSuccess: () => {
|
||||
toast.success("通道创建成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
|
||||
setChannelDialogOpen(false)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("通道创建失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const updateChannelMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) =>
|
||||
PushService.updateChannel(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success("通道更新成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
|
||||
setChannelDialogOpen(false)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("通道更新失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteChannelMutation = useMutation({
|
||||
mutationFn: (id: number) => PushService.deleteChannel(id),
|
||||
onSuccess: () => {
|
||||
toast.success("通道删除成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("通道删除失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
// --- 消息通道与设置相关 State ---
|
||||
const [channelDialogOpen, setChannelDialogOpen] = React.useState(false)
|
||||
const [editingChannel, setEditingChannel] = React.useState<PushChannel | null>(null)
|
||||
const [channelName, setChannelName] = React.useState("")
|
||||
const [channelDescription, setChannelDescription] = React.useState("")
|
||||
const [channelType, setChannelType] = React.useState("custom")
|
||||
const [channelToken, setChannelToken] = React.useState("")
|
||||
const [channelUrl, setChannelUrl] = React.useState("")
|
||||
const [channelOther, setChannelOther] = React.useState("")
|
||||
|
||||
const activeDef = React.useMemo<ChannelDefinition | undefined>(() => {
|
||||
return (definitionsQuery.data ?? []).find(d => d.type === channelType)
|
||||
}, [definitionsQuery.data, channelType])
|
||||
|
||||
const [testChannelOpen, setTestChannelOpen] = React.useState(false)
|
||||
const [testChannelName, setTestChannelName] = React.useState("")
|
||||
const [testChannelTarget, setTestChannelTarget] = React.useState("")
|
||||
const [isTestingChannel, setIsTestingChannel] = React.useState(false)
|
||||
|
||||
const handleChannelTypeChange = (newType: string) => {
|
||||
setChannelType(newType)
|
||||
setChannelUrl("")
|
||||
setChannelToken("")
|
||||
if (newType === "custom") {
|
||||
setChannelOther(JSON.stringify({
|
||||
title: "$title",
|
||||
description: "$description",
|
||||
content: "$content",
|
||||
url: "$url",
|
||||
to: "$to"
|
||||
}, null, 2))
|
||||
} else {
|
||||
setChannelOther("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateChannelClick = () => {
|
||||
setEditingChannel(null)
|
||||
setChannelName("")
|
||||
setChannelDescription("")
|
||||
setChannelType("custom")
|
||||
setChannelToken("")
|
||||
setChannelUrl("")
|
||||
setChannelOther(JSON.stringify({
|
||||
title: "$title",
|
||||
description: "$description",
|
||||
content: "$content",
|
||||
url: "$url",
|
||||
to: "$to"
|
||||
}, null, 2))
|
||||
setChannelDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleEditChannelClick = (channel: PushChannel) => {
|
||||
setEditingChannel(channel)
|
||||
setChannelName(channel.name)
|
||||
setChannelDescription(channel.description ?? "")
|
||||
setChannelType(channel.type)
|
||||
setChannelToken(channel.token ?? "")
|
||||
setChannelUrl(channel.url)
|
||||
setChannelOther(channel.other)
|
||||
setChannelDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveChannel = () => {
|
||||
if (!channelName && !editingChannel) {
|
||||
toast.error("通道名称不能为空")
|
||||
return
|
||||
}
|
||||
if (!/^[a-zA-Z_0-9]+$/.test(channelName)) {
|
||||
toast.error("通道名称只能使用英文字母、数字和下划线")
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeDef) {
|
||||
toast.error("无效的通道类型")
|
||||
return
|
||||
}
|
||||
|
||||
// 动态字段必填性校验
|
||||
for (const field of activeDef.fields) {
|
||||
const value = field.key === "url"
|
||||
? channelUrl
|
||||
: field.key === "token"
|
||||
? channelToken
|
||||
: channelOther;
|
||||
if (field.required && !value.trim()) {
|
||||
toast.error(`${field.label}不能为空`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 协议安全校验(非邮件服务且配置了地址时,强制 HTTPS 协议)
|
||||
if (channelType !== "email") {
|
||||
if (channelUrl && !channelUrl.startsWith("https://")) {
|
||||
toast.error("地址必须以 https:// 开头以确保安全性")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 结构格式校验
|
||||
if (channelType === "custom") {
|
||||
try {
|
||||
JSON.parse(channelOther)
|
||||
} catch {
|
||||
toast.error("请求体必须是合法的 JSON 格式")
|
||||
return
|
||||
}
|
||||
} else if (channelType === "lark" && channelOther) {
|
||||
try {
|
||||
JSON.parse(channelOther)
|
||||
} catch {
|
||||
toast.error("自定义卡片模版必须是合法的 JSON 格式")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (editingChannel) {
|
||||
updateChannelMutation.mutate({
|
||||
id: editingChannel.id,
|
||||
data: {
|
||||
description: channelDescription,
|
||||
type: channelType,
|
||||
token: channelToken || undefined,
|
||||
url: channelUrl,
|
||||
other: channelOther,
|
||||
enabled: editingChannel.enabled,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
createChannelMutation.mutate({
|
||||
name: channelName,
|
||||
description: channelDescription,
|
||||
type: channelType,
|
||||
token: channelToken || undefined,
|
||||
url: channelUrl,
|
||||
other: channelOther,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestChannelClick = (name: string) => {
|
||||
setTestChannelName(name)
|
||||
setTestChannelTarget("")
|
||||
setTestChannelOpen(true)
|
||||
}
|
||||
|
||||
const handleSendChannelTest = async () => {
|
||||
try {
|
||||
setIsTestingChannel(true)
|
||||
toast.info("正在发送测试推送...")
|
||||
await PushService.testChannel({
|
||||
name: testChannelName,
|
||||
target: testChannelTarget || undefined,
|
||||
})
|
||||
toast.success("测试推送发送成功,请前往对应平台确认。")
|
||||
setTestChannelOpen(false)
|
||||
} catch (err: unknown) {
|
||||
toast.error("连通性测试失败: " + (err as Error).message)
|
||||
} finally {
|
||||
setIsTestingChannel(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">自定义推送通道</h2>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||
添加、配置及管理用于第三方 Webhook 对接的自定义数据推送通道
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleCreateChannelClick} className="text-xs">
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
新建消息通道
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{channelsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Settings} description="加载消息通道中..." />
|
||||
) : channelsQuery.isError ? (
|
||||
<div className="p-8 border border-dashed rounded-xl bg-card">
|
||||
<ErrorInline error={channelsQuery.error} onRetry={() => channelsQuery.refetch()} className="justify-center" />
|
||||
</div>
|
||||
) : (channelsQuery.data ?? []).length === 0 ? (
|
||||
<div className="py-12 border border-dashed rounded-lg flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Settings className="size-8 mb-2 opacity-30 animate-spin" style={{ animationDuration: '3s' }} />
|
||||
<span className="text-xs font-medium">暂无自定义通道配置,请点击右上角新建</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<Table className="w-full caption-bottom text-sm min-w-full">
|
||||
<TableHeader className="sticky top-0 z-20 bg-background">
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">名称</TableHead>
|
||||
<TableHead className="w-[100px] whitespace-nowrap py-2 h-8">类型</TableHead>
|
||||
<TableHead className="whitespace-nowrap py-2 h-8">备注</TableHead>
|
||||
<TableHead className="w-[80px] text-center whitespace-nowrap py-2 h-8">状态</TableHead>
|
||||
<TableHead className="sticky right-0 text-center bg-background z-10 w-[180px] py-2 h-8">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(channelsQuery.data ?? []).map(ch => (
|
||||
<TableRow
|
||||
key={ch.id}
|
||||
className="border-dashed hover:bg-muted/30 cursor-pointer group"
|
||||
onClick={() => handleEditChannelClick(ch)}
|
||||
>
|
||||
<TableCell className="text-xs font-mono font-bold py-1">
|
||||
{ch.name}
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<Badge variant="outline" className="text-[10px] py-0 px-1.5 h-4.5 whitespace-nowrap">
|
||||
{(definitionsQuery.data ?? []).find(d => d.type === ch.type)?.name ?? ch.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate py-1">
|
||||
{ch.description || <span className="italic">无备注</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={ch.enabled}
|
||||
onCheckedChange={checked => {
|
||||
updateChannelMutation.mutate({
|
||||
id: ch.id,
|
||||
data: {
|
||||
description: ch.description,
|
||||
type: ch.type,
|
||||
token: ch.token,
|
||||
url: ch.url,
|
||||
other: ch.other,
|
||||
enabled: checked,
|
||||
}
|
||||
})
|
||||
}}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTestChannelClick(ch.name)}
|
||||
className="h-6 px-2 text-[10px] text-primary hover:text-primary hover:bg-primary/10"
|
||||
>
|
||||
<Play className="size-2.5 mr-1" />
|
||||
测试
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditChannelClick(ch)}
|
||||
className="h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Edit2 className="size-2.5 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={deleteChannelMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(`确定要删除通道 "${ch.name}" 吗?`)) {
|
||||
deleteChannelMutation.mutate(ch.id)
|
||||
}
|
||||
}}
|
||||
className="h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="size-2.5 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== 对话框:新增/编辑消息通道 ==================== */}
|
||||
<Dialog open={channelDialogOpen} onOpenChange={setChannelDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingChannel ? "编辑消息通道" : "新建消息通道"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置自定义通知推送通道,支持以 POST 请求方式向第三方 Webhook 或推送服务投递数据
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">名称</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入通道名称,请仅使用英文字母和下划线,该名称必须唯一"
|
||||
value={channelName}
|
||||
onChange={e => setChannelName(e.target.value)}
|
||||
disabled={!!editingChannel}
|
||||
className="text-xs h-9 font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">通道唯一标识,创建后不可修改</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">备注</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入备注信息"
|
||||
value={channelDescription}
|
||||
onChange={e => setChannelDescription(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">通道类型</Label>
|
||||
<Select value={channelType} onValueChange={handleChannelTypeChange}>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="选择通道类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(definitionsQuery.data ?? []).map(d => (
|
||||
<SelectItem key={d.type} value={d.type} className="text-xs">
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{activeDef && (
|
||||
<>
|
||||
<div className="p-3.5 border rounded-lg bg-muted/20 space-y-1.5">
|
||||
<div className="text-xs font-semibold">{activeDef.name}配置说明</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{activeDef.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{activeDef.fields.map(field => {
|
||||
const value = field.key === "url"
|
||||
? channelUrl
|
||||
: field.key === "token"
|
||||
? channelToken
|
||||
: channelOther;
|
||||
const onChange = (val: string) => {
|
||||
if (field.key === "url") setChannelUrl(val);
|
||||
else if (field.key === "token") setChannelToken(val);
|
||||
else setChannelOther(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</Label>
|
||||
|
||||
{field.type === "textarea" ? (
|
||||
<Textarea
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
rows={field.key === "other" && channelType === "custom" ? 6 : 4}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type}
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="text-xs h-9 font-mono"
|
||||
/>
|
||||
)}
|
||||
{field.description && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{field.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom post helper templates card */}
|
||||
{channelType === "custom" && (
|
||||
<div className="p-3.5 border rounded-lg bg-muted/20 space-y-2.5">
|
||||
<Label className="text-[11px] font-semibold">快捷加载常用模版实例:</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN");
|
||||
setChannelOther(JSON.stringify({
|
||||
msg_type: "text",
|
||||
content: {
|
||||
text: "$title\n$description\n$content\n$url"
|
||||
}
|
||||
}, null, 2));
|
||||
toast.success("已加载飞书 Webhook 模版");
|
||||
}}
|
||||
>
|
||||
飞书 Webhook
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN");
|
||||
setChannelOther(JSON.stringify({
|
||||
msgtype: "markdown",
|
||||
markdown: {
|
||||
title: "$title",
|
||||
text: "### $title\n$content\n\n[查看详情]($url)"
|
||||
}
|
||||
}, null, 2));
|
||||
toast.success("已加载钉钉机器人模版");
|
||||
}}
|
||||
>
|
||||
钉钉群机器人
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY");
|
||||
setChannelOther(JSON.stringify({
|
||||
msgtype: "markdown",
|
||||
markdown: {
|
||||
content: "### $title\n$content\n\n[查看详情]($url)"
|
||||
}
|
||||
}, null, 2));
|
||||
toast.success("已加载企业微信机器人模版");
|
||||
}}
|
||||
>
|
||||
企业微信群机器人
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://api.day.app/push");
|
||||
setChannelOther(JSON.stringify({
|
||||
device_key: "$to",
|
||||
title: "$title",
|
||||
body: "$content",
|
||||
url: "$url"
|
||||
}, null, 2));
|
||||
toast.success("已加载 Bark App 模版");
|
||||
}}
|
||||
>
|
||||
Bark App
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setChannelDialogOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={createChannelMutation.isPending || updateChannelMutation.isPending}
|
||||
onClick={handleSaveChannel}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{(createChannelMutation.isPending || updateChannelMutation.isPending) && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ==================== 对话框:测试渠道连通性 ==================== */}
|
||||
<Dialog open={testChannelOpen} onOpenChange={setTestChannelOpen}>
|
||||
<DialogContent className="sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>发送测试通知</DialogTitle>
|
||||
<DialogDescription>
|
||||
请输入消息测试的接收目标,点击发送后系统将通过此通道执行连通性推送测试
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">推送通道名称</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={testChannelName}
|
||||
disabled
|
||||
className="text-xs h-9 bg-muted font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">测试推送目标 (对应模板变量 $to)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入测试推送接收人/目标标识,如 Bark Token、邮箱等"
|
||||
value={testChannelTarget}
|
||||
onChange={e => setTestChannelTarget(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setTestChannelOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={isTestingChannel}
|
||||
onClick={handleSendChannelTest}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{isTestingChannel && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
发送测试
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {motion} from "motion/react"
|
||||
import {Bell, History, Layers, Settings} from "lucide-react"
|
||||
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import {EventsTab} from "./components/events-tab"
|
||||
import {HistoriesTab} from "./components/histories-tab"
|
||||
import {SettingsTab} from "./components/settings-tab"
|
||||
|
||||
export default function PushAdminPage() {
|
||||
const [activeTab, setActiveTab] = React.useState("events")
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="w-full py-6 space-y-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">通知推送管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList variant="line" className="w-fit inline-flex gap-8">
|
||||
<TabsTrigger value="events" className="px-0 pb-2 text-xs font-semibold">
|
||||
<Layers className="size-3.5 mr-1" />
|
||||
事件管理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="histories" className="px-0 pb-2 text-xs font-semibold">
|
||||
<History className="size-3.5 mr-1" />
|
||||
通知历史
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="px-0 pb-2 text-xs font-semibold">
|
||||
<Settings className="size-3.5 mr-1" />
|
||||
通道管理与设置
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ==================== 1. 事件管理 TAB ==================== */}
|
||||
<TabsContent value="events" className="focus-visible:outline-none">
|
||||
<EventsTab />
|
||||
</TabsContent>
|
||||
|
||||
{/* ==================== 2. 通知历史 TAB ==================== */}
|
||||
<TabsContent value="histories" className="focus-visible:outline-none">
|
||||
<HistoriesTab />
|
||||
</TabsContent>
|
||||
|
||||
{/* ==================== 3. 通道管理与设置 TAB ==================== */}
|
||||
<TabsContent value="settings" className="focus-visible:outline-none">
|
||||
<SettingsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import type {OptionItem} from "@/lib/services/openflare"
|
||||
|
||||
export type OpenFlareOpsFields = {
|
||||
AgentHeartbeatInterval: string
|
||||
AgentWebsocketUpgradeEnabled: boolean
|
||||
NodeOfflineThreshold: string
|
||||
AgentUpdateRepo: string
|
||||
GeoIPProvider: string
|
||||
ServerAddress: string
|
||||
UptimeKumaEnabled: boolean
|
||||
UptimeKumaUrl: string
|
||||
UptimeKumaUsername: string
|
||||
UptimeKumaPassword: string
|
||||
UptimeKumaMonitorScope: string
|
||||
UptimeKumaSelectedSites: string
|
||||
UptimeKumaSyncInterval: string
|
||||
UptimeKumaInterval: string
|
||||
UptimeKumaRetry: string
|
||||
UptimeKumaRetryInterval: string
|
||||
UptimeKumaTimeout: string
|
||||
DatabaseAutoCleanupEnabled: boolean
|
||||
DatabaseAutoCleanupRetentionDays: string
|
||||
}
|
||||
|
||||
export const defaultOpenFlareOpsFields: OpenFlareOpsFields = {
|
||||
AgentHeartbeatInterval: "10000",
|
||||
AgentWebsocketUpgradeEnabled: true,
|
||||
NodeOfflineThreshold: "120000",
|
||||
AgentUpdateRepo: "Rain-kl/OpenFlare",
|
||||
GeoIPProvider: "ipinfo",
|
||||
ServerAddress: "",
|
||||
UptimeKumaEnabled: false,
|
||||
UptimeKumaUrl: "",
|
||||
UptimeKumaUsername: "",
|
||||
UptimeKumaPassword: "",
|
||||
UptimeKumaMonitorScope: "all",
|
||||
UptimeKumaSelectedSites: "",
|
||||
UptimeKumaSyncInterval: "5",
|
||||
UptimeKumaInterval: "60",
|
||||
UptimeKumaRetry: "0",
|
||||
UptimeKumaRetryInterval: "60",
|
||||
UptimeKumaTimeout: "48",
|
||||
DatabaseAutoCleanupEnabled: false,
|
||||
DatabaseAutoCleanupRetentionDays: "30",
|
||||
}
|
||||
|
||||
export const INSTALLER_SCRIPT_URL =
|
||||
"https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh"
|
||||
|
||||
export function optionsToMap(options: OptionItem[]) {
|
||||
return options.reduce<Record<string, string>>((accumulator, option) => {
|
||||
accumulator[option.key] = option.value
|
||||
return accumulator
|
||||
}, {})
|
||||
}
|
||||
|
||||
function toBoolean(value: string | undefined, fallback: boolean) {
|
||||
if (value === undefined) return fallback
|
||||
return value === "true"
|
||||
}
|
||||
|
||||
export function mapOptionsToOpsFields(
|
||||
optionMap: Record<string, string>,
|
||||
serverAddress = "",
|
||||
): OpenFlareOpsFields {
|
||||
return {
|
||||
AgentHeartbeatInterval: optionMap.AgentHeartbeatInterval ?? "10000",
|
||||
AgentWebsocketUpgradeEnabled: toBoolean(optionMap.AgentWebsocketUpgradeEnabled, true),
|
||||
NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? "120000",
|
||||
AgentUpdateRepo: optionMap.AgentUpdateRepo ?? "Rain-kl/OpenFlare",
|
||||
GeoIPProvider: optionMap.GeoIPProvider ?? "ipinfo",
|
||||
ServerAddress: optionMap.ServerAddress || serverAddress,
|
||||
UptimeKumaEnabled: toBoolean(optionMap.UptimeKumaEnabled, false),
|
||||
UptimeKumaUrl: optionMap.UptimeKumaUrl ?? "",
|
||||
UptimeKumaUsername: optionMap.UptimeKumaUsername ?? "",
|
||||
UptimeKumaPassword: "",
|
||||
UptimeKumaMonitorScope: optionMap.UptimeKumaMonitorScope ?? "all",
|
||||
UptimeKumaSelectedSites: optionMap.UptimeKumaSelectedSites ?? "",
|
||||
UptimeKumaSyncInterval: optionMap.UptimeKumaSyncInterval ?? "5",
|
||||
UptimeKumaInterval: optionMap.UptimeKumaInterval ?? "60",
|
||||
UptimeKumaRetry: optionMap.UptimeKumaRetry ?? "0",
|
||||
UptimeKumaRetryInterval: optionMap.UptimeKumaRetryInterval ?? "60",
|
||||
UptimeKumaTimeout: optionMap.UptimeKumaTimeout ?? "48",
|
||||
DatabaseAutoCleanupEnabled: toBoolean(optionMap.DatabaseAutoCleanupEnabled, false),
|
||||
DatabaseAutoCleanupRetentionDays: optionMap.DatabaseAutoCleanupRetentionDays ?? "30",
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDurationLabel(value: string) {
|
||||
const milliseconds = Number.parseInt(value, 10)
|
||||
if (Number.isNaN(milliseconds)) return value
|
||||
if (milliseconds >= 60000) return `${milliseconds / 60000} 分钟`
|
||||
return `${milliseconds / 1000} 秒`
|
||||
}
|
||||
|
||||
export function normalizeServerUrl(value: string) {
|
||||
return value.trim().replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
export function getBrowserOrigin() {
|
||||
if (typeof window === "undefined") return ""
|
||||
return normalizeServerUrl(window.location.origin)
|
||||
}
|
||||
|
||||
export function buildDiscoveryCommand(serverUrl: string, discoveryToken: string) {
|
||||
return [
|
||||
`curl -fsSL ${INSTALLER_SCRIPT_URL} | bash -s -- \\`,
|
||||
` --server-url ${normalizeServerUrl(serverUrl)} \\`,
|
||||
` --discovery-token ${discoveryToken}`,
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export function validateAgentFields(fields: OpenFlareOpsFields) {
|
||||
const heartbeat = Number.parseInt(fields.AgentHeartbeatInterval, 10)
|
||||
const offline = Number.parseInt(fields.NodeOfflineThreshold, 10)
|
||||
if (Number.isNaN(heartbeat) || heartbeat < 5000) {
|
||||
throw new Error("心跳间隔不能小于 5000 毫秒。")
|
||||
}
|
||||
if (Number.isNaN(offline) || offline < 10000) {
|
||||
throw new Error("离线阈值不能小于 10000 毫秒。")
|
||||
}
|
||||
}
|
||||
|
||||
export function validateUptimeKumaFields(fields: OpenFlareOpsFields) {
|
||||
const syncInt = Number.parseInt(fields.UptimeKumaSyncInterval, 10)
|
||||
const interval = Number.parseInt(fields.UptimeKumaInterval, 10)
|
||||
const retry = Number.parseInt(fields.UptimeKumaRetry, 10)
|
||||
const retryInt = Number.parseInt(fields.UptimeKumaRetryInterval, 10)
|
||||
const timeout = Number.parseInt(fields.UptimeKumaTimeout, 10)
|
||||
|
||||
if (fields.UptimeKumaEnabled) {
|
||||
if (!fields.UptimeKumaUrl.trim()) throw new Error("请输入 Uptime Kuma 地址。")
|
||||
if (!fields.UptimeKumaUsername.trim()) throw new Error("请输入 Uptime Kuma 用户名。")
|
||||
}
|
||||
if (Number.isNaN(syncInt) || syncInt <= 0) throw new Error("同步间隔必须为正整数。")
|
||||
if (Number.isNaN(interval) || interval <= 0) throw new Error("心跳间隔必须为正整数。")
|
||||
if (Number.isNaN(retry) || retry < 0) throw new Error("重试次数必须为非负整数。")
|
||||
if (Number.isNaN(retryInt) || retryInt <= 0) throw new Error("心跳重试间隔必须为正整数。")
|
||||
if (Number.isNaN(timeout) || timeout <= 0) throw new Error("请求超时必须为正整数。")
|
||||
}
|
||||
|
||||
export function validateDatabaseAutoCleanup(fields: OpenFlareOpsFields) {
|
||||
const retentionDays = Number.parseInt(fields.DatabaseAutoCleanupRetentionDays, 10)
|
||||
if (Number.isNaN(retentionDays) || retentionDays < 1) {
|
||||
throw new Error("自动清理保留天数至少为 1 天。")
|
||||
}
|
||||
}
|
||||
|
||||
export function agentOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
|
||||
validateAgentFields(fields)
|
||||
return [
|
||||
{ key: "AgentHeartbeatInterval", value: fields.AgentHeartbeatInterval },
|
||||
{ key: "AgentWebsocketUpgradeEnabled", value: String(fields.AgentWebsocketUpgradeEnabled) },
|
||||
{ key: "NodeOfflineThreshold", value: fields.NodeOfflineThreshold },
|
||||
{ key: "AgentUpdateRepo", value: fields.AgentUpdateRepo.trim() },
|
||||
{ key: "GeoIPProvider", value: fields.GeoIPProvider },
|
||||
]
|
||||
}
|
||||
|
||||
export function uptimeKumaOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
|
||||
validateUptimeKumaFields(fields)
|
||||
return [
|
||||
{ key: "UptimeKumaEnabled", value: String(fields.UptimeKumaEnabled) },
|
||||
{ key: "UptimeKumaUrl", value: fields.UptimeKumaUrl.trim() },
|
||||
{ key: "UptimeKumaUsername", value: fields.UptimeKumaUsername.trim() },
|
||||
{ key: "UptimeKumaPassword", value: fields.UptimeKumaPassword },
|
||||
{ key: "UptimeKumaMonitorScope", value: fields.UptimeKumaMonitorScope },
|
||||
{ key: "UptimeKumaSelectedSites", value: fields.UptimeKumaSelectedSites },
|
||||
{ key: "UptimeKumaSyncInterval", value: fields.UptimeKumaSyncInterval },
|
||||
{ key: "UptimeKumaInterval", value: fields.UptimeKumaInterval },
|
||||
{ key: "UptimeKumaRetry", value: fields.UptimeKumaRetry },
|
||||
{ key: "UptimeKumaRetryInterval", value: fields.UptimeKumaRetryInterval },
|
||||
{ key: "UptimeKumaTimeout", value: fields.UptimeKumaTimeout },
|
||||
]
|
||||
}
|
||||
|
||||
export function databaseAutoCleanupEntries(fields: OpenFlareOpsFields): OptionItem[] {
|
||||
validateDatabaseAutoCleanup(fields)
|
||||
return [
|
||||
{ key: "DatabaseAutoCleanupEnabled", value: String(fields.DatabaseAutoCleanupEnabled) },
|
||||
{ key: "DatabaseAutoCleanupRetentionDays", value: fields.DatabaseAutoCleanupRetentionDays },
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import {useEffect, useMemo, useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {Copy, ExternalLink, Loader2, RefreshCw, RotateCw, Save, Server, Trash2,} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import type {DatabaseCleanupTarget} from "@/lib/services/openflare"
|
||||
import {AdminStatusService} from "@/lib/services/admin"
|
||||
import {NodeService, OptionService, StatusService, UptimeKumaService,} from "@/lib/services/openflare"
|
||||
import {VersionUpgradeDialog} from "@/app/(main)/components/version-upgrade-dialog"
|
||||
import {adminUpdateStatusQueryKey, openflarePublicStatusQueryKey,} from "@/lib/hooks/use-openflare-server-upgrade"
|
||||
|
||||
import {
|
||||
agentOptionEntries,
|
||||
buildDiscoveryCommand,
|
||||
databaseAutoCleanupEntries,
|
||||
defaultOpenFlareOpsFields,
|
||||
formatDurationLabel,
|
||||
getBrowserOrigin,
|
||||
mapOptionsToOpsFields,
|
||||
type OpenFlareOpsFields,
|
||||
optionsToMap,
|
||||
uptimeKumaOptionEntries,
|
||||
} from "./openflare-ops-utils"
|
||||
import {UptimeKumaSiteSelectModal} from "./uptimekuma-site-modal"
|
||||
|
||||
const optionsQueryKey = ["openflare", "options"] as const
|
||||
|
||||
const cleanupTargets: Array<{
|
||||
target: DatabaseCleanupTarget
|
||||
label: string
|
||||
description: string
|
||||
}> = [
|
||||
{
|
||||
target: "node_access_logs",
|
||||
label: "访问日志",
|
||||
description: "清理 node_access_logs,影响访问明细与 IP 汇总。",
|
||||
},
|
||||
{
|
||||
target: "node_metric_snapshots",
|
||||
label: "性能快照",
|
||||
description: "清理 node_metric_snapshots,影响节点资源趋势。",
|
||||
},
|
||||
{
|
||||
target: "node_request_reports",
|
||||
label: "请求聚合",
|
||||
description: "清理 node_request_reports,影响请求量与错误量统计。",
|
||||
},
|
||||
]
|
||||
|
||||
async function copyText(value: string) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
}
|
||||
|
||||
export function OpenFlareOpsSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
const [fields, setFields] = useState<OpenFlareOpsFields>(defaultOpenFlareOpsFields)
|
||||
const [savingSection, setSavingSection] = useState<string | null>(null)
|
||||
const [geoIPTestIP, setGeoIPTestIP] = useState("8.8.8.8")
|
||||
const [uptimeKumaModalOpen, setUptimeKumaModalOpen] = useState(false)
|
||||
const [cleanupTarget, setCleanupTarget] = useState<{
|
||||
target: DatabaseCleanupTarget
|
||||
label: string
|
||||
} | null>(null)
|
||||
const [cleanupRetentionDays, setCleanupRetentionDays] = useState("")
|
||||
const [versionDialogOpen, setVersionDialogOpen] = useState(false)
|
||||
|
||||
const optionsQuery = useQuery({
|
||||
queryKey: optionsQueryKey,
|
||||
queryFn: () => OptionService.list(),
|
||||
})
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: openflarePublicStatusQueryKey,
|
||||
queryFn: () => StatusService.getPublicStatus(),
|
||||
})
|
||||
|
||||
const bootstrapQuery = useQuery({
|
||||
queryKey: ["openflare", "bootstrap-token"],
|
||||
queryFn: () => NodeService.getBootstrapToken(),
|
||||
})
|
||||
|
||||
const releaseQuery = useQuery({
|
||||
queryKey: adminUpdateStatusQueryKey,
|
||||
queryFn: () => AdminStatusService.getUpdateStatus(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!optionsQuery.data) return
|
||||
const optionMap = optionsToMap(optionsQuery.data)
|
||||
const serverAddress =
|
||||
optionMap.ServerAddress ||
|
||||
statusQuery.data?.server_address ||
|
||||
getBrowserOrigin()
|
||||
setFields(mapOptionsToOpsFields(optionMap, serverAddress))
|
||||
}, [optionsQuery.data, statusQuery.data?.server_address])
|
||||
|
||||
const geoIPMutation = useMutation({
|
||||
mutationFn: () => OptionService.lookupGeoIP(fields.GeoIPProvider, geoIPTestIP.trim()),
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
section,
|
||||
entries,
|
||||
}: {
|
||||
section: string
|
||||
entries: Array<{ key: string; value: string }>
|
||||
}) => {
|
||||
setSavingSection(section)
|
||||
await OptionService.updateBatch(entries)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success("OpenFlare 运维设置已保存")
|
||||
await queryClient.invalidateQueries({ queryKey: optionsQueryKey })
|
||||
setSavingSection(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
setSavingSection(null)
|
||||
toast.error(error instanceof Error ? error.message : "保存失败")
|
||||
},
|
||||
})
|
||||
|
||||
const rotateTokenMutation = useMutation({
|
||||
mutationFn: () => NodeService.rotateBootstrapToken(),
|
||||
onSuccess: async (data) => {
|
||||
toast.success("Discovery Token 已重新生成")
|
||||
await queryClient.invalidateQueries({ queryKey: ["openflare", "bootstrap-token"] })
|
||||
if (data.discovery_token) {
|
||||
try {
|
||||
await copyText(data.discovery_token)
|
||||
} catch {
|
||||
// ignore clipboard errors
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Token 轮换失败")
|
||||
},
|
||||
})
|
||||
|
||||
const syncUptimeKumaMutation = useMutation({
|
||||
mutationFn: () => UptimeKumaService.sync(),
|
||||
onSuccess: () => toast.success("Uptime Kuma 同步任务已执行"),
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : "同步失败"),
|
||||
})
|
||||
|
||||
const cleanupMutation = useMutation({
|
||||
mutationFn: (payload: { target: DatabaseCleanupTarget; retention_days?: number }) =>
|
||||
OptionService.cleanupDatabase(payload),
|
||||
onSuccess: (result) => {
|
||||
setCleanupTarget(null)
|
||||
setCleanupRetentionDays("")
|
||||
toast.success(
|
||||
result.delete_all
|
||||
? `已清空${result.target_label},共删除 ${result.deleted_count} 条。`
|
||||
: `已清理${result.target_label},共删除 ${result.deleted_count} 条。`,
|
||||
)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "清理失败")
|
||||
},
|
||||
})
|
||||
|
||||
const discoveryToken = bootstrapQuery.data?.discovery_token ?? ""
|
||||
const discoveryCommand = useMemo(() => {
|
||||
if (!fields.ServerAddress || !discoveryToken) return ""
|
||||
return buildDiscoveryCommand(fields.ServerAddress, discoveryToken)
|
||||
}, [discoveryToken, fields.ServerAddress])
|
||||
|
||||
const updateField = <K extends keyof OpenFlareOpsFields>(
|
||||
key: K,
|
||||
value: OpenFlareOpsFields[K],
|
||||
) => {
|
||||
setFields((previous) => ({ ...previous, [key]: value }))
|
||||
}
|
||||
|
||||
const saveAgentSettings = () => {
|
||||
try {
|
||||
saveMutation.mutate({
|
||||
section: "agent",
|
||||
entries: agentOptionEntries(fields),
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "参数校验失败")
|
||||
}
|
||||
}
|
||||
|
||||
const saveUptimeKumaSettings = () => {
|
||||
try {
|
||||
saveMutation.mutate({
|
||||
section: "uptimekuma",
|
||||
entries: uptimeKumaOptionEntries(fields),
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "参数校验失败")
|
||||
}
|
||||
}
|
||||
|
||||
const saveDatabaseAutoCleanup = () => {
|
||||
try {
|
||||
saveMutation.mutate({
|
||||
section: "database-auto",
|
||||
entries: databaseAutoCleanupEntries(fields),
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "参数校验失败")
|
||||
}
|
||||
}
|
||||
|
||||
if (optionsQuery.isLoading) {
|
||||
return <LoadingStateWithBorder icon={Server} description="加载 OpenFlare 运维设置..." />
|
||||
}
|
||||
|
||||
if (optionsQuery.isError) {
|
||||
return (
|
||||
<ErrorInline
|
||||
message={
|
||||
optionsQuery.error instanceof Error ? optionsQuery.error.message : "加载失败"
|
||||
}
|
||||
onRetry={() => void optionsQuery.refetch()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">Agent 运行参数</CardTitle>
|
||||
<CardDescription>心跳间隔与离线阈值会在下个心跳周期同步到节点。</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" disabled={savingSection === "agent"} onClick={saveAgentSettings}>
|
||||
{savingSection === "agent" ? (
|
||||
<Loader2 className="size-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<Save className="size-3.5 mr-1" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FieldInput
|
||||
label={`心跳间隔 (${formatDurationLabel(fields.AgentHeartbeatInterval)})`}
|
||||
value={fields.AgentHeartbeatInterval}
|
||||
type="number"
|
||||
onChange={(value) => updateField("AgentHeartbeatInterval", value)}
|
||||
/>
|
||||
<FieldInput
|
||||
label={`离线阈值 (${formatDurationLabel(fields.NodeOfflineThreshold)})`}
|
||||
value={fields.NodeOfflineThreshold}
|
||||
type="number"
|
||||
onChange={(value) => updateField("NodeOfflineThreshold", value)}
|
||||
/>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label="开启 WS 连接升级"
|
||||
description="HTTP 心跳成功后尝试升级为 WebSocket,配置发布可即时通知。"
|
||||
checked={fields.AgentWebsocketUpgradeEnabled}
|
||||
onChange={(value) => updateField("AgentWebsocketUpgradeEnabled", value)}
|
||||
/>
|
||||
<FieldInput
|
||||
label="Agent 更新仓库"
|
||||
value={fields.AgentUpdateRepo}
|
||||
placeholder="Rain-kl/OpenFlare"
|
||||
onChange={(value) => updateField("AgentUpdateRepo", value)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">IP 归属解析</CardTitle>
|
||||
<CardDescription>
|
||||
控制节点地图等场景的 GeoIP 来源;访客访问记录归属地固定使用 MaxMind mmdb。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" disabled={savingSection === "agent"} onClick={saveAgentSettings}>
|
||||
{savingSection === "agent" ? (
|
||||
<Loader2 className="size-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<Save className="size-3.5 mr-1" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>归属方式</Label>
|
||||
<Select
|
||||
value={fields.GeoIPProvider}
|
||||
onValueChange={(value) => updateField("GeoIPProvider", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="disabled">关闭</SelectItem>
|
||||
<SelectItem value="mmdb">MaxMind mmdb</SelectItem>
|
||||
<SelectItem value="ip-api">ip-api.com</SelectItem>
|
||||
<SelectItem value="geojs">geojs.io</SelectItem>
|
||||
<SelectItem value="ipinfo">ipinfo.io</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-dashed p-3 sm:flex-row sm:items-end">
|
||||
<FieldInput
|
||||
label="测试 IP"
|
||||
value={geoIPTestIP}
|
||||
onChange={setGeoIPTestIP}
|
||||
placeholder="8.8.8.8"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={geoIPMutation.isPending}
|
||||
onClick={() => geoIPMutation.mutate()}
|
||||
>
|
||||
{geoIPMutation.isPending ? "查询中..." : "查询归属"}
|
||||
</Button>
|
||||
</div>
|
||||
{geoIPMutation.data ? (
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<InfoCell label="国家/地区" value={geoIPMutation.data.name || "—"} />
|
||||
<InfoCell label="ISO Code" value={geoIPMutation.data.iso_code || "—"} />
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">Discovery Token 与部署</CardTitle>
|
||||
<CardDescription>
|
||||
新节点首次接入使用 Discovery Token;轮换请前往节点管理或使用下方操作。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/nodes">
|
||||
<ExternalLink className="size-3.5 mr-1" />
|
||||
节点管理
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={rotateTokenMutation.isPending}
|
||||
onClick={() => rotateTokenMutation.mutate()}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1" />
|
||||
轮换 Token
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FieldInput
|
||||
label="Server URL"
|
||||
value={fields.ServerAddress}
|
||||
onChange={(value) => updateField("ServerAddress", value)}
|
||||
placeholder="https://yourdomain.com"
|
||||
/>
|
||||
<div className="rounded-lg border border-dashed p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Discovery Token(只读)
|
||||
</p>
|
||||
<p className="mt-2 break-all text-sm font-mono">
|
||||
{bootstrapQuery.isLoading ? "加载中..." : discoveryToken || "未生成"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>一键部署命令</Label>
|
||||
<Textarea
|
||||
readOnly
|
||||
value={discoveryCommand || "请先填写可访问的 Server URL 并获取 Token。"}
|
||||
className="min-h-24 font-mono text-xs"
|
||||
/>
|
||||
{discoveryCommand ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void copyText(discoveryCommand).then(() => toast.success("命令已复制"))}
|
||||
>
|
||||
<Copy className="size-3.5 mr-1" />
|
||||
复制命令
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">Uptime Kuma 集成</CardTitle>
|
||||
<CardDescription>将反代站点差分同步至 Uptime Kuma 监控实例。</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!fields.UptimeKumaEnabled || syncUptimeKumaMutation.isPending}
|
||||
onClick={() => syncUptimeKumaMutation.mutate()}
|
||||
>
|
||||
<RefreshCw className="size-3.5 mr-1" />
|
||||
立即同步
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={savingSection === "uptimekuma"}
|
||||
onClick={saveUptimeKumaSettings}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ToggleRow
|
||||
label="开启 Uptime Kuma"
|
||||
checked={fields.UptimeKumaEnabled}
|
||||
onChange={(value) => updateField("UptimeKumaEnabled", value)}
|
||||
/>
|
||||
{fields.UptimeKumaEnabled ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FieldInput
|
||||
label="Uptime Kuma 地址"
|
||||
value={fields.UptimeKumaUrl}
|
||||
onChange={(value) => updateField("UptimeKumaUrl", value)}
|
||||
placeholder="http://localhost:3001"
|
||||
/>
|
||||
<FieldInput
|
||||
label="用户名"
|
||||
value={fields.UptimeKumaUsername}
|
||||
onChange={(value) => updateField("UptimeKumaUsername", value)}
|
||||
/>
|
||||
<FieldInput
|
||||
label="密码"
|
||||
value={fields.UptimeKumaPassword}
|
||||
type="password"
|
||||
onChange={(value) => updateField("UptimeKumaPassword", value)}
|
||||
placeholder="留空表示不更新"
|
||||
/>
|
||||
<FieldInput
|
||||
label="同步间隔 (分钟)"
|
||||
value={fields.UptimeKumaSyncInterval}
|
||||
type="number"
|
||||
onChange={(value) => updateField("UptimeKumaSyncInterval", value)}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label>监控范围</Label>
|
||||
<Select
|
||||
value={fields.UptimeKumaMonitorScope}
|
||||
onValueChange={(value) => updateField("UptimeKumaMonitorScope", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部站点</SelectItem>
|
||||
<SelectItem value="selected">选择站点</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{fields.UptimeKumaMonitorScope === "selected" ? (
|
||||
<div className="rounded-lg border border-dashed p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">已选站点</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setUptimeKumaModalOpen(true)}
|
||||
>
|
||||
选择监控站点
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 break-all text-xs text-muted-foreground">
|
||||
{fields.UptimeKumaSelectedSites
|
||||
? fields.UptimeKumaSelectedSites.split(",").join(", ")
|
||||
: "未选择任何站点"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FieldInput
|
||||
label="检测频率 (秒)"
|
||||
value={fields.UptimeKumaInterval}
|
||||
type="number"
|
||||
onChange={(value) => updateField("UptimeKumaInterval", value)}
|
||||
/>
|
||||
<FieldInput
|
||||
label="重试次数"
|
||||
value={fields.UptimeKumaRetry}
|
||||
type="number"
|
||||
onChange={(value) => updateField("UptimeKumaRetry", value)}
|
||||
/>
|
||||
<FieldInput
|
||||
label="重试间隔 (秒)"
|
||||
value={fields.UptimeKumaRetryInterval}
|
||||
type="number"
|
||||
onChange={(value) => updateField("UptimeKumaRetryInterval", value)}
|
||||
/>
|
||||
<FieldInput
|
||||
label="请求超时 (秒)"
|
||||
value={fields.UptimeKumaTimeout}
|
||||
type="number"
|
||||
onChange={(value) => updateField("UptimeKumaTimeout", value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">数据库自动清理</CardTitle>
|
||||
<CardDescription>每天凌晨 3 点清理超出保留期的观测数据。</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={savingSection === "database-auto"}
|
||||
onClick={saveDatabaseAutoCleanup}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ToggleRow
|
||||
label="启用每日自动清理"
|
||||
checked={fields.DatabaseAutoCleanupEnabled}
|
||||
onChange={(value) => updateField("DatabaseAutoCleanupEnabled", value)}
|
||||
/>
|
||||
<FieldInput
|
||||
label="保留天数"
|
||||
value={fields.DatabaseAutoCleanupRetentionDays}
|
||||
type="number"
|
||||
onChange={(value) => updateField("DatabaseAutoCleanupRetentionDays", value)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">手动数据清理</CardTitle>
|
||||
<CardDescription>保留天数留空时将删除该类数据的全部历史记录。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{cleanupTargets.map((item) => (
|
||||
<div
|
||||
key={item.target}
|
||||
className="flex items-start justify-between gap-3 rounded-lg border border-dashed p-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{item.label}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setCleanupTarget({ target: item.target, label: item.label })
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
清理
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">版本信息</CardTitle>
|
||||
<CardDescription>
|
||||
检查上游 GitHub Release 并升级当前服务。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={() => setVersionDialogOpen(true)}>
|
||||
管理升级
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<InfoCell label="当前版本" value={statusQuery.data?.version ?? releaseQuery.data?.current_version ?? "—"} />
|
||||
<InfoCell
|
||||
label="最新 Release"
|
||||
value={releaseQuery.data?.latest_version ?? "—"}
|
||||
/>
|
||||
<div className="rounded-lg border border-dashed px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">更新状态</p>
|
||||
<div className="mt-2">
|
||||
{releaseQuery.data?.update_available ? (
|
||||
<Badge variant="secondary">有新版本</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">已是最新</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<InfoCell
|
||||
label="启动时间"
|
||||
value={
|
||||
statusQuery.data?.start_time
|
||||
? new Date(statusQuery.data.start_time * 1000).toLocaleString()
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<VersionUpgradeDialog
|
||||
open={versionDialogOpen}
|
||||
onOpenChange={setVersionDialogOpen}
|
||||
canUpgrade
|
||||
/>
|
||||
|
||||
<UptimeKumaSiteSelectModal
|
||||
open={uptimeKumaModalOpen}
|
||||
selectedSites={
|
||||
fields.UptimeKumaSelectedSites
|
||||
? fields.UptimeKumaSelectedSites.split(",")
|
||||
: []
|
||||
}
|
||||
onOpenChange={setUptimeKumaModalOpen}
|
||||
onSave={(sites) => updateField("UptimeKumaSelectedSites", sites.join(","))}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={cleanupTarget !== null}
|
||||
onOpenChange={(open) => !open && setCleanupTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>清理{cleanupTarget?.label}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
输入保留天数后仅删除超出范围的数据;留空则删除全部历史记录,操作不可恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<FieldInput
|
||||
label="保留天数"
|
||||
value={cleanupRetentionDays}
|
||||
type="number"
|
||||
onChange={setCleanupRetentionDays}
|
||||
placeholder="留空则全部删除"
|
||||
/>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={cleanupMutation.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={cleanupMutation.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (!cleanupTarget) return
|
||||
const trimmed = cleanupRetentionDays.trim()
|
||||
if (trimmed !== "") {
|
||||
const retentionDays = Number.parseInt(trimmed, 10)
|
||||
if (Number.isNaN(retentionDays) || retentionDays < 1) {
|
||||
toast.error("保留天数至少为 1 天")
|
||||
return
|
||||
}
|
||||
cleanupMutation.mutate({
|
||||
target: cleanupTarget.target,
|
||||
retention_days: retentionDays,
|
||||
})
|
||||
return
|
||||
}
|
||||
cleanupMutation.mutate({ target: cleanupTarget.target })
|
||||
}}
|
||||
>
|
||||
{cleanupMutation.isPending ? "清理中..." : "确认清理"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
placeholder,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
type?: string
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-dashed px-3 py-2">
|
||||
<div>
|
||||
<Label className="text-xs">{label}</Label>
|
||||
{description ? (
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch checked={checked} onCheckedChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoCell({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</p>
|
||||
<p className="mt-2 text-sm font-medium break-all">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useRef, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Activity, Cpu, Database, HardDrive, Layers, RefreshCw} from "lucide-react"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Progress} from "@/components/ui/progress"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import type {SystemStatus} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时系统状态展示与管理组件
|
||||
*/
|
||||
export function SystemStatusManager() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [wavelet, setWavelet] = useState(false)
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [intervalTime, setIntervalTime] = useState("5000") // 默认5秒
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const prevStatusRef = useRef<SystemStatus | null>(null)
|
||||
const [changedFields, setChangedFields] = useState<Record<string, boolean>>({})
|
||||
|
||||
// 获取状态数据
|
||||
const fetchStatus = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setWavelet(true)
|
||||
try {
|
||||
const data = await services.adminStatus.getSystemStatus()
|
||||
|
||||
// 检测变化的字段用于微动画效果
|
||||
if (prevStatusRef.current) {
|
||||
const changes: Record<string, boolean> = {}
|
||||
Object.keys(data).forEach((key) => {
|
||||
const k = key as keyof SystemStatus
|
||||
if (prevStatusRef.current && prevStatusRef.current[k] !== data[k]) {
|
||||
changes[k] = true
|
||||
}
|
||||
})
|
||||
setChangedFields(changes)
|
||||
// 1秒后清除变化状态
|
||||
setTimeout(() => {
|
||||
setChangedFields({})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
setStatus(data)
|
||||
prevStatusRef.current = data
|
||||
} catch (err) {
|
||||
toast.error("获取系统状态失败", {
|
||||
description: err instanceof Error ? err.message : "请求时发生未知错误"
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setWavelet(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 轮询逻辑
|
||||
useEffect(() => {
|
||||
// 首次加载
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
|
||||
if (autoRefresh) {
|
||||
const time = parseInt(intervalTime, 10)
|
||||
intervalRef.current = setInterval(() => {
|
||||
fetchStatus(true)
|
||||
}, time)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [autoRefresh, intervalTime, fetchStatus])
|
||||
|
||||
// 计算内存分配百分比 (Alloc / Sys)
|
||||
const getMemoryUsagePercent = () => {
|
||||
if (!status) return 0
|
||||
|
||||
const parseSizeToBytes = (sizeStr: string): number => {
|
||||
const parts = sizeStr.split(" ")
|
||||
if (parts.length < 2) return 0
|
||||
const value = parseFloat(parts[0])
|
||||
const unit = parts[1].toLowerCase()
|
||||
|
||||
if (unit.startsWith("gib")) return value * 1024 * 1024 * 1024
|
||||
if (unit.startsWith("mib")) return value * 1024 * 1024
|
||||
if (unit.startsWith("kib")) return value * 1024
|
||||
return value
|
||||
}
|
||||
|
||||
const alloc = parseSizeToBytes(status.alloc)
|
||||
const sys = parseSizeToBytes(status.sys)
|
||||
if (sys === 0) return 0
|
||||
return Math.min(Math.round((alloc / sys) * 100), 100)
|
||||
}
|
||||
|
||||
// 闪烁动画类
|
||||
const getPulseClass = (field: string) => {
|
||||
return changedFields[field] ? "animate-pulse text-primary font-bold transition-all duration-300 scale-105 inline-block" : "transition-all duration-300"
|
||||
}
|
||||
|
||||
const RenderMetricRow = ({ label, value, field }: { label: string; value: string | number; field?: string }) => (
|
||||
<div className="flex items-center justify-between py-2 border-b border-dashed border-border/40 last:border-b-0 hover:bg-muted/30 px-2 rounded-sm transition-colors duration-150">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<span className={`text-xs font-mono font-medium ${field ? getPulseClass(field) : ""}`}>
|
||||
{typeof value === "number" ? formatNumber(value) : value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6 p-1">
|
||||
<div className="flex items-center justify-between border-b border-border/40 pb-4">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
{/* 顶部控制栏 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between pb-4 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">系统状态</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center flex-wrap gap-4 bg-muted/30 p-1.5 rounded-lg border border-border/40 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Switch
|
||||
id="auto-refresh"
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
<label htmlFor="auto-refresh" className="text-xs font-medium cursor-pointer select-none">
|
||||
自动刷新
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{autoRefresh && (
|
||||
<Select value={intervalTime} onValueChange={setIntervalTime}>
|
||||
<SelectTrigger className="h-7 w-20 text-[11px] bg-background border-border/40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1000">1s</SelectItem>
|
||||
<SelectItem value="2000">2s</SelectItem>
|
||||
<SelectItem value="5000">5s</SelectItem>
|
||||
<SelectItem value="10000">10s</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-7 text-[11px] gap-1.5"
|
||||
onClick={() => fetchStatus()}
|
||||
disabled={wavelet}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${wavelet ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<>
|
||||
{/* 主网格排版 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* 1. 服务概览 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">服务概览</CardTitle>
|
||||
<CardDescription className="text-[10px]">服务的基础生命指标</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<Activity className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<RenderMetricRow label="服务运行时间" value={status.uptime} field="uptime" />
|
||||
<RenderMetricRow label="当前 Goroutines 数量" value={status.num_goroutine} field="num_goroutine" />
|
||||
<RenderMetricRow label="Heap 对象数量" value={status.heap_objects} field="heap_objects" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2. 内存统计 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">内存统计</CardTitle>
|
||||
<CardDescription className="text-[10px]">主内存消耗概览</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<Cpu className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<RenderMetricRow label="当前内存使用量" value={status.alloc} field="alloc" />
|
||||
<RenderMetricRow label="所有已分配的内存" value={status.total_alloc} field="total_alloc" />
|
||||
<RenderMetricRow label="内存占用量" value={status.sys} field="sys" />
|
||||
<RenderMetricRow label="下次 GC 内存回收量" value={status.next_gc} field="next_gc" />
|
||||
</div>
|
||||
{/* 物理内存水位比例 */}
|
||||
<div className="space-y-1 px-1 pt-1">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>当前使用率 (Alloc / Sys)</span>
|
||||
<span className="font-mono">{getMemoryUsagePercent()}%</span>
|
||||
</div>
|
||||
<Progress value={getMemoryUsagePercent()} className="h-1.5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. 堆/栈详情 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">堆/栈详情</CardTitle>
|
||||
<CardDescription className="text-[10px]">运行时堆栈内存空间细节</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<Database className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<RenderMetricRow label="当前 Heap 内存使用量" value={status.heap_alloc} field="heap_alloc" />
|
||||
<RenderMetricRow label="Heap 内存占用量" value={status.heap_sys} field="heap_sys" />
|
||||
<RenderMetricRow label="Heap 内存空闲量" value={status.heap_idle} field="heap_idle" />
|
||||
<RenderMetricRow label="正在使用的 Heap 内存" value={status.heap_inuse} field="heap_inuse" />
|
||||
<RenderMetricRow label="已释放的 Heap 内存" value={status.heap_released} field="heap_released" />
|
||||
<RenderMetricRow label="启动 Stack 使用量" value={status.stack_inuse} field="stack_inuse" />
|
||||
<RenderMetricRow label="已分配的 Stack 内存" value={status.stack_sys} field="stack_sys" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 4. 底层组件与结构体 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">底层及结构体内存</CardTitle>
|
||||
<CardDescription className="text-[10px]">运行时底层管理结构体开销</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<HardDrive className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<RenderMetricRow label="MSpan 结构内存使用量" value={status.mspan_inuse} field="mspan_inuse" />
|
||||
<RenderMetricRow label="已分配的 MSpan 结构内存" value={status.mspan_sys} field="mspan_sys" />
|
||||
<RenderMetricRow label="MCache 结构内存使用量" value={status.mcache_inuse} field="mcache_inuse" />
|
||||
<RenderMetricRow label="已分配的 MCache 结构内存" value={status.mcache_sys} field="mcache_sys" />
|
||||
<RenderMetricRow label="已分配的剖析哈希表内存" value={status.buck_hash_sys} field="buck_hash_sys" />
|
||||
<RenderMetricRow label="已分配的 GC 元数据内存" value={status.gc_sys} field="gc_sys" />
|
||||
<RenderMetricRow label="其它已分配的系统内存" value={status.other_sys} field="other_sys" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 5. 垃圾回收与分配计数 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300 md:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">垃圾回收与分配计数</CardTitle>
|
||||
<CardDescription className="text-[10px]">GC 历史数据与分配频次</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<Layers className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6">
|
||||
<div>
|
||||
<RenderMetricRow label="GC 执行次数" value={status.num_gc} field="num_gc" />
|
||||
<RenderMetricRow label="距离上次 GC 时间" value={status.last_gc_time} field="last_gc_time" />
|
||||
<RenderMetricRow label="GC 暂停时间总量" value={status.pause_total_ns} field="pause_total_ns" />
|
||||
<RenderMetricRow label="上次 GC 暂停时间" value={status.last_pause} field="last_pause" />
|
||||
</div>
|
||||
<div>
|
||||
<RenderMetricRow label="内存分配次数" value={status.mallocs} field="mallocs" />
|
||||
<RenderMetricRow label="内存释放次数" value={status.frees} field="frees" />
|
||||
<RenderMetricRow label="指针查找次数" value={status.lookups} field="lookups" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useMemo, useState} from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {ProxyRouteService} from "@/lib/services/openflare"
|
||||
|
||||
type UptimeKumaSiteSelectModalProps = {
|
||||
open: boolean
|
||||
selectedSites: string[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (sites: string[]) => void
|
||||
}
|
||||
|
||||
export function UptimeKumaSiteSelectModal({
|
||||
open,
|
||||
selectedSites,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: UptimeKumaSiteSelectModalProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set())
|
||||
|
||||
const routesQuery = useQuery({
|
||||
queryKey: ["openflare", "proxy-routes"],
|
||||
queryFn: () => ProxyRouteService.list(),
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setTempSelected(new Set(selectedSites.map((site) => site.trim()).filter(Boolean)))
|
||||
setSearchTerm("")
|
||||
}, [open, selectedSites])
|
||||
|
||||
const filteredRoutes = useMemo(() => {
|
||||
const routes = routesQuery.data ?? []
|
||||
const keyword = searchTerm.trim().toLowerCase()
|
||||
if (!keyword) return routes
|
||||
return routes.filter(
|
||||
(route) =>
|
||||
route.site_name.toLowerCase().includes(keyword) ||
|
||||
route.primary_domain.toLowerCase().includes(keyword),
|
||||
)
|
||||
}, [routesQuery.data, searchTerm])
|
||||
|
||||
const toggleSite = (siteName: string) => {
|
||||
setTempSelected((previous) => {
|
||||
const next = new Set(previous)
|
||||
if (next.has(siteName)) next.delete(siteName)
|
||||
else next.add(siteName)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setTempSelected((previous) => {
|
||||
const next = new Set(previous)
|
||||
filteredRoutes.forEach((route) => next.add(route.site_name))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setTempSelected((previous) => {
|
||||
const next = new Set(previous)
|
||||
filteredRoutes.forEach((route) => next.delete(route.site_name))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择监控站点</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择要同步到 Uptime Kuma 的反代站点,支持按站点名称和域名搜索。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>搜索站点</Label>
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="按名称或域名搜索..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
||||
全选过滤项
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleDeselectAll}>
|
||||
清空过滤项
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto rounded-lg border">
|
||||
{routesQuery.isLoading ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">加载站点列表...</p>
|
||||
) : routesQuery.isError ? (
|
||||
<p className="p-4 text-sm text-destructive">
|
||||
{routesQuery.error instanceof Error
|
||||
? routesQuery.error.message
|
||||
: "加载站点失败"}
|
||||
</p>
|
||||
) : filteredRoutes.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">无匹配的站点</p>
|
||||
) : (
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="sticky top-0 bg-muted/60 text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-12 px-3 py-2">选择</th>
|
||||
<th className="px-3 py-2">站点名称</th>
|
||||
<th className="px-3 py-2">主域名</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{filteredRoutes.map((route) => {
|
||||
const checked = tempSelected.has(route.site_name)
|
||||
return (
|
||||
<tr
|
||||
key={route.id}
|
||||
className="cursor-pointer hover:bg-muted/30"
|
||||
onClick={() => toggleSite(route.site_name)}
|
||||
>
|
||||
<td className="px-3 py-2" onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleSite(route.site_name)}
|
||||
className="size-4 rounded border-input"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium">{route.site_name}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{route.primary_domain}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
已选择 {tempSelected.size} 个监控站点
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSave(Array.from(tempSelected))
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
保存选择
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import {useEffect, useMemo} from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {Loader2, Settings} from "lucide-react"
|
||||
import {useRouter} from "next/navigation"
|
||||
import {motion} from "motion/react"
|
||||
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
|
||||
const tabFallback = (
|
||||
<div className="h-64 animate-pulse rounded-lg border border-border/40 bg-muted/20" />
|
||||
)
|
||||
|
||||
const SecurityTab = dynamic(
|
||||
() => import("@/components/common/settings/security-tab").then((mod) => mod.SecurityTab),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const OperationTab = dynamic(
|
||||
() => import("@/components/common/settings/operation-tab").then((mod) => mod.OperationTab),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const SystemTab = dynamic(
|
||||
() => import("@/components/common/settings/system-tab").then((mod) => mod.SystemTab),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const OtherTab = dynamic(
|
||||
() => import("@/components/common/settings/other-tab").then((mod) => mod.OtherTab),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const InfoTab = dynamic(
|
||||
() => import("@/components/common/settings/info-tab").then((mod) => mod.InfoTab),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const SystemStatusManager = dynamic(
|
||||
() => import("./components/system-status").then((mod) => mod.SystemStatusManager),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const OpenFlareOpsSettings = dynamic(
|
||||
() => import("./components/openflare-ops").then((mod) => mod.OpenFlareOpsSettings),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
function systemConfigMap(configs: SystemConfig[]) {
|
||||
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
|
||||
accumulator[config.key] = config
|
||||
return accumulator
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function AdminSettingsPageClient() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const systemConfigsQuery = useQuery({
|
||||
queryKey: ["admin", "system-configs"],
|
||||
queryFn: () => services.adminSystemConfig.listSystemConfigs("system"),
|
||||
enabled: !!user?.is_admin,
|
||||
})
|
||||
|
||||
const configs = useMemo(
|
||||
() => systemConfigMap(systemConfigsQuery.data ?? []),
|
||||
[systemConfigsQuery.data],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && (!user || !user.is_admin)) {
|
||||
router.replace("/settings/profile")
|
||||
}
|
||||
}, [user, loading, router])
|
||||
|
||||
if (loading || !user || !user.is_admin) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="size-6 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
className="py-6 space-y-6 w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">系统设置</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs defaultValue="security" className="w-full">
|
||||
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
|
||||
<TabsTrigger value="openflare-ops" className="px-0 pb-2 text-xs font-semibold">
|
||||
OpenFlare
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security" className="px-0 pb-2 text-xs font-semibold">
|
||||
安全设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="operation" className="px-0 pb-2 text-xs font-semibold">
|
||||
业务设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system" className="px-0 pb-2 text-xs font-semibold">
|
||||
系统设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="other" className="px-0 pb-2 text-xs font-semibold">
|
||||
其他设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="status" className="px-0 pb-2 text-xs font-semibold">
|
||||
系统状态
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="px-0 pb-2 text-xs font-semibold">
|
||||
系统信息
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="security" className="focus-visible:outline-none">
|
||||
<SecurityTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
|
||||
</TabsContent>
|
||||
<TabsContent value="operation" className="focus-visible:outline-none">
|
||||
<OperationTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
|
||||
</TabsContent>
|
||||
<TabsContent value="system" className="focus-visible:outline-none">
|
||||
<SystemTab configs={configs} systemConfigsQuery={systemConfigsQuery} />
|
||||
</TabsContent>
|
||||
<TabsContent value="status" className="focus-visible:outline-none">
|
||||
<SystemStatusManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="other" className="focus-visible:outline-none">
|
||||
<OtherTab configs={configs} />
|
||||
</TabsContent>
|
||||
<TabsContent value="info" className="focus-visible:outline-none">
|
||||
<InfoTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="openflare-ops" className="focus-visible:outline-none">
|
||||
<OpenFlareOpsSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import {AdminSettingsPageClient} from "./page-client"
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
return <AdminSettingsPageClient />
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {ManageDetailPanel, ManagePage} from "@/components/common/general/manage-pannel"
|
||||
import {Tabs, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import {ShieldCheck} from "lucide-react"
|
||||
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {AdminProvider, useAdmin} from "@/contexts/admin-context"
|
||||
|
||||
|
||||
/**
|
||||
* 系统配置
|
||||
* 显示系统配置的详细信息和编辑面板
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SystemConfigDetailPanel
|
||||
* config={config}
|
||||
* editData={editData}
|
||||
* onEditDataChange={onEditDataChange}
|
||||
* onSave={onSave}
|
||||
* saving={saving}
|
||||
* />
|
||||
* ```
|
||||
* @param {SystemConfig} config - 系统配置
|
||||
* @param {Partial<SystemConfig>} editData - 编辑数据
|
||||
* @param {function} onEditDataChange - 编辑数据改变回调
|
||||
* @param {function} onSave - 保存回调
|
||||
* @param {boolean} saving - 是否正在保存
|
||||
* @returns {React.ReactNode} 系统配置详情面板组件
|
||||
*/
|
||||
function SystemConfigDetailPanel({
|
||||
config,
|
||||
editData,
|
||||
onEditDataChange,
|
||||
onSave,
|
||||
saving
|
||||
}: {
|
||||
config: SystemConfig | null
|
||||
editData: Partial<SystemConfig>
|
||||
onEditDataChange: (field: keyof SystemConfig, value: SystemConfig[keyof SystemConfig]) => void
|
||||
onSave: () => void
|
||||
saving: boolean
|
||||
}) {
|
||||
|
||||
return (
|
||||
<ManageDetailPanel
|
||||
isEmpty={!config}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-0">
|
||||
<div className="border border-dashed rounded-lg">
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">配置键</label>
|
||||
<p className="text-xs text-muted-foreground font-mono">{config?.key}</p>
|
||||
</div>
|
||||
|
||||
<div className="pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">配置值</label>
|
||||
<div className="flex gap-1 w-[90%] justify-end items-center pr-3">
|
||||
{config?.key.endsWith('_enabled') ? (
|
||||
<Switch
|
||||
checked={editData.value !== undefined ? editData.value === 'true' : (config?.value === 'true')}
|
||||
onCheckedChange={(checked) => {
|
||||
onEditDataChange('value', checked ? 'true' : 'false')
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={
|
||||
config?.key && (
|
||||
config.key.endsWith('_limit') ||
|
||||
config.key.endsWith('_minutes') ||
|
||||
config.key.endsWith('_days') ||
|
||||
config.key.endsWith('_seconds') ||
|
||||
config.key.endsWith('_hours') ||
|
||||
config.key.endsWith('_ttl') ||
|
||||
config.key.endsWith('_max') ||
|
||||
config.key.endsWith('_min') ||
|
||||
config.key.includes('max_') ||
|
||||
config.key.includes('min_') ||
|
||||
config.key.includes('limit') ||
|
||||
config.key.includes('count')
|
||||
) ? "number" : "text"
|
||||
}
|
||||
step="1"
|
||||
min="0"
|
||||
value={editData.value !== undefined ? editData.value : (config?.value || '')}
|
||||
placeholder={editData.value === undefined && !config?.value ? '必需' : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '') {
|
||||
onEditDataChange('value', '')
|
||||
return
|
||||
}
|
||||
onEditDataChange('value', value)
|
||||
}}
|
||||
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-inner-spin-button]"
|
||||
style={{
|
||||
MozAppearance: 'textfield'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">配置描述</label>
|
||||
<div className="flex gap-1 w-[90%]">
|
||||
<Input
|
||||
type="text"
|
||||
value={editData.description !== undefined ? editData.description : (config?.description || '')}
|
||||
placeholder="可选描述"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
onEditDataChange('description', value)
|
||||
}}
|
||||
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">公共可见</label>
|
||||
<Switch
|
||||
checked={(editData.visibility ?? config?.visibility ?? 0) === 1}
|
||||
onCheckedChange={(checked) => {
|
||||
onEditDataChange('visibility', checked ? 1 : 0)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">配置类型</label>
|
||||
<span className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${
|
||||
config?.type === 'system'
|
||||
? 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400'
|
||||
: 'bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
|
||||
}`}>
|
||||
{config?.type === 'system' ? '系统配置' : '业务配置'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
|
||||
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.created_at) : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ManageDetailPanel>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 系统配置管理组件
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SystemConfigs />
|
||||
* ```
|
||||
* @returns {React.ReactNode} 系统配置管理组件
|
||||
*/
|
||||
export function SystemConfigs() {
|
||||
const {
|
||||
systemConfigs: configs,
|
||||
systemConfigsLoading: loading,
|
||||
systemConfigsError: error,
|
||||
refetchSystemConfigs,
|
||||
updateSystemConfig
|
||||
} = useAdmin()
|
||||
|
||||
const [activeTab, setActiveTab] = React.useState<'system' | 'business'>('business')
|
||||
|
||||
React.useEffect(() => {
|
||||
refetchSystemConfigs(activeTab)
|
||||
}, [activeTab, refetchSystemConfigs])
|
||||
|
||||
const getInitialEditData = (config: SystemConfig) => ({
|
||||
value: config.value,
|
||||
visibility: config.visibility,
|
||||
description: config.description
|
||||
})
|
||||
|
||||
const handleSave = async (config: SystemConfig, editData: Partial<SystemConfig>) => {
|
||||
if (!config) return
|
||||
|
||||
await updateSystemConfig(config.key, {
|
||||
value: editData.value ?? config.value,
|
||||
visibility: editData.visibility ?? config.visibility,
|
||||
description: editData.description ?? config.description
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ManagePage<SystemConfig>
|
||||
title="系统配置"
|
||||
icon={ShieldCheck}
|
||||
data={configs}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onReload={() => refetchSystemConfigs(activeTab)}
|
||||
getInitialEditData={getInitialEditData}
|
||||
onSave={handleSave}
|
||||
getId={(config) => config.key}
|
||||
emptyDescription="未发现系统配置"
|
||||
loadingDescription="配置加载中"
|
||||
headerExtra={
|
||||
<Tabs value={activeTab} onValueChange={(val) => setActiveTab(val as 'system' | 'business')} className="w-[180px]">
|
||||
<TabsList className="grid w-full grid-cols-2 h-8">
|
||||
<TabsTrigger value="business" className="text-[11px] h-7">业务配置</TabsTrigger>
|
||||
<TabsTrigger value="system" className="text-[11px] h-7">系统配置</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
}
|
||||
columns={[
|
||||
{ header: "配置键", cell: (item) => <span className="font-mono font-medium">{item.key}</span>, width: "200px" },
|
||||
{ header: "配置值", cell: (item) => <span className="truncate max-w-[120px] inline-block" title={item.value}>{item.value}</span>, width: "120px" },
|
||||
{ header: "公共可见", cell: (item) => <span>{item.visibility === 1 ? "可见" : "不可见"}</span>, width: "80px" },
|
||||
{ header: "描述", cell: (item) => <span className="truncate max-w-[200px] inline-block text-muted-foreground" title={item.description}>{item.description}</span>, width: "200px" },
|
||||
]}
|
||||
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
|
||||
<SystemConfigDetailPanel
|
||||
config={selected || hovered}
|
||||
editData={editData}
|
||||
onEditDataChange={onEditDataChange}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SystemConfigPage() {
|
||||
return (
|
||||
<AdminProvider>
|
||||
<SystemConfigs />
|
||||
</AdminProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
"use client"
|
||||
|
||||
import {useMemo, useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {toast} from "sonner"
|
||||
import {format} from "date-fns"
|
||||
import {Activity, ChevronLeft, ChevronRight, RefreshCw, RotateCcw} from "lucide-react"
|
||||
|
||||
import services from "@/lib/services"
|
||||
import type {TaskExecution, TaskExecutionStatus, TaskMeta} from "@/lib/services/admin"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle} from "@/components/ui/sheet"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
|
||||
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
|
||||
pending: "等待中",
|
||||
running: "执行中",
|
||||
succeeded: "成功",
|
||||
failed: "失败",
|
||||
}
|
||||
|
||||
const TRIGGER_LABELS: Record<string, string> = {
|
||||
system: "系统",
|
||||
manual: "手动",
|
||||
retry: "重试",
|
||||
schedule: "定时",
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) return "-"
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return format(date, "yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
|
||||
function formatDuration(duration: number) {
|
||||
if (!duration) return "-"
|
||||
if (duration < 1000) return `${ duration }ms`
|
||||
return `${ (duration / 1000).toFixed(2) }s`
|
||||
}
|
||||
|
||||
function statusVariant(status: TaskExecutionStatus) {
|
||||
if (status === "failed") return "destructive"
|
||||
if (status === "succeeded") return "secondary"
|
||||
return "outline"
|
||||
}
|
||||
|
||||
export function TaskExecutionsManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const [executionsPage, setExecutionsPage] = useState(1)
|
||||
const [executionStatus, setExecutionStatus] = useState<TaskExecutionStatus | "all">("all")
|
||||
const [executionTaskType, setExecutionTaskType] = useState<string>("all")
|
||||
const [selectedExecutionId, setSelectedExecutionId] = useState<string | null>(null)
|
||||
const [executionPreview, setExecutionPreview] = useState<TaskExecution | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
|
||||
const taskTypesQuery = useQuery({
|
||||
queryKey: ["admin", "task-types"],
|
||||
queryFn: () => services.adminTask.getTaskTypes(),
|
||||
})
|
||||
|
||||
const executionsQuery = useQuery({
|
||||
queryKey: ["admin", "task-executions", executionsPage, executionStatus, executionTaskType],
|
||||
queryFn: () => services.adminTask.listTaskExecutions({
|
||||
page: executionsPage,
|
||||
page_size: PAGE_SIZE,
|
||||
status: executionStatus === "all" ? undefined : executionStatus,
|
||||
task_type: executionTaskType === "all" ? undefined : executionTaskType,
|
||||
}),
|
||||
})
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["admin", "task-execution", selectedExecutionId],
|
||||
queryFn: () => services.adminTask.getTaskExecution(selectedExecutionId!),
|
||||
enabled: detailOpen && !!selectedExecutionId,
|
||||
})
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (id: string) => services.adminTask.retryTaskExecution(id),
|
||||
onSuccess: (taskID) => {
|
||||
toast.success("任务已重新下发", {
|
||||
description: `新任务 ID:${ taskID }`,
|
||||
})
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "task-executions"] })
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error("任务重试失败", {
|
||||
description: err.message || "未知错误",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const taskTypes: TaskMeta[] = taskTypesQuery.data ?? []
|
||||
const executions = executionsQuery.data?.items ?? []
|
||||
const executionsTotal = executionsQuery.data?.total ?? 0
|
||||
const executionsLoading = executionsQuery.isPending || executionsQuery.isFetching
|
||||
const executionsError = executionsQuery.isError ? executionsQuery.error : null
|
||||
const selectedExecution = detailQuery.data ?? executionPreview
|
||||
const detailLoading = detailQuery.isFetching
|
||||
|
||||
const totalPages = useMemo(
|
||||
() => Math.max(1, Math.ceil(executionsTotal / PAGE_SIZE)),
|
||||
[executionsTotal],
|
||||
)
|
||||
|
||||
const openExecutionDetail = (execution: TaskExecution) => {
|
||||
setExecutionPreview(execution)
|
||||
setSelectedExecutionId(execution.id)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
const handleStatusChange = (value: TaskExecutionStatus | "all") => {
|
||||
setExecutionStatus(value)
|
||||
setExecutionsPage(1)
|
||||
}
|
||||
|
||||
const handleTaskTypeChange = (value: string) => {
|
||||
setExecutionTaskType(value)
|
||||
setExecutionsPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<br/>
|
||||
<div className="flex flex-col gap-3 border-b border-border pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={executionStatus} onValueChange={(value) => handleStatusChange(value as TaskExecutionStatus | "all")}>
|
||||
<SelectTrigger size="sm" className="w-[120px]">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="pending">等待中</SelectItem>
|
||||
<SelectItem value="running">执行中</SelectItem>
|
||||
<SelectItem value="succeeded">成功</SelectItem>
|
||||
<SelectItem value="failed">失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={executionTaskType} onValueChange={handleTaskTypeChange}>
|
||||
<SelectTrigger size="sm" className="w-[180px]">
|
||||
<SelectValue placeholder="任务类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部任务</SelectItem>
|
||||
{taskTypes.map((task) => (
|
||||
<SelectItem key={task.type} value={task.type}>{task.name || task.type}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" onClick={() => void executionsQuery.refetch()} disabled={executionsLoading}>
|
||||
{executionsLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{executionsError ? (
|
||||
<div className="p-8 border border-dashed rounded-lg bg-card">
|
||||
<ErrorInline error={executionsError} onRetry={() => void executionsQuery.refetch()} className="justify-center" />
|
||||
</div>
|
||||
) : executionsLoading && executions.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Activity} description="加载任务执行记录中..." />
|
||||
) : executions.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Activity} description="暂无任务执行记录" />
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table className="min-w-[900px]">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[180px]">任务</TableHead>
|
||||
<TableHead className="w-[100px]">状态</TableHead>
|
||||
<TableHead className="w-[110px]">触发</TableHead>
|
||||
<TableHead className="w-[120px]">重试</TableHead>
|
||||
<TableHead className="w-[120px]">耗时</TableHead>
|
||||
<TableHead className="min-w-[220px]">结果/错误</TableHead>
|
||||
<TableHead className="w-[170px]">创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{executions.map((execution) => (
|
||||
<TableRow
|
||||
key={execution.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => openExecutionDetail(execution)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">{execution.task_name || execution.task_type}</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{execution.task_id}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(execution.status)}>
|
||||
{STATUS_LABELS[execution.status] || execution.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{TRIGGER_LABELS[execution.triggered_by] || execution.triggered_by}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{execution.retry_count}/{execution.max_retry}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatDuration(execution.duration)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[320px] truncate text-xs text-muted-foreground">
|
||||
{execution.error_message || execution.result || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[11px] text-muted-foreground">
|
||||
{formatDateTime(execution.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
共 {executionsTotal} 条,当前第 {executionsPage}/{totalPages} 页
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExecutionsPage((page) => Math.max(1, page - 1))}
|
||||
disabled={executionsPage <= 1 || executionsLoading}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExecutionsPage((page) => Math.min(totalPages, page + 1))}
|
||||
disabled={executionsPage >= totalPages || executionsLoading}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<SheetContent className="w-full p-0 sm:max-w-[640px]">
|
||||
<SheetHeader className="border-b">
|
||||
<SheetTitle>任务执行详情</SheetTitle>
|
||||
<SheetDescription>
|
||||
{selectedExecution?.task_name || selectedExecution?.task_type || "任务记录"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||
{detailLoading && !selectedExecution ? (
|
||||
<LoadingStateWithBorder icon={Activity} description="加载任务详情中..." />
|
||||
) : selectedExecution ? (
|
||||
<div className="space-y-5 py-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground">状态</div>
|
||||
<div className="mt-2">
|
||||
<Badge variant={statusVariant(selectedExecution.status)}>
|
||||
{STATUS_LABELS[selectedExecution.status] || selectedExecution.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground">触发来源</div>
|
||||
<div className="mt-2 text-sm font-medium">
|
||||
{TRIGGER_LABELS[selectedExecution.triggered_by] || selectedExecution.triggered_by}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground">重试次数</div>
|
||||
<div className="mt-2 font-mono text-sm">
|
||||
{selectedExecution.retry_count}/{selectedExecution.max_retry}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground">耗时</div>
|
||||
<div className="mt-2 font-mono text-sm">
|
||||
{formatDuration(selectedExecution.duration)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>任务标识</Label>
|
||||
<div className="rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all">
|
||||
{selectedExecution.task_id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>创建时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.created_at)}</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>开始时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.started_at)}</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>结束时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.finished_at)}</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>更新时间</Label>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.updated_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>执行结果</Label>
|
||||
<div className="min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all">
|
||||
{selectedExecution.result || "-"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedExecution.error_message && (
|
||||
<div className="grid gap-2">
|
||||
<Label>错误信息</Label>
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive whitespace-pre-wrap break-all">
|
||||
{selectedExecution.error_message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Payload</Label>
|
||||
<pre className="max-h-40 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed">
|
||||
{selectedExecution.payload || "{}"}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>执行日志</Label>
|
||||
<pre className="min-h-48 max-h-[420px] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed whitespace-pre-wrap">
|
||||
{selectedExecution.log || "暂无日志"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStateWithBorder icon={Activity} description="未选择任务记录" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t">
|
||||
<Button variant="outline" onClick={() => void detailQuery.refetch()} disabled={!selectedExecutionId || detailLoading}>
|
||||
{detailLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
|
||||
刷新详情
|
||||
</Button>
|
||||
{selectedExecution && selectedExecution.status === "failed" && selectedExecution.retryable && (
|
||||
<Button onClick={() => retryMutation.mutate(selectedExecution.id)} disabled={retryMutation.isPending}>
|
||||
{retryMutation.isPending ? <Spinner className="size-4" /> : <RotateCcw className="size-4" />}
|
||||
重试任务
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
"use client"
|
||||
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Calendar as CalendarIcon, Clock, Info, Layers, Play} from "lucide-react"
|
||||
|
||||
import type {DispatchTaskRequest, TaskMeta} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
import {buildTaskPayload} from "@/lib/task-param-utils"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
import {format} from "date-fns"
|
||||
import {zhCN} from "date-fns/locale"
|
||||
import {Calendar} from "@/components/ui/calendar"
|
||||
import {Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"
|
||||
|
||||
const TASK_CONFIGS: Record<string, { icon: React.ComponentType<{ className?: string }>, color: string, gradient: string }> = {
|
||||
'order_sync': {
|
||||
icon: Layers,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
gradient: "from-blue-500/10 via-blue-500/5 to-transparent border-blue-200/50 dark:border-blue-800/50 hover:border-blue-400 dark:hover:border-blue-500",
|
||||
},
|
||||
'user_gamification': {
|
||||
icon: Play,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
gradient: "from-amber-500/10 via-amber-500/5 to-transparent border-amber-200/50 dark:border-amber-800/50 hover:border-amber-400 dark:hover:border-amber-500",
|
||||
},
|
||||
'dispute_auto_refund': {
|
||||
icon: Clock,
|
||||
color: "text-rose-600 dark:text-rose-400",
|
||||
gradient: "from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500",
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TASK_CONFIG = {
|
||||
icon: Layers,
|
||||
color: "text-zinc-600 dark:text-zinc-400",
|
||||
gradient: "from-zinc-500/10 via-zinc-500/5 to-transparent border-zinc-200/50 dark:border-zinc-800/50 hover:border-zinc-400 dark:hover:border-zinc-500",
|
||||
}
|
||||
|
||||
function DatePickerWithTime({ date, setDate }: { date: Date | undefined, setDate: (date: Date | undefined) => void }) {
|
||||
const timeString = date ? format(date, "HH:mm:ss") : ""
|
||||
|
||||
const handleDateSelect = (newDate: Date | undefined) => {
|
||||
if (!newDate) {
|
||||
setDate(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const updated = new Date(newDate)
|
||||
|
||||
if (date) {
|
||||
updated.setHours(date.getHours())
|
||||
updated.setMinutes(date.getMinutes())
|
||||
updated.setSeconds(date.getSeconds())
|
||||
} else {
|
||||
updated.setHours(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
setDate(updated)
|
||||
}
|
||||
|
||||
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = e.target.value
|
||||
if (!newTime) return
|
||||
|
||||
if (date) {
|
||||
const [hours, minutes, seconds] = newTime.split(':').map(Number)
|
||||
const updated = new Date(date)
|
||||
updated.setHours(hours || 0)
|
||||
updated.setMinutes(minutes || 0)
|
||||
updated.setSeconds(seconds || 0)
|
||||
setDate(updated)
|
||||
} else {
|
||||
const today = new Date()
|
||||
const [hours, minutes, seconds] = newTime.split(':').map(Number)
|
||||
today.setHours(hours || 0)
|
||||
today.setMinutes(minutes || 0)
|
||||
today.setSeconds(seconds || 0)
|
||||
setDate(today)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal text-xs h-8",
|
||||
!date && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-1 size-3" />
|
||||
{date ? format(date, "yyyy-MM-dd") : <span>选择日期</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={handleDateSelect}
|
||||
locale={zhCN}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="w-[120px]">
|
||||
<Input
|
||||
type="time"
|
||||
step="1"
|
||||
value={timeString}
|
||||
onChange={handleTimeChange}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TaskManager() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
|
||||
|
||||
const [dispatching, setDispatching] = useState(false)
|
||||
const [selectedTaskType, setSelectedTaskType] = useState<string | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
const [startTime, setStartTime] = useState<Date | undefined>(undefined)
|
||||
const [endTime, setEndTime] = useState<Date | undefined>(undefined)
|
||||
const [userId, setUserId] = useState("")
|
||||
const [paramValues, setParamValues] = useState<Record<string, string>>({})
|
||||
|
||||
const fetchTaskTypes = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await services.adminTask.getTaskTypes()
|
||||
setTaskTypes(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('加载任务类型失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskTypes()
|
||||
}, [fetchTaskTypes])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTaskType) {
|
||||
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
|
||||
if (targetTask?.params) {
|
||||
const initialValues: Record<string, string> = {}
|
||||
targetTask.params.forEach(p => {
|
||||
initialValues[p.name] = ""
|
||||
})
|
||||
setParamValues(initialValues)
|
||||
} else {
|
||||
setParamValues({})
|
||||
}
|
||||
}
|
||||
}, [selectedTaskType, taskTypes])
|
||||
|
||||
const handleDispatch = async () => {
|
||||
if (!selectedTaskType) return
|
||||
|
||||
try {
|
||||
setDispatching(true)
|
||||
|
||||
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
|
||||
|
||||
const params: DispatchTaskRequest = {
|
||||
task_type: selectedTaskType
|
||||
}
|
||||
|
||||
if (targetTask?.supports_time) {
|
||||
if (startTime) params.start_time = startTime.toISOString()
|
||||
if (endTime) params.end_time = endTime.toISOString()
|
||||
}
|
||||
|
||||
if (targetTask?.type === 'user_gamification') {
|
||||
if (userId) params.user_id = userId
|
||||
}
|
||||
|
||||
// Handle dynamic parameters — type coercion (number vs string) is
|
||||
// centralised in buildTaskPayload; do not inline it here.
|
||||
if (targetTask?.params && targetTask.params.length > 0) {
|
||||
const { payload, error } = buildTaskPayload(targetTask.params, paramValues)
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
setDispatching(false)
|
||||
return
|
||||
}
|
||||
params.payload = payload ?? undefined
|
||||
}
|
||||
|
||||
const taskID = await services.adminTask.dispatchTask(params)
|
||||
|
||||
toast.success('任务下发成功', {
|
||||
description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列:${ taskID }`
|
||||
})
|
||||
setDialogOpen(false)
|
||||
|
||||
setStartTime(undefined)
|
||||
setEndTime(undefined)
|
||||
setUserId("")
|
||||
setParamValues({})
|
||||
} catch (err) {
|
||||
toast.error('任务下发失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误'
|
||||
})
|
||||
} finally {
|
||||
setDispatching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDispatchDialog = (type: string) => {
|
||||
setSelectedTaskType(type)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const getSelectedTaskMeta = () => {
|
||||
return taskTypes.find(t => t.type === selectedTaskType)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<br/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{error ? (
|
||||
<div className="p-8 border border-dashed rounded-xl bg-card">
|
||||
<ErrorInline error={error} onRetry={fetchTaskTypes} className="justify-center" />
|
||||
</div>
|
||||
) : loading && taskTypes.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Layers} description="加载任务类型中..." />
|
||||
) : taskTypes.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Layers} description="暂无可用任务类型" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{taskTypes.map((task, index) => {
|
||||
const config = TASK_CONFIGS[task.type] || DEFAULT_TASK_CONFIG
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${ task.type }-${ index }`}
|
||||
className={cn(
|
||||
"relative group overflow-hidden rounded-xl border bg-gradient-to-br transition-all duration-500",
|
||||
config.gradient
|
||||
)}
|
||||
>
|
||||
<div className="relative h-full bg-card/40 backdrop-blur-sm p-4 flex flex-col justify-between hover:bg-card/0 transition-colors duration-500">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-base tracking-tight">{task.name}</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2 min-h-[36px]">
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="font-mono text-[10px] bg-background/50 backdrop-blur-md border px-1.5 h-5">
|
||||
{task.queue}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
|
||||
类型:{task.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
|
||||
重试:{task.max_retry}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 mt-1">
|
||||
<Button
|
||||
className="w-full h-7 text-xs"
|
||||
variant="secondary"
|
||||
onClick={() => openDispatchDialog(task.type)}
|
||||
>
|
||||
<Play className="size-3 mr-1" />
|
||||
立即执行
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>下发任务</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置任务参数并启动,任务将在后台异步执行。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>执行任务</Label>
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/50">
|
||||
{(() => {
|
||||
const meta = getSelectedTaskMeta()
|
||||
if (!meta) return null
|
||||
const config = TASK_CONFIGS[meta.type] || DEFAULT_TASK_CONFIG
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<>
|
||||
<div className={cn("p-2", config.color)}>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{meta.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">{meta.type}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const targetTask = getSelectedTaskMeta()
|
||||
if (targetTask?.supports_time) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>开始时间</Label>
|
||||
<DatePickerWithTime date={startTime} setDate={setStartTime} />
|
||||
<p className="text-xs text-muted-foreground">选择同步的起始时间点</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>结束时间</Label>
|
||||
<DatePickerWithTime date={endTime} setDate={setEndTime} />
|
||||
<p className="text-xs text-muted-foreground">选择同步的结束时间点</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const targetTask = getSelectedTaskMeta()
|
||||
if (!targetTask || !targetTask.params || targetTask.params.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{targetTask.params.map((param) => (
|
||||
<div key={param.name} className="grid gap-2">
|
||||
<Label htmlFor={`param-${param.name}`} className="flex items-center gap-1">
|
||||
{param.label}
|
||||
{param.required && <span className="text-destructive font-bold">*</span>}
|
||||
</Label>
|
||||
{param.type === 'text' ? (
|
||||
<Textarea
|
||||
id={`param-${param.name}`}
|
||||
placeholder={param.placeholder}
|
||||
className="text-xs min-h-[80px]"
|
||||
value={paramValues[param.name] || ""}
|
||||
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
|
||||
/>
|
||||
) : param.type === 'boolean' ? (
|
||||
<div className="flex items-center gap-2 pt-1 h-9">
|
||||
<Switch
|
||||
id={`param-${param.name}`}
|
||||
checked={paramValues[param.name] === 'true'}
|
||||
onCheckedChange={(checked) => setParamValues(prev => ({ ...prev, [param.name]: checked ? 'true' : 'false' }))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{paramValues[param.name] === 'true' ? '开启' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id={`param-${param.name}`}
|
||||
type={param.type === 'number' ? 'number' : 'text'}
|
||||
placeholder={param.placeholder}
|
||||
className="text-xs"
|
||||
value={paramValues[param.name] || ""}
|
||||
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
{param.description && (
|
||||
<p className="text-[10px] text-muted-foreground">{param.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const targetTask = getSelectedTaskMeta()
|
||||
const hasTime = targetTask?.supports_time
|
||||
const hasParams = targetTask?.params && targetTask.params.length > 0
|
||||
if (!hasTime && !hasParams) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md border border-dashed">
|
||||
<Info className="h-4 w-4" />
|
||||
<span>此任务无需额外配置参数,确认后将直接下发。</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={dispatching} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleDispatch} disabled={dispatching} className="h-8 text-xs">
|
||||
{dispatching ? <Spinner className="size-3" /> : <Play className="size-3" />}
|
||||
{dispatching ? '下发中' : '启动'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
"use client"
|
||||
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {Clock, Edit2, Info, Plus, RefreshCw, Trash2} from "lucide-react"
|
||||
|
||||
import type {CreateScheduleRequest, Schedule, TaskMeta, UpdateScheduleRequest} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
import {buildTaskPayload} from "@/lib/task-param-utils"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
|
||||
export function TaskSchedulesManager() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([])
|
||||
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
|
||||
|
||||
// Modal States
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [submitLoading, setSubmitLoading] = useState(false)
|
||||
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null)
|
||||
|
||||
// Delete States
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
|
||||
// Form Fields
|
||||
const [name, setName] = useState("")
|
||||
const [selectedTaskType, setSelectedTaskType] = useState("")
|
||||
const [cron, setCron] = useState("")
|
||||
const [isActive, setIsActive] = useState(true)
|
||||
const [paramValues, setParamValues] = useState<Record<string, string>>({})
|
||||
|
||||
// Fetch Schedules & Task Types
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const [schedulesData, taskTypesData] = await Promise.all([
|
||||
services.adminTask.listSchedules(),
|
||||
services.adminTask.getTaskTypes()
|
||||
])
|
||||
setSchedules(schedulesData || [])
|
||||
setTaskTypes(taskTypesData || [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error("加载定时任务配置失败"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
// Track task type selection to initialize parameters
|
||||
useEffect(() => {
|
||||
if (selectedTaskType) {
|
||||
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
|
||||
if (editingSchedule && editingSchedule.task_type === selectedTaskType) {
|
||||
// Parse existing payload when editing the same task type
|
||||
try {
|
||||
const parsed = JSON.parse(editingSchedule.payload || "{}")
|
||||
const values: Record<string, string> = {}
|
||||
targetTask?.params?.forEach(p => {
|
||||
values[p.name] = String(parsed[p.name] ?? "")
|
||||
})
|
||||
setParamValues(values)
|
||||
} catch {
|
||||
const initialValues: Record<string, string> = {}
|
||||
targetTask?.params?.forEach(p => {
|
||||
initialValues[p.name] = ""
|
||||
})
|
||||
setParamValues(initialValues)
|
||||
}
|
||||
} else {
|
||||
const initialValues: Record<string, string> = {}
|
||||
targetTask?.params?.forEach(p => {
|
||||
initialValues[p.name] = ""
|
||||
})
|
||||
setParamValues(initialValues)
|
||||
}
|
||||
} else {
|
||||
setParamValues({})
|
||||
}
|
||||
}, [selectedTaskType, taskTypes, editingSchedule])
|
||||
|
||||
const handleToggleActive = async (schedule: Schedule) => {
|
||||
try {
|
||||
const updated = await services.adminTask.updateSchedule(schedule.id, {
|
||||
name: schedule.name,
|
||||
task_type: schedule.task_type,
|
||||
cron: schedule.cron,
|
||||
payload: schedule.payload,
|
||||
is_active: !schedule.is_active
|
||||
})
|
||||
|
||||
setSchedules(prev => prev.map(s => s.id === schedule.id ? updated : s))
|
||||
toast.success(updated.is_active ? `已启用定时任务:${updated.name}` : `已停用定时任务:${updated.name}`)
|
||||
} catch (err) {
|
||||
toast.error("修改任务状态失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingSchedule(null)
|
||||
setName("")
|
||||
setSelectedTaskType(taskTypes[0]?.type || "")
|
||||
setCron("0 */2 * * *")
|
||||
setIsActive(true)
|
||||
setParamValues({})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (schedule: Schedule) => {
|
||||
setEditingSchedule(schedule)
|
||||
setName(schedule.name)
|
||||
setSelectedTaskType(schedule.task_type)
|
||||
setCron(schedule.cron)
|
||||
setIsActive(schedule.is_active)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("任务名称不能为空")
|
||||
return
|
||||
}
|
||||
if (!cron.trim()) {
|
||||
toast.error("Cron 表达式不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitLoading(true)
|
||||
|
||||
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
|
||||
let payloadStr = '{}'
|
||||
|
||||
if (targetTask?.params && targetTask.params.length > 0) {
|
||||
const { payload, error } = buildTaskPayload(targetTask.params, paramValues)
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
setSubmitLoading(false)
|
||||
return
|
||||
}
|
||||
payloadStr = payload ?? '{}'
|
||||
}
|
||||
|
||||
if (editingSchedule) {
|
||||
const req: UpdateScheduleRequest = {
|
||||
name,
|
||||
task_type: selectedTaskType,
|
||||
cron,
|
||||
payload: payloadStr,
|
||||
is_active: isActive
|
||||
}
|
||||
const updated = await services.adminTask.updateSchedule(editingSchedule.id, req)
|
||||
setSchedules(prev => prev.map(s => s.id === editingSchedule.id ? updated : s))
|
||||
toast.success("定时任务更新成功")
|
||||
} else {
|
||||
const req: CreateScheduleRequest = {
|
||||
name,
|
||||
task_type: selectedTaskType,
|
||||
cron,
|
||||
payload: payloadStr,
|
||||
is_active: isActive
|
||||
}
|
||||
const created = await services.adminTask.createSchedule(req)
|
||||
setSchedules(prev => [created, ...prev])
|
||||
toast.success("定时任务创建成功")
|
||||
}
|
||||
|
||||
setDialogOpen(false)
|
||||
} catch (err) {
|
||||
toast.error("提交失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误"
|
||||
})
|
||||
} finally {
|
||||
setSubmitLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDeleteDialog = (id: string) => {
|
||||
setDeletingId(id)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletingId) return
|
||||
try {
|
||||
setDeleteLoading(true)
|
||||
await services.adminTask.deleteSchedule(deletingId)
|
||||
setSchedules(prev => prev.filter(s => s.id !== deletingId))
|
||||
toast.success("定时任务删除成功")
|
||||
setDeleteOpen(false)
|
||||
} catch (err) {
|
||||
toast.error("删除失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误"
|
||||
})
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getSelectedTaskMeta = () => {
|
||||
return taskTypes.find(t => t.type === selectedTaskType)
|
||||
}
|
||||
|
||||
const getTaskName = (type: string) => {
|
||||
const meta = taskTypes.find(t => t.type === type)
|
||||
return meta ? meta.name : type
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<br/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||
{loading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
|
||||
刷新
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreateDialog} disabled={taskTypes.length === 0} variant={'secondary'}>
|
||||
<Plus className="size-4 mr-1" />
|
||||
新增定时任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="p-8 border border-dashed rounded-lg bg-card">
|
||||
<ErrorInline error={error} onRetry={fetchData} className="justify-center" />
|
||||
</div>
|
||||
) : loading && schedules.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Clock} description="加载定时任务配置中..." />
|
||||
) : schedules.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Clock} description="暂无定时任务配置,点击上方按钮新增" />
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table className="min-w-[800px]">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[180px]">任务名称</TableHead>
|
||||
<TableHead className="w-[160px]">关联异步任务</TableHead>
|
||||
<TableHead className="w-[120px]">Cron 表达式</TableHead>
|
||||
<TableHead className="min-w-[200px]">执行参数 (Payload)</TableHead>
|
||||
<TableHead className="w-[100px] text-center">启用状态</TableHead>
|
||||
<TableHead className="w-[120px] text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{schedules.map((schedule) => (
|
||||
<TableRow key={schedule.id}>
|
||||
<TableCell className="font-medium">{schedule.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-semibold">{getTaskName(schedule.task_type)}</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{schedule.task_type}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-blue-600 dark:text-blue-400 font-semibold">
|
||||
{schedule.cron}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[300px] truncate">
|
||||
<code className="text-xs bg-muted/60 px-1 py-0.5 rounded font-mono">
|
||||
{schedule.payload || "{}"}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex justify-center">
|
||||
<Switch
|
||||
checked={schedule.is_active}
|
||||
onCheckedChange={() => handleToggleActive(schedule)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => openEditDialog(schedule)}
|
||||
>
|
||||
<Edit2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive/90"
|
||||
onClick={() => openDeleteDialog(schedule.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add / Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingSchedule ? "修改定时任务" : "新增定时任务"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置定时任务的调度参数和运行载荷。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sched-name">任务名称</Label>
|
||||
<Input
|
||||
id="sched-name"
|
||||
placeholder="例如:每日数据清理"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sched-type">关联异步任务</Label>
|
||||
<select
|
||||
id="sched-type"
|
||||
value={selectedTaskType}
|
||||
onChange={(e) => setSelectedTaskType(e.target.value)}
|
||||
disabled={!!editingSchedule}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{taskTypes.map(t => (
|
||||
<option key={t.type} value={t.type}>
|
||||
{t.name} ({t.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sched-cron">Cron 表达式</Label>
|
||||
<Input
|
||||
id="sched-cron"
|
||||
placeholder="e.g. 0 */2 * * * 或 @daily"
|
||||
value={cron}
|
||||
onChange={(e) => setCron(e.target.value)}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">使用标准 5 位 Cron 字段格式(分钟、小时、日期、月份、星期几)。</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sched-active">启用状态</Label>
|
||||
<p className="text-[10px] text-muted-foreground">决定此定时任务是否会定时触发执行。</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="sched-active"
|
||||
checked={isActive}
|
||||
onCheckedChange={setIsActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const targetTask = getSelectedTaskMeta()
|
||||
if (!targetTask || !targetTask.params || targetTask.params.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/40 p-3 rounded-md border border-dashed">
|
||||
<Info className="h-4 w-4 shrink-0" />
|
||||
<span>所选任务类型不需要运行参数参数。</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4 pt-2 border-t">
|
||||
<Label className="text-xs font-semibold">运行参数配置 (Payload)</Label>
|
||||
{targetTask.params.map((param) => (
|
||||
<div key={param.name} className="grid gap-2 pl-2 border-l-2 border-muted">
|
||||
<Label htmlFor={`param-${param.name}`} className="flex items-center gap-1 text-xs">
|
||||
{param.label}
|
||||
{param.required && <span className="text-destructive font-bold">*</span>}
|
||||
</Label>
|
||||
{param.type === 'text' ? (
|
||||
<Textarea
|
||||
id={`param-${param.name}`}
|
||||
placeholder={param.placeholder}
|
||||
className="text-xs min-h-[70px]"
|
||||
value={paramValues[param.name] || ""}
|
||||
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
|
||||
/>
|
||||
) : param.type === 'boolean' ? (
|
||||
<div className="flex items-center gap-2 pt-1 h-9">
|
||||
<Switch
|
||||
id={`param-${param.name}`}
|
||||
checked={paramValues[param.name] === 'true'}
|
||||
onCheckedChange={(checked) => setParamValues(prev => ({ ...prev, [param.name]: checked ? 'true' : 'false' }))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{paramValues[param.name] === 'true' ? '开启' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id={`param-${param.name}`}
|
||||
type={param.type === 'number' ? 'number' : 'text'}
|
||||
placeholder={param.placeholder}
|
||||
className="text-xs"
|
||||
value={paramValues[param.name] || ""}
|
||||
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
{param.description && (
|
||||
<p className="text-[10px] text-muted-foreground">{param.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={submitLoading} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitLoading} className="h-8 text-xs">
|
||||
{submitLoading && <Spinner className="size-3 mr-1" />}
|
||||
{editingSchedule ? '保存修改' : '确认创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除定时任务</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除此定时任务吗?该操作不可撤销,且会立即取消其在调度器中的调度计划。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="ghost" onClick={() => setDeleteOpen(false)} disabled={deleteLoading} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleteLoading} className="h-8 text-xs">
|
||||
{deleteLoading && <Spinner className="size-3 mr-1" />}
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import {Suspense} from "react"
|
||||
import {useRouter, useSearchParams} from "next/navigation"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import {TaskManager} from "./components/task-manager"
|
||||
import {TaskSchedulesManager} from "./components/task-schedules"
|
||||
import {TaskExecutionsManager} from "./components/task-executions"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {Layers} from "lucide-react"
|
||||
|
||||
function TasksPageContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const activeTab = searchParams.get("tab") || "tasks"
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
router.push(`/admin/tasks?tab=${value}`)
|
||||
}
|
||||
return (
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">任务管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
|
||||
<TabsTrigger value="tasks" className="px-0 pb-2 text-xs font-semibold">
|
||||
任务管理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="schedules" className="px-0 pb-2 text-xs font-semibold">
|
||||
定时任务
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="executions" className="px-0 pb-2 text-xs font-semibold">
|
||||
任务日志
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tasks" className="space-y-4 outline-none">
|
||||
<TaskManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="schedules" className="space-y-4 outline-none">
|
||||
<TaskSchedulesManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="executions" className="space-y-4 outline-none">
|
||||
<TaskExecutionsManager />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TasksPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Spinner className="h-8 w-8" />
|
||||
</div>
|
||||
}>
|
||||
<TasksPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useState} from "react"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {useAdminUsers} from "@/contexts/admin-users-context"
|
||||
import type {CreateUserRequest} from "@/lib/services/admin"
|
||||
|
||||
const emptyForm: CreateUserRequest = {
|
||||
username: "",
|
||||
password: "",
|
||||
nickname: "",
|
||||
email: "",
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
}
|
||||
|
||||
export function CreateUserModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { createUser } = useAdminUsers()
|
||||
const [form, setForm] = useState<CreateUserRequest>(emptyForm)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setForm(emptyForm)
|
||||
setErrors({})
|
||||
} else {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
if (!form.username.trim()) {
|
||||
newErrors.username = "用户名不能为空"
|
||||
} else if (form.username.trim().length < 3) {
|
||||
newErrors.username = "用户名长度不能少于 3 位"
|
||||
}
|
||||
|
||||
if (!form.email.trim()) {
|
||||
newErrors.email = "邮箱不能为空"
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||
newErrors.email = "邮箱格式不正确"
|
||||
}
|
||||
|
||||
if (!form.password) {
|
||||
newErrors.password = "密码不能为空"
|
||||
} else if (form.password.length < 8) {
|
||||
newErrors.password = "密码长度不能少于 8 位"
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validate()) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await createUser({
|
||||
...form,
|
||||
username: form.username.trim(),
|
||||
nickname: form.nickname?.trim() || undefined,
|
||||
email: form.email.trim(),
|
||||
})
|
||||
onClose()
|
||||
} catch {
|
||||
// Errors are already handled by context toast notifications
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增用户</DialogTitle>
|
||||
<DialogDescription>
|
||||
直接创建一个本地密码登录的新账户。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
|
||||
placeholder="请输入用户名 (至少 3 位)"
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="text-xs text-destructive">{errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nickname">昵称 (选填)</Label>
|
||||
<Input
|
||||
id="nickname"
|
||||
value={form.nickname}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, nickname: e.target.value }))}
|
||||
placeholder="请输入昵称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="请输入邮箱地址"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-destructive">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="请输入登录密码 (至少 8 位)"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
|
||||
<div>
|
||||
<div className="font-medium text-sm">启用账户</div>
|
||||
<div className="text-xs text-muted-foreground">禁用后此账号将无法登录系统。</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.is_active}
|
||||
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
|
||||
<div>
|
||||
<div className="font-medium text-sm">管理员权限</div>
|
||||
<div className="text-xs text-muted-foreground">开启后此账号将拥有后台管理权限。</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.is_admin}
|
||||
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_admin: checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t mt-2">
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving} variant="secondary">
|
||||
{saving ? "创建中..." : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {Globe, Loader2, Mail, MapPin, ShieldCheck, Smartphone, Trash2, UserCheck,} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
||||
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
|
||||
import type {AdminUser} from "@/lib/services/admin"
|
||||
import {cn, formatDateTime} from "@/lib/utils"
|
||||
|
||||
interface UserDetailSheetProps {
|
||||
selectedUser: AdminUser | null
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
detailLoading: boolean
|
||||
onStatusToggle: (user: AdminUser) => Promise<void>
|
||||
onDeleteTarget: (user: AdminUser) => void
|
||||
}
|
||||
|
||||
export function UserDetailSheet({
|
||||
selectedUser,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
detailLoading,
|
||||
onStatusToggle,
|
||||
onDeleteTarget,
|
||||
}: UserDetailSheetProps) {
|
||||
|
||||
const displayValue = (value?: string) => value && value.trim() ? value : "-"
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-[400px] w-full p-0 flex flex-col gap-0">
|
||||
<SheetTitle className="px-5 py-3">用户档案</SheetTitle>
|
||||
|
||||
{selectedUser && (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||
<div className="flex flex-col pb-6">
|
||||
<div className="px-5 py-6 border-b border-border/50">
|
||||
<div className="flex flex-col items-center text-center gap-3">
|
||||
<Avatar className="h-20 w-20 rounded-full border-4 border-background ring-1 ring-border/20">
|
||||
<AvatarImage src={selectedUser.avatar_url} />
|
||||
<AvatarFallback className="rounded-full text-xl font-medium bg-secondary text-secondary-foreground">
|
||||
{selectedUser.username.substring(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-lg font-bold tracking-tight">{selectedUser.nickname}</h3>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<code className="px-1.5 py-0.5 rounded-md bg-muted text-[10px] font-mono text-muted-foreground">@{selectedUser.username}</code>
|
||||
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
|
||||
UID: {selectedUser.id}
|
||||
</Badge>
|
||||
{selectedUser.is_admin && (
|
||||
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailLoading && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
正在刷新详情
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] uppercase tracking-widest text-muted-foreground font-medium">注册时间</span>
|
||||
<span className="font-mono text-xs font-semibold">{formatDateTime(selectedUser.created_at).split(' ')[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">个人资料</h4>
|
||||
<div className="rounded-lg border divide-y bg-background/50">
|
||||
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
|
||||
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<Mail className="size-3" />
|
||||
邮箱
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.email)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
|
||||
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<Smartphone className="size-3" />
|
||||
手机
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.phone)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
|
||||
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<span className="size-3 flex items-center justify-center font-bold text-[9px]">⚧</span>
|
||||
性别
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.gender)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
|
||||
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<MapPin className="size-3" />
|
||||
所在地
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.location)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 p-3.5 text-sm">
|
||||
<span className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<Globe className="size-3" />
|
||||
网站
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-right text-[10px]">{displayValue(selectedUser.website)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-3.5 text-sm">
|
||||
<span className="text-[10px] text-muted-foreground">简介</span>
|
||||
<span className="break-words text-[10px] leading-5">{displayValue(selectedUser.bio)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">系统记录</h4>
|
||||
<div className="rounded-lg border divide-y bg-background/50">
|
||||
<div className="flex items-center justify-between p-3.5 text-sm">
|
||||
<span className="text-[10px]">账户状态</span>
|
||||
<Badge variant={selectedUser.is_active ? "secondary" : "outline"} className="text-[10px]">
|
||||
{selectedUser.is_active ? "正常" : "禁用"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3.5 text-sm">
|
||||
<span className="text-[10px]">管理员</span>
|
||||
<span className="font-mono text-[10px]">{selectedUser.is_admin ? "是" : "否"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3.5 text-sm">
|
||||
<span className="text-[10px]">最后登录</span>
|
||||
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.last_login_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3.5 text-sm">
|
||||
<span className="text-[10px]">注册时间</span>
|
||||
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3.5 text-sm">
|
||||
<span className="text-[10px]">最后更新</span>
|
||||
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedUser.is_admin && (
|
||||
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0 flex flex-col gap-2">
|
||||
<Button
|
||||
variant={selectedUser.is_active ? "destructive" : "default"}
|
||||
className={cn(
|
||||
"w-full h-9 text-xs font-medium transition-all active:scale-[0.98]",
|
||||
selectedUser.is_active
|
||||
? "bg-red-500 hover:bg-red-600 text-white"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
)}
|
||||
onClick={() => onStatusToggle(selectedUser)}
|
||||
>
|
||||
{selectedUser.is_active ? (
|
||||
<>
|
||||
<ShieldCheck className="size-3 mr-1" />
|
||||
封禁账户
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserCheck className="size-3 mr-1" />
|
||||
解除封禁
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-9 text-xs font-medium"
|
||||
onClick={() => onDeleteTarget(selectedUser)}
|
||||
>
|
||||
<Trash2 className="size-3 mr-1" />
|
||||
删除用户
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {ChevronDown, ChevronLeft, ChevronRight, Filter, Loader2, Search, X} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Separator} from "@/components/ui/separator"
|
||||
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"
|
||||
import {useAdminUsers} from "@/contexts/admin-users-context"
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
export function UserFilterBar() {
|
||||
const {
|
||||
total,
|
||||
loading,
|
||||
page,
|
||||
pageSize,
|
||||
searchUserId,
|
||||
searchUsername,
|
||||
statusFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
setSearchUserId,
|
||||
setSearchUsername,
|
||||
setStatusFilter,
|
||||
fetchUsers
|
||||
} = useAdminUsers()
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const hasSearchFilter = Boolean(searchUserId || searchUsername)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
|
||||
hasSearchFilter && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
>
|
||||
<Search className="size-3 mr-1" />
|
||||
搜索
|
||||
{hasSearchFilter && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
!
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 p-3" align="start">
|
||||
<div className="space-y-2.5">
|
||||
<input
|
||||
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
|
||||
placeholder="输入用户 ID..."
|
||||
value={searchUserId}
|
||||
onChange={(e) => setSearchUserId(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
|
||||
placeholder="输入 username..."
|
||||
value={searchUsername}
|
||||
onChange={(e) => setSearchUsername(e.target.value)}
|
||||
/>
|
||||
{hasSearchFilter && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full h-6 text-xs"
|
||||
onClick={() => {
|
||||
setSearchUserId("")
|
||||
setSearchUsername("")
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
|
||||
statusFilter !== 'all' && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
>
|
||||
<Filter className="size-3" />
|
||||
状态
|
||||
{statusFilter !== 'all' && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
1
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[120px]" align="start">
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => { e.preventDefault(); setStatusFilter('all') }}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
|
||||
statusFilter === 'all'
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50"
|
||||
)} />
|
||||
<span className="text-xs">全部状态</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => { e.preventDefault(); setStatusFilter('active') }}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
|
||||
statusFilter === 'active'
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50"
|
||||
)} />
|
||||
<span className="text-xs">正常</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => { e.preventDefault(); setStatusFilter('inactive') }}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
|
||||
statusFilter === 'inactive'
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50"
|
||||
)} />
|
||||
<span className="text-xs">禁用</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{(hasSearchFilter || statusFilter !== 'all') && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchUserId("")
|
||||
setSearchUsername("")
|
||||
setStatusFilter('all')
|
||||
}}
|
||||
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
清空筛选
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="lg:hidden" />
|
||||
|
||||
<div className="flex items-center gap-1.5 self-end lg:self-auto">
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{total} 条记录
|
||||
</span>
|
||||
<div className="flex items-center border border-dashed rounded-md shadow-none">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5.5 w-6 rounded-none rounded-l-md disabled:opacity-30"
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</Button>
|
||||
<span className="text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed">
|
||||
{page}/{totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5.5 w-6 rounded-none rounded-r-md disabled:opacity-30"
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
<ChevronRight className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-6 border-dashed text-[10px] px-2 font-mono shadow-none" disabled={loading}>
|
||||
{pageSize}条/页
|
||||
<ChevronDown className="size-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{[20, 50, 100].map(size => (
|
||||
<DropdownMenuItem
|
||||
key={size}
|
||||
onClick={() => setPageSize(size)}
|
||||
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
|
||||
>
|
||||
{size}条/页
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 border-dashed shadow-none"
|
||||
onClick={() => fetchUsers(true)}
|
||||
disabled={loading}
|
||||
title="刷新数据"
|
||||
>
|
||||
<Loader2 className={cn("size-3", loading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useState} from "react"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
||||
import {Eye, Layers, Loader2, Plus, Trash2, UserRound, UserX,} from "lucide-react"
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
import type {AdminUser} from "@/lib/services/admin"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {useAdminUsers} from "@/contexts/admin-users-context"
|
||||
import {CreateUserModal} from "./components/create-user-modal"
|
||||
import {UserFilterBar} from "./components/user-filter-bar"
|
||||
import {UserDetailSheet} from "./components/user-detail-sheet"
|
||||
|
||||
export default function UsersPage() {
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
fetchUsers,
|
||||
getUserDetail,
|
||||
updateUserStatus,
|
||||
deleteUser
|
||||
} = useAdminUsers()
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
|
||||
const handleStatusToggle = async (user: AdminUser) => {
|
||||
await updateUserStatus(user)
|
||||
|
||||
if (selectedUser?.id === user.id) {
|
||||
setSelectedUser(prev => prev ? { ...prev, is_active: !prev.is_active } : null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowDetail = async (user: AdminUser) => {
|
||||
setSelectedUser(user)
|
||||
setDetailOpen(true)
|
||||
setDetailLoading(true)
|
||||
|
||||
try {
|
||||
const detail = await getUserDetail(user.id)
|
||||
setSelectedUser(detail)
|
||||
} catch {
|
||||
setSelectedUser(user)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!deleteTarget) return
|
||||
|
||||
setDeleteLoading(true)
|
||||
try {
|
||||
await deleteUser(deleteTarget)
|
||||
if (selectedUser?.id === deleteTarget.id) {
|
||||
setDetailOpen(false)
|
||||
setSelectedUser(null)
|
||||
}
|
||||
setDeleteTarget(null)
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-4">
|
||||
{/* 顶部标题栏 */}
|
||||
<div className="flex items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserRound className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">用户管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={() => setCreateModalOpen(true)}>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
新增用户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选与分页栏 (已拆分为独立自治组件) */}
|
||||
<UserFilterBar />
|
||||
|
||||
{error ? (
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
<ErrorInline error={error} onRetry={() => fetchUsers(true)} className="justify-center" />
|
||||
</div>
|
||||
) : loading && users.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Layers} description="加载用户列表中..." />
|
||||
) : users.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={UserX} description="暂无用户数据" />
|
||||
) : (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Table className="w-full caption-bottom text-sm min-w-full">
|
||||
<TableHeader className="sticky top-0 z-20 bg-background">
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="w-[90px] whitespace-nowrap py-2 h-8">ID</TableHead>
|
||||
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">用户</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8 pl-4">上次登陆</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
|
||||
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className="border-dashed hover:bg-muted/30 cursor-pointer group"
|
||||
onClick={() => handleShowDetail(user)}
|
||||
>
|
||||
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{user.id}</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-7 w-7 rounded-sm border">
|
||||
<AvatarImage src={user.avatar_url} />
|
||||
<AvatarFallback className="rounded-sm text-[10px]">
|
||||
{user.username.substring(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-[11px] leading-tight max-w-[100px] truncate" title={user.nickname}>{user.nickname}</span>
|
||||
{user.is_admin && (
|
||||
<Badge variant="secondary" className="text-[9px] h-3.5 px-0.5 rounded-[2px] font-normal leading-none tracking-tighter">
|
||||
ADM
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground font-mono leading-tight">@{user.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
|
||||
{formatDateTime(user.last_login_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
|
||||
{formatDateTime(user.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
|
||||
{formatDateTime(user.updated_at)}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Switch
|
||||
checked={user.is_active}
|
||||
onCheckedChange={() => handleStatusToggle(user)}
|
||||
disabled={user.is_admin}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{user.is_admin ? '管理员账户' : user.is_active ? '禁用账户' : '启用账户'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground" onClick={() => handleShowDetail(user)}>
|
||||
<Eye className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
查看详情
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{!user.is_admin && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(user)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
删除用户
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户详情侧拉抽屉 (已拆分为独立子组件) */}
|
||||
<UserDetailSheet
|
||||
selectedUser={selectedUser}
|
||||
isOpen={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
detailLoading={detailLoading}
|
||||
onStatusToggle={handleStatusToggle}
|
||||
onDeleteTarget={setDeleteTarget}
|
||||
/>
|
||||
|
||||
{/* 删除确认警告弹窗 */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && !deleteLoading && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除用户 {deleteTarget?.nickname || deleteTarget?.username} 吗?该操作会移除用户账号,删除后无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteLoading}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteUser} disabled={deleteLoading}>
|
||||
{deleteLoading && <Loader2 className="size-3 animate-spin" />}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 新建用户模态弹窗 */}
|
||||
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from "@/components/ui/sheet"
|
||||
import type {ApplyLogItem} from "@/lib/services/openflare"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
|
||||
interface LogDetailSheetProps {
|
||||
log: ApplyLogItem | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function getResultBadge(result: string) {
|
||||
if (result === "success") {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2"
|
||||
>
|
||||
<span className="size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0" />
|
||||
成功
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (result === "warning") {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2"
|
||||
>
|
||||
<span className="size-1 bg-amber-500 rounded-full mr-1.5 shrink-0" />
|
||||
警告
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2"
|
||||
>
|
||||
<span className="size-1 bg-destructive rounded-full mr-1.5 shrink-0" />
|
||||
失败
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogDetailSheet({ log, open, onOpenChange }: LogDetailSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-md w-full p-0 flex flex-col gap-0">
|
||||
<SheetHeader className="px-5 py-4 border-b">
|
||||
<SheetTitle>应用日志详情</SheetTitle>
|
||||
<SheetDescription>查看单条应用日志的完整结果与校验信息。</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{log ? (
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{getResultBadge(log.result)}
|
||||
<Badge variant="outline" className="text-[10px] rounded-full">
|
||||
Node: {log.node_id}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px] rounded-full">
|
||||
版本: {log.version}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-xs">
|
||||
<div>
|
||||
<p className="text-muted-foreground">创建时间</p>
|
||||
<p className="font-medium">{formatDateTime(log.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">目标 Checksum</p>
|
||||
<p className="font-mono break-all">{log.checksum || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">主配置摘要</p>
|
||||
<p className="font-mono break-all">{log.main_config_checksum || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">路由配置摘要</p>
|
||||
<p className="font-mono break-all">{log.route_config_checksum || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">支持文件数</p>
|
||||
<p className="font-medium">{log.support_file_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dashed p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">消息</p>
|
||||
<pre className="text-xs whitespace-pre-wrap break-words">{log.message || "—"}</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SheetFooter className="px-5 py-4 border-t">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
"use client"
|
||||
|
||||
import {useCallback, useEffect, useMemo, useState} from "react"
|
||||
import Link from "next/link"
|
||||
import {useSearchParams} from "next/navigation"
|
||||
import {ClipboardList, Eye, RefreshCw, Search} from "lucide-react"
|
||||
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
||||
import {type ApplyLogItem, ApplyLogService,} from "@/lib/services/openflare"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
|
||||
import {LogDetailSheet} from "./components/log-detail-sheet"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [20, 50, 100]
|
||||
|
||||
function truncateHash(value: string) {
|
||||
if (!value) return "—"
|
||||
return value.length > 12 ? `${value.slice(0, 12)}...` : value
|
||||
}
|
||||
|
||||
function getResultBadge(result: string) {
|
||||
if (result === "success") {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2"
|
||||
>
|
||||
<span className="size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0" />
|
||||
成功
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (result === "warning") {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2"
|
||||
>
|
||||
<span className="size-1 bg-amber-500 rounded-full mr-1.5 shrink-0" />
|
||||
警告
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2"
|
||||
>
|
||||
<span className="size-1 bg-destructive rounded-full mr-1.5 shrink-0" />
|
||||
失败
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApplyLogsPageClient() {
|
||||
const searchParams = useSearchParams()
|
||||
const initialNodeId = searchParams.get("node_id")?.trim() ?? ""
|
||||
|
||||
const [nodeFilterInput, setNodeFilterInput] = useState(initialNodeId)
|
||||
const [nodeFilter, setNodeFilter] = useState(initialNodeId)
|
||||
const [pageNo, setPageNo] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const [rows, setRows] = useState<ApplyLogItem[]>([])
|
||||
const [current, setCurrent] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [totalPage, setTotalPage] = useState(0)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [selectedLog, setSelectedLog] = useState<ApplyLogItem | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const nodeIds = new Set(rows.map((item) => item.node_id))
|
||||
return [
|
||||
{ label: "总记录数", value: total },
|
||||
{ label: "当前页", value: current },
|
||||
{ label: "总页数", value: totalPage },
|
||||
{ label: "当前页节点数", value: nodeIds.size },
|
||||
]
|
||||
}, [rows, total, current, totalPage])
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await ApplyLogService.list({
|
||||
node_id: nodeFilter || undefined,
|
||||
pageNo,
|
||||
pageSize,
|
||||
})
|
||||
setRows(data.rows)
|
||||
setCurrent(data.current)
|
||||
setTotal(data.total)
|
||||
setTotalPage(data.totalPage)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [nodeFilter, pageNo, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchLogs()
|
||||
}, [fetchLogs])
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = searchParams.get("node_id")?.trim()
|
||||
if (!nodeId) return
|
||||
setNodeFilterInput(nodeId)
|
||||
setNodeFilter(nodeId)
|
||||
setPageNo(1)
|
||||
}, [searchParams])
|
||||
|
||||
const handleSearch = () => {
|
||||
setPageNo(1)
|
||||
setNodeFilter(nodeFilterInput.trim())
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setNodeFilterInput("")
|
||||
setNodeFilter("")
|
||||
setPageNo(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">应用日志</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
查看节点应用配置的成功、警告和失败记录,支持按 node_id 过滤。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchLogs()} disabled={loading}>
|
||||
<RefreshCw className={`size-3.5 mr-1 ${loading ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/nodes">返回节点</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-lg border border-dashed px-4 py-3 bg-background"
|
||||
>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_160px]">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Node ID</p>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={nodeFilterInput}
|
||||
onChange={(e) => setNodeFilterInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSearch()
|
||||
}}
|
||||
placeholder="输入 node_id 过滤应用日志"
|
||||
className="pl-8 h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">每页条数</p>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => {
|
||||
setPageSize(Number.parseInt(value, 10))
|
||||
setPageNo(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{option} 条
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={handleSearch}>
|
||||
筛选
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <ErrorInline message={error} onRetry={() => void fetchLogs()} /> : null}
|
||||
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
|
||||
{loading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title="暂无应用日志"
|
||||
description="当前筛选条件下没有可展示的应用记录。"
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow className="border-dashed hover:bg-transparent">
|
||||
<TableHead className="text-xs font-semibold">Node ID</TableHead>
|
||||
<TableHead className="text-xs font-semibold">版本</TableHead>
|
||||
<TableHead className="text-xs font-semibold">结果</TableHead>
|
||||
<TableHead className="text-xs font-semibold">Checksum</TableHead>
|
||||
<TableHead className="text-xs font-semibold">时间</TableHead>
|
||||
<TableHead className="text-xs font-semibold">消息</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((log) => (
|
||||
<TableRow
|
||||
key={log.id}
|
||||
className="border-dashed hover:bg-muted/10 transition-colors align-top"
|
||||
>
|
||||
<TableCell className="text-xs font-medium">{log.node_id}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{log.version}</TableCell>
|
||||
<TableCell>{getResultBadge(log.result)}</TableCell>
|
||||
<TableCell
|
||||
className="text-xs font-mono text-muted-foreground"
|
||||
title={log.checksum}
|
||||
>
|
||||
{truncateHash(log.checksum)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDateTime(log.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-56">
|
||||
<div className="line-clamp-2 break-words">{log.message || "—"}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
setSelectedLog(log)
|
||||
setDetailOpen(true)
|
||||
}}
|
||||
>
|
||||
<Eye className="size-3 mr-1" />
|
||||
详情
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loading && rows.length > 0 ? (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={current <= 1}
|
||||
onClick={() => setPageNo((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={totalPage === 0 || current >= totalPage}
|
||||
onClick={() =>
|
||||
setPageNo((prev) =>
|
||||
totalPage > 0 ? Math.min(totalPage, prev + 1) : prev,
|
||||
)
|
||||
}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LogDetailSheet
|
||||
log={selectedLog}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {Suspense} from 'react';
|
||||
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
|
||||
import {ApplyLogsPageClient} from './page-client';
|
||||
|
||||
function ApplyLogsPageFallback() {
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApplyLogsPage() {
|
||||
return (
|
||||
<Suspense fallback={<ApplyLogsPageFallback />}>
|
||||
<ApplyLogsPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import {TrendChart} from '@/components/data/trend-chart';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import type {CapacityTrendPoint} from '@/lib/services/openflare';
|
||||
|
||||
import {formatPercent, formatTrendHour} from './dashboard-utils';
|
||||
|
||||
export function CapacityTrendChart({
|
||||
points,
|
||||
title = '24 小时容量趋势',
|
||||
description = '按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。',
|
||||
}: {
|
||||
points: CapacityTrendPoint[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
<CardDescription className="text-xs">{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TrendChart
|
||||
labels={points.map((point) => formatTrendHour(point.bucket_started_at))}
|
||||
yAxisValueFormatter={formatPercent}
|
||||
series={[
|
||||
{
|
||||
label: '平均 CPU',
|
||||
color: '#0f766e',
|
||||
fillColor: 'rgba(15, 118, 110, 0.15)',
|
||||
variant: 'area',
|
||||
values: points.map((point) => point.average_cpu_usage_percent),
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
{
|
||||
label: '平均内存',
|
||||
color: '#2563eb',
|
||||
values: points.map((point) => point.average_memory_usage_percent),
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
calculateNiceAxisMax,
|
||||
formatBytes,
|
||||
formatBytesPerSecond,
|
||||
formatCompactNumber,
|
||||
formatPercent,
|
||||
} from '@/lib/utils/metrics';
|
||||
|
||||
export function formatTrendHour(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return `${date.getHours().toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user