mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-17 11:30:11 +02:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e2b30c029 | |||
| 8c7c22369e | |||
| 9b1aba692b | |||
| db730b48c1 | |||
| dfb7dd7390 | |||
| 9f6eb33047 | |||
| 616d87f4cc | |||
| 8d999792b8 | |||
| afae8970d1 | |||
| 4d7330c5c3 | |||
| 8884bfb0b4 | |||
| fb351c80b6 | |||
| 664834e338 | |||
| 95bf62db88 | |||
| 656242614d | |||
| a9d6d8c00e | |||
| 0d6a43c0a8 | |||
| 702f286eb1 |
+1
-1
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.37"
|
||||
version: "v1.6.38"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
|
||||
@@ -69,12 +69,12 @@ func buildAuditLogsWhere(filter ListAuditLogsFilter) (string, []interface{}) {
|
||||
args = append(args, filter.ResourceID)
|
||||
}
|
||||
if filter.Since != nil {
|
||||
conditions = append(conditions, "created_at >= ?")
|
||||
args = append(args, *filter.Since)
|
||||
conditions = append(conditions, sqliteEpochGE("created_at", ">="))
|
||||
args = append(args, formatSQLiteUTC(*filter.Since))
|
||||
}
|
||||
if filter.Until != nil {
|
||||
conditions = append(conditions, "created_at <= ?")
|
||||
args = append(args, *filter.Until)
|
||||
conditions = append(conditions, sqliteEpochGE("created_at", "<="))
|
||||
args = append(args, formatSQLiteUTC(*filter.Until))
|
||||
}
|
||||
if q := strings.TrimSpace(filter.Query); q != "" {
|
||||
like := "%" + q + "%"
|
||||
@@ -93,7 +93,9 @@ func (db *DB) AppendAuditLog(row *AuditLog) error {
|
||||
return errors.New("audit id is required")
|
||||
}
|
||||
if row.CreatedAt.IsZero() {
|
||||
row.CreatedAt = time.Now()
|
||||
row.CreatedAt = time.Now().UTC()
|
||||
} else {
|
||||
row.CreatedAt = row.CreatedAt.UTC()
|
||||
}
|
||||
if strings.TrimSpace(row.Level) == "" {
|
||||
row.Level = "info"
|
||||
@@ -111,7 +113,7 @@ func (db *DB) AppendAuditLog(row *AuditLog) error {
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
_, err := db.Exec(query,
|
||||
row.ID, row.CreatedAt, row.Level, row.Category, row.Action, row.Result,
|
||||
row.ID, formatSQLiteUTC(row.CreatedAt), row.Level, row.Category, row.Action, row.Result,
|
||||
row.Actor, row.SessionHint, row.ClientIP, row.UserAgent,
|
||||
row.ResourceType, row.ResourceID, row.Message, detailJSON,
|
||||
)
|
||||
@@ -202,7 +204,7 @@ func (db *DB) ListAuditLogs(filter ListAuditLogsFilter) ([]*AuditLog, error) {
|
||||
|
||||
// DeleteAuditLogsBefore removes rows older than cutoff.
|
||||
func (db *DB) DeleteAuditLogsBefore(cutoff time.Time) (int64, error) {
|
||||
res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < ?`, cutoff)
|
||||
res, err := db.Exec(`DELETE FROM audit_logs WHERE `+sqliteEpochGE("created_at", "<"), formatSQLiteUTC(cutoff))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestBuildAuditLogsWhere_timeFilterSQL(t *testing.T) {
|
||||
since := time.Date(2026, 6, 16, 17, 2, 0, 0, time.UTC)
|
||||
until := time.Date(2026, 6, 17, 3, 3, 0, 0, time.UTC)
|
||||
where, args := buildAuditLogsWhere(ListAuditLogsFilter{Since: &since, Until: &until})
|
||||
if !strings.Contains(where, "strftime('%s', created_at) >=") {
|
||||
t.Fatalf("expected epoch comparison for since, got %q", where)
|
||||
}
|
||||
if !strings.Contains(where, "strftime('%s', created_at) <=") {
|
||||
t.Fatalf("expected epoch comparison for until, got %q", where)
|
||||
}
|
||||
if len(args) != 2 {
|
||||
t.Fatalf("expected 2 time args, got %d", len(args))
|
||||
}
|
||||
for i, arg := range args {
|
||||
s, ok := arg.(string)
|
||||
if !ok || s == "" {
|
||||
t.Fatalf("arg %d: want non-empty UTC RFC3339 string, got %v", i, arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditLogs_timeFilterMixedStorageFormats(t *testing.T) {
|
||||
root, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
dbPath := filepath.Join(root, "..", "..", "data", "conversations.db")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
t.Skip("conversations.db not found")
|
||||
}
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
since, _ := ParseRFC3339Time("2026-06-16T17:02:00Z")
|
||||
until, _ := ParseRFC3339Time("2026-06-17T03:03:00Z")
|
||||
filter := ListAuditLogsFilter{Since: &since, Until: &until, Limit: 50}
|
||||
logs, err := db.ListAuditLogs(filter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range logs {
|
||||
at := row.CreatedAt.UTC()
|
||||
if at.Before(since) || at.After(until) {
|
||||
t.Fatalf("log %s at %s outside [%s, %s]", row.ID, at, since, until)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// formatSQLiteUTC stores instants as UTC RFC3339 for consistent SQLite reads/writes.
|
||||
func formatSQLiteUTC(t time.Time) string {
|
||||
return t.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// sqliteEpochGE returns SQL comparing column to param as Unix seconds (timezone-safe).
|
||||
func sqliteEpochGE(column, op string) string {
|
||||
return "strftime('%s', " + column + ") " + op + " strftime('%s', ?)"
|
||||
}
|
||||
|
||||
// ParseRFC3339Time parses API/query timestamps (RFC3339 or RFC3339Nano).
|
||||
func ParseRFC3339Time(value string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, errors.New("empty time value")
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return t.UTC(), nil
|
||||
}
|
||||
@@ -1185,6 +1185,8 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
}
|
||||
}
|
||||
flushResponsePlan()
|
||||
// 助手正文开始前,推理流通常已结束;落库以便刷新后「渗透测试详情」可回放
|
||||
flushThinkingStreams()
|
||||
respPlan.meta = nil
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
respPlan.meta = make(map[string]interface{}, len(dataMap))
|
||||
@@ -1220,6 +1222,19 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
}
|
||||
if eventType == "response" {
|
||||
flushResponsePlan()
|
||||
flushThinkingStreams()
|
||||
return
|
||||
}
|
||||
if eventType == "done" {
|
||||
flushResponsePlan()
|
||||
flushThinkingStreams()
|
||||
return
|
||||
}
|
||||
|
||||
// 流式思考/推理结束:聚合落库(与 eino_agent_reply_stream_end 同理)
|
||||
if eventType == "thinking_stream_end" || eventType == "reasoning_chain_stream_end" {
|
||||
flushResponsePlan()
|
||||
flushThinkingStreams()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -46,3 +50,50 @@ func TestCreateProgressCallback_ConcurrentToolEvents(t *testing.T) {
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestCreateProgressCallback_FlushesReasoningOnDone 流式推理聚合须在 done/response 时落库,刷新后可回放。
|
||||
func TestCreateProgressCallback_FlushesReasoningOnDone(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
conv, err := db.CreateConversation("test", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation: %v", err)
|
||||
}
|
||||
asst, err := db.AddMessage(conv.ID, "assistant", "处理中...", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessage: %v", err)
|
||||
}
|
||||
|
||||
h := &AgentHandler{logger: zap.NewNop(), db: db}
|
||||
cb := h.createProgressCallback(context.Background(), nil, conv.ID, asst.ID, nil)
|
||||
|
||||
streamID := "eino-reasoning-test-1"
|
||||
cb("reasoning_chain_stream_start", " ", map[string]interface{}{
|
||||
"streamId": streamID,
|
||||
"source": "eino",
|
||||
})
|
||||
cb("reasoning_chain_stream_delta", "step one", openai.WithSSEAccumulated(map[string]interface{}{
|
||||
"streamId": streamID,
|
||||
}, "step one"))
|
||||
cb("done", "", map[string]interface{}{"conversationId": conv.ID})
|
||||
|
||||
details, err := db.GetProcessDetails(asst.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetails: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, d := range details {
|
||||
if d.EventType == "reasoning_chain" && d.Message == "step one" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected reasoning_chain persisted on done, got %+v", details)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
@@ -20,12 +19,12 @@ func auditFilterFromQuery(c *gin.Context) database.ListAuditLogsFilter {
|
||||
ResourceID: c.Query("resource_id"),
|
||||
}
|
||||
if since := c.Query("since"); since != "" {
|
||||
if t, err := time.Parse(time.RFC3339, since); err == nil {
|
||||
if t, err := database.ParseRFC3339Time(since); err == nil {
|
||||
filter.Since = &t
|
||||
}
|
||||
}
|
||||
if until := c.Query("until"); until != "" {
|
||||
if t, err := time.Parse(time.RFC3339, until); err == nil {
|
||||
if t, err := database.ParseRFC3339Time(until); err == nil {
|
||||
filter.Until = &t
|
||||
}
|
||||
}
|
||||
|
||||
@@ -785,6 +785,16 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
}
|
||||
}
|
||||
}
|
||||
if progress != nil && reasoningStreamID != "" && strings.TrimSpace(reasoningBuf) != "" {
|
||||
progress("reasoning_chain_stream_end", openai.DisplayReasoningContent(strings.TrimSpace(reasoningBuf)), map[string]interface{}{
|
||||
"streamId": reasoningStreamID,
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"einoAgent": ev.AgentName,
|
||||
"einoRole": einoRoleTag(ev.AgentName),
|
||||
"orchestration": orchMode,
|
||||
})
|
||||
}
|
||||
if streamsMainAssistant(ev.AgentName) {
|
||||
s := strings.TrimSpace(mainAssistantBuf)
|
||||
if mainAssistDupTarget != "" {
|
||||
|
||||
+876
-70
File diff suppressed because it is too large
Load Diff
@@ -1083,6 +1083,7 @@
|
||||
"botAgent": "Bot Agent",
|
||||
"ilinkBotId": "iLink Bot ID (filled after bind)",
|
||||
"boundSuccess": "Binding successful. WeChat bot is enabled.",
|
||||
"alreadyBound": "This WeChat account is already bound.",
|
||||
"openLink": "QR not showing? Open link in WeChat on your phone"
|
||||
},
|
||||
"wecom": {
|
||||
@@ -2086,14 +2087,35 @@
|
||||
"filterResult": "Result",
|
||||
"pageSize": "Per page",
|
||||
"statTotal": "Filtered total",
|
||||
"statSuccess": "Success",
|
||||
"statFailures": "Failures",
|
||||
"statRecent7d": "Last 7 days",
|
||||
"retentionHint": "Audit records are kept for {{days}} days, then purged automatically.",
|
||||
"disabledHint": "Audit logging is disabled; new actions are not written.",
|
||||
"filterSince": "From",
|
||||
"filterUntil": "Until",
|
||||
"filterTimeZone": "Timezone: {{tz}} (filter uses your browser's local time)",
|
||||
"datetimePlaceholder": "Select date & time",
|
||||
"timePresets": "Quick range",
|
||||
"preset15m": "Last 15 min",
|
||||
"preset1h": "Last 1 hour",
|
||||
"preset24h": "Last 24 hours",
|
||||
"preset7d": "Last 7 days",
|
||||
"presetToday": "Today",
|
||||
"pickerHour": "Hour",
|
||||
"pickerMinute": "Min",
|
||||
"pickerClear": "Clear",
|
||||
"pickerToday": "Today",
|
||||
"pickerConfirm": "OK",
|
||||
"filterQuery": "Keyword",
|
||||
"filterQueryPlaceholder": "Message / resource ID / action",
|
||||
"colTime": "Time",
|
||||
"colMessage": "Message",
|
||||
"colCategory": "Category",
|
||||
"colAction": "Action",
|
||||
"colResult": "Result",
|
||||
"colIp": "IP",
|
||||
"colResource": "Resource ID",
|
||||
"cat": {
|
||||
"auth": "Auth",
|
||||
"config": "Config",
|
||||
@@ -2166,6 +2188,93 @@
|
||||
"exportDone": "Export complete",
|
||||
"loading": "Loading...",
|
||||
"empty": "No audit records",
|
||||
"result": {
|
||||
"success": "success",
|
||||
"failure": "failure"
|
||||
},
|
||||
"msg": {
|
||||
"auth": {
|
||||
"login": "Login successful",
|
||||
"login_failed": "Login failed: incorrect password",
|
||||
"logout": "Logged out",
|
||||
"change_password": "Login password changed",
|
||||
"change_password_failed": "Password change failed: current password incorrect"
|
||||
},
|
||||
"config": {
|
||||
"apply": "Configuration applied",
|
||||
"update": "In-memory configuration updated",
|
||||
"apply_fail_kb_init": "Failed to apply config: knowledge base init",
|
||||
"apply_fail_kb_reinit": "Failed to apply config: knowledge base re-init",
|
||||
"apply_fail_c2": "Failed to apply config: C2"
|
||||
},
|
||||
"conversation": {
|
||||
"create": "Conversation created",
|
||||
"delete": "Conversation deleted",
|
||||
"delete_turn": "Conversation turn deleted"
|
||||
},
|
||||
"c2": {
|
||||
"listener_create": "C2 listener created",
|
||||
"listener_delete": "C2 listener deleted",
|
||||
"listener_start": "C2 listener started",
|
||||
"listener_stop": "C2 listener stopped",
|
||||
"session_delete": "C2 session deleted",
|
||||
"task_create": "C2 task created",
|
||||
"task_cancel": "C2 task cancelled",
|
||||
"task_delete": "C2 tasks deleted (batch)"
|
||||
},
|
||||
"webshell": {
|
||||
"connection_create": "WebShell connection created",
|
||||
"connection_delete": "WebShell connection deleted"
|
||||
},
|
||||
"knowledge": {
|
||||
"item_delete": "Knowledge item deleted",
|
||||
"index_rebuild": "Knowledge index rebuilt"
|
||||
},
|
||||
"vulnerability": {
|
||||
"create": "Vulnerability record created",
|
||||
"update": "Vulnerability record updated",
|
||||
"delete": "Vulnerability record deleted",
|
||||
"delete_batch": "Vulnerability records deleted (batch)"
|
||||
},
|
||||
"external_mcp": {
|
||||
"upsert": "External MCP configuration updated",
|
||||
"delete": "External MCP configuration deleted"
|
||||
},
|
||||
"task": {
|
||||
"create_queue": "Batch task queue created",
|
||||
"start_queue": "Batch task queue started",
|
||||
"delete_queue": "Batch task queue deleted",
|
||||
"pause_queue": "Batch task queue paused",
|
||||
"rerun_queue": "Batch task queue rerun",
|
||||
"delete_batch_task": "Batch subtask deleted"
|
||||
},
|
||||
"tool": {
|
||||
"execution_delete": "Tool execution record deleted",
|
||||
"execution_delete_batch": "Tool execution records deleted (batch)"
|
||||
},
|
||||
"file": {
|
||||
"upload": "Chat attachment uploaded",
|
||||
"delete": "Chat attachment deleted"
|
||||
},
|
||||
"hitl": {
|
||||
"decision": "HITL approval decision"
|
||||
},
|
||||
"role": {
|
||||
"create": "Role created",
|
||||
"update": "Role updated",
|
||||
"delete": "Role deleted"
|
||||
},
|
||||
"skill": {
|
||||
"create": "Skill created",
|
||||
"update": "Skill updated",
|
||||
"delete": "Skill deleted"
|
||||
},
|
||||
"agent": {
|
||||
"markdown_create": "Markdown sub-agent created",
|
||||
"markdown_update": "Markdown sub-agent updated",
|
||||
"markdown_delete": "Markdown sub-agent deleted"
|
||||
}
|
||||
},
|
||||
"paginationShow": "{{start}}-{{end}} of {{total}}",
|
||||
"detailTitle": "Audit detail",
|
||||
"detailTime": "Time",
|
||||
|
||||
@@ -1071,6 +1071,7 @@
|
||||
"botAgent": "Bot Agent",
|
||||
"ilinkBotId": "iLink Bot ID(绑定后自动填充)",
|
||||
"boundSuccess": "绑定成功,微信机器人已启用。",
|
||||
"alreadyBound": "该微信已绑定过,无需重复绑定。",
|
||||
"openLink": "无法显示二维码?点击用手机微信打开链接"
|
||||
},
|
||||
"wecom": {
|
||||
@@ -2074,14 +2075,35 @@
|
||||
"filterResult": "结果",
|
||||
"pageSize": "每页",
|
||||
"statTotal": "当前筛选",
|
||||
"statSuccess": "成功",
|
||||
"statFailures": "失败",
|
||||
"statRecent7d": "近 7 天",
|
||||
"retentionHint": "审计记录保留 {{days}} 天,超期自动清理。",
|
||||
"disabledHint": "审计功能已关闭,新操作不会写入审计表。",
|
||||
"filterSince": "开始时间",
|
||||
"filterUntil": "结束时间",
|
||||
"filterTimeZone": "时区:{{tz}}(筛选按浏览器本地时间)",
|
||||
"datetimePlaceholder": "选择日期时间",
|
||||
"timePresets": "快捷",
|
||||
"preset15m": "最近15分钟",
|
||||
"preset1h": "最近1小时",
|
||||
"preset24h": "最近24小时",
|
||||
"preset7d": "最近7天",
|
||||
"presetToday": "今天",
|
||||
"pickerHour": "时",
|
||||
"pickerMinute": "分",
|
||||
"pickerClear": "清除",
|
||||
"pickerToday": "今天",
|
||||
"pickerConfirm": "确定",
|
||||
"filterQuery": "关键词",
|
||||
"filterQueryPlaceholder": "消息 / 资源 ID / 操作名",
|
||||
"colTime": "时间",
|
||||
"colMessage": "说明",
|
||||
"colCategory": "类别",
|
||||
"colAction": "操作",
|
||||
"colResult": "结果",
|
||||
"colIp": "IP",
|
||||
"colResource": "资源 ID",
|
||||
"cat": {
|
||||
"auth": "认证",
|
||||
"config": "配置",
|
||||
@@ -2154,6 +2176,93 @@
|
||||
"exportDone": "导出完成",
|
||||
"loading": "加载中...",
|
||||
"empty": "暂无审计记录",
|
||||
"result": {
|
||||
"success": "成功",
|
||||
"failure": "失败"
|
||||
},
|
||||
"msg": {
|
||||
"auth": {
|
||||
"login": "登录成功",
|
||||
"login_failed": "登录失败:密码错误",
|
||||
"logout": "退出登录",
|
||||
"change_password": "登录密码已修改",
|
||||
"change_password_failed": "修改密码失败:当前密码不正确"
|
||||
},
|
||||
"config": {
|
||||
"apply": "配置已应用",
|
||||
"update": "更新内存配置",
|
||||
"apply_fail_kb_init": "应用配置失败:初始化知识库",
|
||||
"apply_fail_kb_reinit": "应用配置失败:重新初始化知识库",
|
||||
"apply_fail_c2": "应用配置失败:C2"
|
||||
},
|
||||
"conversation": {
|
||||
"create": "创建对话",
|
||||
"delete": "删除对话",
|
||||
"delete_turn": "删除对话轮次"
|
||||
},
|
||||
"c2": {
|
||||
"listener_create": "创建 C2 监听器",
|
||||
"listener_delete": "删除 C2 监听器",
|
||||
"listener_start": "启动 C2 监听器",
|
||||
"listener_stop": "停止 C2 监听器",
|
||||
"session_delete": "删除 C2 会话",
|
||||
"task_create": "创建 C2 任务",
|
||||
"task_cancel": "取消 C2 任务",
|
||||
"task_delete": "批量删除 C2 任务"
|
||||
},
|
||||
"webshell": {
|
||||
"connection_create": "创建 WebShell 连接",
|
||||
"connection_delete": "删除 WebShell 连接"
|
||||
},
|
||||
"knowledge": {
|
||||
"item_delete": "删除知识项",
|
||||
"index_rebuild": "重建知识库索引"
|
||||
},
|
||||
"vulnerability": {
|
||||
"create": "创建漏洞记录",
|
||||
"update": "更新漏洞记录",
|
||||
"delete": "删除漏洞记录",
|
||||
"delete_batch": "批量删除漏洞记录"
|
||||
},
|
||||
"external_mcp": {
|
||||
"upsert": "更新外部 MCP 配置",
|
||||
"delete": "删除外部 MCP 配置"
|
||||
},
|
||||
"task": {
|
||||
"create_queue": "创建批量任务队列",
|
||||
"start_queue": "启动批量任务队列",
|
||||
"delete_queue": "删除批量任务队列",
|
||||
"pause_queue": "暂停批量任务队列",
|
||||
"rerun_queue": "重跑批量任务队列",
|
||||
"delete_batch_task": "删除批量子任务"
|
||||
},
|
||||
"tool": {
|
||||
"execution_delete": "删除工具执行记录",
|
||||
"execution_delete_batch": "批量删除工具执行记录"
|
||||
},
|
||||
"file": {
|
||||
"upload": "上传对话附件",
|
||||
"delete": "删除对话附件"
|
||||
},
|
||||
"hitl": {
|
||||
"decision": "HITL 审批决策"
|
||||
},
|
||||
"role": {
|
||||
"create": "创建角色",
|
||||
"update": "更新角色",
|
||||
"delete": "删除角色"
|
||||
},
|
||||
"skill": {
|
||||
"create": "创建 Skill",
|
||||
"update": "更新 Skill",
|
||||
"delete": "删除 Skill"
|
||||
},
|
||||
"agent": {
|
||||
"markdown_create": "创建 Markdown 子代理",
|
||||
"markdown_update": "更新 Markdown 子代理",
|
||||
"markdown_delete": "删除 Markdown 子代理"
|
||||
}
|
||||
},
|
||||
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 条",
|
||||
"detailTitle": "审计详情",
|
||||
"detailTime": "时间",
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Audit log datetime picker — cross-browser, locale-aware (SLS-style calendar + time columns).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var registry = {};
|
||||
var popover = null;
|
||||
var activeFieldId = null;
|
||||
var draft = null;
|
||||
var viewYear = 0;
|
||||
var viewMonth = 0;
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function pickerLocale() {
|
||||
if (typeof auditLocale === 'function') return auditLocale();
|
||||
if (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) return 'zh-CN';
|
||||
return 'en-US';
|
||||
}
|
||||
|
||||
function pickerT(key, fallback) {
|
||||
if (typeof auditT === 'function') return auditT(key, null, fallback);
|
||||
if (typeof t === 'function') {
|
||||
var v = t(key);
|
||||
if (v && v !== key) return v;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function partsToStorage(p) {
|
||||
if (!p) return '';
|
||||
return p.y + '-' + pad2(p.m) + '-' + pad2(p.d) + 'T' + pad2(p.h) + ':' + pad2(p.mi);
|
||||
}
|
||||
|
||||
function parseStorage(value) {
|
||||
if (!value) return null;
|
||||
var m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(String(value).trim());
|
||||
if (!m) return null;
|
||||
return { y: +m[1], m: +m[2], d: +m[3], h: +m[4], mi: +m[5] };
|
||||
}
|
||||
|
||||
function formatDisplay(parts) {
|
||||
if (!parts) return '';
|
||||
var loc = pickerLocale();
|
||||
try {
|
||||
var d = new Date(parts.y, parts.m - 1, parts.d, parts.h, parts.mi, 0, 0);
|
||||
return d.toLocaleString(loc, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
} catch (_) {
|
||||
return partsToStorage(parts).replace('T', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
function nowParts() {
|
||||
var n = new Date();
|
||||
return { y: n.getFullYear(), m: n.getMonth() + 1, d: n.getDate(), h: n.getHours(), mi: n.getMinutes() };
|
||||
}
|
||||
|
||||
function startOfTodayParts() {
|
||||
var n = new Date();
|
||||
return { y: n.getFullYear(), m: n.getMonth() + 1, d: n.getDate(), h: 0, mi: 0 };
|
||||
}
|
||||
|
||||
function monthTitle(year, month) {
|
||||
var loc = pickerLocale();
|
||||
if (loc.startsWith('zh')) {
|
||||
return year + '\u5e74' + pad2(month) + '\u6708';
|
||||
}
|
||||
try {
|
||||
return new Date(year, month - 1, 1).toLocaleString(loc, { month: 'long', year: 'numeric' });
|
||||
} catch (_) {
|
||||
return year + '-' + pad2(month);
|
||||
}
|
||||
}
|
||||
|
||||
function weekdayHeaders() {
|
||||
var loc = pickerLocale();
|
||||
if (loc.startsWith('zh')) {
|
||||
return ['\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d'];
|
||||
}
|
||||
return ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
}
|
||||
|
||||
function buildMonthGrid(year, month) {
|
||||
var first = new Date(year, month - 1, 1);
|
||||
var start = new Date(first);
|
||||
start.setDate(first.getDate() - first.getDay());
|
||||
var cells = [];
|
||||
var cursor = new Date(start);
|
||||
for (var i = 0; i < 42; i++) {
|
||||
cells.push({
|
||||
y: cursor.getFullYear(),
|
||||
m: cursor.getMonth() + 1,
|
||||
d: cursor.getDate(),
|
||||
inMonth: cursor.getMonth() === month - 1
|
||||
});
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function ensurePopover() {
|
||||
if (popover) return popover;
|
||||
popover = document.createElement('div');
|
||||
popover.className = 'audit-dt-popover';
|
||||
popover.hidden = true;
|
||||
popover.setAttribute('role', 'dialog');
|
||||
popover.innerHTML =
|
||||
'<div class="audit-dt-popover-inner">' +
|
||||
'<div class="audit-dt-head">' +
|
||||
'<button type="button" class="audit-dt-nav" data-nav="prev" aria-label="prev">‹</button>' +
|
||||
'<span class="audit-dt-month-label"></span>' +
|
||||
'<button type="button" class="audit-dt-nav" data-nav="next" aria-label="next">›</button>' +
|
||||
'</div>' +
|
||||
'<div class="audit-dt-body">' +
|
||||
'<div class="audit-dt-calendar"></div>' +
|
||||
'<div class="audit-dt-time">' +
|
||||
'<div class="audit-dt-time-col" data-part="hour">' +
|
||||
'<span class="audit-dt-time-label audit-dt-hour-label"></span>' +
|
||||
'<div class="audit-dt-time-list"></div>' +
|
||||
'</div>' +
|
||||
'<div class="audit-dt-time-col" data-part="minute">' +
|
||||
'<span class="audit-dt-time-label audit-dt-minute-label"></span>' +
|
||||
'<div class="audit-dt-time-list"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="audit-dt-footer">' +
|
||||
'<button type="button" class="audit-dt-footer-btn" data-action="clear"></button>' +
|
||||
'<button type="button" class="audit-dt-footer-btn" data-action="today"></button>' +
|
||||
'<button type="button" class="audit-dt-footer-btn audit-dt-footer-btn--primary" data-action="confirm"></button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(popover);
|
||||
|
||||
popover.addEventListener('click', function (ev) {
|
||||
ev.stopPropagation();
|
||||
var btn = ev.target.closest('[data-nav]');
|
||||
if (btn) {
|
||||
if (btn.getAttribute('data-nav') === 'prev') {
|
||||
viewMonth -= 1;
|
||||
if (viewMonth < 1) { viewMonth = 12; viewYear -= 1; }
|
||||
} else {
|
||||
viewMonth += 1;
|
||||
if (viewMonth > 12) { viewMonth = 1; viewYear += 1; }
|
||||
}
|
||||
renderPopover();
|
||||
return;
|
||||
}
|
||||
var dayBtn = ev.target.closest('[data-day]');
|
||||
if (dayBtn && draft) {
|
||||
draft.y = +dayBtn.getAttribute('data-y');
|
||||
draft.m = +dayBtn.getAttribute('data-m');
|
||||
draft.d = +dayBtn.getAttribute('data-d');
|
||||
if (draft.y !== viewYear || draft.m !== viewMonth) {
|
||||
viewYear = draft.y;
|
||||
viewMonth = draft.m;
|
||||
renderCalendar();
|
||||
} else {
|
||||
updateDaySelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
var timeBtn = ev.target.closest('[data-time]');
|
||||
if (timeBtn && draft) {
|
||||
var part = timeBtn.getAttribute('data-part');
|
||||
var val = +timeBtn.getAttribute('data-time');
|
||||
if (part === 'hour') draft.h = val;
|
||||
if (part === 'minute') draft.mi = val;
|
||||
updateTimeSelection();
|
||||
return;
|
||||
}
|
||||
var actionBtn = ev.target.closest('[data-action]');
|
||||
if (!actionBtn) return;
|
||||
var action = actionBtn.getAttribute('data-action');
|
||||
if (action === 'clear') {
|
||||
applyValue(activeFieldId, '');
|
||||
closePopover();
|
||||
} else if (action === 'today') {
|
||||
if (draft) {
|
||||
var t = nowParts();
|
||||
draft.y = t.y; draft.m = t.m; draft.d = t.d;
|
||||
viewYear = t.y; viewMonth = t.m;
|
||||
}
|
||||
renderPopover();
|
||||
} else if (action === 'confirm') {
|
||||
applyValue(activeFieldId, partsToStorage(draft));
|
||||
closePopover();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
document.addEventListener('keydown', onDocumentKeydown);
|
||||
document.addEventListener('languagechange', function () {
|
||||
if (!popover.hidden) renderPopover();
|
||||
refreshAllDisplays();
|
||||
});
|
||||
|
||||
return popover;
|
||||
}
|
||||
|
||||
function onDocumentClick(ev) {
|
||||
if (!popover || popover.hidden) return;
|
||||
if (popover.contains(ev.target)) return;
|
||||
if (activeFieldId && registry[activeFieldId] && registry[activeFieldId].wrap.contains(ev.target)) return;
|
||||
closePopover();
|
||||
}
|
||||
|
||||
function onDocumentKeydown(ev) {
|
||||
if (ev.key === 'Escape' && popover && !popover.hidden) {
|
||||
closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
function positionPopover(fieldWrap) {
|
||||
var rect = fieldWrap.getBoundingClientRect();
|
||||
var width = 320;
|
||||
popover.style.width = width + 'px';
|
||||
var left = rect.left;
|
||||
if (left + width > window.innerWidth - 12) {
|
||||
left = Math.max(12, window.innerWidth - width - 12);
|
||||
}
|
||||
popover.style.left = left + 'px';
|
||||
var top = rect.bottom + 6;
|
||||
if (top + 340 > window.innerHeight - 12) {
|
||||
top = Math.max(12, rect.top - 340 - 6);
|
||||
}
|
||||
popover.style.top = top + 'px';
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
if (!popover || !draft) return;
|
||||
popover.querySelector('.audit-dt-month-label').textContent = monthTitle(viewYear, viewMonth);
|
||||
var cal = popover.querySelector('.audit-dt-calendar');
|
||||
var headers = weekdayHeaders();
|
||||
var html = '<div class="audit-dt-weekdays">';
|
||||
headers.forEach(function (h) { html += '<span>' + h + '</span>'; });
|
||||
html += '</div><div class="audit-dt-days">';
|
||||
buildMonthGrid(viewYear, viewMonth).forEach(function (cell) {
|
||||
var cls = 'audit-dt-day';
|
||||
if (!cell.inMonth) cls += ' is-other-month';
|
||||
if (draft && cell.y === draft.y && cell.m === draft.m && cell.d === draft.d) cls += ' is-selected';
|
||||
html += '<button type="button" class="' + cls + '" data-day="1" data-y="' + cell.y +
|
||||
'" data-m="' + cell.m + '" data-d="' + cell.d + '">' + cell.d + '</button>';
|
||||
});
|
||||
html += '</div>';
|
||||
cal.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderTimeLists() {
|
||||
if (!popover || !draft) return;
|
||||
var hourList = popover.querySelector('[data-part="hour"] .audit-dt-time-list');
|
||||
var minuteList = popover.querySelector('[data-part="minute"] .audit-dt-time-list');
|
||||
var hourHtml = '';
|
||||
var minuteHtml = '';
|
||||
var h;
|
||||
for (h = 0; h < 24; h++) {
|
||||
hourHtml += '<button type="button" class="audit-dt-time-item' + (draft && draft.h === h ? ' is-selected' : '') +
|
||||
'" data-part="hour" data-time="' + h + '">' + pad2(h) + '</button>';
|
||||
}
|
||||
for (h = 0; h < 60; h++) {
|
||||
minuteHtml += '<button type="button" class="audit-dt-time-item' + (draft && draft.mi === h ? ' is-selected' : '') +
|
||||
'" data-part="minute" data-time="' + h + '">' + pad2(h) + '</button>';
|
||||
}
|
||||
hourList.innerHTML = hourHtml;
|
||||
minuteList.innerHTML = minuteHtml;
|
||||
scrollTimeSelection(hourList, draft.h);
|
||||
scrollTimeSelection(minuteList, draft.mi);
|
||||
}
|
||||
|
||||
function updateDaySelection() {
|
||||
if (!popover || !draft) return;
|
||||
popover.querySelectorAll('.audit-dt-day').forEach(function (btn) {
|
||||
var selected = +btn.getAttribute('data-y') === draft.y &&
|
||||
+btn.getAttribute('data-m') === draft.m &&
|
||||
+btn.getAttribute('data-d') === draft.d;
|
||||
btn.classList.toggle('is-selected', selected);
|
||||
});
|
||||
}
|
||||
|
||||
function updateTimeSelection() {
|
||||
if (!popover || !draft) return;
|
||||
var hourList = popover.querySelector('[data-part="hour"] .audit-dt-time-list');
|
||||
var minuteList = popover.querySelector('[data-part="minute"] .audit-dt-time-list');
|
||||
if (!hourList || !minuteList || !hourList.children.length) {
|
||||
renderTimeLists();
|
||||
return;
|
||||
}
|
||||
hourList.querySelectorAll('.audit-dt-time-item').forEach(function (btn) {
|
||||
btn.classList.toggle('is-selected', +btn.getAttribute('data-time') === draft.h);
|
||||
});
|
||||
minuteList.querySelectorAll('.audit-dt-time-item').forEach(function (btn) {
|
||||
btn.classList.toggle('is-selected', +btn.getAttribute('data-time') === draft.mi);
|
||||
});
|
||||
scrollTimeSelection(hourList, draft.h);
|
||||
scrollTimeSelection(minuteList, draft.mi);
|
||||
}
|
||||
|
||||
function renderPopover() {
|
||||
if (!popover || !draft) return;
|
||||
popover.querySelector('.audit-dt-hour-label').textContent = pickerT('settingsAudit.pickerHour', 'Hour');
|
||||
popover.querySelector('.audit-dt-minute-label').textContent = pickerT('settingsAudit.pickerMinute', 'Min');
|
||||
popover.querySelector('[data-action="clear"]').textContent = pickerT('settingsAudit.pickerClear', 'Clear');
|
||||
popover.querySelector('[data-action="today"]').textContent = pickerT('settingsAudit.pickerToday', 'Today');
|
||||
popover.querySelector('[data-action="confirm"]').textContent = pickerT('settingsAudit.pickerConfirm', 'OK');
|
||||
renderCalendar();
|
||||
renderTimeLists();
|
||||
}
|
||||
|
||||
function scrollTimeSelection(listEl, value) {
|
||||
var sel = listEl.querySelector('.is-selected');
|
||||
if (sel && sel.scrollIntoView) {
|
||||
sel.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
function openPopover(fieldId) {
|
||||
ensurePopover();
|
||||
var entry = registry[fieldId];
|
||||
if (!entry) return;
|
||||
activeFieldId = fieldId;
|
||||
var stored = entry.wrap.dataset.value || '';
|
||||
draft = parseStorage(stored) || nowParts();
|
||||
viewYear = draft.y;
|
||||
viewMonth = draft.m;
|
||||
renderPopover();
|
||||
positionPopover(entry.wrap);
|
||||
popover.hidden = false;
|
||||
}
|
||||
|
||||
function closePopover() {
|
||||
if (!popover) return;
|
||||
popover.hidden = true;
|
||||
activeFieldId = null;
|
||||
draft = null;
|
||||
}
|
||||
|
||||
function refreshDisplay(fieldId) {
|
||||
var entry = registry[fieldId];
|
||||
if (!entry) return;
|
||||
var parts = parseStorage(entry.wrap.dataset.value || '');
|
||||
entry.input.value = parts ? formatDisplay(parts) : '';
|
||||
entry.input.placeholder = pickerT('settingsAudit.datetimePlaceholder', 'Select date & time');
|
||||
entry.clearBtn.hidden = !parts;
|
||||
}
|
||||
|
||||
function refreshAllDisplays() {
|
||||
Object.keys(registry).forEach(refreshDisplay);
|
||||
}
|
||||
|
||||
function applyValue(fieldId, storageValue) {
|
||||
var entry = registry[fieldId];
|
||||
if (!entry) return;
|
||||
entry.wrap.dataset.value = storageValue || '';
|
||||
refreshDisplay(fieldId);
|
||||
}
|
||||
|
||||
function bindField(fieldId) {
|
||||
var wrap = document.getElementById(fieldId);
|
||||
if (!wrap || wrap.dataset.auditDtBound === '1') return;
|
||||
var input = wrap.querySelector('.audit-datetime-input');
|
||||
var openBtn = wrap.querySelector('.audit-datetime-open-btn');
|
||||
var clearBtn = wrap.querySelector('.audit-datetime-clear-btn');
|
||||
if (!input || !openBtn || !clearBtn) return;
|
||||
|
||||
wrap.dataset.auditDtBound = '1';
|
||||
registry[fieldId] = { wrap: wrap, input: input, clearBtn: clearBtn };
|
||||
|
||||
openBtn.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (!popover || popover.hidden || activeFieldId !== fieldId) {
|
||||
openPopover(fieldId);
|
||||
} else {
|
||||
closePopover();
|
||||
}
|
||||
});
|
||||
input.addEventListener('click', function (ev) {
|
||||
ev.stopPropagation();
|
||||
openPopover(fieldId);
|
||||
});
|
||||
clearBtn.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
applyValue(fieldId, '');
|
||||
});
|
||||
refreshDisplay(fieldId);
|
||||
}
|
||||
|
||||
window.AuditDatetimePicker = {
|
||||
init: function () {
|
||||
bindField('audit-filter-since-field');
|
||||
bindField('audit-filter-until-field');
|
||||
refreshAllDisplays();
|
||||
},
|
||||
getValue: function (inputId) {
|
||||
var fieldId = inputId === 'audit-filter-since' ? 'audit-filter-since-field' : 'audit-filter-until-field';
|
||||
var entry = registry[fieldId];
|
||||
return entry ? (entry.wrap.dataset.value || '') : '';
|
||||
},
|
||||
setValue: function (inputId, dateObj) {
|
||||
if (!dateObj || Number.isNaN(dateObj.getTime())) return;
|
||||
var fieldId = inputId === 'audit-filter-since' ? 'audit-filter-since-field' : 'audit-filter-until-field';
|
||||
var p = {
|
||||
y: dateObj.getFullYear(),
|
||||
m: dateObj.getMonth() + 1,
|
||||
d: dateObj.getDate(),
|
||||
h: dateObj.getHours(),
|
||||
mi: dateObj.getMinutes()
|
||||
};
|
||||
applyValue(fieldId, partsToStorage(p));
|
||||
},
|
||||
clearAll: function () {
|
||||
applyValue('audit-filter-since-field', '');
|
||||
applyValue('audit-filter-until-field', '');
|
||||
closePopover();
|
||||
}
|
||||
};
|
||||
})();
|
||||
+352
-56
@@ -4,6 +4,7 @@
|
||||
let auditLogsPage = 1;
|
||||
let auditLogsPageSize = 20;
|
||||
let auditLogsTotal = 0;
|
||||
let auditLogsCache = [];
|
||||
|
||||
const AUDIT_PAGE_SIZE_KEY = 'cyberstrike_audit_page_size';
|
||||
|
||||
@@ -52,24 +53,113 @@ function auditActionLabel(action) {
|
||||
return auditT('settingsAudit.act.' + action, null, action);
|
||||
}
|
||||
|
||||
/** Stored DB messages that share category+action but need distinct i18n keys. */
|
||||
const AUDIT_MSG_BY_STORED_TEXT = {
|
||||
'登录失败:密码错误': 'settingsAudit.msg.auth.login_failed',
|
||||
'修改密码失败:当前密码不正确': 'settingsAudit.msg.auth.change_password_failed',
|
||||
'应用配置失败:初始化知识库': 'settingsAudit.msg.config.apply_fail_kb_init',
|
||||
'应用配置失败:重新初始化知识库': 'settingsAudit.msg.config.apply_fail_kb_reinit',
|
||||
'应用配置失败:C2': 'settingsAudit.msg.config.apply_fail_c2'
|
||||
};
|
||||
|
||||
function auditMessageLabel(log) {
|
||||
if (!log) return '';
|
||||
const raw = (log.message || '').trim();
|
||||
if (raw && AUDIT_MSG_BY_STORED_TEXT[raw]) {
|
||||
return auditT(AUDIT_MSG_BY_STORED_TEXT[raw], null, raw);
|
||||
}
|
||||
const cat = (log.category || '').trim();
|
||||
const act = (log.action || '').trim();
|
||||
const res = (log.result || '').trim();
|
||||
if (cat && act) {
|
||||
if (cat === 'auth' && act === 'login' && res === 'failure') {
|
||||
return auditT('settingsAudit.msg.auth.login_failed', null, raw);
|
||||
}
|
||||
if (cat === 'auth' && act === 'change_password' && res === 'failure') {
|
||||
return auditT('settingsAudit.msg.auth.change_password_failed', null, raw);
|
||||
}
|
||||
const key = 'settingsAudit.msg.' + cat + '.' + act;
|
||||
const translated = auditT(key, null, null);
|
||||
if (translated && translated !== key) return translated;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function auditResultLabel(result) {
|
||||
if (!result) return '';
|
||||
return auditT('settingsAudit.result.' + result, null, result);
|
||||
}
|
||||
|
||||
function auditLocale() {
|
||||
if (typeof window.__locale === 'string' && window.__locale.length) {
|
||||
return window.__locale.startsWith('zh') ? 'zh-CN' : 'en-US';
|
||||
}
|
||||
return (typeof navigator !== 'undefined' && navigator.language) ? navigator.language : 'en-US';
|
||||
}
|
||||
|
||||
function auditTimezoneShortLabel() {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat(auditLocale(), { timeZoneName: 'short' }).formatToParts(new Date());
|
||||
const tz = parts.find(function (p) { return p.type === 'timeZoneName'; });
|
||||
return tz ? tz.value : '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function formatAuditTime(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString();
|
||||
return d.toLocaleString(auditLocale(), {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
} catch (_) {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read stored local datetime (YYYY-MM-DDTHH:mm) from custom picker or raw input. */
|
||||
function getAuditFilterDatetimeValue(inputId) {
|
||||
if (typeof window.AuditDatetimePicker !== 'undefined' && typeof window.AuditDatetimePicker.getValue === 'function') {
|
||||
return window.AuditDatetimePicker.getValue(inputId) || '';
|
||||
}
|
||||
var el = document.getElementById(inputId);
|
||||
return el ? (el.value || '') : '';
|
||||
}
|
||||
|
||||
/** datetime-local / picker storage -> UTC RFC3339 for API. */
|
||||
function auditDatetimeLocalToRFC3339(value) {
|
||||
if (!value || !value.trim()) return '';
|
||||
const d = new Date(value);
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value.trim());
|
||||
if (!m) return '';
|
||||
const d = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], 0, 0);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function updateAuditTimezoneHint() {
|
||||
const el = document.getElementById('audit-filter-timezone-hint');
|
||||
if (!el) return;
|
||||
const tz = auditTimezoneShortLabel();
|
||||
if (!tz) {
|
||||
el.hidden = true;
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
el.hidden = false;
|
||||
el.textContent = auditT('settingsAudit.filterTimeZone', { tz: tz },
|
||||
'时区:' + tz + '(筛选按浏览器本地时间,API 使用 UTC)');
|
||||
}
|
||||
|
||||
function initAuditPageSizeFromStorage() {
|
||||
try {
|
||||
const saved = parseInt(localStorage.getItem(AUDIT_PAGE_SIZE_KEY), 10);
|
||||
@@ -113,6 +203,7 @@ function rebuildAuditActionSelect() {
|
||||
actEl.disabled = true;
|
||||
actEl.value = '';
|
||||
actEl.title = hint;
|
||||
syncAuditCustomSelect('audit-filter-action');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -129,6 +220,7 @@ function rebuildAuditActionSelect() {
|
||||
if (prev && Array.prototype.some.call(actEl.options, function (o) { return o.value === prev; })) {
|
||||
actEl.value = prev;
|
||||
}
|
||||
syncAuditCustomSelect('audit-filter-action');
|
||||
}
|
||||
|
||||
function onAuditCategoryFilterChange() {
|
||||
@@ -145,43 +237,17 @@ function buildAuditQueryParams(forExport) {
|
||||
const act = document.getElementById('audit-filter-action');
|
||||
const res = document.getElementById('audit-filter-result');
|
||||
const q = document.getElementById('audit-filter-q');
|
||||
const since = document.getElementById('audit-filter-since');
|
||||
const until = document.getElementById('audit-filter-until');
|
||||
if (cat && cat.value) params.set('category', cat.value);
|
||||
if (act && !act.disabled && act.value) params.set('action', act.value);
|
||||
if (res && res.value) params.set('result', res.value);
|
||||
if (q && q.value.trim()) params.set('q', q.value.trim());
|
||||
const sinceISO = since ? auditDatetimeLocalToRFC3339(since.value) : '';
|
||||
const untilISO = until ? auditDatetimeLocalToRFC3339(until.value) : '';
|
||||
const sinceISO = auditDatetimeLocalToRFC3339(getAuditFilterDatetimeValue('audit-filter-since'));
|
||||
const untilISO = auditDatetimeLocalToRFC3339(getAuditFilterDatetimeValue('audit-filter-until'));
|
||||
if (sinceISO) params.set('since', sinceISO);
|
||||
if (untilISO) params.set('until', untilISO);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
async function loadAuditMeta() {
|
||||
if (typeof apiFetch !== 'function') return;
|
||||
const hint = document.getElementById('audit-retention-hint');
|
||||
try {
|
||||
const r = await apiFetch('/api/audit/meta');
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
if (!hint) return;
|
||||
if (!data.enabled) {
|
||||
hint.hidden = false;
|
||||
hint.textContent = auditT('settingsAudit.disabledHint', null, '审计功能已关闭,新操作不会写入审计表。');
|
||||
return;
|
||||
}
|
||||
const days = data.retention_days;
|
||||
if (days > 0) {
|
||||
hint.hidden = false;
|
||||
hint.textContent = auditT('settingsAudit.retentionHint', { days: days },
|
||||
'审计记录保留 ' + days + ' 天,超期自动清理。');
|
||||
} else {
|
||||
hint.hidden = true;
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function loadAuditSummary() {
|
||||
if (typeof apiFetch !== 'function') return;
|
||||
const wrap = document.getElementById('audit-summary-stats');
|
||||
@@ -191,10 +257,14 @@ async function loadAuditSummary() {
|
||||
const data = await r.json();
|
||||
if (wrap) wrap.hidden = false;
|
||||
const elTotal = document.getElementById('audit-stat-total');
|
||||
const elSuccess = document.getElementById('audit-stat-success');
|
||||
const elFail = document.getElementById('audit-stat-failures');
|
||||
const elRecent = document.getElementById('audit-stat-recent');
|
||||
if (elTotal) elTotal.textContent = String(data.total != null ? data.total : 0);
|
||||
if (elFail) elFail.textContent = String(data.failures != null ? data.failures : 0);
|
||||
const total = data.total != null ? data.total : 0;
|
||||
const failures = data.failures != null ? data.failures : 0;
|
||||
if (elTotal) elTotal.textContent = String(total);
|
||||
if (elSuccess) elSuccess.textContent = String(Math.max(0, total - failures));
|
||||
if (elFail) elFail.textContent = String(failures);
|
||||
if (elRecent) elRecent.textContent = String(data.recent_7d != null ? data.recent_7d : 0);
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
@@ -214,7 +284,8 @@ async function loadAuditLogs(page) {
|
||||
throw new Error(err.error || r.statusText);
|
||||
}
|
||||
const data = await r.json();
|
||||
renderAuditLogs(data.logs || []);
|
||||
auditLogsCache = data.logs || [];
|
||||
renderAuditLogs(auditLogsCache);
|
||||
auditLogsTotal = typeof data.total === 'number' ? data.total : 0;
|
||||
const maxPage = Math.max(1, Math.ceil(auditLogsTotal / auditLogsPageSize));
|
||||
if (auditLogsPage > maxPage) {
|
||||
@@ -234,37 +305,57 @@ async function loadAuditLogs(page) {
|
||||
}
|
||||
}
|
||||
|
||||
function auditResultTagClass(result) {
|
||||
return result === 'failure' ? 'audit-tag--fail' : 'audit-tag--ok';
|
||||
}
|
||||
|
||||
function renderAuditLogs(logs) {
|
||||
const listEl = document.getElementById('audit-log-list');
|
||||
if (!listEl) return;
|
||||
const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); };
|
||||
if (!logs.length) {
|
||||
listEl.innerHTML = '<div class="c2-empty">' + esc(auditT('settingsAudit.empty', null, '暂无审计记录')) + '</div>';
|
||||
listEl.innerHTML = '<div class="audit-log-empty">' + esc(auditT('settingsAudit.empty', null, '暂无审计记录')) + '</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = logs.map(function (log) {
|
||||
const lvl = log.result === 'failure' ? 'warn' : (log.level || 'info');
|
||||
const dash = '<span class="audit-log-cell-muted">—</span>';
|
||||
const head = (
|
||||
'<div class="audit-log-table-wrap">' +
|
||||
'<table class="audit-log-table">' +
|
||||
'<thead><tr>' +
|
||||
'<th data-i18n="settingsAudit.colTime">时间</th>' +
|
||||
'<th data-i18n="settingsAudit.colMessage">说明</th>' +
|
||||
'<th data-i18n="settingsAudit.colCategory">类别</th>' +
|
||||
'<th data-i18n="settingsAudit.colAction">操作</th>' +
|
||||
'<th data-i18n="settingsAudit.colResult">结果</th>' +
|
||||
'<th data-i18n="settingsAudit.colIp">IP</th>' +
|
||||
'<th data-i18n="settingsAudit.colResource">资源 ID</th>' +
|
||||
'</tr></thead><tbody>'
|
||||
);
|
||||
const rows = logs.map(function (log) {
|
||||
const catLabel = esc(auditCategoryLabel(log.category || ''));
|
||||
const actionLabel = esc(auditActionLabel(log.action || ''));
|
||||
const msg = esc(log.message || '');
|
||||
const msg = esc(auditMessageLabel(log));
|
||||
const ip = esc(log.clientIp || '');
|
||||
const when = esc(formatAuditTime(log.createdAt));
|
||||
const res = esc(log.result || '');
|
||||
const rid = log.resourceId || '';
|
||||
const meta = rid ? (' · ' + esc(rid)) : '';
|
||||
const res = esc(auditResultLabel(log.result || ''));
|
||||
const rid = log.resourceId ? esc(log.resourceId) : '';
|
||||
const eid = esc(log.id || '');
|
||||
const resultCls = auditResultTagClass(log.result || '');
|
||||
const rowClick = 'onclick="showAuditLogDetail(\'' + eid + '\')" ' +
|
||||
'onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();showAuditLogDetail(\'' + eid + '\')}"';
|
||||
return (
|
||||
'<div class="c2-event-item audit-log-item" role="button" tabindex="0" ' +
|
||||
'onclick="showAuditLogDetail(\'' + eid + '\')" ' +
|
||||
'onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();showAuditLogDetail(\'' + eid + '\')}">' +
|
||||
'<div class="c2-event-level ' + esc(lvl) + '"></div>' +
|
||||
'<div class="c2-event-content">' +
|
||||
'<div class="c2-event-message">' + msg + '</div>' +
|
||||
'<div class="c2-event-meta">' + when + ' · ' + catLabel + '/' + actionLabel + ' · ' + res + meta +
|
||||
(ip ? ' · IP ' + ip : '') +
|
||||
'</div></div></div>'
|
||||
'<tr class="audit-log-row" role="button" tabindex="0" ' + rowClick + '>' +
|
||||
'<td class="audit-log-col-time">' + when + '</td>' +
|
||||
'<td class="audit-log-col-msg" title="' + msg + '">' + (msg || dash) + '</td>' +
|
||||
'<td>' + (catLabel ? '<span class="audit-tag audit-tag--cat">' + catLabel + '</span>' : dash) + '</td>' +
|
||||
'<td>' + (actionLabel ? '<span class="audit-tag audit-tag--act">' + actionLabel + '</span>' : dash) + '</td>' +
|
||||
'<td>' + (res ? '<span class="audit-tag ' + resultCls + '">' + res + '</span>' : dash) + '</td>' +
|
||||
'<td class="audit-log-col-ip">' + (ip || dash) + '</td>' +
|
||||
'<td class="audit-log-col-resource" title="' + rid + '">' + (rid || dash) + '</td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}).join('');
|
||||
listEl.innerHTML = head + rows + '</tbody></table></div>';
|
||||
if (typeof applyTranslations === 'function') {
|
||||
applyTranslations(listEl);
|
||||
}
|
||||
@@ -326,17 +417,58 @@ function resetAuditLogFilters() {
|
||||
const act = document.getElementById('audit-filter-action');
|
||||
const res = document.getElementById('audit-filter-result');
|
||||
const q = document.getElementById('audit-filter-q');
|
||||
const since = document.getElementById('audit-filter-since');
|
||||
const until = document.getElementById('audit-filter-until');
|
||||
if (cat) cat.value = '';
|
||||
if (res) res.value = '';
|
||||
if (q) q.value = '';
|
||||
if (since) since.value = '';
|
||||
if (until) until.value = '';
|
||||
if (typeof window.AuditDatetimePicker !== 'undefined' && typeof window.AuditDatetimePicker.clearAll === 'function') {
|
||||
window.AuditDatetimePicker.clearAll();
|
||||
}
|
||||
rebuildAuditActionSelect();
|
||||
syncAuditCustomSelect('audit-filter-category');
|
||||
syncAuditCustomSelect('audit-filter-result');
|
||||
filterAuditLogs();
|
||||
}
|
||||
|
||||
function applyAuditTimePreset(preset) {
|
||||
if (typeof window.AuditDatetimePicker === 'undefined') return;
|
||||
const now = new Date();
|
||||
let since = new Date(now.getTime());
|
||||
let until = new Date(now.getTime());
|
||||
switch (preset) {
|
||||
case '15m':
|
||||
since = new Date(now.getTime() - 15 * 60 * 1000);
|
||||
break;
|
||||
case '1h':
|
||||
since = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
break;
|
||||
case '24h':
|
||||
since = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '7d':
|
||||
since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'today':
|
||||
since = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
window.AuditDatetimePicker.setValue('audit-filter-since', since);
|
||||
window.AuditDatetimePicker.setValue('audit-filter-until', until);
|
||||
filterAuditLogs();
|
||||
}
|
||||
|
||||
function initAuditTimePresets() {
|
||||
const wrap = document.getElementById('audit-time-presets');
|
||||
if (!wrap || wrap.dataset.bound === '1') return;
|
||||
wrap.dataset.bound = '1';
|
||||
wrap.addEventListener('click', function (ev) {
|
||||
const btn = ev.target.closest('[data-preset]');
|
||||
if (!btn) return;
|
||||
applyAuditTimePreset(btn.getAttribute('data-preset'));
|
||||
});
|
||||
}
|
||||
|
||||
/** 资源已被删除/移除的审计操作,不再提供「打开关联资源」 */
|
||||
const AUDIT_ACTIONS_RESOURCE_REMOVED = {
|
||||
delete: true,
|
||||
@@ -565,8 +697,8 @@ async function showAuditLogDetail(id) {
|
||||
'<div class="modal-body audit-detail-body">' +
|
||||
'<p><strong>' + esc(auditT('settingsAudit.detailTime', null, '时间')) + ':</strong> ' + esc(formatAuditTime(log.createdAt)) + '</p>' +
|
||||
'<p><strong>' + esc(auditT('settingsAudit.detailCategory', null, '类别')) + ':</strong> ' + catAction + '</p>' +
|
||||
'<p><strong>' + esc(auditT('settingsAudit.detailResult', null, '结果')) + ':</strong> ' + esc(log.result || '') + '</p>' +
|
||||
'<p><strong>' + esc(auditT('settingsAudit.detailMessage', null, '说明')) + ':</strong> ' + esc(log.message || '') + '</p>' +
|
||||
'<p><strong>' + esc(auditT('settingsAudit.detailResult', null, '结果')) + ':</strong> ' + esc(auditResultLabel(log.result || '')) + '</p>' +
|
||||
'<p><strong>' + esc(auditT('settingsAudit.detailMessage', null, '说明')) + ':</strong> ' + esc(auditMessageLabel(log)) + '</p>' +
|
||||
(log.clientIp ? '<p><strong>IP:</strong> ' + esc(log.clientIp) + '</p>' : '') +
|
||||
(log.sessionHint ? '<p><strong>' + esc(auditT('settingsAudit.detailSession', null, '会话')) + ':</strong> ' + esc(log.sessionHint) + '</p>' : '') +
|
||||
(log.userAgent ? '<p><strong>UA:</strong> ' + esc(log.userAgent) + '</p>' : '') +
|
||||
@@ -597,7 +729,171 @@ async function showAuditLogDetail(id) {
|
||||
function initAuditLogsSection() {
|
||||
if (!document.getElementById('audit-log-list')) return;
|
||||
initAuditPageSizeFromStorage();
|
||||
initAuditFilterSelects();
|
||||
rebuildAuditActionSelect();
|
||||
loadAuditMeta();
|
||||
if (typeof window.AuditDatetimePicker !== 'undefined' && typeof window.AuditDatetimePicker.init === 'function') {
|
||||
window.AuditDatetimePicker.init();
|
||||
}
|
||||
initAuditTimePresets();
|
||||
updateAuditTimezoneHint();
|
||||
loadAuditLogs(1);
|
||||
}
|
||||
|
||||
function refreshAuditFilterI18n() {
|
||||
const section = document.getElementById('settings-section-audit');
|
||||
if (section && typeof applyTranslations === 'function') {
|
||||
applyTranslations(section);
|
||||
}
|
||||
rebuildAuditActionSelect();
|
||||
syncAuditCustomSelect('audit-filter-category');
|
||||
syncAuditCustomSelect('audit-filter-action');
|
||||
syncAuditCustomSelect('audit-filter-result');
|
||||
updateAuditTimezoneHint();
|
||||
}
|
||||
|
||||
function refreshAuditLogsI18n() {
|
||||
if (!document.getElementById('audit-log-list')) return;
|
||||
refreshAuditFilterI18n();
|
||||
if (auditLogsCache.length) {
|
||||
renderAuditLogs(auditLogsCache);
|
||||
renderAuditLogsPagination();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('languagechange', function () {
|
||||
try {
|
||||
refreshAuditLogsI18n();
|
||||
} catch (e) {
|
||||
console.warn('languagechange audit refresh failed', e);
|
||||
}
|
||||
});
|
||||
|
||||
var auditCustomSelectMap = {};
|
||||
var auditFilterSelectsDocListener = false;
|
||||
|
||||
function closeAllAuditCustomSelects() {
|
||||
Object.keys(auditCustomSelectMap).forEach(function (id) {
|
||||
auditCustomSelectMap[id].wrapper.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
function syncAuditCustomSelect(selectId) {
|
||||
var reg = auditCustomSelectMap[selectId];
|
||||
if (!reg) return;
|
||||
var select = reg.select;
|
||||
var dropdown = reg.dropdown;
|
||||
var trigger = reg.trigger;
|
||||
var wrapper = reg.wrapper;
|
||||
var valueSpan = trigger.querySelector('.audit-custom-select-value');
|
||||
|
||||
dropdown.innerHTML = '';
|
||||
Array.prototype.forEach.call(select.options, function (opt) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'audit-custom-select-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-value', opt.value);
|
||||
if (opt.value === select.value) {
|
||||
item.classList.add('is-selected');
|
||||
item.setAttribute('aria-selected', 'true');
|
||||
}
|
||||
var check = document.createElement('span');
|
||||
check.className = 'audit-custom-select-check';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
check.textContent = '✓';
|
||||
var label = document.createElement('span');
|
||||
label.className = 'audit-custom-select-label';
|
||||
label.textContent = opt.textContent;
|
||||
item.appendChild(check);
|
||||
item.appendChild(label);
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
var selectedOpt = select.options[select.selectedIndex];
|
||||
if (valueSpan) {
|
||||
valueSpan.textContent = selectedOpt ? selectedOpt.textContent : '';
|
||||
}
|
||||
trigger.disabled = !!select.disabled;
|
||||
wrapper.classList.toggle('is-disabled', !!select.disabled);
|
||||
}
|
||||
|
||||
function enhanceAuditFilterSelect(selectId) {
|
||||
var select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
if (select.dataset.auditCustom === '1') {
|
||||
syncAuditCustomSelect(selectId);
|
||||
return;
|
||||
}
|
||||
select.dataset.auditCustom = '1';
|
||||
select.classList.add('audit-native-select');
|
||||
select.tabIndex = -1;
|
||||
select.setAttribute('aria-hidden', 'true');
|
||||
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'audit-custom-select';
|
||||
|
||||
var trigger = document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'audit-custom-select-trigger';
|
||||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
var valueSpan = document.createElement('span');
|
||||
valueSpan.className = 'audit-custom-select-value';
|
||||
trigger.appendChild(valueSpan);
|
||||
var caret = document.createElement('span');
|
||||
caret.className = 'audit-custom-select-caret';
|
||||
caret.setAttribute('aria-hidden', 'true');
|
||||
caret.textContent = '▾';
|
||||
trigger.appendChild(caret);
|
||||
|
||||
var dropdown = document.createElement('div');
|
||||
dropdown.className = 'audit-custom-select-dropdown';
|
||||
dropdown.setAttribute('role', 'listbox');
|
||||
|
||||
var parent = select.parentNode;
|
||||
parent.insertBefore(wrapper, select);
|
||||
wrapper.appendChild(trigger);
|
||||
wrapper.appendChild(dropdown);
|
||||
wrapper.appendChild(select);
|
||||
|
||||
auditCustomSelectMap[selectId] = {
|
||||
wrapper: wrapper,
|
||||
trigger: trigger,
|
||||
dropdown: dropdown,
|
||||
select: select
|
||||
};
|
||||
|
||||
trigger.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (select.disabled) return;
|
||||
var open = wrapper.classList.contains('open');
|
||||
closeAllAuditCustomSelects();
|
||||
if (!open) wrapper.classList.add('open');
|
||||
});
|
||||
|
||||
dropdown.addEventListener('click', function (e) {
|
||||
var opt = e.target.closest('.audit-custom-select-option');
|
||||
if (!opt) return;
|
||||
var val = opt.getAttribute('data-value');
|
||||
if (val === null) val = '';
|
||||
if (select.value !== val) {
|
||||
select.value = val;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
wrapper.classList.remove('open');
|
||||
syncAuditCustomSelect(selectId);
|
||||
});
|
||||
|
||||
syncAuditCustomSelect(selectId);
|
||||
}
|
||||
|
||||
function initAuditFilterSelects() {
|
||||
if (!document.getElementById('audit-filter-category')) return;
|
||||
if (!auditFilterSelectsDocListener) {
|
||||
document.addEventListener('click', function () {
|
||||
closeAllAuditCustomSelects();
|
||||
});
|
||||
auditFilterSelectsDocListener = true;
|
||||
}
|
||||
enhanceAuditFilterSelect('audit-filter-category');
|
||||
enhanceAuditFilterSelect('audit-filter-action');
|
||||
enhanceAuditFilterSelect('audit-filter-result');
|
||||
}
|
||||
|
||||
+119
-7
@@ -2164,6 +2164,97 @@ function showCopySuccess(button) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Claude extended thinking 内部尾缀(与后端 DisplayReasoningContent 一致,UI 不展示) */
|
||||
const CLAUDE_REASONING_UI_SUFFIX = '\n---CSAI_CLAUDE_THINKING_BLOCKS---\n';
|
||||
|
||||
function normalizeReasoningContentForDisplay(text) {
|
||||
if (text == null) return '';
|
||||
let s = String(text).trim();
|
||||
if (!s) return '';
|
||||
const idx = s.lastIndexOf(CLAUDE_REASONING_UI_SUFFIX);
|
||||
if (idx >= 0) {
|
||||
s = s.slice(0, idx).trim();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function setMessageReasoningContent(messageIdOrEl, reasoningContent) {
|
||||
const el = typeof messageIdOrEl === 'string' ? document.getElementById(messageIdOrEl) : messageIdOrEl;
|
||||
if (!el || !el.dataset) return;
|
||||
const rc = normalizeReasoningContentForDisplay(reasoningContent);
|
||||
if (rc) {
|
||||
el.dataset.reasoningContent = rc;
|
||||
} else {
|
||||
delete el.dataset.reasoningContent;
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageReasoningContent(messageIdOrEl) {
|
||||
const el = typeof messageIdOrEl === 'string' ? document.getElementById(messageIdOrEl) : messageIdOrEl;
|
||||
if (!el || !el.dataset) return '';
|
||||
return normalizeReasoningContentForDisplay(el.dataset.reasoningContent || '');
|
||||
}
|
||||
|
||||
function reasoningTextAlreadyInProcessDetails(processDetails, rc) {
|
||||
if (!rc) return true;
|
||||
const list = Array.isArray(processDetails) ? processDetails : [];
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const d = list[i];
|
||||
if (!d) continue;
|
||||
const et = d.eventType || '';
|
||||
if (et !== 'reasoning_chain' && et !== 'thinking') continue;
|
||||
const msg = normalizeReasoningContentForDisplay(d.message || '');
|
||||
if (!msg) continue;
|
||||
if (msg === rc || msg.includes(rc) || rc.includes(msg)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 合并 messages.reasoningContent 与 process_details 中的 reasoning_chain,两者都读、都展示(去重后) */
|
||||
function mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningContent) {
|
||||
const rc = normalizeReasoningContentForDisplay(reasoningContent);
|
||||
const details = Array.isArray(processDetails) ? processDetails.slice() : [];
|
||||
if (!rc || reasoningTextAlreadyInProcessDetails(details, rc)) {
|
||||
return details;
|
||||
}
|
||||
details.push({
|
||||
eventType: 'reasoning_chain',
|
||||
message: rc,
|
||||
data: { source: 'message.reasoningContent' }
|
||||
});
|
||||
return details;
|
||||
}
|
||||
|
||||
async function syncAssistantReasoningContentFromServer(backendMessageId, domAssistantId) {
|
||||
if (!backendMessageId || !domAssistantId || !currentConversationId || typeof apiFetch !== 'function') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const convRes = await apiFetch(`/api/conversations/${encodeURIComponent(currentConversationId)}?include_process_details=0`);
|
||||
const conv = await convRes.json().catch(() => ({}));
|
||||
if (!convRes.ok || !Array.isArray(conv.messages)) return;
|
||||
const msg = conv.messages.find((m) => m && String(m.id) === String(backendMessageId));
|
||||
if (!msg || !msg.reasoningContent) return;
|
||||
setMessageReasoningContent(domAssistantId, msg.reasoningContent);
|
||||
const pdRes = await apiFetch(`/api/messages/${encodeURIComponent(String(backendMessageId))}/process-details`);
|
||||
const pdJson = await pdRes.json().catch(() => ({}));
|
||||
const details = pdRes.ok && Array.isArray(pdJson.processDetails) ? pdJson.processDetails : [];
|
||||
if (typeof renderProcessDetails === 'function') {
|
||||
renderProcessDetails(domAssistantId, details);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('syncAssistantReasoningContentFromServer failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
window.normalizeReasoningContentForDisplay = normalizeReasoningContentForDisplay;
|
||||
window.setMessageReasoningContent = setMessageReasoningContent;
|
||||
window.getMessageReasoningContent = getMessageReasoningContent;
|
||||
window.mergeMessageReasoningContentIntoProcessDetails = mergeMessageReasoningContentIntoProcessDetails;
|
||||
window.syncAssistantReasoningContentFromServer = syncAssistantReasoningContentFromServer;
|
||||
|
||||
/** 相邻且类型/正文/data 完全一致的过程详情只保留一条(与后端去重一致,避免时间线叠多条相同块) */
|
||||
function dedupeConsecutiveProcessDetailRows(details) {
|
||||
if (!Array.isArray(details) || details.length < 2) {
|
||||
@@ -2282,20 +2373,27 @@ function renderProcessDetails(messageId, processDetails) {
|
||||
detailsContainer.appendChild(contentDiv);
|
||||
}
|
||||
|
||||
// processDetails === null 表示“尚未加载(懒加载)”
|
||||
// processDetails === null 表示“尚未加载(懒加载)”;messages.reasoningContent 可先展示
|
||||
const isLazyNotLoaded = (processDetails === null);
|
||||
if (isLazyNotLoaded) {
|
||||
const reasoningFromMessage = getMessageReasoningContent(messageElement);
|
||||
if (isLazyNotLoaded && !reasoningFromMessage) {
|
||||
detailsContainer.dataset.lazyNotLoaded = '1';
|
||||
detailsContainer.dataset.loaded = '0';
|
||||
timeline.innerHTML = '<div class="progress-timeline-empty">' +
|
||||
(typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') +
|
||||
'(点击后加载)</div>';
|
||||
// 默认折叠
|
||||
timeline.classList.remove('expanded');
|
||||
return;
|
||||
}
|
||||
detailsContainer.dataset.lazyNotLoaded = '0';
|
||||
detailsContainer.dataset.loaded = '1';
|
||||
if (isLazyNotLoaded) {
|
||||
detailsContainer.dataset.lazyNotLoaded = '1';
|
||||
detailsContainer.dataset.loaded = '0';
|
||||
processDetails = [];
|
||||
} else {
|
||||
detailsContainer.dataset.lazyNotLoaded = '0';
|
||||
detailsContainer.dataset.loaded = '1';
|
||||
}
|
||||
processDetails = mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningFromMessage);
|
||||
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
|
||||
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
|
||||
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
|
||||
@@ -2426,6 +2524,14 @@ function renderProcessDetails(messageId, processDetails) {
|
||||
}
|
||||
addTimelineItem(timeline, eventType, timelineOpts);
|
||||
});
|
||||
|
||||
if (isLazyNotLoaded && reasoningFromMessage) {
|
||||
const lazyHint = document.createElement('div');
|
||||
lazyHint.className = 'progress-timeline-empty progress-timeline-lazy-hint';
|
||||
lazyHint.textContent = (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') +
|
||||
'(点击后加载完整过程详情)';
|
||||
timeline.appendChild(lazyHint);
|
||||
}
|
||||
|
||||
// 检查是否有错误或取消事件,如果有,确保详情默认折叠(但仍有待审批 HITL 时保持展开,由 restoreHitlInlineForConversation 处理)
|
||||
const hasPendingHitlInDetails = processDetails.some(d => d && d.eventType === 'hitl_interrupt');
|
||||
@@ -3193,6 +3299,9 @@ async function loadConversation(conversationId) {
|
||||
attachDeleteTurnButton(messageEl);
|
||||
}
|
||||
if (msg.role === 'assistant') {
|
||||
if (messageEl && msg.reasoningContent) {
|
||||
setMessageReasoningContent(messageEl, msg.reasoningContent);
|
||||
}
|
||||
const hasField = msg && Object.prototype.hasOwnProperty.call(msg, 'processDetails');
|
||||
renderProcessDetails(messageId, hasField ? (msg.processDetails || []) : null);
|
||||
if (msg.processDetails && msg.processDetails.length > 0) {
|
||||
@@ -7359,8 +7468,11 @@ async function deleteSelectedConversations() {
|
||||
for (const id of ids) {
|
||||
await deleteConversation(id, true); // 跳过内部确认,因为批量删除时已经确认过了
|
||||
}
|
||||
closeBatchManageModal();
|
||||
loadConversationsWithGroups();
|
||||
// 删除后保持弹窗打开,便于继续管理剩余对话
|
||||
const selectAll = document.getElementById('batch-select-all');
|
||||
if (selectAll) {
|
||||
selectAll.checked = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
const failedMsg = typeof window.t === 'function' ? window.t('batchManageModal.deleteFailed') : '删除失败';
|
||||
|
||||
@@ -172,6 +172,59 @@ function einoMainStreamPlanningTitle(responseData) {
|
||||
return prefix + '📝 ' + plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主通道 response 结束时:将流式占位条目固化为 planning(与后端 flushResponsePlan 落库类型一致),
|
||||
* 避免 integrateProgressToMCPSection 快照前删除占位导致「助手输出」仅刷新后才出现。
|
||||
*/
|
||||
function finalizeMainResponseStreamItem(streamState, finalMessage, responseData) {
|
||||
if (!streamState || !streamState.itemId) return false;
|
||||
const item = document.getElementById(streamState.itemId);
|
||||
if (!item || !item.parentNode) return false;
|
||||
|
||||
const fullText = (finalMessage != null && String(finalMessage).trim() !== '')
|
||||
? String(finalMessage)
|
||||
: (streamState.buffer || '');
|
||||
if (!String(fullText).trim()) {
|
||||
item.parentNode.removeChild(item);
|
||||
return false;
|
||||
}
|
||||
|
||||
const meta = Object.assign({}, streamState.streamMeta || {}, responseData || {});
|
||||
|
||||
item.classList.remove('timeline-item-thinking');
|
||||
item.classList.add('timeline-item-planning');
|
||||
item.dataset.timelineType = 'planning';
|
||||
delete item.dataset.responseStreamPlaceholder;
|
||||
if (meta.orchestration != null && String(meta.orchestration).trim() !== '') {
|
||||
item.dataset.orchestration = String(meta.orchestration).trim();
|
||||
}
|
||||
if (meta.einoAgent != null && String(meta.einoAgent).trim() !== '') {
|
||||
item.dataset.einoAgent = String(meta.einoAgent).trim();
|
||||
}
|
||||
|
||||
const titleEl = item.querySelector('.timeline-item-title');
|
||||
if (titleEl && typeof einoMainStreamPlanningTitle === 'function') {
|
||||
titleEl.textContent = einoMainStreamPlanningTitle(meta);
|
||||
}
|
||||
|
||||
let contentEl = item.querySelector('.timeline-item-content');
|
||||
if (!contentEl) {
|
||||
contentEl = document.createElement('div');
|
||||
contentEl.className = 'timeline-item-content';
|
||||
item.appendChild(contentEl);
|
||||
}
|
||||
flushStreamPlainTextUpdate(contentEl);
|
||||
const body = typeof formatTimelineStreamBody === 'function'
|
||||
? formatTimelineStreamBody(fullText, meta)
|
||||
: fullText;
|
||||
if (typeof formatMarkdown === 'function') {
|
||||
setTimelineItemContentStreamRich(contentEl, formatMarkdown(body, timelineMarkdownOpts));
|
||||
} else {
|
||||
setTimelineItemContentStreamPlain(contentEl, body);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function translateProgressMessage(message, data) {
|
||||
if (!message || typeof message !== 'string') return message;
|
||||
if (typeof window.t !== 'function') return message;
|
||||
@@ -224,6 +277,7 @@ if (typeof window !== 'undefined') {
|
||||
window.translateProgressMessage = translateProgressMessage;
|
||||
window.translatePlanExecuteAgentName = translatePlanExecuteAgentName;
|
||||
window.einoMainStreamPlanningTitle = einoMainStreamPlanningTitle;
|
||||
window.finalizeMainResponseStreamItem = finalizeMainResponseStreamItem;
|
||||
window.formatTimelineStreamBody = formatTimelineStreamBody;
|
||||
}
|
||||
|
||||
@@ -2401,14 +2455,18 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
updateAssistantBubbleContent(assistantIdFinal, event.message, true);
|
||||
}
|
||||
|
||||
// 移除 response_start/response_delta 阶段创建的「规划中」占位条目。
|
||||
// 该条目属于 UI-only 的流式展示,不应被拷贝到最终的过程详情里;
|
||||
// 否则会出现“不刷新页面仍显示规划中,刷新后消失”的不一致。
|
||||
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
|
||||
if (streamState && streamState.itemId) {
|
||||
const planningItem = document.getElementById(streamState.itemId);
|
||||
if (planningItem && planningItem.parentNode) {
|
||||
planningItem.parentNode.removeChild(planningItem);
|
||||
}
|
||||
finalizeMainResponseStreamItem(streamState, event.message, responseData);
|
||||
} else if (event.message && String(event.message).trim()) {
|
||||
addTimelineItem(timeline, 'planning', {
|
||||
title: typeof einoMainStreamPlanningTitle === 'function'
|
||||
? einoMainStreamPlanningTitle(responseData)
|
||||
: ('📝 ' + (typeof window.t === 'function' ? window.t('chat.planning') : '规划中')),
|
||||
message: event.message,
|
||||
data: responseData,
|
||||
expanded: false
|
||||
});
|
||||
}
|
||||
|
||||
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
|
||||
@@ -2429,6 +2487,11 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
const respMid = responseData.messageId;
|
||||
if (respMid) {
|
||||
applyBackendMessageIdToAssistantDom(assistantIdFinal, respMid);
|
||||
if (typeof window.syncAssistantReasoningContentFromServer === 'function') {
|
||||
setTimeout(function () {
|
||||
window.syncAssistantReasoningContentFromServer(respMid, assistantIdFinal);
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -3824,7 +3887,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
||||
const tipTime = formatMcpTimelineLabel(c.p.t, rangeKey, locale);
|
||||
const isPeak = c.i === peakIdx && (c.p.total || 0) > 0;
|
||||
const dotClass = 'mcp-stats-timeline-dot' + (isPeak ? ' mcp-stats-timeline-dot--peak' : '');
|
||||
return `<circle class="${dotClass}" cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${isPeak ? 3 : 2.5}"
|
||||
return `<circle class="${dotClass}" cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${isPeak ? 2 : 1.5}"
|
||||
data-time="${escapeHtml(tipTime)}"
|
||||
data-total="${c.p.total || 0}"
|
||||
data-failed="${c.p.failed || 0}" />`;
|
||||
@@ -3832,7 +3895,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
||||
|
||||
const peakC = coords[peakIdx];
|
||||
const peakMarker = (peakC.p.total || 0) > 0
|
||||
? `<circle class="mcp-stats-timeline-peak-glow" cx="${peakC.x.toFixed(2)}" cy="${peakC.y.toFixed(2)}" r="7" />`
|
||||
? `<circle class="mcp-stats-timeline-peak-glow" cx="${peakC.x.toFixed(2)}" cy="${peakC.y.toFixed(2)}" r="5" />`
|
||||
: '';
|
||||
|
||||
return `<svg class="mcp-stats-timeline__chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">
|
||||
|
||||
@@ -1270,12 +1270,18 @@ async function saveProjectModal() {
|
||||
return;
|
||||
}
|
||||
const fromChat = !!window._projectModalFromChat;
|
||||
const fromWebshellConnId = window._projectModalFromWebshellConnId || '';
|
||||
window._projectModalFromChat = false;
|
||||
window._projectModalFromWebshellConnId = '';
|
||||
closeProjectModal();
|
||||
const saved = await res.json();
|
||||
await loadProjectsList();
|
||||
if (saved.id) {
|
||||
if (fromChat && !editId) {
|
||||
if (fromWebshellConnId && !editId) {
|
||||
if (typeof applyWebshellAiProjectSelection === 'function') {
|
||||
await applyWebshellAiProjectSelection(saved.id);
|
||||
}
|
||||
} else if (fromChat && !editId) {
|
||||
await applyChatProjectSelection(saved.id);
|
||||
} else {
|
||||
await selectProject(saved.id);
|
||||
|
||||
+263
-8
@@ -27,6 +27,9 @@ const WEBSHELL_HISTORY_MAX = 100;
|
||||
let webshellClearInProgress = false;
|
||||
// AI 助手:按连接 ID 保存对话 ID,便于多轮对话
|
||||
let webshellAiConvMap = {};
|
||||
// AI 助手:项目绑定(已有对话按 convId,新对话按 connId 草稿)
|
||||
let webshellAiProjectByConvId = {};
|
||||
let webshellAiDraftProjectByConn = {};
|
||||
let webshellAiSending = false;
|
||||
let webshellAiAbortController = null; // AbortController for current AI stream
|
||||
let webshellAiStreamReader = null; // Current ReadableStreamDefaultReader
|
||||
@@ -266,6 +269,7 @@ function wsToggleRolePanel() {
|
||||
var isOpen = panel.style.display === 'flex';
|
||||
if (isOpen) { wsCloseRolePanel(); return; }
|
||||
wsCloseAgentModePanel();
|
||||
wsCloseProjectPanel();
|
||||
panel.style.display = 'flex';
|
||||
}
|
||||
function wsCloseRolePanel() {
|
||||
@@ -340,6 +344,7 @@ function wsToggleAgentModePanel() {
|
||||
var isOpen = panel.style.display === 'flex';
|
||||
if (isOpen) { wsCloseAgentModePanel(); return; }
|
||||
wsCloseRolePanel();
|
||||
wsCloseProjectPanel();
|
||||
panel.style.display = 'flex';
|
||||
}
|
||||
function wsCloseAgentModePanel() {
|
||||
@@ -347,10 +352,204 @@ function wsCloseAgentModePanel() {
|
||||
if (panel) panel.style.display = 'none';
|
||||
}
|
||||
|
||||
// ─── WebShell AI 项目选择器(与主「对话」页对齐) ───
|
||||
|
||||
function wsProjectT(key, fallback) {
|
||||
if (typeof window.t === 'function') {
|
||||
var v = window.t(key);
|
||||
if (v && v !== key) return v;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getWebshellAiConvId(conn) {
|
||||
if (!conn || !conn.id) return '';
|
||||
return webshellAiConvMap[conn.id] || '';
|
||||
}
|
||||
|
||||
function getWebshellAiProjectSelection(conn) {
|
||||
if (!conn || !conn.id) return '';
|
||||
var convId = getWebshellAiConvId(conn);
|
||||
if (convId) return webshellAiProjectByConvId[convId] || '';
|
||||
return webshellAiDraftProjectByConn[conn.id] || '';
|
||||
}
|
||||
|
||||
function wsSetWebshellAiProject(conn, projectId) {
|
||||
if (!conn || !conn.id) return;
|
||||
var pid = projectId || '';
|
||||
var convId = getWebshellAiConvId(conn);
|
||||
if (convId) {
|
||||
if (pid) webshellAiProjectByConvId[convId] = pid;
|
||||
else delete webshellAiProjectByConvId[convId];
|
||||
} else if (pid) {
|
||||
webshellAiDraftProjectByConn[conn.id] = pid;
|
||||
} else {
|
||||
delete webshellAiDraftProjectByConn[conn.id];
|
||||
}
|
||||
wsUpdateProjectButtonLabel();
|
||||
}
|
||||
|
||||
function wsIsActiveProjectId(id) {
|
||||
if (!id) return false;
|
||||
var map = window.projectNameById || {};
|
||||
return !!map[id];
|
||||
}
|
||||
|
||||
function wsResolveWebshellAiProjectSelection(conn) {
|
||||
var raw = getWebshellAiProjectSelection(conn);
|
||||
if (!raw) return '';
|
||||
return wsIsActiveProjectId(raw) ? raw : '';
|
||||
}
|
||||
|
||||
function wsUpdateProjectButtonLabel() {
|
||||
var textEl = document.getElementById('ws-project-text');
|
||||
if (!textEl || !webshellCurrentConn) return;
|
||||
var id = wsResolveWebshellAiProjectSelection(webshellCurrentConn);
|
||||
var nameMap = window.projectNameById || {};
|
||||
textEl.textContent = id && nameMap[id] ? nameMap[id] : wsProjectT('projects.noProject', '无项目');
|
||||
}
|
||||
|
||||
async function wsRenderProjectPanelList() {
|
||||
var list = document.getElementById('ws-project-list');
|
||||
if (!list || !webshellCurrentConn) return;
|
||||
var conn = webshellCurrentConn;
|
||||
var selected = wsResolveWebshellAiProjectSelection(conn);
|
||||
var projects = [];
|
||||
try {
|
||||
if (typeof window.fetchAllProjects === 'function') {
|
||||
projects = await window.fetchAllProjects(false);
|
||||
}
|
||||
} catch (e) {
|
||||
list.innerHTML = '<div class="chat-project-panel-empty">' + escapeHtml(wsProjectT('projects.loadFailedRetry', '加载失败,请重试')) + '</div>';
|
||||
return;
|
||||
}
|
||||
if (typeof window.rebuildProjectNameMap === 'function') {
|
||||
window.rebuildProjectNameMap(projects);
|
||||
}
|
||||
var activeProjects = projects.filter(function (p) { return p.status !== 'archived'; });
|
||||
var items = [{ id: '', name: wsProjectT('projects.noProject', '无项目'), description: wsProjectT('projects.noProjectDescription', '不绑定项目') }].concat(activeProjects);
|
||||
list.innerHTML = '';
|
||||
items.forEach(function (p) {
|
||||
var isNone = !p.id;
|
||||
var isSelected = isNone ? !selected : selected === p.id;
|
||||
var desc = isNone
|
||||
? (p.description || '')
|
||||
: ((p.description || '').trim().slice(0, 80) || wsProjectT('projects.sharedFactBoard', '共享事实黑板'));
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
|
||||
btn.setAttribute('role', 'option');
|
||||
btn.onclick = function () { wsSelectProject(p.id || ''); };
|
||||
btn.innerHTML = '<div class="role-selection-item-icon-main">' + (isNone ? '—' : '📁') + '</div>' +
|
||||
'<div class="role-selection-item-content-main">' +
|
||||
'<div class="role-selection-item-name-main">' + escapeHtml(p.name || '未命名') + '</div>' +
|
||||
'<div class="role-selection-item-description-main">' + escapeHtml(desc) + '</div></div>' +
|
||||
(isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : '');
|
||||
list.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
async function wsRenderProjectPanel() {
|
||||
var list = document.getElementById('ws-project-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '<div class="chat-project-panel-loading">' + escapeHtml(wsProjectT('common.loading', '加载中...')) + '</div>';
|
||||
await wsRenderProjectPanelList();
|
||||
}
|
||||
|
||||
function wsCloseProjectPanel() {
|
||||
var panel = document.getElementById('ws-project-panel');
|
||||
var btn = document.getElementById('ws-project-btn');
|
||||
if (panel) panel.style.display = 'none';
|
||||
if (btn) {
|
||||
btn.classList.remove('active');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
async function wsToggleProjectPanel() {
|
||||
var panel = document.getElementById('ws-project-panel');
|
||||
var btn = document.getElementById('ws-project-btn');
|
||||
if (!panel) return;
|
||||
var isHidden = panel.style.display === 'none' || !panel.style.display;
|
||||
if (!isHidden) {
|
||||
wsCloseProjectPanel();
|
||||
return;
|
||||
}
|
||||
wsCloseRolePanel();
|
||||
wsCloseAgentModePanel();
|
||||
panel.style.display = 'flex';
|
||||
if (btn) {
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
await wsRenderProjectPanel();
|
||||
}
|
||||
|
||||
async function wsSelectProject(projectId) {
|
||||
wsCloseProjectPanel();
|
||||
await applyWebshellAiProjectSelection(projectId || '');
|
||||
}
|
||||
|
||||
async function applyWebshellAiProjectSelection(projectId) {
|
||||
var conn = webshellCurrentConn;
|
||||
if (!conn || !conn.id) return;
|
||||
var prev = getWebshellAiProjectSelection(conn);
|
||||
if (projectId === prev) {
|
||||
wsUpdateProjectButtonLabel();
|
||||
return;
|
||||
}
|
||||
var convId = getWebshellAiConvId(conn);
|
||||
if (convId) {
|
||||
try {
|
||||
var res = await apiFetch('/api/conversations/' + encodeURIComponent(convId) + '/project', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectId: projectId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
var err = await res.json().catch(function () { return {}; });
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
wsSetWebshellAiProject(conn, projectId);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(
|
||||
projectId ? wsProjectT('projects.projectBound', '已绑定项目') : wsProjectT('projects.projectUnbound', '已解除项目绑定'),
|
||||
'success'
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(wsProjectT('projects.updateProjectBindingFailed', '更新项目绑定失败') + ': ' + (e.message || e));
|
||||
wsUpdateProjectButtonLabel();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
wsSetWebshellAiProject(conn, projectId);
|
||||
}
|
||||
wsUpdateProjectButtonLabel();
|
||||
}
|
||||
|
||||
function showNewProjectModalFromWebshellAi() {
|
||||
wsCloseProjectPanel();
|
||||
if (webshellCurrentConn && webshellCurrentConn.id) {
|
||||
window._projectModalFromWebshellConnId = webshellCurrentConn.id;
|
||||
}
|
||||
window._projectModalFromChat = false;
|
||||
if (typeof showNewProjectModal === 'function') showNewProjectModal();
|
||||
}
|
||||
|
||||
window.applyWebshellAiProjectSelection = applyWebshellAiProjectSelection;
|
||||
window.showNewProjectModalFromWebshellAi = showNewProjectModalFromWebshellAi;
|
||||
window.wsToggleProjectPanel = wsToggleProjectPanel;
|
||||
window.wsCloseProjectPanel = wsCloseProjectPanel;
|
||||
|
||||
// ─── end WebShell AI 项目选择器 ───
|
||||
|
||||
/** 当 WebShell AI Tab 可见时刷新选择器显示(同步主页可能的更改) */
|
||||
function wsRefreshSelectors() {
|
||||
wsUpdateRoleSelectorDisplay();
|
||||
wsRenderRoleList();
|
||||
wsUpdateProjectButtonLabel();
|
||||
var stored = localStorage.getItem('cyberstrike-chat-agent-mode') || 'eino_single';
|
||||
if (stored !== 'eino_single' && stored !== 'deep' && stored !== 'plan_execute' && stored !== 'supervisor') {
|
||||
stored = 'eino_single';
|
||||
@@ -370,6 +569,11 @@ document.addEventListener('click', function (e) {
|
||||
if (modePanel && modePanel.style.display !== 'none' && modeBtn && !modePanel.contains(e.target) && !modeBtn.contains(e.target)) {
|
||||
wsCloseAgentModePanel();
|
||||
}
|
||||
var projectPanel = document.getElementById('ws-project-panel');
|
||||
var projectBtn = document.getElementById('ws-project-btn');
|
||||
if (projectPanel && projectPanel.style.display !== 'none' && projectBtn && !projectPanel.contains(e.target) && !projectBtn.contains(e.target)) {
|
||||
wsCloseProjectPanel();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── end WebShell AI 选择器 ───
|
||||
@@ -1873,6 +2077,7 @@ function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) {
|
||||
apiFetch('/api/conversations/' + encodeURIComponent(convId) + '?include_process_details=1', { method: 'GET' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
wsSetWebshellAiProject(conn, data.projectId || data.project_id || '');
|
||||
messagesContainer.innerHTML = '';
|
||||
var list = data.messages || [];
|
||||
list.forEach(function (msg) {
|
||||
@@ -1893,9 +2098,14 @@ function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) {
|
||||
}
|
||||
}
|
||||
messagesContainer.appendChild(div);
|
||||
if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) {
|
||||
var block = renderWebshellProcessDetailsBlock(msg.processDetails, true);
|
||||
if (block) messagesContainer.appendChild(block);
|
||||
if (role === 'assistant') {
|
||||
var wsMergedDetails = (typeof window.mergeMessageReasoningContentIntoProcessDetails === 'function')
|
||||
? window.mergeMessageReasoningContentIntoProcessDetails(msg.processDetails || [], msg.reasoningContent)
|
||||
: (msg.processDetails || []);
|
||||
if (wsMergedDetails.length > 0) {
|
||||
var block = renderWebshellProcessDetailsBlock(wsMergedDetails, true);
|
||||
if (block) messagesContainer.appendChild(block);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (list.length === 0) {
|
||||
@@ -2003,6 +2213,25 @@ function selectWebshell(id, stateReady) {
|
||||
'<div id="webshell-ai-messages" class="webshell-ai-messages"></div>' +
|
||||
'<div class="webshell-ai-input-area">' +
|
||||
'<div class="webshell-ai-selectors-row">' +
|
||||
'<div class="ws-project-selector-wrapper project-selector-wrapper">' +
|
||||
'<button type="button" id="ws-project-btn" class="role-selector-btn" onclick="wsToggleProjectPanel()" aria-label="' + escapeHtml(wsProjectT('projects.chatSelectorButton', '选择项目')) + '" aria-haspopup="listbox" aria-expanded="false" title="' + escapeHtml(wsProjectT('projects.chatSelectorButton', '绑定项目后共享事实黑板(跨对话)')) + '">' +
|
||||
'<span class="role-selector-icon" aria-hidden="true">📁</span>' +
|
||||
'<span id="ws-project-text" class="role-selector-text">' + escapeHtml(wsProjectT('projects.noProject', '无项目')) + '</span>' +
|
||||
'<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' +
|
||||
'</button>' +
|
||||
'<div id="ws-project-panel" class="role-selection-panel chat-project-panel" style="display:none;" role="listbox">' +
|
||||
'<div class="role-selection-panel-header">' +
|
||||
'<h3 class="role-selection-panel-title">' + escapeHtml(wsProjectT('projects.selectProject', '选择项目')) + '</h3>' +
|
||||
'<button type="button" class="role-selection-panel-close" onclick="wsCloseProjectPanel()" title="' + escapeHtml(wsProjectT('common.close', '关闭')) + '" aria-label="' + escapeHtml(wsProjectT('common.close', '关闭')) + '">' +
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
|
||||
'</div>' +
|
||||
'<div class="chat-project-panel-body">' +
|
||||
'<div id="ws-project-list" class="role-selection-list-main"></div>' +
|
||||
'<div class="chat-project-panel-footer">' +
|
||||
'<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromWebshellAi()">' +
|
||||
'<span class="chat-project-panel-create-icon" aria-hidden="true">+</span>' +
|
||||
'<span class="chat-project-panel-create-label">' + escapeHtml(wsProjectT('projects.newProject', '新建项目')) + '</span>' +
|
||||
'</button></div></div></div></div>' +
|
||||
'<div class="ws-role-selector-wrapper">' +
|
||||
'<button type="button" class="role-selector-btn ws-role-selector-btn" id="ws-role-selector-btn" onclick="wsToggleRolePanel()">' +
|
||||
'<span id="ws-role-selector-icon" class="role-selector-icon">\ud83d\udd35</span>' +
|
||||
@@ -2174,9 +2403,11 @@ function selectWebshell(id, stateReady) {
|
||||
var aiNewConvBtn = document.getElementById('webshell-ai-new-conv');
|
||||
var aiConvListEl = document.getElementById('webshell-ai-conv-list');
|
||||
|
||||
// 初始化角色 + 模式选择器
|
||||
// 初始化角色 + 模式 + 项目选择器
|
||||
wsLoadRoles();
|
||||
wsInitAgentMode();
|
||||
if (typeof prefetchProjectsForChat === 'function') prefetchProjectsForChat();
|
||||
wsUpdateProjectButtonLabel();
|
||||
var aiMemoInput = document.getElementById('webshell-ai-memo-input');
|
||||
var aiMemoStatus = document.getElementById('webshell-ai-memo-status');
|
||||
var aiMemoClearBtn = document.getElementById('webshell-ai-memo-clear');
|
||||
@@ -2225,6 +2456,8 @@ function selectWebshell(id, stateReady) {
|
||||
if (aiNewConvBtn) {
|
||||
aiNewConvBtn.addEventListener('click', function () {
|
||||
delete webshellAiConvMap[conn.id];
|
||||
delete webshellAiDraftProjectByConn[conn.id];
|
||||
wsUpdateProjectButtonLabel();
|
||||
if (aiMessages) {
|
||||
aiMessages.innerHTML = '';
|
||||
var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||
@@ -2767,7 +3000,15 @@ function loadWebshellAiHistory(conn, messagesContainer) {
|
||||
return apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id) + '/ai-history', { method: 'GET' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.conversationId) webshellAiConvMap[conn.id] = data.conversationId;
|
||||
if (data.conversationId) {
|
||||
webshellAiConvMap[conn.id] = data.conversationId;
|
||||
apiFetch('/api/conversations/' + encodeURIComponent(data.conversationId), { method: 'GET' })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (conv) {
|
||||
if (conv) wsSetWebshellAiProject(conn, conv.projectId || conv.project_id || '');
|
||||
})
|
||||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
var list = Array.isArray(data.messages) ? data.messages : [];
|
||||
list.forEach(function (msg) {
|
||||
var role = (msg.role || '').toLowerCase();
|
||||
@@ -2787,9 +3028,14 @@ function loadWebshellAiHistory(conn, messagesContainer) {
|
||||
}
|
||||
}
|
||||
messagesContainer.appendChild(div);
|
||||
if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) {
|
||||
var block = renderWebshellProcessDetailsBlock(msg.processDetails, true);
|
||||
if (block) messagesContainer.appendChild(block);
|
||||
if (role === 'assistant') {
|
||||
var wsHistMerged = (typeof window.mergeMessageReasoningContentIntoProcessDetails === 'function')
|
||||
? window.mergeMessageReasoningContentIntoProcessDetails(msg.processDetails || [], msg.reasoningContent)
|
||||
: (msg.processDetails || []);
|
||||
if (wsHistMerged.length > 0) {
|
||||
var block = renderWebshellProcessDetailsBlock(wsHistMerged, true);
|
||||
if (block) messagesContainer.appendChild(block);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (list.length === 0) {
|
||||
@@ -2922,6 +3168,10 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
conversationId: convId,
|
||||
role: wsRole
|
||||
};
|
||||
if (!convId) {
|
||||
var wsPid = getWebshellAiProjectSelection(conn);
|
||||
if (wsPid) body.projectId = wsPid;
|
||||
}
|
||||
|
||||
// 流式输出:支持 progress 实时更新、response 打字机效果;若后端发送多段 response 则追加
|
||||
var streamingTarget = ''; // 当前要打字显示的目标全文(用于打字机效果)
|
||||
@@ -2970,6 +3220,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
|
||||
if (_et === 'conversation' && _ed.conversationId) {
|
||||
var convId = _ed.conversationId;
|
||||
var prevDraft = webshellAiDraftProjectByConn[conn.id];
|
||||
if (prevDraft) {
|
||||
webshellAiProjectByConvId[convId] = prevDraft;
|
||||
delete webshellAiDraftProjectByConn[conn.id];
|
||||
}
|
||||
webshellAiConvMap[conn.id] = convId;
|
||||
var listEl = document.getElementById('webshell-ai-conv-list');
|
||||
if (listEl) fetchAndRenderWebshellAiConvList(conn, listEl).then(function () {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
let wechatBindSessionKey = null;
|
||||
let wechatBindPollTimer = null;
|
||||
let wechatBindFlashTimer = null;
|
||||
|
||||
function wechatT(key, fallback) {
|
||||
return typeof t === 'function' ? t(key) : fallback;
|
||||
@@ -88,13 +89,50 @@ function stopWechatBindPoll() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 已绑定:仅展示成功状态,不显示二维码/配对码 */
|
||||
function clearWechatBindSuccessNotice() {
|
||||
if (wechatBindFlashTimer) {
|
||||
clearTimeout(wechatBindFlashTimer);
|
||||
wechatBindFlashTimer = null;
|
||||
}
|
||||
const flash = document.getElementById('robot-wechat-bound-flash');
|
||||
if (flash) {
|
||||
flash.classList.remove('is-visible');
|
||||
flash.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 绑定成功后的内联提示(约 4.5 秒后自动淡出) */
|
||||
function showWechatBindSuccessNotice(message) {
|
||||
const text = message || wechatT('settings.robots.wechat.boundSuccess', '绑定成功,微信机器人已启用。');
|
||||
const flash = document.getElementById('robot-wechat-bound-flash');
|
||||
const flashText = document.getElementById('robot-wechat-bound-flash-text');
|
||||
|
||||
if (flash) {
|
||||
if (flashText) flashText.textContent = text;
|
||||
flash.hidden = false;
|
||||
requestAnimationFrame(() => flash.classList.add('is-visible'));
|
||||
if (wechatBindFlashTimer) clearTimeout(wechatBindFlashTimer);
|
||||
wechatBindFlashTimer = setTimeout(() => {
|
||||
flash.classList.remove('is-visible');
|
||||
wechatBindFlashTimer = setTimeout(() => {
|
||||
flash.hidden = true;
|
||||
wechatBindFlashTimer = null;
|
||||
}, 300);
|
||||
}, 4500);
|
||||
}
|
||||
|
||||
if (typeof window.showChatToast === 'function') {
|
||||
window.showChatToast(text, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
/** 已绑定:收起二维码区,仅展示紧凑摘要 */
|
||||
function showWechatBoundUI(wechat) {
|
||||
const wc = wechat || {};
|
||||
const wrap = document.getElementById('robot-wechat-qr-wrap');
|
||||
const boundPanel = document.getElementById('robot-wechat-bound-panel');
|
||||
const scanPanel = document.getElementById('robot-wechat-scan-panel');
|
||||
const boundId = document.getElementById('robot-wechat-bound-id');
|
||||
const summary = document.getElementById('robot-wechat-bound-summary');
|
||||
const btn = document.getElementById('robot-wechat-bind-btn');
|
||||
|
||||
stopWechatBindPoll();
|
||||
@@ -102,8 +140,8 @@ function showWechatBoundUI(wechat) {
|
||||
setWechatBadge('bound');
|
||||
setWechatCardBound(true);
|
||||
|
||||
if (wrap) wrap.hidden = false;
|
||||
if (boundPanel) boundPanel.hidden = false;
|
||||
if (wrap) wrap.hidden = true;
|
||||
if (boundPanel) boundPanel.hidden = true;
|
||||
if (scanPanel) scanPanel.hidden = true;
|
||||
|
||||
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
|
||||
@@ -117,14 +155,15 @@ function showWechatBoundUI(wechat) {
|
||||
}
|
||||
if (ph) ph.hidden = false;
|
||||
|
||||
if (boundId) {
|
||||
const id = wc.ilink_bot_id || document.getElementById('robot-wechat-ilink-bot-id')?.value?.trim() || '';
|
||||
const id = wc.ilink_bot_id || document.getElementById('robot-wechat-ilink-bot-id')?.value?.trim() || '';
|
||||
if (summary) {
|
||||
if (id) {
|
||||
boundId.textContent = wechatT('settings.robots.wechat.boundBotId', '已绑定 Bot ID:') + id;
|
||||
boundId.hidden = false;
|
||||
const prefix = wechatT('settings.robots.wechat.boundBotId', '已绑定 Bot ID:');
|
||||
summary.innerHTML = `${prefix}<code>${escapeHtml(id)}</code>`;
|
||||
summary.hidden = false;
|
||||
} else {
|
||||
boundId.textContent = '';
|
||||
boundId.hidden = true;
|
||||
summary.textContent = '';
|
||||
summary.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,21 +172,32 @@ function showWechatBoundUI(wechat) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 扫码绑定进行中 */
|
||||
function showWechatScanUI() {
|
||||
const wrap = document.getElementById('robot-wechat-qr-wrap');
|
||||
const boundPanel = document.getElementById('robot-wechat-bound-panel');
|
||||
const scanPanel = document.getElementById('robot-wechat-scan-panel');
|
||||
const summary = document.getElementById('robot-wechat-bound-summary');
|
||||
const btn = document.getElementById('robot-wechat-bind-btn');
|
||||
|
||||
setWechatBadge('scanning');
|
||||
setWechatCardBound(false);
|
||||
clearWechatBindSuccessNotice();
|
||||
ensureWechatSteps();
|
||||
updateWechatSteps('generate');
|
||||
|
||||
if (wrap) wrap.hidden = false;
|
||||
if (boundPanel) boundPanel.hidden = true;
|
||||
if (scanPanel) scanPanel.hidden = false;
|
||||
if (summary) summary.hidden = true;
|
||||
|
||||
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
|
||||
if (verifyWrap) verifyWrap.hidden = true;
|
||||
@@ -163,7 +213,10 @@ function showWechatScanUI() {
|
||||
/** 未绑定且未在扫码:隐藏面板 */
|
||||
function hideWechatQrWrap() {
|
||||
const wrap = document.getElementById('robot-wechat-qr-wrap');
|
||||
const summary = document.getElementById('robot-wechat-bound-summary');
|
||||
if (wrap) wrap.hidden = true;
|
||||
if (summary) summary.hidden = true;
|
||||
clearWechatBindSuccessNotice();
|
||||
setWechatBadge('idle');
|
||||
setWechatCardBound(false);
|
||||
}
|
||||
@@ -278,6 +331,9 @@ async function pollWechatBindStatus() {
|
||||
const idEl = document.getElementById('robot-wechat-ilink-bot-id');
|
||||
if (idEl) idEl.value = data.ilink_bot_id;
|
||||
}
|
||||
showWechatBindSuccessNotice(
|
||||
data.message || wechatT('settings.robots.wechat.boundSuccess', '绑定成功,微信机器人已启用。')
|
||||
);
|
||||
if (typeof loadConfig === 'function') {
|
||||
await loadConfig(false);
|
||||
} else {
|
||||
@@ -299,6 +355,9 @@ async function pollWechatBindStatus() {
|
||||
break;
|
||||
case 'binded_redirect':
|
||||
stopWechatBindPoll();
|
||||
showWechatBindSuccessNotice(
|
||||
data.message || wechatT('settings.robots.wechat.alreadyBound', '该微信已绑定过,无需重复绑定。')
|
||||
);
|
||||
showWechatBoundUI({ bound: true });
|
||||
return;
|
||||
case 'expired':
|
||||
|
||||
Vendored
+32
File diff suppressed because one or more lines are too long
Vendored
+6696
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+22
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(function(){return(()=>{"use strict";var e={775:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0;var r=function(){function e(){}return e.prototype.activate=function(e){this._terminal=e},e.prototype.dispose=function(){},e.prototype.fit=function(){var e=this.proposeDimensions();if(e&&this._terminal){var t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}},e.prototype.proposeDimensions=function(){if(this._terminal&&this._terminal.element&&this._terminal.element.parentElement){var e=this._terminal._core;if(0!==e._renderService.dimensions.actualCellWidth&&0!==e._renderService.dimensions.actualCellHeight){var t=window.getComputedStyle(this._terminal.element.parentElement),r=parseInt(t.getPropertyValue("height")),i=Math.max(0,parseInt(t.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),o=r-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=i-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-e.viewport.scrollBarWidth;return{cols:Math.max(2,Math.floor(a/e._renderService.dimensions.actualCellWidth)),rows:Math.max(1,Math.floor(o/e._renderService.dimensions.actualCellHeight))}}}},e}();t.FitAddon=r}},t={};return function r(i){if(t[i])return t[i].exports;var n=t[i]={exports:{}};return e[i](n,n.exports,r),n.exports}(775)})()}));
|
||||
//# sourceMappingURL=xterm-addon-fit.js.map
|
||||
Vendored
+190
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.xterm-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 7;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -934,7 +934,7 @@ Content-Type: application/json
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/i18next@23.11.5/i18next.min.js"></script>
|
||||
<script src="/static/vendor/i18next.min.js"></script>
|
||||
<script src="/static/js/i18n.js"></script>
|
||||
<script src="/static/js/api-docs.js"></script>
|
||||
</body>
|
||||
|
||||
+73
-39
@@ -8,7 +8,7 @@
|
||||
<link rel="shortcut icon" type="image/png" href="/static/favicon.ico">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/c2.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.19.0/css/xterm.css">
|
||||
<link rel="stylesheet" href="/static/vendor/xterm.css">
|
||||
<script src="/static/js/router.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -2817,6 +2817,13 @@
|
||||
<button type="button" class="btn-primary" id="robot-wechat-bind-btn" onclick="startWechatRobotBind()" data-i18n="settings.robots.wechat.bindButton">生成二维码并绑定</button>
|
||||
<p class="robot-wechat-hint" id="robot-wechat-bind-hint" data-i18n="settings.robots.wechat.bindHint">用微信扫码确认后会自动保存并启用。</p>
|
||||
</div>
|
||||
<div id="robot-wechat-bound-flash" class="robot-wechat-bound-flash" hidden role="status">
|
||||
<span class="robot-wechat-bound-flash-icon" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
</span>
|
||||
<span id="robot-wechat-bound-flash-text" data-i18n="settings.robots.wechat.boundSuccess">绑定成功,微信机器人已启用。</span>
|
||||
</div>
|
||||
<p id="robot-wechat-bound-summary" class="robot-wechat-bound-summary" hidden></p>
|
||||
<div id="robot-wechat-qr-wrap" class="robot-wechat-panel" hidden>
|
||||
<div id="robot-wechat-bound-panel" class="robot-wechat-bound-panel" hidden>
|
||||
<div class="robot-wechat-bound-icon" aria-hidden="true">
|
||||
@@ -3010,19 +3017,27 @@
|
||||
|
||||
<!-- 日志审计 -->
|
||||
<div id="settings-section-audit" class="settings-section-content">
|
||||
<div class="settings-section-header">
|
||||
<div class="audit-section-head">
|
||||
<h3 data-i18n="settingsAudit.title">日志审计</h3>
|
||||
<p class="settings-description" data-i18n="settingsAudit.description">记录平台管理类操作(登录、配置、删除等),不记录对话正文、终端/WebShell 每次命令与工具调用明细。</p>
|
||||
<p id="audit-retention-hint" class="settings-description audit-retention-hint" hidden></p>
|
||||
<div id="audit-summary-stats" class="audit-summary-tags" hidden>
|
||||
<span class="audit-summary-tag"><span class="audit-summary-tag-label" data-i18n="settingsAudit.statTotal">当前筛选</span><strong id="audit-stat-total">0</strong></span>
|
||||
<span class="audit-summary-tag audit-summary-tag--ok"><span class="audit-summary-tag-label" data-i18n="settingsAudit.statSuccess">成功</span><strong id="audit-stat-success">0</strong></span>
|
||||
<span class="audit-summary-tag audit-summary-tag--warn"><span class="audit-summary-tag-label" data-i18n="settingsAudit.statFailures">失败</span><strong id="audit-stat-failures">0</strong></span>
|
||||
<span class="audit-summary-tag"><span class="audit-summary-tag-label" data-i18n="settingsAudit.statRecent7d">近 7 天</span><strong id="audit-stat-recent">0</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="audit-summary-stats" class="audit-summary-stats" hidden>
|
||||
<div class="audit-stat-card"><span class="audit-stat-label" data-i18n="settingsAudit.statTotal">当前筛选</span><strong id="audit-stat-total">0</strong></div>
|
||||
<div class="audit-stat-card"><span class="audit-stat-label" data-i18n="settingsAudit.statFailures">失败</span><strong id="audit-stat-failures">0</strong></div>
|
||||
<div class="audit-stat-card"><span class="audit-stat-label" data-i18n="settingsAudit.statRecent7d">近 7 天</span><strong id="audit-stat-recent">0</strong></div>
|
||||
</div>
|
||||
<div class="audit-logs-toolbar">
|
||||
<div class="audit-logs-filters">
|
||||
<label class="audit-filter-cascade-group">
|
||||
<div class="audit-filter-card">
|
||||
<div class="audit-time-presets" id="audit-time-presets">
|
||||
<span class="audit-time-presets-label" data-i18n="settingsAudit.timePresets">快捷</span>
|
||||
<button type="button" class="audit-time-preset-btn" data-preset="15m" data-i18n="settingsAudit.preset15m">最近15分钟</button>
|
||||
<button type="button" class="audit-time-preset-btn" data-preset="1h" data-i18n="settingsAudit.preset1h">最近1小时</button>
|
||||
<button type="button" class="audit-time-preset-btn" data-preset="24h" data-i18n="settingsAudit.preset24h">最近24小时</button>
|
||||
<button type="button" class="audit-time-preset-btn" data-preset="7d" data-i18n="settingsAudit.preset7d">最近7天</button>
|
||||
<button type="button" class="audit-time-preset-btn" data-preset="today" data-i18n="settingsAudit.presetToday">今天</button>
|
||||
</div>
|
||||
<div class="audit-filter-fields">
|
||||
<div class="audit-filter-row">
|
||||
<label class="audit-field audit-field--event">
|
||||
<span data-i18n="settingsAudit.filterEvent">事件类型</span>
|
||||
<div class="audit-filter-cascade">
|
||||
<select id="audit-filter-category" onchange="onAuditCategoryFilterChange()" aria-label="类别">
|
||||
@@ -3049,7 +3064,7 @@
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<label class="audit-field audit-field--result">
|
||||
<span data-i18n="settingsAudit.filterResult">结果</span>
|
||||
<select id="audit-filter-result">
|
||||
<option value="" data-i18n="settingsAudit.filterAll">全部</option>
|
||||
@@ -3057,36 +3072,54 @@
|
||||
<option value="failure">failure</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<div class="audit-filter-time-group">
|
||||
<label class="audit-field audit-field--time">
|
||||
<span data-i18n="settingsAudit.filterSince">开始时间</span>
|
||||
<input type="datetime-local" id="audit-filter-since" />
|
||||
<div class="audit-datetime-field" id="audit-filter-since-field">
|
||||
<input type="text" id="audit-filter-since" class="audit-datetime-input" readonly autocomplete="off" data-i18n="settingsAudit.datetimePlaceholder" data-i18n-attr="placeholder" placeholder="选择日期时间" />
|
||||
<button type="button" class="audit-datetime-btn audit-datetime-clear-btn" title="Clear" aria-label="Clear" hidden>×</button>
|
||||
<button type="button" class="audit-datetime-btn audit-datetime-open-btn" title="Open" aria-label="Open">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<label class="audit-field audit-field--time">
|
||||
<span data-i18n="settingsAudit.filterUntil">结束时间</span>
|
||||
<input type="datetime-local" id="audit-filter-until" />
|
||||
<div class="audit-datetime-field" id="audit-filter-until-field">
|
||||
<input type="text" id="audit-filter-until" class="audit-datetime-input" readonly autocomplete="off" data-i18n="settingsAudit.datetimePlaceholder" data-i18n-attr="placeholder" placeholder="选择日期时间" />
|
||||
<button type="button" class="audit-datetime-btn audit-datetime-clear-btn" title="Clear" aria-label="Clear" hidden>×</button>
|
||||
<button type="button" class="audit-datetime-btn audit-datetime-open-btn" title="Open" aria-label="Open">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="audit-filter-bottom">
|
||||
<label class="audit-field audit-field--keyword">
|
||||
<span data-i18n="settingsAudit.filterQuery">关键词</span>
|
||||
<input type="text" id="audit-filter-q" data-i18n="settingsAudit.filterQueryPlaceholder" data-i18n-attr="placeholder" placeholder="消息 / 资源 ID / 操作名" />
|
||||
</label>
|
||||
<button type="button" class="btn-secondary" onclick="filterAuditLogs()" data-i18n="settingsAudit.filterBtn">筛选</button>
|
||||
<button type="button" class="btn-secondary" onclick="resetAuditLogFilters()" data-i18n="settingsAudit.resetBtn">重置</button>
|
||||
</div>
|
||||
<div class="audit-logs-actions">
|
||||
<button type="button" class="btn-secondary" onclick="refreshAuditLogs()" data-i18n="common.refresh">刷新</button>
|
||||
<div class="audit-export-dropdown">
|
||||
<button type="button" class="btn-secondary audit-export-trigger" id="audit-export-trigger" onclick="toggleAuditExportMenu(event)" aria-haspopup="true" aria-expanded="false">
|
||||
<span data-i18n="settingsAudit.exportBtn">导出</span>
|
||||
<span class="audit-export-caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<div id="audit-export-menu" class="audit-export-menu" role="menu" hidden>
|
||||
<button type="button" class="audit-export-menu-item" role="menuitem" onclick="runAuditExport('json')" data-i18n="settingsAudit.exportJson">导出 JSON</button>
|
||||
<button type="button" class="audit-export-menu-item" role="menuitem" onclick="runAuditExport('csv')" data-i18n="settingsAudit.exportCsv">导出 CSV</button>
|
||||
<div class="audit-logs-actions">
|
||||
<button type="button" class="btn-primary" onclick="filterAuditLogs()" data-i18n="settingsAudit.filterBtn">筛选</button>
|
||||
<button type="button" class="btn-secondary" onclick="resetAuditLogFilters()" data-i18n="settingsAudit.resetBtn">重置</button>
|
||||
<button type="button" class="btn-secondary" onclick="refreshAuditLogs()" data-i18n="common.refresh">刷新</button>
|
||||
<div class="audit-export-dropdown">
|
||||
<button type="button" class="btn-secondary audit-export-trigger" id="audit-export-trigger" onclick="toggleAuditExportMenu(event)" aria-haspopup="true" aria-expanded="false">
|
||||
<span data-i18n="settingsAudit.exportBtn">导出</span>
|
||||
<span class="audit-export-caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<div id="audit-export-menu" class="audit-export-menu" role="menu" hidden>
|
||||
<button type="button" class="audit-export-menu-item" role="menuitem" onclick="runAuditExport('json')" data-i18n="settingsAudit.exportJson">导出 JSON</button>
|
||||
<button type="button" class="audit-export-menu-item" role="menuitem" onclick="runAuditExport('csv')" data-i18n="settingsAudit.exportCsv">导出 CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="audit-filter-timezone-hint" class="audit-timezone-hint" hidden></p>
|
||||
</div>
|
||||
<div id="audit-log-list" class="audit-log-list c2-event-list"></div>
|
||||
<div id="audit-log-list" class="audit-log-list"></div>
|
||||
<div id="audit-logs-pagination" class="pagination-container audit-logs-pagination"></div>
|
||||
</div>
|
||||
|
||||
@@ -3484,16 +3517,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marked.js + DOMPurify:本地 vendor,避免 CDN 不可用导致 Markdown 降级为纯文本 -->
|
||||
<!-- Marked.js + DOMPurify + 其他前端依赖:本地 vendor,内网/离线部署不依赖 CDN -->
|
||||
<script src="/static/vendor/marked.min.js"></script>
|
||||
<script src="/static/vendor/purify.min.js"></script>
|
||||
<script src="/static/js/sanitize-markdown.js"></script>
|
||||
<!-- Cytoscape.js for attack chain visualization -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.27.0/dist/cytoscape.min.js"></script>
|
||||
<script src="/static/vendor/cytoscape.min.js"></script>
|
||||
<!-- ELK.js for high-quality DAG layout (reduces edge crossings) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/elkjs@0.9.2/lib/elk.bundled.js"></script>
|
||||
<script src="/static/vendor/elk.bundled.js"></script>
|
||||
<!-- SheetJS for XLSX export (info-collect) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
||||
<script src="/static/vendor/xlsx.full.min.js"></script>
|
||||
<script>
|
||||
// 确保ELK对象全局可用
|
||||
if (typeof ELK === 'undefined' && typeof elk !== 'undefined') {
|
||||
@@ -4287,7 +4320,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/i18next@23.11.5/i18next.min.js"></script>
|
||||
<script src="/static/vendor/i18next.min.js"></script>
|
||||
<script src="/static/js/i18n.js"></script>
|
||||
<script src="/static/js/builtin-tools.js"></script>
|
||||
<script src="/static/js/auth.js"></script>
|
||||
@@ -4301,10 +4334,11 @@
|
||||
<script src="/static/js/chat.js"></script>
|
||||
<script src="/static/js/hitl.js"></script>
|
||||
<script src="/static/js/settings.js"></script>
|
||||
<script src="/static/js/audit-datetime-picker.js"></script>
|
||||
<script src="/static/js/audit.js"></script>
|
||||
<script src="/static/js/wechat-robot.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
|
||||
<script src="/static/vendor/xterm.js"></script>
|
||||
<script src="/static/vendor/xterm-addon-fit.js"></script>
|
||||
<script src="/static/js/terminal.js"></script>
|
||||
<script src="/static/js/knowledge.js"></script>
|
||||
<script src="/static/js/skills.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user