update
This commit is contained in:
1 parent
f5cf3f4440
commit
0fcdbc5f6c
29 files changed
+773
-264
No files matched your search
+1
-2
@@ -1,9 +1,8 @@
|
|||||||
FROM golang:1.26-bookworm AS build
|
FROM golang:1.26-bookworm AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN go mod tidy
|
||||||
RUN CGO_ENABLED=1 GOOS=linux go build -o opsxsql ./cmd/server/
|
RUN CGO_ENABLED=1 GOOS=linux go build -o opsxsql ./cmd/server/
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|||||||
@@ -13,20 +13,34 @@ import (
|
|||||||
func ListAuditLogs(c *gin.Context) {
|
func ListAuditLogs(c *gin.Context) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
if page < 1 { page = 1 }
|
if page < 1 {
|
||||||
if pageSize < 1 || pageSize > 100 { pageSize = 20 }
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
model.DB.Model(&model.AuditLog{}).Count(&total)
|
query := model.DB.Model(&model.AuditLog{})
|
||||||
|
if action := c.Query("action"); action != "" {
|
||||||
|
query = query.Where("action = ?", action)
|
||||||
|
}
|
||||||
|
if targetType := c.Query("target_type"); targetType != "" {
|
||||||
|
query = query.Where("target_type = ?", targetType)
|
||||||
|
}
|
||||||
|
if resultStatus := c.Query("result_status"); resultStatus != "" {
|
||||||
|
query = query.Where("result_status = ?", resultStatus)
|
||||||
|
}
|
||||||
|
query.Count(&total)
|
||||||
|
|
||||||
var logs []model.AuditLog
|
var logs []model.AuditLog
|
||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
model.DB.Order("id DESC").Offset(offset).Limit(pageSize).Find(&logs)
|
query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&logs)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"items": logs,
|
"items": logs,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": pageSize,
|
"page_size": pageSize,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -159,17 +159,20 @@ func TestConnection(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result := testDBConnectionFull(&instance)
|
result := testDBConnectionFull(&instance)
|
||||||
// Update version & cluster_mode in DB if detected
|
// Keep the persisted list status in sync with an explicit connection test.
|
||||||
updates := map[string]interface{}{}
|
updates := map[string]interface{}{}
|
||||||
|
if result.Success {
|
||||||
|
updates["status"] = "connected"
|
||||||
|
} else {
|
||||||
|
updates["status"] = "error"
|
||||||
|
}
|
||||||
if result.Success && result.Version != "" {
|
if result.Success && result.Version != "" {
|
||||||
updates["version"] = result.Version
|
updates["version"] = result.Version
|
||||||
}
|
}
|
||||||
if instance.DBType == "redis" {
|
if instance.DBType == "redis" {
|
||||||
updates["cluster_mode"] = result.ClusterMode
|
updates["cluster_mode"] = result.ClusterMode
|
||||||
}
|
}
|
||||||
if len(updates) > 0 {
|
model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates)
|
||||||
model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates)
|
|
||||||
}
|
|
||||||
if result.Success {
|
if result.Success {
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": result.Message, "version": result.Version, "cluster_mode": result.ClusterMode})
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": result.Message, "version": result.Version, "cluster_mode": result.ClusterMode})
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -505,7 +505,7 @@ func DetailListUsers(c *gin.Context) {
|
|||||||
|
|
||||||
// POST /api/v1/query/execute
|
// POST /api/v1/query/execute
|
||||||
func ExecuteQuery(c *gin.Context) {
|
func ExecuteQuery(c *gin.Context) {
|
||||||
userID, _, _ := middleware.GetCurrentUser(c)
|
userID, username, _ := middleware.GetCurrentUser(c)
|
||||||
var input struct {
|
var input struct {
|
||||||
InstanceID int `json:"instance_id"`
|
InstanceID int `json:"instance_id"`
|
||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
@@ -535,17 +535,6 @@ func ExecuteQuery(c *gin.Context) {
|
|||||||
if input.Offset < 0 {
|
if input.Offset < 0 {
|
||||||
input.Offset = 0
|
input.Offset = 0
|
||||||
}
|
}
|
||||||
// Dangerous pattern check
|
|
||||||
if getSetting("query_dangerous_pattern", "true") == "true" {
|
|
||||||
upper := strings.ToUpper(input.SQL)
|
|
||||||
for _, pat := range []string{"DELETE FROM", "UPDATE ", "DROP ", "TRUNCATE", "ALTER ", "RENAME "} {
|
|
||||||
if strings.Contains(upper, pat) && !strings.Contains(upper, " WHERE ") && !strings.Contains(upper, " LIMIT ") {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": 0,
|
|
||||||
"error": "危险操作检测: " + pat + " 缺少 WHERE/LIMIT 条件,请在工单中提交"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var instance model.Instance
|
var instance model.Instance
|
||||||
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
|
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
|
||||||
@@ -553,6 +542,19 @@ func ExecuteQuery(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dangerous pattern check
|
||||||
|
if getSetting("query_dangerous_pattern", "true") == "true" {
|
||||||
|
upper := strings.ToUpper(input.SQL)
|
||||||
|
for _, pat := range []string{"DELETE FROM", "UPDATE ", "DROP ", "TRUNCATE", "ALTER ", "RENAME "} {
|
||||||
|
if strings.Contains(upper, pat) && !strings.Contains(upper, " WHERE ") && !strings.Contains(upper, " LIMIT ") {
|
||||||
|
saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, 0, 0, "failed", "危险操作检测: "+pat+" 缺少 WHERE/LIMIT 条件,请在工单中提交", true)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": 0,
|
||||||
|
"error": "危险操作检测: " + pat + " 缺少 WHERE/LIMIT 条件,请在工单中提交"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
switch instance.DBType {
|
switch instance.DBType {
|
||||||
@@ -560,6 +562,7 @@ func ExecuteQuery(c *gin.Context) {
|
|||||||
result, err := executeSQLQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset)
|
result, err := executeSQLQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset)
|
||||||
costMs := time.Since(start).Milliseconds()
|
costMs := time.Since(start).Milliseconds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, 0, "failed", err.Error(), false)
|
||||||
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()})
|
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -567,12 +570,14 @@ func ExecuteQuery(c *gin.Context) {
|
|||||||
rowsInterface, _ := result["rows"].([]interface{})
|
rowsInterface, _ := result["rows"].([]interface{})
|
||||||
applyDataMasking(result, &instance)
|
applyDataMasking(result, &instance)
|
||||||
saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, len(rowsInterface))
|
saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, len(rowsInterface))
|
||||||
|
saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, len(rowsInterface), "success", "", false)
|
||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
|
|
||||||
case "redis":
|
case "redis":
|
||||||
result, err := executeRedisQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset)
|
result, err := executeRedisQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset)
|
||||||
costMs := time.Since(start).Milliseconds()
|
costMs := time.Since(start).Milliseconds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, 0, "failed", err.Error(), false)
|
||||||
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()})
|
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -583,12 +588,14 @@ func ExecuteQuery(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
applyDataMasking(result, &instance)
|
applyDataMasking(result, &instance)
|
||||||
saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, rowCount)
|
saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, rowCount)
|
||||||
|
saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, rowCount, "success", "", false)
|
||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
|
|
||||||
case "mongodb", "mongo":
|
case "mongodb", "mongo":
|
||||||
result, err := executeMongoQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset)
|
result, err := executeMongoQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset)
|
||||||
costMs := time.Since(start).Milliseconds()
|
costMs := time.Since(start).Milliseconds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, 0, "failed", err.Error(), false)
|
||||||
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()})
|
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -596,6 +603,7 @@ func ExecuteQuery(c *gin.Context) {
|
|||||||
rowsInterface, _ := result["rows"].([]interface{})
|
rowsInterface, _ := result["rows"].([]interface{})
|
||||||
applyDataMasking(result, &instance)
|
applyDataMasking(result, &instance)
|
||||||
saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, len(rowsInterface))
|
saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, len(rowsInterface))
|
||||||
|
saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, len(rowsInterface), "success", "", false)
|
||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -1594,6 +1602,45 @@ func saveQueryHistory(userID int, instanceID int, database string, sqlStr string
|
|||||||
model.DB.Create(&history)
|
model.DB.Create(&history)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func saveQueryAuditLog(c *gin.Context, userID int, username string, instance *model.Instance, database string, sqlStr string, costMs int64, rowCount int, status string, errMsg string, isRisky bool) {
|
||||||
|
targetID := instance.ID
|
||||||
|
cost := int(costMs)
|
||||||
|
rows := rowCount
|
||||||
|
action := "query"
|
||||||
|
if status != "success" {
|
||||||
|
action = "query_failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := fmt.Sprintf("数据查询: %s/%s, 返回 %d 行, 耗时 %dms", instance.Name, database, rowCount, costMs)
|
||||||
|
if errMsg != "" {
|
||||||
|
detail = fmt.Sprintf("数据查询失败: %s/%s, %s", instance.Name, database, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
auditLog := model.AuditLog{
|
||||||
|
UserID: userID,
|
||||||
|
Username: &username,
|
||||||
|
Action: action,
|
||||||
|
TargetType: "query",
|
||||||
|
TargetID: &targetID,
|
||||||
|
Detail: &detail,
|
||||||
|
IPAddress: auditStringPtr(c.ClientIP()),
|
||||||
|
DBType: &instance.DBType,
|
||||||
|
DatabaseName: &database,
|
||||||
|
InstanceName: &instance.Name,
|
||||||
|
ExecutionTimeMs: &cost,
|
||||||
|
RowsAffected: &rows,
|
||||||
|
ResultStatus: &status,
|
||||||
|
QueryContent: &sqlStr,
|
||||||
|
UserAgent: auditStringPtr(c.GetHeader("User-Agent")),
|
||||||
|
IsRisky: &isRisky,
|
||||||
|
}
|
||||||
|
model.DB.Create(&auditLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditStringPtr(value string) *string {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
// applyDataMasking masks sensitive fields in query results with "*".
|
// applyDataMasking masks sensitive fields in query results with "*".
|
||||||
// It reads global settings (data_masking_enabled + sensitive fields) and
|
// It reads global settings (data_masking_enabled + sensitive fields) and
|
||||||
// merges with instance-level mask_fields.
|
// merges with instance-level mask_fields.
|
||||||
|
|||||||
@@ -484,9 +484,20 @@ func RedisDbsize(c *gin.Context) {
|
|||||||
func RedisScanKeys(c *gin.Context) {
|
func RedisScanKeys(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
dbParam := c.Query("db")
|
dbParam := c.Query("db")
|
||||||
rawPattern := strings.TrimSpace(c.DefaultQuery("pattern", "*"))
|
type KeyInfo struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
TTL int64 `json:"ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
rawPattern := strings.TrimSpace(c.Query("pattern"))
|
||||||
if rawPattern == "" {
|
if rawPattern == "" {
|
||||||
rawPattern = "*"
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"keys": []KeyInfo{},
|
||||||
|
"cursor": 0,
|
||||||
|
"total": 0,
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
pattern := rawPattern
|
pattern := rawPattern
|
||||||
hasWildcard := strings.ContainsAny(pattern, "*?[")
|
hasWildcard := strings.ContainsAny(pattern, "*?[")
|
||||||
@@ -512,13 +523,6 @@ func RedisScanKeys(c *gin.Context) {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Get types for each key
|
|
||||||
type KeyInfo struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
TTL int64 `json:"ttl"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redis Cluster 精确 key 查询应优先走槽位路由直查。全量 SCAN 需要访问各 master,
|
// Redis Cluster 精确 key 查询应优先走槽位路由直查。全量 SCAN 需要访问各 master,
|
||||||
// 在 NAT/代理暴露单入口时容易受节点通告地址影响。
|
// 在 NAT/代理暴露单入口时容易受节点通告地址影响。
|
||||||
if !hasWildcard && pattern != "*" {
|
if !hasWildcard && pattern != "*" {
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,18 +35,19 @@ function PageFallback() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RouteGuard() {
|
function RouteGuard() {
|
||||||
const { isLoggedIn, hasPermission } = useAuth();
|
const { isLoggedIn, hasPermission, visibleMenuItems } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const path = location.pathname.split('/')[1] || 'dashboard';
|
const path = location.pathname.split('/')[1] || 'dashboard';
|
||||||
const permissionPath = ({ redis: 'instances', mongo: 'instances' } as Record<string, string>)[path] || path;
|
const permissionPath = ({ redis: 'instances', mongo: 'instances' } as Record<string, string>)[path] || path;
|
||||||
|
const fallbackPath = '/' + (visibleMenuItems[0]?.id || 'queries');
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查当前路径是否有权限访问
|
// 检查当前路径是否有权限访问
|
||||||
if (permissionPath !== 'dashboard' && !hasPermission(permissionPath)) {
|
if (!hasPermission(permissionPath)) {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to={fallbackPath} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
@@ -54,9 +55,9 @@ function RouteGuard() {
|
|||||||
|
|
||||||
// 登录页守卫:已登录用户自动跳转到仪表盘
|
// 登录页守卫:已登录用户自动跳转到仪表盘
|
||||||
function LoginGuard() {
|
function LoginGuard() {
|
||||||
const { isLoggedIn } = useAuth();
|
const { isLoggedIn, visibleMenuItems } = useAuth();
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to={'/' + (visibleMenuItems[0]?.id || 'queries')} replace />;
|
||||||
}
|
}
|
||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,7 @@ function AppRoutes() {
|
|||||||
<Route path="/login" element={<LoginGuard />} />
|
<Route path="/login" element={<LoginGuard />} />
|
||||||
<Route element={<RouteGuard />}>
|
<Route element={<RouteGuard />}>
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route index element={<Navigate to="/queries" replace />} />
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
<Route path="instances" element={<InstancePage />} />
|
<Route path="instances" element={<InstancePage />} />
|
||||||
<Route path="instance-groups" element={<InstanceGroupPage />} />
|
<Route path="instance-groups" element={<InstanceGroupPage />} />
|
||||||
@@ -88,7 +89,7 @@ function AppRoutes() {
|
|||||||
<Route path="notifications" element={<NotificationPage />} />
|
<Route path="notifications" element={<NotificationPage />} />
|
||||||
<Route path="announcement" element={<AnnouncementPage />} />
|
<Route path="announcement" element={<AnnouncementPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/queries" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function Layout() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden">
|
<div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden">
|
||||||
{/* 侧边栏 — z-[200] 永远在弹框之上 */}
|
{/* 侧边栏 — z-[200] 永远在弹框之上 */}
|
||||||
<aside className="relative z-[200] flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[220px] min-w-[220px]">
|
<aside className="relative z-[200] hidden md:flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[220px] min-w-[220px]">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="h-14 flex items-center px-4">
|
<div className="h-14 flex items-center px-4">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -126,18 +126,18 @@ export default function Layout() {
|
|||||||
{/* 主内容区 */}
|
{/* 主内容区 */}
|
||||||
<main id="app-main" className="relative flex-1 flex flex-col min-w-0 overflow-hidden">
|
<main id="app-main" className="relative flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
{/* 顶部栏 — z-[200] 永远在弹框之上 */}
|
{/* 顶部栏 — z-[200] 永远在弹框之上 */}
|
||||||
<header className="relative z-[200] h-14 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-5 flex-shrink-0">
|
<header className="relative z-[200] h-14 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-3 md:px-5 flex-shrink-0">
|
||||||
{/* Breadcrumb area */}
|
{/* Breadcrumb area */}
|
||||||
<div className="flex items-center gap-1.5 text-sm">
|
<div className="flex items-center gap-1.5 text-sm min-w-0">
|
||||||
<span className="text-[var(--dg-text-muted)] font-medium">OpsxSQL</span>
|
<span className="text-[var(--dg-text-muted)] font-medium">OpsxSQL</span>
|
||||||
{breadcrumbInfo.sectionLabel && (
|
{breadcrumbInfo.sectionLabel && (
|
||||||
<>
|
<span className="hidden sm:contents">
|
||||||
<ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)] opacity-50" />
|
<ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)] opacity-50" />
|
||||||
<span className="text-[var(--dg-text-muted)] opacity-70">{breadcrumbInfo.sectionLabel}</span>
|
<span className="text-[var(--dg-text-muted)] opacity-70">{breadcrumbInfo.sectionLabel}</span>
|
||||||
</>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)] opacity-50" />
|
<ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)] opacity-50" />
|
||||||
<span className="text-[var(--dg-text-primary)] font-medium">{breadcrumbInfo.pageLabel}</span>
|
<span className="text-[var(--dg-text-primary)] font-medium truncate">{breadcrumbInfo.pageLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right actions */}
|
{/* Right actions */}
|
||||||
@@ -169,7 +169,7 @@ export default function Layout() {
|
|||||||
<div className="w-7 h-7 rounded-full dg-gradient-br flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
|
<div className="w-7 h-7 rounded-full dg-gradient-br flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
|
||||||
{user.avatar}
|
{user.avatar}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-[var(--dg-text-secondary)] max-w-[80px] truncate">{user.username}</span>
|
<span className="hidden sm:inline text-sm text-[var(--dg-text-secondary)] max-w-[80px] truncate">{user.username}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => { logout(); navigate('/login'); }}
|
onClick={() => { logout(); navigate('/login'); }}
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-red-500/10 hover:text-red-400 transition-colors duration-200"
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-red-500/10 hover:text-red-400 transition-colors duration-200"
|
||||||
@@ -182,11 +182,34 @@ export default function Layout() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 页面内容 */}
|
{/* 页面内容 */}
|
||||||
<div className="flex-1 overflow-y-auto scrollbar-thin p-5">
|
<div className="flex-1 overflow-y-auto scrollbar-thin p-3 md:p-5 pb-20 md:pb-5">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<nav className="fixed bottom-0 left-0 right-0 z-[210] md:hidden border-t border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]/95 backdrop-blur">
|
||||||
|
<div className="flex overflow-x-auto px-2 py-1.5 gap-1 scrollbar-thin">
|
||||||
|
{visibleMenuItems.map(item => {
|
||||||
|
const Icon = iconMap[item.icon] || Database;
|
||||||
|
const isActive = currentPath === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => navigate('/' + item.id)}
|
||||||
|
className={`min-w-[70px] flex flex-col items-center justify-center gap-1 rounded-lg px-2 py-1.5 text-[11px] transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[var(--dg-blue)]/12 text-[var(--dg-blue)]'
|
||||||
|
: 'text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span className="max-w-[64px] truncate">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* Keyframe animation for active indicator */}
|
{/* Keyframe animation for active indicator */}
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes slideInLeft {
|
@keyframes slideInLeft {
|
||||||
|
|||||||
@@ -24,17 +24,17 @@ export default function PageHeader({ title, description, action, secondaryAction
|
|||||||
|| hasActionPermission(action.permission);
|
|| hasActionPermission(action.permission);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
<h1 className="text-xl sm:text-2xl font-semibold">{title}</h1>
|
||||||
{description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{description}</p>}
|
{description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{description}</p>}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-2 sm:gap-3 flex-wrap">
|
||||||
{secondaryAction && (
|
{secondaryAction && (
|
||||||
<button
|
<button
|
||||||
onClick={secondaryAction.onClick}
|
onClick={secondaryAction.onClick}
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] hover:border-[var(--dg-text-muted)] transition-all"
|
className="flex items-center gap-2 px-3 sm:px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] hover:border-[var(--dg-text-muted)] transition-all"
|
||||||
>
|
>
|
||||||
{(() => { const Icon = secondaryAction.icon; return <Icon className="w-4 h-4" />; })()}
|
{(() => { const Icon = secondaryAction.icon; return <Icon className="w-4 h-4" />; })()}
|
||||||
{secondaryAction.label}
|
{secondaryAction.label}
|
||||||
@@ -43,7 +43,7 @@ export default function PageHeader({ title, description, action, secondaryAction
|
|||||||
{canShowAction && action && (
|
{canShowAction && action && (
|
||||||
<button
|
<button
|
||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
|
className="flex items-center gap-2 px-3 sm:px-4 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
|
||||||
>
|
>
|
||||||
{(() => { const Icon = action.icon; return <Icon className="w-4 h-4" />; })()}
|
{(() => { const Icon = action.icon; return <Icon className="w-4 h-4" />; })()}
|
||||||
{action.label}
|
{action.label}
|
||||||
|
|||||||
@@ -148,9 +148,17 @@ const appTheme = EditorView.theme({
|
|||||||
'.cm-content': {
|
'.cm-content': {
|
||||||
caretColor: 'var(--dg-text-primary)',
|
caretColor: 'var(--dg-text-primary)',
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace",
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace",
|
||||||
|
lineHeight: '1.55',
|
||||||
},
|
},
|
||||||
'.cm-cursor': {
|
'.cm-cursor': {
|
||||||
borderLeftColor: 'var(--dg-text-primary)',
|
borderLeftColor: 'var(--dg-text-primary)',
|
||||||
|
borderLeftWidth: '1.5px',
|
||||||
|
height: '1.25em !important',
|
||||||
|
marginTop: '0.15em',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
},
|
||||||
|
'.cm-line': {
|
||||||
|
lineHeight: '1.55',
|
||||||
},
|
},
|
||||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||||
backgroundColor: 'rgba(66, 133, 244, 0.3) !important',
|
backgroundColor: 'rgba(66, 133, 244, 0.3) !important',
|
||||||
@@ -448,4 +456,4 @@ const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default SqlEditor;
|
export default SqlEditor;
|
||||||
@@ -16,7 +16,7 @@ export default function SidePanel() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-[var(--dg-bg-primary)] relative overflow-hidden">
|
<div className="min-h-screen flex flex-col md:flex-row bg-[var(--dg-bg-primary)] relative overflow-hidden">
|
||||||
{/* 背景装饰 */}
|
{/* 背景装饰 */}
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(ellipse_at_20%_20%,rgba(88,166,255,0.08)_0%,transparent_50%),radial-gradient(ellipse_at_80%_80%,rgba(137,87,229,0.08)_0%,transparent_50%)]" />
|
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(ellipse_at_20%_20%,rgba(88,166,255,0.08)_0%,transparent_50%),radial-gradient(ellipse_at_80%_80%,rgba(137,87,229,0.08)_0%,transparent_50%)]" />
|
||||||
@@ -24,29 +24,29 @@ export default function SidePanel() {
|
|||||||
{/* 主题切换 */}
|
{/* 主题切换 */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="absolute top-5 right-5 z-20 w-10 h-10 rounded-xl bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
className="absolute top-4 right-4 md:top-5 md:right-5 z-20 w-10 h-10 rounded-xl bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
>
|
>
|
||||||
{theme === 'system' ? <Monitor className="w-5 h-5" /> : theme === 'ocean-deep' || theme === 'pine-green' ? <Moon className="w-5 h-5" /> : <Sun className="w-5 h-5" />}
|
{theme === 'system' ? <Monitor className="w-5 h-5" /> : theme === 'ocean-deep' || theme === 'pine-green' ? <Moon className="w-5 h-5" /> : <Sun className="w-5 h-5" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 左侧 */}
|
{/* 左侧 */}
|
||||||
<div className="flex-1 flex flex-col justify-center px-12 relative z-10">
|
<div className="flex-1 flex flex-col justify-center px-5 pt-12 pb-4 md:px-12 md:py-0 relative z-10">
|
||||||
<div className="mb-8">
|
<div className="mb-5 md:mb-8">
|
||||||
<div className="flex items-center gap-3 mb-5">
|
<div className="flex items-center gap-3 mb-4 md:mb-5">
|
||||||
<div className="w-11 h-11 rounded-xl dg-gradient-br flex items-center justify-center">
|
<div className="w-11 h-11 rounded-xl dg-gradient-br flex items-center justify-center">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L20 6V11C20 16 16 19.5 12 22C8 19.5 4 16 4 11V6L12 2Z" /><path d="M9 12L11 14L15 10" /></svg>
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L20 6V11C20 16 16 19.5 12 22C8 19.5 4 16 4 11V6L12 2Z" /><path d="M9 12L11 14L15 10" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold dg-gradient-text">
|
<h1 className="text-3xl md:text-4xl font-bold dg-gradient-text">
|
||||||
OpsxSQL
|
OpsxSQL
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base text-[var(--dg-text-secondary)] leading-relaxed">
|
<p className="text-sm md:text-base text-[var(--dg-text-secondary)] leading-relaxed">
|
||||||
企业级数据审核与变更管理平台,支持多数据库类型,<br />
|
企业级数据审核与变更管理平台,支持多数据库类型,<br />
|
||||||
提供完善的 SQL 审核、审批流程和审计追踪
|
提供完善的 SQL 审核、审批流程和审计追踪
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="hidden sm:grid grid-cols-2 gap-3">
|
||||||
{features.map((f, i) => (
|
{features.map((f, i) => (
|
||||||
<div key={i} className="flex items-start gap-2.5 p-3 rounded-xl bg-[var(--dg-bg-secondary)]/60 border border-[var(--dg-border)] hover:border-blue-500/50 hover:bg-[var(--dg-bg-secondary)] transition-all group">
|
<div key={i} className="flex items-start gap-2.5 p-3 rounded-xl bg-[var(--dg-bg-secondary)]/60 border border-[var(--dg-border)] hover:border-blue-500/50 hover:bg-[var(--dg-bg-secondary)] transition-all group">
|
||||||
<f.icon className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0 group-hover:scale-110 transition-transform" />
|
<f.icon className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0 group-hover:scale-110 transition-transform" />
|
||||||
@@ -60,7 +60,7 @@ export default function SidePanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧 */}
|
{/* 右侧 */}
|
||||||
<div className="w-[400px] flex flex-col justify-center px-12 bg-[var(--dg-bg-secondary)]/80 backdrop-blur-xl border-l border-[var(--dg-border)] relative z-10">
|
<div className="w-full md:w-[400px] flex flex-col justify-center px-5 pb-8 md:px-12 md:py-0 bg-[var(--dg-bg-secondary)]/80 backdrop-blur-xl md:border-l border-[var(--dg-border)] relative z-10">
|
||||||
<div className="w-full max-w-sm mx-auto">
|
<div className="w-full max-w-sm mx-auto">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Sparkles className="w-5 h-5 text-blue-400" />
|
<Sparkles className="w-5 h-5 text-blue-400" />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const menuSections = [
|
|||||||
|
|
||||||
export const menuItems = [
|
export const menuItems = [
|
||||||
// 工作台
|
// 工作台
|
||||||
{ id: 'dashboard', label: '运营总览', icon: 'LayoutDashboard', section: 'workspace', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'dashboard' },
|
{ id: 'dashboard', label: '运营总览', icon: 'LayoutDashboard', section: 'workspace', roles: ['admin', 'dba', 'auditor'], permission: 'dashboard' },
|
||||||
|
|
||||||
// 数据变更
|
// 数据变更
|
||||||
{ id: 'tickets', label: '变更工单', icon: 'FileEdit', section: 'change', roles: ['admin', 'dba', 'developer'], permission: 'tickets' },
|
{ id: 'tickets', label: '变更工单', icon: 'FileEdit', section: 'change', roles: ['admin', 'dba', 'developer'], permission: 'tickets' },
|
||||||
|
|||||||
@@ -27,23 +27,25 @@ function endEllipsis(value: string, maxLength: number): string {
|
|||||||
return value.slice(0, Math.max(0, maxLength - 3)) + '...';
|
return value.slice(0, Math.max(0, maxLength - 3)) + '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HEADER_KEY_MAX_LENGTH = 96;
|
||||||
|
|
||||||
export function RedisHashQueryResult({ rows, rawKey, rawType, rawTtl, command, onView }: RedisHashQueryResultProps) {
|
export function RedisHashQueryResult({ rows, rawKey, rawType, rawTtl, command, onView }: RedisHashQueryResultProps) {
|
||||||
var redisKey = String(rawKey || parseRedisKeyFromCommand(command) || '');
|
var redisKey = String(rawKey || parseRedisKeyFromCommand(command) || '');
|
||||||
var redisKeyLabel = redisKey ? middleEllipsis(redisKey, 20) : '-';
|
var redisKeyLabel = redisKey ? middleEllipsis(redisKey, HEADER_KEY_MAX_LENGTH) : '-';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-xs">
|
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-xs">
|
||||||
<span className="rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-1.5 font-semibold text-blue-400">
|
<span className="shrink-0 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-1.5 font-semibold text-blue-400">
|
||||||
{String(rawType || 'hash').replace(/^./, function (c) { return c.toUpperCase(); })}
|
{String(rawType || 'hash').replace(/^./, function (c) { return c.toUpperCase(); })}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="min-w-0 max-w-[220px] flex-1 truncate rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 py-1.5 font-mono text-[var(--dg-text-primary)]"
|
className="min-w-0 max-w-full overflow-hidden rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 py-1.5 font-mono text-[var(--dg-text-primary)]"
|
||||||
title={redisKey}
|
title={redisKey}
|
||||||
>
|
>
|
||||||
{redisKeyLabel}
|
{redisKeyLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-1.5 font-mono text-[var(--dg-text-muted)]">
|
<span className="shrink-0 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-1.5 font-mono text-[var(--dg-text-muted)]">
|
||||||
TTL {String(rawTtl ?? '-1')}
|
TTL {String(rawTtl ?? '-1')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||||
import { menuItems } from '@/constants/menu';
|
import { menuItems } from '@/constants/menu';
|
||||||
|
import { instanceApi } from '@/api/instance';
|
||||||
|
|
||||||
const defaultUser = {
|
const defaultUser = {
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -131,6 +132,8 @@ const permissionAliasMap: Record<string, string[]> = {
|
|||||||
settings: ['config-platform'],
|
settings: ['config-platform'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dataChangePermissionIds = new Set(['tickets', 'workflow']);
|
||||||
|
|
||||||
export const actionPermissionMap: Record<string, UserRole[]> = {
|
export const actionPermissionMap: Record<string, UserRole[]> = {
|
||||||
'ticket.create': ['admin', 'dba', 'developer'],
|
'ticket.create': ['admin', 'dba', 'developer'],
|
||||||
'ticket.approve': ['admin', 'dba', 'developer'],
|
'ticket.approve': ['admin', 'dba', 'developer'],
|
||||||
@@ -191,6 +194,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
const [hasWritableDataSourceAccess, setHasWritableDataSourceAccess] = useState(false);
|
||||||
|
|
||||||
/** 权限缓存引用 */
|
/** 权限缓存引用 */
|
||||||
const permissionCacheRef = useRef<PermissionCache>({
|
const permissionCacheRef = useRef<PermissionCache>({
|
||||||
@@ -216,6 +220,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
permissionCacheRef.current.timestamp = 0;
|
permissionCacheRef.current.timestamp = 0;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const hasWritableAccessInUserPayload = useCallback((u: AuthUser): boolean => {
|
||||||
|
return Boolean(u.dataSourcePermissions?.some(ds =>
|
||||||
|
ds.actions.some(action => ['write', 'delete', 'readwrite'].includes(String(action)))
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
/** 触发权限变更事件 */
|
/** 触发权限变更事件 */
|
||||||
const emitPermissionChange = useCallback((updatedUser: AuthUser) => {
|
const emitPermissionChange = useCallback((updatedUser: AuthUser) => {
|
||||||
listenersRef.current.forEach(listener => listener(updatedUser));
|
listenersRef.current.forEach(listener => listener(updatedUser));
|
||||||
@@ -239,6 +249,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return () => window.removeEventListener('storage', handleStorageChange);
|
return () => window.removeEventListener('storage', handleStorageChange);
|
||||||
}, [clearPermissionCache]);
|
}, [clearPermissionCache]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
setHasWritableDataSourceAccess(false);
|
||||||
|
clearPermissionCache();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === 'admin' || user.role === 'dba') {
|
||||||
|
setHasWritableDataSourceAccess(true);
|
||||||
|
clearPermissionCache();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== 'developer') {
|
||||||
|
setHasWritableDataSourceAccess(false);
|
||||||
|
clearPermissionCache();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
instanceApi.accessible('write')
|
||||||
|
.then((instances) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setHasWritableDataSourceAccess((instances || []).length > 0);
|
||||||
|
clearPermissionCache();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setHasWritableDataSourceAccess(hasWritableAccessInUserPayload(user));
|
||||||
|
clearPermissionCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isLoggedIn, user.id, user.role, user.permissionsUpdatedAt, hasWritableAccessInUserPayload, clearPermissionCache]);
|
||||||
|
|
||||||
const login = useCallback(async (username: string, password: string): Promise<boolean> => {
|
const login = useCallback(async (username: string, password: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const res = await authApi.login(username, password);
|
const res = await authApi.login(username, password);
|
||||||
@@ -327,6 +374,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
cache.menu.set(menuId, false);
|
cache.menu.set(menuId, false);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const targetMenuId = menuItem?.id || menuId;
|
||||||
|
if (user.role === 'developer' && dataChangePermissionIds.has(targetMenuId) && !hasWritableDataSourceAccess) {
|
||||||
|
cache.menu.set(menuId, false);
|
||||||
|
cache.timestamp = Date.now();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const permissionKeys = [
|
const permissionKeys = [
|
||||||
menuId,
|
menuId,
|
||||||
@@ -340,7 +393,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
cache.menu.set(menuId, result);
|
cache.menu.set(menuId, result);
|
||||||
cache.timestamp = Date.now();
|
cache.timestamp = Date.now();
|
||||||
return result;
|
return result;
|
||||||
}, [user.role, user.customPermissions, isLoggedIn, isCacheValid]);
|
}, [user.role, user.customPermissions, hasWritableDataSourceAccess, isLoggedIn, isCacheValid]);
|
||||||
|
|
||||||
/** 操作权限检查(带缓存,支持自定义权限覆盖) */
|
/** 操作权限检查(带缓存,支持自定义权限覆盖) */
|
||||||
const hasActionPermission = useCallback((action: PermissionAction | string): boolean => {
|
const hasActionPermission = useCallback((action: PermissionAction | string): boolean => {
|
||||||
@@ -351,6 +404,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return cache.action.get(action)!;
|
return cache.action.get(action)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.role === 'developer' && ['ticket.create', 'ticket.approve'].includes(String(action)) && !hasWritableDataSourceAccess) {
|
||||||
|
cache.action.set(action, false);
|
||||||
|
cache.timestamp = Date.now();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// 优先检查自定义权限
|
// 优先检查自定义权限
|
||||||
if (user.customPermissions && action in user.customPermissions) {
|
if (user.customPermissions && action in user.customPermissions) {
|
||||||
const customResult = user.customPermissions[action];
|
const customResult = user.customPermissions[action];
|
||||||
@@ -369,7 +428,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
cache.action.set(action, result);
|
cache.action.set(action, result);
|
||||||
cache.timestamp = Date.now();
|
cache.timestamp = Date.now();
|
||||||
return result;
|
return result;
|
||||||
}, [user.role, user.customPermissions, isLoggedIn, isCacheValid]);
|
}, [user.role, user.customPermissions, hasWritableDataSourceAccess, isLoggedIn, isCacheValid]);
|
||||||
|
|
||||||
/** 数据源级权限检查 */
|
/** 数据源级权限检查 */
|
||||||
const hasDataSourcePermission = useCallback((
|
const hasDataSourcePermission = useCallback((
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function useLogin(): LoginState {
|
|||||||
}
|
}
|
||||||
if (prefs?.login_style) localStorage.setItem('login-style', prefs.login_style);
|
if (prefs?.login_style) localStorage.setItem('login-style', prefs.login_style);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
navigate('/dashboard');
|
navigate('/queries');
|
||||||
} else {
|
} else {
|
||||||
setError('用户名或密码错误');
|
setError('用户名或密码错误');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,20 @@ interface AuditLog {
|
|||||||
user_id: number;
|
user_id: number;
|
||||||
username: string;
|
username: string;
|
||||||
action: string;
|
action: string;
|
||||||
resource_type: string;
|
resource_type?: string;
|
||||||
resource_id: number | null;
|
resource_id?: number | null;
|
||||||
|
target_type?: string;
|
||||||
|
target_id?: number | null;
|
||||||
detail: string | null;
|
detail: string | null;
|
||||||
ip_address: string | null;
|
ip_address: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
|
result_status?: string | null;
|
||||||
|
query_content?: string | null;
|
||||||
|
db_type?: string | null;
|
||||||
|
database_name?: string | null;
|
||||||
|
instance_name?: string | null;
|
||||||
|
execution_time_ms?: number | null;
|
||||||
|
rows_affected?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuditLogResponse {
|
interface AuditLogResponse {
|
||||||
@@ -41,6 +50,8 @@ const ACTION_COLORS: Record<string, string> = {
|
|||||||
delete: 'bg-red-500/15 text-red-400',
|
delete: 'bg-red-500/15 text-red-400',
|
||||||
login: 'bg-emerald-500/15 text-emerald-400',
|
login: 'bg-emerald-500/15 text-emerald-400',
|
||||||
login_failed: 'bg-red-500/15 text-red-400',
|
login_failed: 'bg-red-500/15 text-red-400',
|
||||||
|
query: 'bg-indigo-500/15 text-indigo-400',
|
||||||
|
query_failed: 'bg-red-500/15 text-red-400',
|
||||||
cancel: 'bg-orange-500/15 text-orange-400',
|
cancel: 'bg-orange-500/15 text-orange-400',
|
||||||
reject: 'bg-red-500/15 text-red-400',
|
reject: 'bg-red-500/15 text-red-400',
|
||||||
};
|
};
|
||||||
@@ -55,6 +66,8 @@ const ACTION_LABELS: Record<string, string> = {
|
|||||||
reject: '拒绝',
|
reject: '拒绝',
|
||||||
login: '登录',
|
login: '登录',
|
||||||
login_failed: '登录失败',
|
login_failed: '登录失败',
|
||||||
|
query: '查询',
|
||||||
|
query_failed: '查询失败',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { icon: React.ElementType; color: string; label: string }> = {
|
const STATUS_CONFIG: Record<string, { icon: React.ElementType; color: string; label: string }> = {
|
||||||
@@ -72,6 +85,7 @@ const TARGET_ICONS: Record<string, React.ElementType> = {
|
|||||||
notification: AlertTriangle,
|
notification: AlertTriangle,
|
||||||
workflow: FileText,
|
workflow: FileText,
|
||||||
system_setting: AlertTriangle,
|
system_setting: AlertTriangle,
|
||||||
|
query: Search,
|
||||||
other: FileText,
|
other: FileText,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,9 +96,9 @@ const CHART_COLORS = ['#6366f1', '#a855f7', '#ec4899', '#f43f5e', '#f97316', '#e
|
|||||||
function exportLogsToCSV(logs: AuditLog[]) {
|
function exportLogsToCSV(logs: AuditLog[]) {
|
||||||
const headers = ['ID', '操作人', '操作类型', '目标类型', '目标ID', '详情', 'IP地址', '结果', '时间'];
|
const headers = ['ID', '操作人', '操作类型', '目标类型', '目标ID', '详情', 'IP地址', '结果', '时间'];
|
||||||
const rows = logs.map(l => [
|
const rows = logs.map(l => [
|
||||||
l.id, l.username, ACTION_LABELS[l.action] || l.action, l.resource_type,
|
l.id, l.username, ACTION_LABELS[l.action] || l.action, getTargetType(l),
|
||||||
l.resource_id || '', l.detail || '', l.ip_address || '',
|
getTargetId(l) || '', l.detail || '', l.ip_address || '',
|
||||||
l.action.includes('fail') || l.action === 'reject' ? '失败' : '成功',
|
getResultStatus(l) === 'failed' ? '失败' : '成功',
|
||||||
l.created_at || '',
|
l.created_at || '',
|
||||||
]);
|
]);
|
||||||
const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
|
const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||||
@@ -97,6 +111,19 @@ function exportLogsToCSV(logs: AuditLog[]) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTargetType(log: AuditLog): string {
|
||||||
|
return log.target_type || log.resource_type || 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTargetId(log: AuditLog): number | null {
|
||||||
|
return log.target_id ?? log.resource_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultStatus(log: AuditLog): string {
|
||||||
|
if (log.result_status) return log.result_status;
|
||||||
|
return log.action.includes('fail') || log.action === 'reject' ? 'failed' : 'success';
|
||||||
|
}
|
||||||
|
|
||||||
/* ───────── Main Component ───────── */
|
/* ───────── Main Component ───────── */
|
||||||
|
|
||||||
export default function AuditLogsPage() {
|
export default function AuditLogsPage() {
|
||||||
@@ -142,8 +169,8 @@ export default function AuditLogsPage() {
|
|||||||
// Client-side filtering for advanced filters
|
// Client-side filtering for advanced filters
|
||||||
const filteredLogs = useMemo(() => {
|
const filteredLogs = useMemo(() => {
|
||||||
return logs.filter(log => {
|
return logs.filter(log => {
|
||||||
if (filterTarget && log.resource_type !== filterTarget) return false;
|
if (filterTarget && getTargetType(log) !== filterTarget) return false;
|
||||||
if (filterStatus && log.action.includes('fail') !== (filterStatus === 'failed')) return false;
|
if (filterStatus && getResultStatus(log) !== filterStatus) return false;
|
||||||
if (filterDateRange && log.created_at) {
|
if (filterDateRange && log.created_at) {
|
||||||
const days = Math.floor((Date.now() - new Date(log.created_at).getTime()) / 86400000);
|
const days = Math.floor((Date.now() - new Date(log.created_at).getTime()) / 86400000);
|
||||||
if (filterDateRange === '1d' && days > 1) return false;
|
if (filterDateRange === '1d' && days > 1) return false;
|
||||||
@@ -187,7 +214,7 @@ export default function AuditLogsPage() {
|
|||||||
const statusDistribution = useMemo(() => {
|
const statusDistribution = useMemo(() => {
|
||||||
let success = 0, failed = 0;
|
let success = 0, failed = 0;
|
||||||
logs.forEach(l => {
|
logs.forEach(l => {
|
||||||
if (l.action.includes('fail') || l.action === 'reject') failed++;
|
if (getResultStatus(l) === 'failed') failed++;
|
||||||
else success++;
|
else success++;
|
||||||
});
|
});
|
||||||
return [
|
return [
|
||||||
@@ -353,6 +380,7 @@ export default function AuditLogsPage() {
|
|||||||
<option value="instance">实例</option>
|
<option value="instance">实例</option>
|
||||||
<option value="user">用户</option>
|
<option value="user">用户</option>
|
||||||
<option value="auth">认证</option>
|
<option value="auth">认证</option>
|
||||||
|
<option value="query">查询</option>
|
||||||
<option value="workflow">审批流</option>
|
<option value="workflow">审批流</option>
|
||||||
<option value="notification">通知</option>
|
<option value="notification">通知</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -439,10 +467,12 @@ export default function AuditLogsPage() {
|
|||||||
) : (
|
) : (
|
||||||
paginatedLogs.map(log => {
|
paginatedLogs.map(log => {
|
||||||
const isExpanded = expandedId === log.id;
|
const isExpanded = expandedId === log.id;
|
||||||
const isFailed = log.action.includes('fail') || log.action === 'reject';
|
const targetType = getTargetType(log);
|
||||||
|
const targetId = getTargetId(log);
|
||||||
|
const isFailed = getResultStatus(log) === 'failed';
|
||||||
const statusConfig = isFailed ? STATUS_CONFIG.failed : STATUS_CONFIG.success;
|
const statusConfig = isFailed ? STATUS_CONFIG.failed : STATUS_CONFIG.success;
|
||||||
const StatusIcon = statusConfig.icon;
|
const StatusIcon = statusConfig.icon;
|
||||||
const TargetIcon = TARGET_ICONS[log.resource_type] || FileText;
|
const TargetIcon = TARGET_ICONS[targetType] || FileText;
|
||||||
return (
|
return (
|
||||||
<Fragment key={log.id}>
|
<Fragment key={log.id}>
|
||||||
<tr
|
<tr
|
||||||
@@ -471,8 +501,8 @@ export default function AuditLogsPage() {
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<TargetIcon className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
<TargetIcon className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||||
<span className="text-[var(--dg-text-secondary)]">{log.resource_type}</span>
|
<span className="text-[var(--dg-text-secondary)]">{targetType}</span>
|
||||||
{log.resource_id && <span className="text-[var(--dg-text-muted)] text-xs">#{log.resource_id}</span>}
|
{targetId && <span className="text-[var(--dg-text-muted)] text-xs">#{targetId}</span>}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--dg-text-secondary)] text-xs font-mono">
|
<td className="px-4 py-3 text-[var(--dg-text-secondary)] text-xs font-mono">
|
||||||
@@ -497,6 +527,11 @@ export default function AuditLogsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">操作详情</div>
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">操作详情</div>
|
||||||
<p className="text-sm text-[var(--dg-text-primary)]">{log.detail || '无详情'}</p>
|
<p className="text-sm text-[var(--dg-text-primary)]">{log.detail || '无详情'}</p>
|
||||||
|
{log.query_content && (
|
||||||
|
<pre className="mt-3 max-h-32 overflow-auto rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-3 text-xs font-mono whitespace-pre-wrap text-[var(--dg-text-secondary)]">
|
||||||
|
{log.query_content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -509,12 +544,36 @@ export default function AuditLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">目标ID</div>
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">目标ID</div>
|
||||||
<div className="text-sm font-mono">{log.resource_id || 'N/A'}</div>
|
<div className="text-sm font-mono">{targetId || 'N/A'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">时间</div>
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">时间</div>
|
||||||
<div className="text-sm">{formatDate(log.created_at)}</div>
|
<div className="text-sm">{formatDate(log.created_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{log.instance_name && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">实例</div>
|
||||||
|
<div className="text-sm">{log.instance_name}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{log.database_name && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">数据库</div>
|
||||||
|
<div className="text-sm">{log.database_name}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{log.execution_time_ms != null && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">耗时</div>
|
||||||
|
<div className="text-sm">{log.execution_time_ms}ms</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{log.rows_affected != null && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mb-1">返回行数</div>
|
||||||
|
<div className="text-sm">{log.rows_affected}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -53,12 +53,14 @@ type ViewMode = 'grid' | 'list';
|
|||||||
const dbTypeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; label: string }> = {
|
const dbTypeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; label: string }> = {
|
||||||
mysql: { icon: MySQLIcon, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]', label: 'MySQL' },
|
mysql: { icon: MySQLIcon, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]', label: 'MySQL' },
|
||||||
postgres: { icon: PostgreSQLIcon, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' },
|
postgres: { icon: PostgreSQLIcon, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' },
|
||||||
|
postgresql: { icon: PostgreSQLIcon, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' },
|
||||||
|
pg: { icon: PostgreSQLIcon, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' },
|
||||||
redis: { icon: RedisIcon, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]', label: 'Redis' },
|
redis: { icon: RedisIcon, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]', label: 'Redis' },
|
||||||
mongo: { icon: MongoDBIcon, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' },
|
mongo: { icon: MongoDBIcon, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const envLabel: Record<string, string> = {
|
const envLabel: Record<string, string> = {
|
||||||
production: '生产', staging: '预发', development: '开发', test: '测试', uat: 'UAT',
|
production: '生产', staging: '预发', development: '开发', test: '测试', testing: '测试', uat: 'UAT',
|
||||||
};
|
};
|
||||||
|
|
||||||
const envColor: Record<string, string> = {
|
const envColor: Record<string, string> = {
|
||||||
@@ -66,6 +68,7 @@ const envColor: Record<string, string> = {
|
|||||||
staging: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
staging: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
||||||
development: 'bg-green-500/10 text-green-400 border-green-500/20',
|
development: 'bg-green-500/10 text-green-400 border-green-500/20',
|
||||||
test: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
test: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||||
|
testing: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||||
uat: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20',
|
uat: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,6 +85,24 @@ function getStatusInfo(status: string) {
|
|||||||
return statusLabel[status] || { text: '未知', online: false };
|
return statusLabel[status] || { text: '未知', online: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOfflineStatus(status: string): boolean {
|
||||||
|
return status === 'offline' || status === 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesStatusFilter(status: string, filterStatus: string): boolean {
|
||||||
|
if (!filterStatus) return true;
|
||||||
|
const info = getStatusInfo(status);
|
||||||
|
if (filterStatus === 'online') return info.online;
|
||||||
|
if (filterStatus === 'offline') return isOfflineStatus(status);
|
||||||
|
return status === filterStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesEnvFilter(env: string, filterEnv: string): boolean {
|
||||||
|
if (!filterEnv) return true;
|
||||||
|
if (filterEnv === 'test') return env === 'test' || env === 'testing';
|
||||||
|
return env === filterEnv;
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Health Score */
|
/* Health Score */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -176,9 +197,9 @@ export default function InstancePage() {
|
|||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return instances.filter(i => {
|
return instances.filter(i => {
|
||||||
if (activeType !== 'all' && i.db_type !== activeType) return false;
|
if (activeType !== 'all' && (activeType === 'postgres' ? !['postgres', 'postgresql', 'pg'].includes(i.db_type) : i.db_type !== activeType)) return false;
|
||||||
if (filterStatus && i.status !== filterStatus) return false;
|
if (!matchesStatusFilter(i.status, filterStatus)) return false;
|
||||||
if (filterEnv && i.env !== filterEnv) return false;
|
if (!matchesEnvFilter(i.env, filterEnv)) return false;
|
||||||
if (searchQuery && !i.name.toLowerCase().includes(searchQuery.toLowerCase()) && !i.host.includes(searchQuery)) return false;
|
if (searchQuery && !i.name.toLowerCase().includes(searchQuery.toLowerCase()) && !i.host.includes(searchQuery)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -186,7 +207,7 @@ export default function InstancePage() {
|
|||||||
|
|
||||||
const statusCounts = useMemo(() => ({
|
const statusCounts = useMemo(() => ({
|
||||||
online: filtered.filter(i => getStatusInfo(i.status).online).length,
|
online: filtered.filter(i => getStatusInfo(i.status).online).length,
|
||||||
offline: filtered.filter(i => !getStatusInfo(i.status).online).length,
|
offline: filtered.filter(i => isOfflineStatus(i.status)).length,
|
||||||
total: filtered.length,
|
total: filtered.length,
|
||||||
}), [filtered]);
|
}), [filtered]);
|
||||||
|
|
||||||
@@ -310,6 +331,7 @@ export default function InstancePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="数据源管理"
|
title="数据源管理"
|
||||||
@@ -738,6 +760,15 @@ export default function InstancePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteConfirm}
|
||||||
|
title={deleteConfirm?.msg || '确认操作'}
|
||||||
|
variant="danger"
|
||||||
|
confirmText="确定"
|
||||||
|
onConfirm={() => { deleteConfirm?.onConfirm(); setDeleteConfirm(null); }}
|
||||||
|
onCancel={() => setDeleteConfirm(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1191,8 +1222,6 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string; cluster_mode?: boolean } | null>(null);
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string; cluster_mode?: boolean } | null>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null);
|
|
||||||
|
|
||||||
const isRedis = dbType === 'redis';
|
const isRedis = dbType === 'redis';
|
||||||
const needUsername = !isRedis; // Redis username is optional (6.0+ ACL)
|
const needUsername = !isRedis; // Redis username is optional (6.0+ ACL)
|
||||||
const needDatabase = !isRedis;
|
const needDatabase = !isRedis;
|
||||||
@@ -1679,14 +1708,6 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalOverlay>
|
</ModalOverlay>
|
||||||
<ConfirmDialog
|
|
||||||
open={!!deleteConfirm}
|
|
||||||
title={deleteConfirm?.msg || '确认操作'}
|
|
||||||
variant="danger"
|
|
||||||
confirmText="确定"
|
|
||||||
onConfirm={() => { deleteConfirm?.onConfirm(); setDeleteConfirm(null); }}
|
|
||||||
onCancel={() => setDeleteConfirm(null)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,8 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (!ready) return null; // avoid flash
|
if (!ready) return null; // avoid flash
|
||||||
|
|
||||||
const Component = LOGIN_STYLES[style] || SidePanel;
|
const Component = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
|
||||||
|
? SidePanel
|
||||||
|
: (LOGIN_STYLES[style] || SidePanel);
|
||||||
return <Component />;
|
return <Component />;
|
||||||
}
|
}
|
||||||
@@ -13,12 +13,15 @@ import SqlEditor from '@/components/SqlEditor';
|
|||||||
import SchemaSidebar from '@/components/SchemaSidebar';
|
import SchemaSidebar from '@/components/SchemaSidebar';
|
||||||
import { queryApi } from '@/api/query';
|
import { queryApi } from '@/api/query';
|
||||||
import { instanceApi } from '@/api/instance';
|
import { instanceApi } from '@/api/instance';
|
||||||
|
import { redisApi } from '@/api/redis';
|
||||||
import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
|
import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
|
||||||
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
|
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
|
||||||
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
|
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
|
||||||
import { RedisHashQueryResult } from '@/features/redis/components/RedisHashQueryResult';
|
import { RedisHashQueryResult } from '@/features/redis/components/RedisHashQueryResult';
|
||||||
|
import { RedisStructuredValue } from '@/features/redis/components/RedisStructuredValue';
|
||||||
import { RedisValueViewerDialog } from '@/features/redis/components/RedisValueViewerDialog';
|
import { RedisValueViewerDialog } from '@/features/redis/components/RedisValueViewerDialog';
|
||||||
import type { RedisLineEditorState } from '@/features/redis/types';
|
import type { RedisLineEditorState } from '@/features/redis/types';
|
||||||
|
import { formatTTL, tryFormatJsonText } from '@/features/redis/utils';
|
||||||
import { QueryHistoryDrawer } from '@/features/query/components/QueryHistoryDrawer';
|
import { QueryHistoryDrawer } from '@/features/query/components/QueryHistoryDrawer';
|
||||||
import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel';
|
import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel';
|
||||||
import type { QueryHistoryItem } from '@/features/query/types';
|
import type { QueryHistoryItem } from '@/features/query/types';
|
||||||
@@ -41,6 +44,21 @@ interface QueryResult {
|
|||||||
masked_columns?: string[];
|
masked_columns?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RedisKeyInfo {
|
||||||
|
key?: string;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
ttl?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RedisKeyDetail {
|
||||||
|
key: string;
|
||||||
|
name?: string;
|
||||||
|
type: string;
|
||||||
|
ttl: number;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
type SortDirection = 'asc' | 'desc' | null;
|
type SortDirection = 'asc' | 'desc' | null;
|
||||||
type SortState = { column: string | null; direction: SortDirection };
|
type SortState = { column: string | null; direction: SortDirection };
|
||||||
|
|
||||||
@@ -94,6 +112,13 @@ export default function QueriesPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [result, setResult] = useState<QueryResult | null>(null);
|
const [result, setResult] = useState<QueryResult | null>(null);
|
||||||
const [redisLineViewer, setRedisLineViewer] = useState<RedisLineEditorState>({ open: false, field: '', value: '', format: 'Json' });
|
const [redisLineViewer, setRedisLineViewer] = useState<RedisLineEditorState>({ open: false, field: '', value: '', format: 'Json' });
|
||||||
|
const [redisPattern, setRedisPattern] = useState('');
|
||||||
|
const [redisType, setRedisType] = useState('');
|
||||||
|
const [redisKeys, setRedisKeys] = useState<RedisKeyInfo[]>([]);
|
||||||
|
const [redisSelectedKey, setRedisSelectedKey] = useState<RedisKeyDetail | null>(null);
|
||||||
|
const [redisLoading, setRedisLoading] = useState(false);
|
||||||
|
const [redisDetailLoading, setRedisDetailLoading] = useState(false);
|
||||||
|
const [redisValueSearch, setRedisValueSearch] = useState('');
|
||||||
|
|
||||||
// -- Pagination --
|
// -- Pagination --
|
||||||
const [pageSize, setPageSize] = useState(100);
|
const [pageSize, setPageSize] = useState(100);
|
||||||
@@ -163,6 +188,54 @@ export default function QueriesPage() {
|
|||||||
const dbType = normalizeDbType(selectedInstance?.db_type);
|
const dbType = normalizeDbType(selectedInstance?.db_type);
|
||||||
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
|
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRedisKeys([]);
|
||||||
|
setRedisSelectedKey(null);
|
||||||
|
setRedisValueSearch('');
|
||||||
|
}, [instanceId, database]);
|
||||||
|
|
||||||
|
const handleRedisSearch = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
const pattern = redisPattern.trim();
|
||||||
|
if (!pattern) {
|
||||||
|
setRedisKeys([]);
|
||||||
|
setRedisSelectedKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRedisLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await redisApi.getKeys(Number(instanceId), {
|
||||||
|
db: Number(database || 0),
|
||||||
|
pattern,
|
||||||
|
type: redisType || undefined,
|
||||||
|
count: 80,
|
||||||
|
});
|
||||||
|
setRedisKeys((res?.keys || res || []) as RedisKeyInfo[]);
|
||||||
|
} catch {
|
||||||
|
setRedisKeys([]);
|
||||||
|
} finally {
|
||||||
|
setRedisLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, database, redisPattern, redisType]);
|
||||||
|
|
||||||
|
const loadRedisKeyDetail = useCallback(async (keyName: string) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setRedisDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const detail = await redisApi.getKeyDetail(Number(instanceId), keyName, Number(database || 0));
|
||||||
|
setRedisSelectedKey(detail as RedisKeyDetail);
|
||||||
|
setRedisValueSearch('');
|
||||||
|
} catch {
|
||||||
|
setRedisSelectedKey(null);
|
||||||
|
} finally {
|
||||||
|
setRedisDetailLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, database]);
|
||||||
|
|
||||||
|
const copyRedisText = useCallback((text: string) => {
|
||||||
|
navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgres' && dbType !== 'postgresql')) return;
|
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgres' && dbType !== 'postgresql')) return;
|
||||||
if (/^\d+$/.test(database)) return;
|
if (/^\d+$/.test(database)) return;
|
||||||
@@ -331,19 +404,19 @@ export default function QueriesPage() {
|
|||||||
}, [history, favFilter, searchHistory]);
|
}, [history, favFilter, searchHistory]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-3rem)] gap-0">
|
<div className="flex h-auto min-h-[calc(100vh-7rem)] md:h-[calc(100vh-3rem)] gap-0">
|
||||||
{/* ====== 主区域 ====== */}
|
{/* ====== 主区域 ====== */}
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-w-0 overflow-visible md:overflow-hidden">
|
||||||
|
|
||||||
{/* -- 顶栏: 实例+库选择 -- */}
|
{/* -- 顶栏: 实例+库选择 -- */}
|
||||||
<div className="shrink-0 px-5 pt-4 pb-2">
|
<div className="shrink-0 px-0 md:px-5 pt-1 md:pt-4 pb-2">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-1 min-w-full md:min-w-0 flex-wrap md:flex-nowrap">
|
||||||
{selectedInstance && dbTypeIcon[dbType] && (
|
{selectedInstance && dbTypeIcon[dbType] && (
|
||||||
<img src={dbTypeIcon[dbType]} alt="" className="w-5 h-5 object-contain" />
|
<img src={dbTypeIcon[dbType]} alt="" className="w-5 h-5 object-contain" />
|
||||||
)}
|
)}
|
||||||
<select value={instanceId} onChange={e => setInstanceId(e.target.value)}
|
<select value={instanceId} onChange={e => setInstanceId(e.target.value)}
|
||||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[200px]">
|
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[200px]">
|
||||||
<option value="">选择实例</option>
|
<option value="">选择实例</option>
|
||||||
{instances.map(inst => (
|
{instances.map(inst => (
|
||||||
<option key={inst.id} value={inst.id}>
|
<option key={inst.id} value={inst.id}>
|
||||||
@@ -354,12 +427,12 @@ export default function QueriesPage() {
|
|||||||
|
|
||||||
{dbType === 'redis' ? (
|
{dbType === 'redis' ? (
|
||||||
<select value={database || '0'} onChange={e => setDatabase(e.target.value)}
|
<select value={database || '0'} onChange={e => setDatabase(e.target.value)}
|
||||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none w-24">
|
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none w-full md:w-24">
|
||||||
{Array.from({ length: 16 }, (_, i) => <option key={i} value={String(i)}>DB{i}</option>)}
|
{Array.from({ length: 16 }, (_, i) => <option key={i} value={String(i)}>DB{i}</option>)}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<select value={database} onChange={e => setDatabase(e.target.value)}
|
<select value={database} onChange={e => setDatabase(e.target.value)}
|
||||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[160px]">
|
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[160px]">
|
||||||
<option value="">{loadingDbs ? '加载中...' : '选择数据库'}</option>
|
<option value="">{loadingDbs ? '加载中...' : '选择数据库'}</option>
|
||||||
{databases.map(db => <option key={db} value={db}>{db}</option>)}
|
{databases.map(db => <option key={db} value={db}>{db}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -367,7 +440,7 @@ export default function QueriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧工具按钮组 */}
|
{/* 右侧工具按钮组 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 w-full md:w-auto overflow-x-auto pb-1 md:pb-0">
|
||||||
{instanceId && (
|
{instanceId && (
|
||||||
<button onClick={() => setShowSidebar(!showSidebar)}
|
<button onClick={() => setShowSidebar(!showSidebar)}
|
||||||
className={`dg-btn dg-btn-ghost dg-btn-sm ${showSidebar ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
|
className={`dg-btn dg-btn-ghost dg-btn-sm ${showSidebar ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
|
||||||
@@ -387,7 +460,7 @@ export default function QueriesPage() {
|
|||||||
历史 {history.length > 0 && <span className="text-[10px] px-1 py-0.5 rounded-full bg-[var(--dg-bg-primary)]">{history.length}</span>}
|
历史 {history.length > 0 && <span className="text-[10px] px-1 py-0.5 rounded-full bg-[var(--dg-bg-primary)]">{history.length}</span>}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleExecute} disabled={executing || !instanceId}
|
<button onClick={handleExecute} disabled={executing || !instanceId}
|
||||||
className="dg-btn dg-btn-primary">
|
className="dg-btn dg-btn-primary ml-auto md:ml-0">
|
||||||
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||||
{executing ? '执行中...' : '执行'}
|
{executing ? '执行中...' : '执行'}
|
||||||
</button>
|
</button>
|
||||||
@@ -409,11 +482,11 @@ export default function QueriesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* -- 编辑器 + 侧边栏 + 结果 (主体区) -- */}
|
{/* -- 编辑器 + 侧边栏 + 结果 (主体区) -- */}
|
||||||
<div className="flex-1 min-h-0 flex gap-0 overflow-hidden">
|
<div className="flex-1 min-h-0 flex gap-0 overflow-visible md:overflow-hidden">
|
||||||
{/* Schema 侧边栏(左侧,独立) */}
|
{/* Schema 侧边栏(左侧,独立) */}
|
||||||
{showSidebar && instanceId && (
|
{showSidebar && instanceId && (
|
||||||
<>
|
<>
|
||||||
<div className="shrink-0 border-r border-[var(--dg-border)] overflow-y-auto" style={{ width: sideWidth + 'px' }}>
|
<div className="hidden md:block shrink-0 border-r border-[var(--dg-border)] overflow-y-auto" style={{ width: sideWidth + 'px' }}>
|
||||||
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
|
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
|
||||||
clusterMode={selectedInstance?.cluster_mode}
|
clusterMode={selectedInstance?.cluster_mode}
|
||||||
onInsert={(text) => {
|
onInsert={(text) => {
|
||||||
@@ -428,7 +501,7 @@ export default function QueriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={sidebarDragRef}
|
ref={sidebarDragRef}
|
||||||
className="w-1 shrink-0 cursor-col-resize hover:bg-blue-500/30 transition-colors relative group"
|
className="hidden md:block w-1 shrink-0 cursor-col-resize hover:bg-blue-500/30 transition-colors relative group"
|
||||||
onMouseDown={handleDragStart}
|
onMouseDown={handleDragStart}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-y-0 -left-1 -right-1" />
|
<div className="absolute inset-y-0 -left-1 -right-1" />
|
||||||
@@ -438,8 +511,126 @@ export default function QueriesPage() {
|
|||||||
|
|
||||||
{/* 编辑器 + 查询结果(右侧主体,垂直排列) */}
|
{/* 编辑器 + 查询结果(右侧主体,垂直排列) */}
|
||||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||||
|
{dbType === 'redis' && instanceId && (
|
||||||
|
<div className={`shrink-0 p-0 md:px-4 md:pt-4 pb-2 ${showSidebar ? 'md:hidden' : ''}`}>
|
||||||
|
<div className="dg-card overflow-hidden">
|
||||||
|
<div className="border-b border-[var(--dg-border)] px-3 py-2">
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<KeyRound className="h-4 w-4 text-red-400" />
|
||||||
|
Redis 键值可视化
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_130px_auto] lg:w-[620px]">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" />
|
||||||
|
<input
|
||||||
|
value={redisPattern}
|
||||||
|
onChange={e => setRedisPattern(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleRedisSearch(); }}
|
||||||
|
className="h-9 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-9 pr-3 font-mono text-sm"
|
||||||
|
placeholder="输入 key/pattern,* 扫描全部"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={redisType}
|
||||||
|
onChange={e => setRedisType(e.target.value)}
|
||||||
|
className="h-9 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">全部类型</option>
|
||||||
|
<option value="string">String</option>
|
||||||
|
<option value="hash">Hash</option>
|
||||||
|
<option value="list">List</option>
|
||||||
|
<option value="set">Set</option>
|
||||||
|
<option value="zset">ZSet</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={handleRedisSearch} disabled={redisLoading || !redisPattern.trim()} className="dg-btn dg-btn-primary h-9 disabled:opacity-40">
|
||||||
|
{redisLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid max-h-[520px] min-h-[260px] grid-cols-1 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
|
<div className="min-h-[180px] overflow-y-auto border-b border-[var(--dg-border)] lg:border-b-0 lg:border-r">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--dg-border)] px-3 py-2 text-xs text-[var(--dg-text-muted)]">
|
||||||
|
<span>Keys</span>
|
||||||
|
<span>{redisKeys.length} 条</span>
|
||||||
|
</div>
|
||||||
|
{redisLoading ? (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{Array.from({ length: 5 }, (_, i) => <div key={i} className="h-10 animate-pulse rounded-md bg-[var(--dg-bg-tertiary)]" />)}
|
||||||
|
</div>
|
||||||
|
) : redisKeys.length === 0 ? (
|
||||||
|
<div className="px-5 py-10 text-center text-sm text-[var(--dg-text-muted)]">输入 key/pattern 后搜索;输入 * 可扫描全部</div>
|
||||||
|
) : redisKeys.map((item, i) => {
|
||||||
|
const keyName = item.key || item.name || '';
|
||||||
|
const active = (redisSelectedKey?.key || redisSelectedKey?.name) === keyName;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={keyName + i}
|
||||||
|
onClick={() => loadRedisKeyDetail(keyName)}
|
||||||
|
className={`block w-full border-b border-[var(--dg-border-light)] px-3 py-2.5 text-left transition-colors ${active ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate font-mono text-xs text-[var(--dg-text-primary)]">{keyName}</span>
|
||||||
|
{item.type && <span className="rounded bg-[var(--dg-bg-tertiary)] px-1.5 py-0.5 text-[10px] uppercase text-[var(--dg-text-muted)]">{item.type}</span>}
|
||||||
|
</div>
|
||||||
|
{typeof item.ttl === 'number' && <div className="mt-1 text-[11px] text-[var(--dg-text-muted)]">TTL {formatTTL(item.ttl)}</div>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 overflow-auto">
|
||||||
|
{redisDetailLoading ? (
|
||||||
|
<div className="m-4 h-52 animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
|
||||||
|
) : redisSelectedKey ? (
|
||||||
|
<div className="flex h-full min-h-[260px] flex-col">
|
||||||
|
<div className="flex flex-col gap-2 border-b border-[var(--dg-border)] px-3 py-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-mono text-xs text-[var(--dg-text-primary)]">{redisSelectedKey.key || redisSelectedKey.name}</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--dg-text-muted)]">
|
||||||
|
<span className="rounded bg-red-500/10 px-1.5 py-0.5 uppercase text-red-400">{redisSelectedKey.type}</span>
|
||||||
|
<span>TTL {formatTTL(redisSelectedKey.ttl)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full sm:w-64">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" />
|
||||||
|
<input value={redisValueSearch} onChange={e => setRedisValueSearch(e.target.value)} className="h-8 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-8 pr-3 text-xs" placeholder="搜索字段或值" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||||
|
{redisSelectedKey.type === 'string' ? (
|
||||||
|
<pre className="min-h-[180px] whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]">
|
||||||
|
{typeof redisSelectedKey.value === 'string' ? (tryFormatJsonText(redisSelectedKey.value) || redisSelectedKey.value) : JSON.stringify(redisSelectedKey.value, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-[var(--dg-border)]">
|
||||||
|
<RedisStructuredValue
|
||||||
|
type={redisSelectedKey.type}
|
||||||
|
value={redisSelectedKey.value}
|
||||||
|
redisKey={redisSelectedKey.key || redisSelectedKey.name || ''}
|
||||||
|
search={redisValueSearch}
|
||||||
|
onCopy={copyRedisText}
|
||||||
|
onInspect={(title, value) => setRedisLineViewer({ open: true, field: title, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' })}
|
||||||
|
onEditHashLine={(field, value) => setRedisLineViewer({ open: true, field, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' })}
|
||||||
|
onDeleteHashLine={() => undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-[260px] items-center justify-center px-5 text-center text-sm text-[var(--dg-text-muted)]">选择左侧 Key 查看结构化值;命令行查询仍可在下方执行</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* SQL 编辑器 */}
|
{/* SQL 编辑器 */}
|
||||||
<div className="shrink-0 p-4 pb-2">
|
<div className="shrink-0 p-0 md:p-4 pb-2">
|
||||||
<div className="dg-card overflow-hidden flex flex-col">
|
<div className="dg-card overflow-hidden flex flex-col">
|
||||||
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
|
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
|
||||||
language={editorLang} height="200px" onExecute={handleExecute}
|
language={editorLang} height="200px" onExecute={handleExecute}
|
||||||
@@ -449,7 +640,7 @@ export default function QueriesPage() {
|
|||||||
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}
|
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}
|
||||||
{dbTypeLabel[dbType]} 模式 · {sql.length} 字符
|
{dbTypeLabel[dbType]} 模式 · {sql.length} 字符
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Enter 执行</span>
|
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Enter 执行</span>
|
||||||
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Shift+F 格式化</span>
|
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Shift+F 格式化</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -472,7 +663,7 @@ export default function QueriesPage() {
|
|||||||
|
|
||||||
{/* -- 查询结果 (编辑器下方,独立滚动) -- */}
|
{/* -- 查询结果 (编辑器下方,独立滚动) -- */}
|
||||||
{result && (
|
{result && (
|
||||||
<div className="flex-1 min-h-0 px-4 pb-4 overflow-hidden flex flex-col">
|
<div className="flex-1 min-h-0 px-0 md:px-4 pb-4 overflow-hidden flex flex-col">
|
||||||
<div className="dg-card overflow-hidden flex-1 flex flex-col">
|
<div className="dg-card overflow-hidden flex-1 flex flex-col">
|
||||||
{/* 结果标题栏 + 折叠按钮 */}
|
{/* 结果标题栏 + 折叠按钮 */}
|
||||||
<div
|
<div
|
||||||
@@ -565,7 +756,7 @@ export default function QueriesPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
/* Standard table for other results */
|
/* Standard table for other results */
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="dg-table-header">
|
<tr className="dg-table-header">
|
||||||
<th className="text-left px-3 py-2 font-semibold text-xs text-[var(--dg-text-muted)] w-10">#</th>
|
<th className="text-left px-3 py-2 font-semibold text-xs text-[var(--dg-text-muted)] w-10">#</th>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function RedisDetailPage() {
|
|||||||
var [infoSearch, setInfoSearch] = useState('');
|
var [infoSearch, setInfoSearch] = useState('');
|
||||||
var [activeTab, setActiveTab] = useState<TabKey>('keys');
|
var [activeTab, setActiveTab] = useState<TabKey>('keys');
|
||||||
var [selectedDb, setSelectedDb] = useState(0);
|
var [selectedDb, setSelectedDb] = useState(0);
|
||||||
var [keyPattern, setKeyPattern] = useState('*');
|
var [keyPattern, setKeyPattern] = useState('');
|
||||||
var [keyType, setKeyType] = useState('');
|
var [keyType, setKeyType] = useState('');
|
||||||
var [keys, setKeys] = useState<any[]>([]);
|
var [keys, setKeys] = useState<any[]>([]);
|
||||||
var [selectedKey, setSelectedKey] = useState<RedisKeyDetail | null>(null);
|
var [selectedKey, setSelectedKey] = useState<RedisKeyDetail | null>(null);
|
||||||
@@ -77,6 +77,11 @@ export default function RedisDetailPage() {
|
|||||||
}, [instanceId]);
|
}, [instanceId]);
|
||||||
|
|
||||||
var fetchKeys = function () {
|
var fetchKeys = function () {
|
||||||
|
if (!keyPattern.trim()) {
|
||||||
|
setKeys([]);
|
||||||
|
setSelectedKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
redisApi.getKeys(instanceId, { db: selectedDb, pattern: keyPattern, type: keyType || undefined, count: 50 }).then(function (res: any) {
|
redisApi.getKeys(instanceId, { db: selectedDb, pattern: keyPattern, type: keyType || undefined, count: 50 }).then(function (res: any) {
|
||||||
setKeys(res.keys || res || []);
|
setKeys(res.keys || res || []);
|
||||||
@@ -317,7 +322,7 @@ export default function RedisDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab */}
|
{/* Tab */}
|
||||||
<div className="flex gap-0 border-b border-[var(--dg-border)]">
|
<div className="flex gap-0 overflow-x-auto border-b border-[var(--dg-border)]">
|
||||||
{tabs.map(function (tab) {
|
{tabs.map(function (tab) {
|
||||||
var Icon = tab.icon;
|
var Icon = tab.icon;
|
||||||
return <button key={tab.key} onClick={function () { setActiveTab(tab.key); }}
|
return <button key={tab.key} onClick={function () { setActiveTab(tab.key); }}
|
||||||
@@ -330,7 +335,7 @@ export default function RedisDetailPage() {
|
|||||||
|
|
||||||
{/* 键管理 */}
|
{/* 键管理 */}
|
||||||
{activeTab === 'keys' && (
|
{activeTab === 'keys' && (
|
||||||
<div className="grid min-h-[620px] grid-cols-[360px_minmax(0,1fr)] gap-4">
|
<div className="grid min-h-[620px] grid-cols-1 gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
<div className="dg-card flex min-h-0 flex-col overflow-hidden">
|
<div className="dg-card flex min-h-0 flex-col overflow-hidden">
|
||||||
<div className="border-b border-[var(--dg-border)] p-3">
|
<div className="border-b border-[var(--dg-border)] p-3">
|
||||||
<div className="mb-3 grid grid-cols-[1fr_auto] gap-2">
|
<div className="mb-3 grid grid-cols-[1fr_auto] gap-2">
|
||||||
@@ -341,7 +346,7 @@ export default function RedisDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative mb-2">
|
<div className="relative mb-2">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--dg-text-muted)]" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--dg-text-muted)]" />
|
||||||
<input value={keyPattern} onChange={function (e) { setKeyPattern(e.target.value); }} onKeyDown={function (e) { if (e.key === 'Enter') fetchKeys(); }} className="h-9 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-9 pr-3 font-mono text-sm" placeholder="pattern, 如 MARKET:*" />
|
<input value={keyPattern} onChange={function (e) { setKeyPattern(e.target.value); }} onKeyDown={function (e) { if (e.key === 'Enter') fetchKeys(); }} className="h-9 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-9 pr-3 font-mono text-sm" placeholder="输入 key/pattern,* 扫描全部" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||||
<select value={keyType} onChange={function (e) { setKeyType(e.target.value); }} className="h-9 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 text-sm">
|
<select value={keyType} onChange={function (e) { setKeyType(e.target.value); }} className="h-9 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 text-sm">
|
||||||
@@ -365,7 +370,7 @@ export default function RedisDetailPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : keys.length === 0 ? (
|
) : keys.length === 0 ? (
|
||||||
<div className="px-6 py-16 text-center text-sm text-[var(--dg-text-muted)]">点击刷新加载键列表</div>
|
<div className="px-6 py-16 text-center text-sm text-[var(--dg-text-muted)]">输入 key/pattern 后搜索;输入 * 可扫描全部</div>
|
||||||
) : keys.map(function (k: any, i: number) {
|
) : keys.map(function (k: any, i: number) {
|
||||||
var keyName = k.key || k.name || k;
|
var keyName = k.key || k.name || k;
|
||||||
var active = (selectedKey?.key || selectedKey?.name) === keyName;
|
var active = (selectedKey?.key || selectedKey?.name) === keyName;
|
||||||
|
|||||||
@@ -44,12 +44,12 @@ const ROLES: RolePreset[] = [
|
|||||||
{
|
{
|
||||||
key: 'developer',
|
key: 'developer',
|
||||||
name: '开发人员',
|
name: '开发人员',
|
||||||
description: '提交 SQL 工单、执行自助查询、导出结果',
|
description: '默认执行自助查询;授权写入数据源后可提交变更工单',
|
||||||
icon: Code,
|
icon: Code,
|
||||||
color: 'text-emerald-400',
|
color: 'text-emerald-400',
|
||||||
bg: 'bg-emerald-500/10',
|
bg: 'bg-emerald-500/10',
|
||||||
ring: 'ring-emerald-500/20',
|
ring: 'ring-emerald-500/20',
|
||||||
permissions: ['workspace', 'dashboard', 'change', 'tickets', 'ticket_create', 'query', 'queries', 'query_create', 'query_export'],
|
permissions: ['query', 'queries', 'query_create', 'query_export'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'auditor',
|
key: 'auditor',
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import PlatformConfigPage from './PlatformConfigPage';
|
import PlatformConfigPage from './PlatformConfigPage';
|
||||||
import AuditRulesPage from './AuditRulesPage';
|
|
||||||
import AnnouncementPage from './AnnouncementPage';
|
|
||||||
import NotificationPage from './NotificationPage';
|
|
||||||
import LdapConfigPage from './LdapConfigPage';
|
import LdapConfigPage from './LdapConfigPage';
|
||||||
import AppearanceSettings from './AppearanceSettings';
|
import AppearanceSettings from './AppearanceSettings';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'appearance', label: '外观设置', icon: '🎨' },
|
{ id: 'appearance', label: '外观设置', icon: '🎨' },
|
||||||
{ id: 'platform', label: '平台配置', icon: '⚙️' },
|
{ id: 'platform', label: '平台配置', icon: '⚙️' },
|
||||||
{ id: 'rules', label: '审核规则', icon: '🛡️' },
|
|
||||||
{ id: 'notification', label: '消息推送', icon: '🔔' },
|
|
||||||
{ id: 'ldap', label: 'LDAP', icon: '🔐' },
|
{ id: 'ldap', label: 'LDAP', icon: '🔐' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -23,7 +18,7 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">系统设置</h1>
|
<h1 className="text-xl font-semibold">系统设置</h1>
|
||||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">管理平台外观、配置、审核规则、消息推送和 LDAP 认证</p>
|
<p className="text-sm text-[var(--dg-text-muted)] mt-1">管理平台外观、配置和 LDAP 认证</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab 导航 */}
|
{/* Tab 导航 */}
|
||||||
@@ -48,8 +43,6 @@ export default function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
{activeTab === 'appearance' && <AppearanceSettings />}
|
{activeTab === 'appearance' && <AppearanceSettings />}
|
||||||
{activeTab === 'platform' && <PlatformConfigPage />}
|
{activeTab === 'platform' && <PlatformConfigPage />}
|
||||||
{activeTab === 'rules' && <AuditRulesPage />}
|
|
||||||
{activeTab === 'notification' && <NotificationPage />}
|
|
||||||
{activeTab === 'ldap' && <LdapConfigPage />}
|
{activeTab === 'ldap' && <LdapConfigPage />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -271,26 +271,26 @@ export default function TicketCreatePage() {
|
|||||||
const [showBrowser, setShowBrowser] = useState(true);
|
const [showBrowser, setShowBrowser] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-3rem)] overflow-y-auto">
|
<div className="min-h-[calc(100vh-3rem)] overflow-y-auto">
|
||||||
<div className="max-w-5xl mx-auto px-6 py-5 space-y-4">
|
<div className="mx-auto max-w-6xl px-3 py-4 space-y-4 sm:px-5 lg:px-6">
|
||||||
|
|
||||||
{/* ====== 顶部:返回 + 工单标题 ====== */}
|
{/* ====== 顶部:返回 + 工单标题 ====== */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<button onClick={() => navigate('/tickets')}
|
<button onClick={() => navigate('/tickets')}
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
className="w-8 h-8 shrink-0 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
||||||
title="返回工单列表">
|
title="返回工单列表">
|
||||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||||
</button>
|
</button>
|
||||||
<input value={title} onChange={(e) => setTitle(e.target.value)}
|
<input value={title} onChange={(e) => setTitle(e.target.value)}
|
||||||
className="flex-1 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium focus:border-blue-400 focus:outline-none transition-colors"
|
className="w-full min-w-0 flex-1 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium focus:border-blue-400 focus:outline-none transition-colors"
|
||||||
placeholder="输入工单标题" />
|
placeholder="输入工单标题" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ====== 工具栏:实例 + 数据库 + 操作 ====== */}
|
{/* ====== 工具栏:实例 + 数据库 + 操作 ====== */}
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="grid grid-cols-1 gap-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-3 md:grid-cols-[minmax(180px,1fr)_minmax(140px,220px)_auto_auto] md:items-center">
|
||||||
{/* 实例 */}
|
{/* 实例 */}
|
||||||
<select value={instanceId} onChange={(e) => setInstanceId(e.target.value)} disabled={loadingInstances}
|
<select value={instanceId} onChange={(e) => setInstanceId(e.target.value)} disabled={loadingInstances}
|
||||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[180px] disabled:opacity-50">
|
className="h-10 w-full min-w-0 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none disabled:opacity-50">
|
||||||
<option value="">{loadingInstances ? '加载中...' : '选择实例'}</option>
|
<option value="">{loadingInstances ? '加载中...' : '选择实例'}</option>
|
||||||
{instances.map((inst) => (
|
{instances.map((inst) => (
|
||||||
<option key={inst.id} value={String(inst.id)}>
|
<option key={inst.id} value={String(inst.id)}>
|
||||||
@@ -302,23 +302,23 @@ export default function TicketCreatePage() {
|
|||||||
{/* 数据库 */}
|
{/* 数据库 */}
|
||||||
{isRedis ? (
|
{isRedis ? (
|
||||||
<select value={database} onChange={(e) => setDatabase(e.target.value)}
|
<select value={database} onChange={(e) => setDatabase(e.target.value)}
|
||||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none w-24">
|
className="h-10 w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
||||||
{databases.length > 0 ? databases.map((db) => <option key={db} value={db}>{db}</option>) : <option value="db0">db0</option>}
|
{databases.length > 0 ? databases.map((db) => <option key={db} value={db}>{db}</option>) : <option value="db0">db0</option>}
|
||||||
</select>
|
</select>
|
||||||
) : databases.length > 0 ? (
|
) : databases.length > 0 ? (
|
||||||
<select value={database} onChange={(e) => setDatabase(e.target.value)} disabled={loadingDatabases}
|
<select value={database} onChange={(e) => setDatabase(e.target.value)} disabled={loadingDatabases}
|
||||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[150px] disabled:opacity-50">
|
className="h-10 w-full min-w-0 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none disabled:opacity-50">
|
||||||
<option value="">{loadingDatabases ? '加载中...' : '选择数据库 *'}</option>
|
<option value="">{loadingDatabases ? '加载中...' : '选择数据库 *'}</option>
|
||||||
{databases.map((db) => <option key={db} value={db}>{db}</option>)}
|
{databases.map((db) => <option key={db} value={db}>{db}</option>)}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<input value={database} onChange={(e) => setDatabase(e.target.value)} disabled={!instanceId}
|
<input value={database} onChange={(e) => setDatabase(e.target.value)} disabled={!instanceId}
|
||||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[150px] disabled:opacity-50"
|
className="h-10 w-full min-w-0 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none disabled:opacity-50"
|
||||||
placeholder={instanceId ? '数据库名' : '先选实例'} />
|
placeholder={instanceId ? '数据库名' : '先选实例'} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 定时执行 */}
|
{/* 定时执行 */}
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer select-none text-xs text-[var(--dg-text-muted)]">
|
<label className="flex h-10 items-center gap-1.5 cursor-pointer select-none rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 text-xs text-[var(--dg-text-muted)]">
|
||||||
<input type="checkbox" checked={enableSchedule} onChange={(e) => setEnableSchedule(e.target.checked)}
|
<input type="checkbox" checked={enableSchedule} onChange={(e) => setEnableSchedule(e.target.checked)}
|
||||||
className="w-3.5 h-3.5 rounded border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-blue-500 focus:ring-blue-500 focus:ring-offset-0" />
|
className="w-3.5 h-3.5 rounded border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-blue-500 focus:ring-blue-500 focus:ring-offset-0" />
|
||||||
<Clock className="w-3.5 h-3.5" />
|
<Clock className="w-3.5 h-3.5" />
|
||||||
@@ -327,7 +327,7 @@ export default function TicketCreatePage() {
|
|||||||
{enableSchedule && (
|
{enableSchedule && (
|
||||||
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)}
|
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)}
|
||||||
step="1" min={new Date().toISOString().slice(0, 16)}
|
step="1" min={new Date().toISOString().slice(0, 16)}
|
||||||
className="px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-xs focus:border-blue-400 focus:outline-none" />
|
className="h-10 w-full px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-xs focus:border-blue-400 focus:outline-none md:col-span-2" />
|
||||||
)}
|
)}
|
||||||
{enableSchedule && scheduledAt && (
|
{enableSchedule && scheduledAt && (
|
||||||
<span className="text-xs font-semibold text-red-500">
|
<span className="text-xs font-semibold text-red-500">
|
||||||
@@ -335,24 +335,19 @@ export default function TicketCreatePage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex flex-wrap gap-2 md:col-span-4">
|
||||||
|
{instanceId && (
|
||||||
{/* 浏览面板 */}
|
<button onClick={() => setShowBrowser(!showBrowser)}
|
||||||
{instanceId && (
|
className={`dg-btn dg-btn-sm ${showBrowser ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'dg-btn-ghost'}`}>
|
||||||
<button onClick={() => setShowBrowser(!showBrowser)}
|
<Database className="w-3.5 h-3.5" />
|
||||||
className={`flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-lg border transition-all ${
|
{showBrowser ? '隐藏浏览' : '库表浏览'}
|
||||||
showBrowser ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
</button>
|
||||||
}`}>
|
)}
|
||||||
<Database className="w-3.5 h-3.5" />
|
<button onClick={handleFormatSQL} disabled={!sqlContent.trim()}
|
||||||
{showBrowser ? '隐藏浏览' : '库表浏览'}
|
className="dg-btn dg-btn-sm dg-btn-ghost text-blue-400 disabled:opacity-30">
|
||||||
|
<Sparkles className="w-3 h-3" /> 格式化
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* 格式化 */}
|
|
||||||
<button onClick={handleFormatSQL} disabled={!sqlContent.trim()}
|
|
||||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-blue-400 bg-blue-500/10 rounded-lg hover:bg-blue-500/20 transition-colors disabled:opacity-30">
|
|
||||||
<Sparkles className="w-3 h-3" /> 格式化
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ====== 错误提示 ====== */}
|
{/* ====== 错误提示 ====== */}
|
||||||
@@ -363,10 +358,10 @@ export default function TicketCreatePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ====== 编辑器 + 浏览面板 ====== */}
|
{/* ====== 编辑器 + 浏览面板 ====== */}
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-col gap-4 xl:flex-row">
|
||||||
{/* 浏览面板 */}
|
{/* 浏览面板 */}
|
||||||
{showBrowser && instanceId && (
|
{showBrowser && instanceId && (
|
||||||
<div className="w-[280px] shrink-0">
|
<div className="min-h-[260px] xl:w-[300px] xl:shrink-0">
|
||||||
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
|
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
|
||||||
clusterMode={selectedInstance?.cluster_mode}
|
clusterMode={selectedInstance?.cluster_mode}
|
||||||
onInsert={handleSchemaInsert} singleDb />
|
onInsert={handleSchemaInsert} singleDb />
|
||||||
@@ -377,7 +372,7 @@ export default function TicketCreatePage() {
|
|||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
<div className="dg-card overflow-hidden flex flex-col">
|
<div className="dg-card overflow-hidden flex flex-col">
|
||||||
{/* 编辑器顶栏:标题 + SQL检测按钮 */}
|
{/* 编辑器顶栏:标题 + SQL检测按钮 */}
|
||||||
<div className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
<div className="flex flex-col gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)] sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span className="text-xs font-medium text-[var(--dg-text-muted)]">SQL 变更语句</span>
|
<span className="text-xs font-medium text-[var(--dg-text-muted)]">SQL 变更语句</span>
|
||||||
<button onClick={handleValidate}
|
<button onClick={handleValidate}
|
||||||
disabled={validating || !sqlContent.trim() || !instanceId || !database}
|
disabled={validating || !sqlContent.trim() || !instanceId || !database}
|
||||||
@@ -390,12 +385,12 @@ export default function TicketCreatePage() {
|
|||||||
height="320px"
|
height="320px"
|
||||||
placeholder="请输入 SQL 变更语句,支持多条 SQL(以分号分隔)..." />
|
placeholder="请输入 SQL 变更语句,支持多条 SQL(以分号分隔)..." />
|
||||||
{/* 编辑器底栏 */}
|
{/* 编辑器底栏 */}
|
||||||
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] text-[var(--dg-text-muted)]">
|
<div className="flex flex-col gap-1 px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] text-[var(--dg-text-muted)] sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span>
|
<span>
|
||||||
{sqlContent.trim() ? sqlContent.trim().split(/;\s*/).filter(Boolean).length + ' 条语句 · ' : ''}
|
{sqlContent.trim() ? sqlContent.trim().split(/;\s*/).filter(Boolean).length + ' 条语句 · ' : ''}
|
||||||
{sqlContent.length} 字符
|
{sqlContent.length} 字符
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{validated && <span className="flex items-center gap-1 text-green-400"><Check className="w-3 h-3" />检测通过</span>}
|
{validated && <span className="flex items-center gap-1 text-green-400"><Check className="w-3 h-3" />检测通过</span>}
|
||||||
<span>Ctrl+Enter 格式化</span>
|
<span>Ctrl+Enter 格式化</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -419,7 +414,7 @@ export default function TicketCreatePage() {
|
|||||||
{auditResult.findings && auditResult.findings.length > 0 && (
|
{auditResult.findings && auditResult.findings.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{auditResult.findings.map((f, idx) => (
|
{auditResult.findings.map((f, idx) => (
|
||||||
<div key={idx} className={'flex items-center gap-2 px-3 py-2 rounded-lg text-xs ' + (LEVEL_COLOR[f.level] || '')}>
|
<div key={idx} className={'flex flex-col gap-1 px-3 py-2 rounded-lg text-xs sm:flex-row sm:items-center sm:gap-2 ' + (LEVEL_COLOR[f.level] || '')}>
|
||||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||||
<span className="font-medium">{LEVEL_LABEL[f.level] || f.level}</span>
|
<span className="font-medium">{LEVEL_LABEL[f.level] || f.level}</span>
|
||||||
<span className="opacity-60">·</span>
|
<span className="opacity-60">·</span>
|
||||||
@@ -449,7 +444,7 @@ export default function TicketCreatePage() {
|
|||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<button onClick={handleSubmit}
|
<button onClick={handleSubmit}
|
||||||
disabled={submitting || !validated}
|
disabled={submitting || !validated}
|
||||||
className="flex items-center gap-1.5 px-6 py-2.5 text-sm font-medium bg-[var(--dg-blue)] rounded-lg text-white hover:opacity-90 disabled:opacity-40 transition-all">
|
className="flex w-full items-center justify-center gap-1.5 px-6 py-2.5 text-sm font-medium bg-[var(--dg-blue)] rounded-lg text-white hover:opacity-90 disabled:opacity-40 transition-all sm:w-auto">
|
||||||
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||||
提交审批
|
提交审批
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ export default function TicketDetailPage() {
|
|||||||
const renderActionButtons = () => {
|
const renderActionButtons = () => {
|
||||||
if (showRollbackConfirm) {
|
if (showRollbackConfirm) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
|
||||||
<AlertTriangle className="w-5 h-5 text-orange-400 shrink-0" />
|
<AlertTriangle className="w-5 h-5 text-orange-400 shrink-0" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-orange-300">确认回滚操作</p>
|
<p className="text-sm font-medium text-orange-300">确认回滚操作</p>
|
||||||
@@ -323,7 +323,7 @@ export default function TicketDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 w-full">
|
||||||
<MessageSquare className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
<MessageSquare className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
<input
|
<input
|
||||||
value={comment}
|
value={comment}
|
||||||
@@ -603,7 +603,7 @@ export default function TicketDetailPage() {
|
|||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-start sm:items-center gap-3 sm:gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/tickets')}
|
onClick={() => navigate('/tickets')}
|
||||||
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all shrink-0"
|
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all shrink-0"
|
||||||
@@ -612,7 +612,7 @@ export default function TicketDetailPage() {
|
|||||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold flex items-center gap-3 flex-wrap">
|
<h1 className="text-xl sm:text-2xl font-semibold flex items-center gap-2 sm:gap-3 flex-wrap">
|
||||||
{ticket.title}
|
{ticket.title}
|
||||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
|
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
|
||||||
{statusMap[ticket.status] || ticket.status}
|
{statusMap[ticket.status] || ticket.status}
|
||||||
@@ -660,13 +660,13 @@ export default function TicketDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0"
|
className="flex flex-col sm:flex-row sm:justify-between gap-1 sm:gap-3 py-2 border-b border-[var(--dg-border-light)] last:border-0"
|
||||||
>
|
>
|
||||||
<span className="text-sm text-[var(--dg-text-secondary)] flex items-center gap-1.5">
|
<span className="text-sm text-[var(--dg-text-secondary)] flex items-center gap-1.5">
|
||||||
{ItemIcon && <ItemIcon className="w-3.5 h-3.5" />}
|
{ItemIcon && <ItemIcon className="w-3.5 h-3.5" />}
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium">{item.value}</span>
|
<span className="text-sm font-medium break-words sm:text-right">{item.value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export default function TicketsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Status Summary Cards */}
|
{/* Status Summary Cards */}
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">
|
||||||
{stats === null ? (
|
{stats === null ? (
|
||||||
Array.from({ length: 4 }).map((_, i) => (
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div key={i} className="dg-card-section animate-pulse">
|
<div key={i} className="dg-card-section animate-pulse">
|
||||||
@@ -314,8 +314,8 @@ export default function TicketsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search + Filters */}
|
{/* Search + Filters */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col gap-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-3 lg:flex-row lg:items-center">
|
||||||
<div className="relative flex-1 max-w-md">
|
<div className="relative flex-1">
|
||||||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
<input
|
<input
|
||||||
className="dg-input w-full pl-10 pr-4 py-2.5"
|
className="dg-input w-full pl-10 pr-4 py-2.5"
|
||||||
@@ -329,13 +329,35 @@ export default function TicketsPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1 lg:pb-0">
|
||||||
|
{[
|
||||||
|
{ key: '', label: '全部' },
|
||||||
|
{ key: 'pending', label: '待审核' },
|
||||||
|
{ key: 'executing', label: '执行中' },
|
||||||
|
{ key: 'success', label: '完成' },
|
||||||
|
{ key: 'failed', label: '失败' },
|
||||||
|
{ key: 'rejected', label: '拒绝' },
|
||||||
|
].map(item => (
|
||||||
|
<button
|
||||||
|
key={item.key || 'all'}
|
||||||
|
onClick={() => { setStatusFilter(item.key); setPage(1); }}
|
||||||
|
className={`dg-btn dg-btn-sm whitespace-nowrap ${statusFilter === item.key ? 'dg-btn-primary' : 'dg-btn-ghost'}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSortAsc(!sortAsc)} className="dg-btn dg-btn-sm dg-btn-ghost justify-center whitespace-nowrap">
|
||||||
|
<ArrowUpDown className="w-3.5 h-3.5" />
|
||||||
|
{sortAsc ? '最早优先' : '最新优先'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ticket Table */}
|
{/* Ticket Table */}
|
||||||
<div className="dg-card overflow-hidden">
|
<div className="dg-card overflow-hidden">
|
||||||
<div className="p-0">
|
<div className="p-0">
|
||||||
{loading && tickets.length === 0 ? (
|
{loading && tickets.length === 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<div className="hidden md:block overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="dg-table-header">
|
<tr className="dg-table-header">
|
||||||
@@ -360,7 +382,7 @@ export default function TicketsPage() {
|
|||||||
) : tickets.length === 0 ? (
|
) : tickets.length === 0 ? (
|
||||||
<EmptyState type="search" />
|
<EmptyState type="search" />
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="hidden md:block overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="dg-table-header">
|
<tr className="dg-table-header">
|
||||||
@@ -524,11 +546,121 @@ export default function TicketsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!loading && tickets.length > 0 && (
|
||||||
|
<div className="md:hidden divide-y divide-[var(--dg-border)]">
|
||||||
|
{sortedTickets.map(ticket => (
|
||||||
|
<div key={ticket.id} className="p-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<button onClick={() => openDetail(ticket.id)} className="min-w-0 text-left">
|
||||||
|
<div className="font-mono text-xs text-[var(--dg-text-muted)]">{ticket.ticket_no || `TK-${ticket.id}`}</div>
|
||||||
|
<div className="mt-1 text-sm font-medium text-[var(--dg-text-primary)] line-clamp-2">{ticket.title}</div>
|
||||||
|
</button>
|
||||||
|
<span className={`dg-badge shrink-0 ${dgBadgeStatusMap[ticket.status] || 'dg-badge-blue'}`}>
|
||||||
|
{displayStatusMap[ticket.status] || statusMap[ticket.status] || ticket.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="rounded-lg bg-[var(--dg-bg-tertiary)] px-3 py-2">
|
||||||
|
<div className="text-[var(--dg-text-muted)]">实例</div>
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className={`dg-badge ${dgBadgeDbMap[ticket.db_type] || 'dg-badge-blue'}`}>{ticket.db_type.toUpperCase()}</span>
|
||||||
|
<span className="truncate">{ticket.instance_name || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-[var(--dg-bg-tertiary)] px-3 py-2">
|
||||||
|
<div className="text-[var(--dg-text-muted)]">提交人</div>
|
||||||
|
<div className="mt-1 truncate">{ticket.applicant_name || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 rounded-lg bg-[var(--dg-bg-tertiary)] px-3 py-2">
|
||||||
|
<div className="text-[var(--dg-text-muted)]">待处理</div>
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{ticket.pending_steps?.length > 0 ? ticket.pending_steps.map(ps => (
|
||||||
|
<div key={ps.step_number} className="flex gap-1.5">
|
||||||
|
<span className="text-[var(--dg-text-muted)] shrink-0">{ps.step_name}:</span>
|
||||||
|
<span className="font-medium">{ps.approver_names.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
)) : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-[var(--dg-text-muted)]">
|
||||||
|
创建于 {new Date(ticket.create_time).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button onClick={() => openDetail(ticket.id)} className="dg-btn dg-btn-sm dg-btn-ghost">
|
||||||
|
<Eye className="w-3.5 h-3.5" /> 查看
|
||||||
|
</button>
|
||||||
|
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await ticketApi.review(ticket.id, { action: 'approve', comment: '通过' });
|
||||||
|
fetchTickets(); fetchStats();
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.detail || e?.message || '审批失败';
|
||||||
|
alert(msg);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="dg-btn dg-btn-sm dg-btn-ghost text-green-400 hover:text-green-300"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" /> 通过
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await ticketApi.review(ticket.id, { action: 'reject', comment: '拒绝' });
|
||||||
|
fetchTickets(); fetchStats();
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.detail || e?.message || '操作失败';
|
||||||
|
alert(msg);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="dg-btn dg-btn-sm dg-btn-ghost text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
<XCircle className="w-3.5 h-3.5" /> 拒绝
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canExecute && ticket.is_executor && ticket.status === 'approved' && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await ticketApi.execute(ticket.id);
|
||||||
|
fetchTickets(); fetchStats();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}}
|
||||||
|
className="dg-btn dg-btn-sm dg-btn-ghost text-cyan-400 hover:text-cyan-300"
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5" /> 执行
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{currentUser?.id === ticket.applicant_id && (ticket.status === 'pending' || ticket.status === 'reviewing' || ticket.status === 'approved') && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await ticketApi.cancel(ticket.id);
|
||||||
|
fetchTickets(); fetchStats();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}}
|
||||||
|
className="dg-btn dg-btn-sm dg-btn-ghost text-amber-400 hover:text-amber-300"
|
||||||
|
>
|
||||||
|
<Ban className="w-3.5 h-3.5" /> 撤销
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between px-4 py-4 border-t border-[var(--dg-border)]">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 py-4 border-t border-[var(--dg-border)]">
|
||||||
<span className="text-sm text-[var(--dg-text-muted)]">共 {total} 条</span>
|
<span className="text-sm text-[var(--dg-text-muted)]">共 {total} 条</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1 overflow-x-auto">
|
||||||
<button
|
<button
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
onClick={() => setPage(p => p - 1)}
|
onClick={() => setPage(p => p - 1)}
|
||||||
@@ -642,17 +774,17 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
|
|||||||
return (
|
return (
|
||||||
<ModalOverlay open={true} onClose={onClose} width="max-w-3xl" className="border border-[var(--dg-border)] overflow-hidden flex flex-col">
|
<ModalOverlay open={true} onClose={onClose} width="max-w-3xl" className="border border-[var(--dg-border)] overflow-hidden flex flex-col">
|
||||||
<div
|
<div
|
||||||
className="w-full max-h-[85vh] overflow-hidden flex flex-col"
|
className="w-full max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--dg-border)]">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 md:px-6 py-4 border-b border-[var(--dg-border)]">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-mono text-xs text-[var(--dg-text-muted)]">{ticket.ticket_no || `TK-${ticket.id}`}</span>
|
<span className="font-mono text-xs text-[var(--dg-text-muted)]">{ticket.ticket_no || `TK-${ticket.id}`}</span>
|
||||||
<span className={`dg-badge ${dgBadgeStatusMap[ticket.status] || 'dg-badge-blue'}`}>
|
<span className={`dg-badge ${dgBadgeStatusMap[ticket.status] || 'dg-badge-blue'}`}>
|
||||||
{displayStatusMap[ticket.status] || statusMap[ticket.status] || ticket.status}
|
{displayStatusMap[ticket.status] || statusMap[ticket.status] || ticket.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -687,10 +819,10 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
|
||||||
|
|
||||||
{/* Status Flow */}
|
{/* Status Flow */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between overflow-x-auto pb-1">
|
||||||
{statusDisplay.map((step, idx) => {
|
{statusDisplay.map((step, idx) => {
|
||||||
const StepIcon = step.icon;
|
const StepIcon = step.icon;
|
||||||
const isCompleted = idx <= currentStepIndex && currentStepIndex >= 0;
|
const isCompleted = idx <= currentStepIndex && currentStepIndex >= 0;
|
||||||
@@ -728,7 +860,7 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Grid */}
|
{/* Info Grid */}
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3">
|
||||||
{[
|
{[
|
||||||
{ label: '提交人', value: ticket.applicant_name || '-' },
|
{ label: '提交人', value: ticket.applicant_name || '-' },
|
||||||
{ label: '当前审核人', value: ticket.current_approver_name || (ticket.status === 'approved' ? '待执行' : '-') },
|
{ label: '当前审核人', value: ticket.current_approver_name || (ticket.status === 'approved' ? '待执行' : '-') },
|
||||||
@@ -737,9 +869,9 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
|
|||||||
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
|
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
|
||||||
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
|
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
|
||||||
].map((item, idx) => (
|
].map((item, idx) => (
|
||||||
<div key={idx} className="flex justify-between py-1.5">
|
<div key={idx} className="flex justify-between gap-3 py-1.5">
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
|
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
|
||||||
<span className="text-xs font-medium">{item.value}</span>
|
<span className="text-xs font-medium text-right break-words">{item.value}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -941,4 +1073,4 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
|
|||||||
</div>
|
</div>
|
||||||
</ModalOverlay>
|
</ModalOverlay>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -353,7 +353,7 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
|||||||
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-h-[90vh] overflow-y-auto"
|
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-5 py-4">
|
<div className="flex items-center justify-between px-4 sm:px-5 py-4">
|
||||||
<h2 className="text-base font-semibold">{isEdit ? '编辑审批流程' : '新建审批流程'}</h2>
|
<h2 className="text-base font-semibold">{isEdit ? '编辑审批流程' : '新建审批流程'}</h2>
|
||||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
|
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -361,7 +361,7 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-5 pb-5 space-y-4">
|
<div className="px-4 sm:px-5 pb-5 space-y-4">
|
||||||
{error && <div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>}
|
{error && <div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -376,9 +376,9 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
|||||||
|
|
||||||
{/* Steps */}
|
{/* Steps */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
|
||||||
<label className="text-xs font-medium text-[var(--dg-text-secondary)]">流程步骤</label>
|
<label className="text-xs font-medium text-[var(--dg-text-secondary)]">流程步骤</label>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 overflow-x-auto pb-1 sm:pb-0">
|
||||||
{STEP_TYPES.map((t) => (
|
{STEP_TYPES.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.value}
|
key={t.value}
|
||||||
@@ -400,12 +400,12 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
|||||||
return (
|
return (
|
||||||
<div key={idx} className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
<div key={idx} className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
||||||
{/* Step header */}
|
{/* Step header */}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2 flex-wrap sm:flex-nowrap">
|
||||||
<div className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${colorCls.split(' ')[0]}`}>
|
<div className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${colorCls.split(' ')[0]}`}>
|
||||||
<Icon className={`w-3.5 h-3.5 ${colorCls.split(' ')[1]}`} />
|
<Icon className={`w-3.5 h-3.5 ${colorCls.split(' ')[1]}`} />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
className="flex-1 px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-sm focus:border-blue-400 focus:outline-none"
|
className="flex-1 min-w-[160px] px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-sm focus:border-blue-400 focus:outline-none"
|
||||||
value={step.name}
|
value={step.name}
|
||||||
onChange={(e) => updateStep(idx, { name: e.target.value })}
|
onChange={(e) => updateStep(idx, { name: e.target.value })}
|
||||||
placeholder="步骤名称"
|
placeholder="步骤名称"
|
||||||
@@ -413,7 +413,7 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
|||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colorCls}`}>
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colorCls}`}>
|
||||||
{typeConf.label}
|
{typeConf.label}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-0.5 ml-1">
|
<div className="flex items-center gap-0.5 sm:ml-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => moveStep(idx, -1)}
|
onClick={() => moveStep(idx, -1)}
|
||||||
disabled={idx === 0}
|
disabled={idx === 0}
|
||||||
@@ -442,7 +442,7 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
|||||||
|
|
||||||
{/* User selection — submit 步骤不需要选人 */}
|
{/* User selection — submit 步骤不需要选人 */}
|
||||||
{step.step_type !== 'submit' && (
|
{step.step_type !== 'submit' && (
|
||||||
<div className="pl-9 mt-2">
|
<div className="sm:pl-9 mt-2">
|
||||||
<UserDropdown
|
<UserDropdown
|
||||||
users={getEligibleUsers(step.step_type)}
|
users={getEligibleUsers(step.step_type)}
|
||||||
selectedIds={step.approver_ids}
|
selectedIds={step.approver_ids}
|
||||||
@@ -468,14 +468,14 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-end gap-3 px-5 py-4">
|
<div className="flex flex-col-reverse sm:flex-row sm:items-center sm:justify-end gap-3 px-4 sm:px-5 py-4">
|
||||||
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">
|
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors"
|
className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
{isEdit ? '保存' : '创建'}
|
{isEdit ? '保存' : '创建'}
|
||||||
@@ -590,12 +590,12 @@ export default function WorkflowPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">审批流程配置</h1>
|
<h1 className="text-xl sm:text-2xl font-semibold">审批流程配置</h1>
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置多级审批流程,支持审核阶段叠加和多人执行</p>
|
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置多级审批流程,支持审核阶段叠加和多人执行</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditorWorkflow(null)}
|
onClick={() => setEditorWorkflow(null)}
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 text-white rounded-lg text-sm font-medium hover:bg-blue-600 transition-colors"
|
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-500 text-white rounded-lg text-sm font-medium hover:bg-blue-600 transition-colors w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
新建流程
|
新建流程
|
||||||
@@ -644,7 +644,7 @@ export default function WorkflowPage() {
|
|||||||
return (
|
return (
|
||||||
<Card key={wf.id} className={`dg-card overflow-hidden ${!wf.is_active ? 'opacity-50' : ''}`}>
|
<Card key={wf.id} className={`dg-card overflow-hidden ${!wf.is_active ? 'opacity-50' : ''}`}>
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||||
<h3 className="text-lg font-semibold">{wf.name}</h3>
|
<h3 className="text-lg font-semibold">{wf.name}</h3>
|
||||||
@@ -731,7 +731,7 @@ export default function WorkflowPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-1 ml-4 shrink-0">
|
<div className="flex items-center gap-1 lg:ml-4 shrink-0 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditorWorkflow(wf)}
|
onClick={() => setEditorWorkflow(wf)}
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-blue-500/10 hover:text-blue-400 transition-colors"
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-blue-500/10 hover:text-blue-400 transition-colors"
|
||||||
@@ -772,8 +772,8 @@ export default function WorkflowPage() {
|
|||||||
{/* Preview Modal */}
|
{/* Preview Modal */}
|
||||||
{previewWorkflow && (
|
{previewWorkflow && (
|
||||||
<ModalOverlay open={!!previewWorkflow} onClose={() => setPreviewWorkflow(null)} width="max-w-2xl">
|
<ModalOverlay open={!!previewWorkflow} onClose={() => setPreviewWorkflow(null)} width="max-w-2xl">
|
||||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 w-full mx-4 max-h-[85vh] overflow-y-auto">
|
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-4 sm:p-6 w-full mx-2 sm:mx-4 max-h-[90vh] sm:max-h-[85vh] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-start sm:items-center justify-between gap-3 mb-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-xl font-semibold">{previewWorkflow.name}</h3>
|
<h3 className="text-xl font-semibold">{previewWorkflow.name}</h3>
|
||||||
{previewWorkflow.is_default && (
|
{previewWorkflow.is_default && (
|
||||||
@@ -793,7 +793,7 @@ export default function WorkflowPage() {
|
|||||||
const colorClass = STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial;
|
const colorClass = STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial;
|
||||||
const uids: number[] = step.approver_ids ? JSON.parse(step.approver_ids) : [];
|
const uids: number[] = step.approver_ids ? JSON.parse(step.approver_ids) : [];
|
||||||
return (
|
return (
|
||||||
<div key={step.id || i} className="flex items-start gap-4">
|
<div key={step.id || i} className="flex items-start gap-3 sm:gap-4">
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass.split(' ')[0]}`}>
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass.split(' ')[0]}`}>
|
||||||
<StepIcon className={`w-5 h-5 ${colorClass.split(' ')[1]}`} />
|
<StepIcon className={`w-5 h-5 ${colorClass.split(' ')[1]}`} />
|
||||||
</div>
|
</div>
|
||||||
@@ -815,7 +815,7 @@ export default function WorkflowPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{i < previewWorkflow.steps.length - 1 && <ArrowRight className="w-5 h-5 text-[var(--dg-text-muted)] mt-2" />}
|
{i < previewWorkflow.steps.length - 1 && <ArrowRight className="hidden sm:block w-5 h-5 text-[var(--dg-text-muted)] mt-2" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react"
|
|||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vite"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: './',
|
base: '/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
@echo off
|
|
||||||
chcp 65001 >nul
|
|
||||||
echo === SQL-Platform 数据审核平台启动脚本 ===
|
|
||||||
|
|
||||||
echo [1/4] 安装后端依赖...
|
|
||||||
cd backend
|
|
||||||
if not exist "venv" (
|
|
||||||
python -m venv venv
|
|
||||||
)
|
|
||||||
call venv\Scripts\activate
|
|
||||||
pip install -r requirements.txt -q
|
|
||||||
|
|
||||||
echo [2/4] 启动后端 (http://localhost:9000)...
|
|
||||||
start /b python -m uvicorn app.main:app --host 0.0.0.0 --port 9000 --reload
|
|
||||||
|
|
||||||
echo [3/4] 安装前端依赖...
|
|
||||||
cd ..\frontend
|
|
||||||
if not exist "node_modules" (
|
|
||||||
npm install
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [4/4] 启动前端 (http://localhost:5173)..."
|
|
||||||
start /b npm run dev
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo === 服务已启动 ===
|
|
||||||
echo 前端: http://localhost:5173
|
|
||||||
echo 后端: http://localhost:9000
|
|
||||||
echo API 文档: http://localhost:9000/docs
|
|
||||||
echo 默认账号: admin / 123456
|
|
||||||
echo.
|
|
||||||
echo 按 Ctrl+C 停止所有服务
|
|
||||||
pause
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# SQL-Platform 测试运行脚本
|
|
||||||
# 用法: bash run_tests.sh [test_file_or_pattern]
|
|
||||||
# 示例: bash run_tests.sh test_auth.py
|
|
||||||
# bash run_tests.sh test_ticket
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
BACKEND_PY="$SCRIPT_DIR/backend-python"
|
|
||||||
TEST_DIR="$SCRIPT_DIR/tests"
|
|
||||||
PYTHON="$BACKEND_PY/venv/bin/python"
|
|
||||||
|
|
||||||
export PYTHONPATH="$BACKEND_PY"
|
|
||||||
|
|
||||||
if [ -n "$1" ]; then
|
|
||||||
"$PYTHON" -m pytest "$TEST_DIR/$1" -v --tb=short --no-header "$@" 2>&1
|
|
||||||
else
|
|
||||||
"$PYTHON" -m pytest "$TEST_DIR/" -v --tb=short --no-header 2>&1
|
|
||||||
fi
|
|
||||||
Reference in new issue
Block a user