auto-sync: 2026-05-17 11:21:29
This commit is contained in:
+552
-97
@@ -1,125 +1,580 @@
|
||||
// API 客户端
|
||||
/**
|
||||
* API 层 — 对接 dashboard/server.py
|
||||
* 生产环境从同源 (port 7891) 请求,开发环境可通过 VITE_API_URL 指定
|
||||
*/
|
||||
|
||||
import type {
|
||||
Task,
|
||||
Project,
|
||||
Observation,
|
||||
Event,
|
||||
DaemonStatus,
|
||||
} from './types';
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
const API_BASE = '/api';
|
||||
// ── 通用请求 ──
|
||||
|
||||
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const resp = await fetch(`${API_BASE}${url}`, {
|
||||
async function fetchJ<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function postJ<T>(url: string, data: unknown): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`API error: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ---- Projects ----
|
||||
|
||||
export async function listProjects(): Promise<Project[]> {
|
||||
const data = await fetchJSON<unknown>('/projects');
|
||||
if (Array.isArray(data)) return data as Project[];
|
||||
if (data && typeof data === 'object') {
|
||||
const obj = data as Record<string, unknown>;
|
||||
const projectsObj = ('projects' in obj ? obj.projects : obj) as Record<string, Project>;
|
||||
return Object.values(projectsObj);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function getProject(projectId: string): Promise<Project> {
|
||||
return fetchJSON(`/projects/${projectId}`);
|
||||
}
|
||||
|
||||
export async function createProject(data: Partial<Project>): Promise<Project> {
|
||||
return fetchJSON('/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---- Tasks ----
|
||||
// ── API 接口 ──
|
||||
|
||||
export async function listTasks(projectId: string): Promise<Task[]> {
|
||||
return fetchJSON(`/projects/${projectId}/tasks`);
|
||||
export const api = {
|
||||
// 核心数据
|
||||
liveStatus: () => fetchJ<LiveStatus>(`${API_BASE}/api/live-status`),
|
||||
agentConfig: () => fetchJ<AgentConfig>(`${API_BASE}/api/agent-config`),
|
||||
modelChangeLog: () => fetchJ<ChangeLogEntry[]>(`${API_BASE}/api/model-change-log`).catch(() => []),
|
||||
officialsStats: () => fetchJ<OfficialsData>(`${API_BASE}/api/officials-stats`),
|
||||
morningBrief: () => fetchJ<MorningBrief>(`${API_BASE}/api/morning-brief`),
|
||||
morningConfig: () => fetchJ<SubConfig>(`${API_BASE}/api/morning-config`),
|
||||
agentsStatus: () => fetchJ<AgentsStatusData>(`${API_BASE}/api/agents-status`),
|
||||
|
||||
// 任务实时动态
|
||||
taskActivity: (id: string) =>
|
||||
fetchJ<TaskActivityData>(`${API_BASE}/api/task-activity/${encodeURIComponent(id)}`),
|
||||
schedulerState: (id: string) =>
|
||||
fetchJ<SchedulerStateData>(`${API_BASE}/api/scheduler-state/${encodeURIComponent(id)}`),
|
||||
|
||||
// 技能内容
|
||||
skillContent: (agentId: string, skillName: string) =>
|
||||
fetchJ<SkillContentResult>(
|
||||
`${API_BASE}/api/skill-content/${encodeURIComponent(agentId)}/${encodeURIComponent(skillName)}`
|
||||
),
|
||||
|
||||
// 操作类
|
||||
setModel: (agentId: string, model: string) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/set-model`, { agentId, model }),
|
||||
setDispatchChannel: (channel: string) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/set-dispatch-channel`, { channel }),
|
||||
agentWake: (agentId: string) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/agent-wake`, { agentId }),
|
||||
taskAction: (taskId: string, action: string, reason: string) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/task-action`, { taskId, action, reason }),
|
||||
reviewAction: (taskId: string, action: string, comment: string) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/review-action`, { taskId, action, comment }),
|
||||
advanceState: (taskId: string, comment: string) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/advance-state`, { taskId, comment }),
|
||||
archiveTask: (taskId: string, archived: boolean) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/archive-task`, { taskId, archived }),
|
||||
archiveAllDone: () =>
|
||||
postJ<ActionResult & { count?: number }>(`${API_BASE}/api/archive-task`, { archiveAllDone: true }),
|
||||
schedulerScan: (thresholdSec = 180) =>
|
||||
postJ<ActionResult & { count?: number; actions?: ScanAction[]; checkedAt?: string }>(
|
||||
`${API_BASE}/api/scheduler-scan`,
|
||||
{ thresholdSec }
|
||||
),
|
||||
schedulerRetry: (taskId: string, reason: string) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/scheduler-retry`, { taskId, reason }),
|
||||
schedulerEscalate: (taskId: string, reason: string) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/scheduler-escalate`, { taskId, reason }),
|
||||
schedulerRollback: (taskId: string, reason: string) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/scheduler-rollback`, { taskId, reason }),
|
||||
refreshMorning: () =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/morning-brief/refresh`, {}),
|
||||
saveMorningConfig: (config: SubConfig) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/morning-config`, config),
|
||||
addSkill: (agentId: string, skillName: string, description: string, trigger: string) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/add-skill`, { agentId, skillName, description, trigger }),
|
||||
|
||||
// 远程 Skills 管理
|
||||
addRemoteSkill: (agentId: string, skillName: string, sourceUrl: string, description?: string) =>
|
||||
postJ<ActionResult & { skillName?: string; agentId?: string; source?: string; localPath?: string; size?: number; addedAt?: string }>(
|
||||
`${API_BASE}/api/add-remote-skill`, { agentId, skillName, sourceUrl, description: description || '' }
|
||||
),
|
||||
remoteSkillsList: () =>
|
||||
fetchJ<RemoteSkillsListResult>(`${API_BASE}/api/remote-skills-list`),
|
||||
updateRemoteSkill: (agentId: string, skillName: string) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/update-remote-skill`, { agentId, skillName }),
|
||||
removeRemoteSkill: (agentId: string, skillName: string) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/remove-remote-skill`, { agentId, skillName }),
|
||||
|
||||
createTask: (data: CreateTaskPayload) =>
|
||||
postJ<ActionResult & { taskId?: string }>(`${API_BASE}/api/tasks`, data),
|
||||
|
||||
// ── M3: 成果物 ──
|
||||
artifacts: (taskId: string) =>
|
||||
fetchJ<ArtifactsResult>(`${API_BASE}/api/tasks/${encodeURIComponent(taskId)}/artifacts`),
|
||||
artifactPreview: (taskId: string, path: string) =>
|
||||
fetchJ<ArtifactPreviewResult>(`${API_BASE}/api/tasks/${encodeURIComponent(taskId)}/artifacts/preview?path=${encodeURIComponent(path)}`),
|
||||
artifactDownloadUrl: (taskId: string, path: string) =>
|
||||
`${API_BASE}/api/tasks/${encodeURIComponent(taskId)}/artifacts/download?path=${encodeURIComponent(path)}`,
|
||||
|
||||
// ── M3: 人工干预 ──
|
||||
humanInput: (taskId: string) =>
|
||||
fetchJ<HumanInputResult>(`${API_BASE}/api/tasks/${encodeURIComponent(taskId)}/human-input`),
|
||||
humanInputRespond: (taskId: string, data: HumanInputRespondPayload) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/tasks/${encodeURIComponent(taskId)}/human-input/respond`, data),
|
||||
|
||||
// ── 朝堂议政 ──
|
||||
courtDiscussStart: (topic: string, officials: string[], taskId?: string) =>
|
||||
postJ<CourtDiscussResult>(`${API_BASE}/api/court-discuss/start`, { topic, officials, taskId }),
|
||||
courtDiscussAdvance: (sessionId: string, userMessage?: string, decree?: string) =>
|
||||
postJ<CourtDiscussResult>(`${API_BASE}/api/court-discuss/advance`, { sessionId, userMessage, decree }),
|
||||
courtDiscussConclude: (sessionId: string) =>
|
||||
postJ<ActionResult & { summary?: string }>(`${API_BASE}/api/court-discuss/conclude`, { sessionId }),
|
||||
courtDiscussDestroy: (sessionId: string) =>
|
||||
postJ<ActionResult>(`${API_BASE}/api/court-discuss/destroy`, { sessionId }),
|
||||
courtDiscussFate: () =>
|
||||
fetchJ<{ ok: boolean; event: string }>(`${API_BASE}/api/court-discuss/fate`),
|
||||
};
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface ActionResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function getTask(projectId: string, taskId: string): Promise<Task> {
|
||||
return fetchJSON(`/projects/${projectId}/tasks/${taskId}`);
|
||||
export interface FlowEntry {
|
||||
at: string;
|
||||
from: string;
|
||||
to: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export async function createTask(
|
||||
projectId: string,
|
||||
data: Partial<Task>
|
||||
): Promise<Task> {
|
||||
return fetchJSON(`/projects/${projectId}/tasks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export interface TodoItem {
|
||||
id: string | number;
|
||||
title: string;
|
||||
status: 'not-started' | 'in-progress' | 'completed';
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export async function updateTask(
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
data: Partial<Task>
|
||||
): Promise<Task> {
|
||||
return fetchJSON(`/projects/${projectId}/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export interface Heartbeat {
|
||||
status: 'active' | 'warn' | 'stalled' | 'unknown' | 'idle';
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ---- Observations ----
|
||||
|
||||
export async function listObservations(
|
||||
projectId: string,
|
||||
taskId: string
|
||||
): Promise<Observation[]> {
|
||||
return fetchJSON(`/projects/${projectId}/tasks/${taskId}/observations`);
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
state: string;
|
||||
org: string;
|
||||
now: string;
|
||||
eta: string;
|
||||
block: string;
|
||||
ac: string;
|
||||
output: string;
|
||||
heartbeat: Heartbeat;
|
||||
flow_log: FlowEntry[];
|
||||
todos: TodoItem[];
|
||||
review_round: number;
|
||||
archived: boolean;
|
||||
archivedAt?: string;
|
||||
updatedAt?: string;
|
||||
updated_at?: string; // moziplus backend returns snake_case
|
||||
// moziplus fields
|
||||
status?: string; // moziplus task status: created/planning/executing/completed/failed/cancelled/paused
|
||||
plan_status?: string; // moziplus plan status: pending/ready/challenging/approved/rejected
|
||||
nodes?: TaskNode[]; // moziplus DAG nodes
|
||||
latest_event?: TaskEvent; // moziplus latest event
|
||||
sourceMeta?: Record<string, unknown>;
|
||||
activity?: ActivityEntry[];
|
||||
_prev_state?: string;
|
||||
}
|
||||
|
||||
// ---- Events ----
|
||||
|
||||
export async function listEvents(
|
||||
projectId: string,
|
||||
taskId: string
|
||||
): Promise<Event[]> {
|
||||
return fetchJSON(`/projects/${projectId}/tasks/${taskId}/events`);
|
||||
export interface TaskNode {
|
||||
node_id: string;
|
||||
name: string;
|
||||
agent_id: string;
|
||||
status: string;
|
||||
depends_on?: string[];
|
||||
output_summary?: string;
|
||||
output?: string;
|
||||
// Guard 字段
|
||||
guard_status?: 'not_checked' | 'entry_passed' | 'entry_failed' | 'exit_passed' | 'exit_failed' | 'exit_warned';
|
||||
guard_checks?: GuardCheck[];
|
||||
rollback_count?: number;
|
||||
}
|
||||
|
||||
// ---- Daemon ----
|
||||
|
||||
export async function getDaemonStatus(): Promise<DaemonStatus> {
|
||||
return fetchJSON('/daemon/status');
|
||||
export interface GuardCheck {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
message: string;
|
||||
severity: 'error' | 'warn' | 'info';
|
||||
}
|
||||
|
||||
export async function triggerTick(): Promise<{ tick: number }> {
|
||||
return fetchJSON('/daemon/tick', { method: 'POST' });
|
||||
export interface TaskEvent {
|
||||
event_type: string;
|
||||
reason: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ---- SSE ----
|
||||
export interface SyncStatus {
|
||||
ok: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function createEventSource(
|
||||
onEvent: (data: unknown) => void,
|
||||
onError?: () => void
|
||||
): EventSource {
|
||||
const es = new EventSource(`${API_BASE}/events`);
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
onEvent(JSON.parse(e.data));
|
||||
} catch {
|
||||
onEvent(e.data);
|
||||
}
|
||||
export interface LiveStatus {
|
||||
tasks: Task[];
|
||||
syncStatus: SyncStatus;
|
||||
}
|
||||
|
||||
export interface AgentInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
emoji: string;
|
||||
role: string;
|
||||
model: string;
|
||||
skills: SkillInfo[];
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface KnownModel {
|
||||
id: string;
|
||||
label: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
agents: AgentInfo[];
|
||||
knownModels?: KnownModel[];
|
||||
dispatchChannel?: string;
|
||||
}
|
||||
|
||||
export interface ChangeLogEntry {
|
||||
at: string;
|
||||
agentId: string;
|
||||
oldModel: string;
|
||||
newModel: string;
|
||||
rolledBack?: boolean;
|
||||
}
|
||||
|
||||
export interface OfficialInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
emoji: string;
|
||||
role: string;
|
||||
rank: string;
|
||||
model: string;
|
||||
model_short: string;
|
||||
tokens_in: number;
|
||||
tokens_out: number;
|
||||
cache_read: number;
|
||||
cache_write: number;
|
||||
cost_cny: number;
|
||||
cost_usd: number;
|
||||
sessions: number;
|
||||
messages: number;
|
||||
tasks_done: number;
|
||||
tasks_active: number;
|
||||
tasks_failed?: number; // moziplus: 失败任务数
|
||||
memorySize?: number; // MEMORY.md 字数
|
||||
memoryUpdatedAt?: string; // MEMORY.md 最后修改时间
|
||||
flow_participations: number;
|
||||
merit_score: number;
|
||||
merit_rank: number;
|
||||
last_active: string;
|
||||
heartbeat: Heartbeat;
|
||||
participated_edicts: { id: string; title: string; state: string; status?: string }[];
|
||||
}
|
||||
|
||||
export interface OfficialsData {
|
||||
officials: OfficialInfo[];
|
||||
totals: { tasks_done: number; cost_cny: number };
|
||||
top_official: string;
|
||||
}
|
||||
|
||||
export interface AgentStatusInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
emoji: string;
|
||||
role: string;
|
||||
status: 'running' | 'idle' | 'offline' | 'unconfigured';
|
||||
statusLabel: string;
|
||||
lastActive?: string;
|
||||
}
|
||||
|
||||
export interface GatewayStatus {
|
||||
alive: boolean;
|
||||
probe: boolean;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AgentsStatusData {
|
||||
ok: boolean;
|
||||
gateway: GatewayStatus;
|
||||
agents: AgentStatusInfo[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface MorningNewsItem {
|
||||
title: string;
|
||||
summary?: string;
|
||||
desc?: string;
|
||||
link: string;
|
||||
source: string;
|
||||
image?: string;
|
||||
pub_date?: string;
|
||||
}
|
||||
|
||||
export interface MorningBrief {
|
||||
date?: string;
|
||||
generated_at?: string;
|
||||
categories: Record<string, MorningNewsItem[]>;
|
||||
}
|
||||
|
||||
export interface SubCategoryConfig {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CustomFeed {
|
||||
name: string;
|
||||
url: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface SubConfig {
|
||||
categories: SubCategoryConfig[];
|
||||
keywords: string[];
|
||||
custom_feeds: CustomFeed[];
|
||||
feishu_webhook: string;
|
||||
}
|
||||
|
||||
export interface ActivityEntry {
|
||||
kind: string;
|
||||
at?: number | string;
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
agent?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
remark?: string;
|
||||
tools?: { name: string; input_preview?: string }[];
|
||||
tool?: string;
|
||||
output?: string;
|
||||
exitCode?: number | null;
|
||||
items?: TodoItem[];
|
||||
diff?: {
|
||||
changed?: { id: string; from: string; to: string }[];
|
||||
added?: { id: string; title: string }[];
|
||||
removed?: { id: string; title: string }[];
|
||||
};
|
||||
es.onerror = () => {
|
||||
onError?.();
|
||||
};
|
||||
return es;
|
||||
}
|
||||
|
||||
export interface PhaseDuration {
|
||||
phase: string;
|
||||
durationSec: number;
|
||||
durationText: string;
|
||||
ongoing?: boolean;
|
||||
}
|
||||
|
||||
export interface TodosSummary {
|
||||
total: number;
|
||||
completed: number;
|
||||
inProgress: number;
|
||||
notStarted: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface ResourceSummary {
|
||||
totalTokens?: number;
|
||||
totalCost?: number;
|
||||
totalElapsedSec?: number;
|
||||
}
|
||||
|
||||
export interface TimelineEntry {
|
||||
time: string;
|
||||
phase: string;
|
||||
node_id: string;
|
||||
node_name: string;
|
||||
agent: string;
|
||||
action: string;
|
||||
verdict: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface TaskActivityData {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
activity?: ActivityEntry[];
|
||||
timeline?: TimelineEntry[];
|
||||
relatedAgents?: string[];
|
||||
agentLabel?: string;
|
||||
lastActive?: string;
|
||||
phaseDurations?: PhaseDuration[];
|
||||
totalDuration?: string;
|
||||
todosSummary?: TodosSummary;
|
||||
resourceSummary?: ResourceSummary;
|
||||
}
|
||||
|
||||
export interface SchedulerInfo {
|
||||
retryCount?: number;
|
||||
escalationLevel?: number;
|
||||
lastDispatchStatus?: string;
|
||||
stallThresholdSec?: number;
|
||||
enabled?: boolean;
|
||||
lastProgressAt?: string;
|
||||
lastDispatchAt?: string;
|
||||
lastDispatchAgent?: string;
|
||||
autoRollback?: boolean;
|
||||
}
|
||||
|
||||
export interface SchedulerStateData {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
scheduler?: SchedulerInfo;
|
||||
stalledSec?: number;
|
||||
}
|
||||
|
||||
export interface SkillContentResult {
|
||||
ok: boolean;
|
||||
name?: string;
|
||||
agent?: string;
|
||||
content?: string;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ScanAction {
|
||||
taskId: string;
|
||||
action: string;
|
||||
to?: string;
|
||||
toState?: string;
|
||||
stalledSec?: number;
|
||||
}
|
||||
|
||||
export interface CreateTaskPayload {
|
||||
title: string;
|
||||
org?: string;
|
||||
targetDept?: string;
|
||||
priority?: string;
|
||||
templateId?: string;
|
||||
params?: Record<string, string>;
|
||||
// moziplus fields
|
||||
requirement?: string;
|
||||
project_root?: string;
|
||||
project_type?: string;
|
||||
}
|
||||
|
||||
export interface RemoteSkillItem {
|
||||
skillName: string;
|
||||
agentId: string;
|
||||
sourceUrl: string;
|
||||
description: string;
|
||||
localPath: string;
|
||||
addedAt: string;
|
||||
lastUpdated: string;
|
||||
status: 'valid' | 'not-found' | string;
|
||||
}
|
||||
|
||||
export interface RemoteSkillsListResult {
|
||||
ok: boolean;
|
||||
remoteSkills?: RemoteSkillItem[];
|
||||
count?: number;
|
||||
listedAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ── 朝堂议政 ──
|
||||
|
||||
export interface CourtDiscussResult {
|
||||
ok: boolean;
|
||||
session_id?: string;
|
||||
topic?: string;
|
||||
round?: number;
|
||||
new_messages?: Array<{
|
||||
official_id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
emotion?: string;
|
||||
action?: string;
|
||||
}>;
|
||||
scene_note?: string;
|
||||
total_messages?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ── M3: 成果物 ──
|
||||
|
||||
export interface ArtifactEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
type: string; // document | code | data | config | other
|
||||
render_as: string; // markdown | code | table | binary
|
||||
previewable: boolean;
|
||||
}
|
||||
|
||||
export interface ArtifactNode {
|
||||
node_id: string;
|
||||
name: string;
|
||||
agent_id: string;
|
||||
status: string;
|
||||
artifacts: ArtifactEntry[];
|
||||
}
|
||||
|
||||
export interface ArtifactsResult {
|
||||
ok: boolean;
|
||||
deliverable: ArtifactEntry | null;
|
||||
nodes: ArtifactNode[];
|
||||
}
|
||||
|
||||
export interface ArtifactPreviewResult {
|
||||
ok: boolean;
|
||||
previewable: boolean;
|
||||
name: string;
|
||||
type: string;
|
||||
render_as: string;
|
||||
size: number;
|
||||
content: string | null;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ── M3: 人工干预 ──
|
||||
|
||||
export interface CheckpointInfo {
|
||||
node_id: string;
|
||||
node_name: string;
|
||||
agent_id: string;
|
||||
checkpoint: HumanInputCheckpoint;
|
||||
triggered_at: string;
|
||||
}
|
||||
|
||||
export interface HumanInputCheckpoint {
|
||||
type: 'verify' | 'decision' | 'action';
|
||||
title: string;
|
||||
description?: string;
|
||||
urgency?: string;
|
||||
options?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
pros?: string[];
|
||||
cons?: string[];
|
||||
recommended?: boolean;
|
||||
}>;
|
||||
verificationSteps?: string[];
|
||||
autoCheckResults?: Array<{ label: string; passed: boolean }>;
|
||||
actionSteps?: Array<{ id: string; description: string; command?: string }>;
|
||||
verificationCommand?: string;
|
||||
artifacts?: Array<{ name: string; path: string }>;
|
||||
}
|
||||
|
||||
export interface HumanInputResult {
|
||||
ok: boolean;
|
||||
active: boolean;
|
||||
checkpoints: CheckpointInfo[];
|
||||
}
|
||||
|
||||
export interface HumanInputRespondPayload {
|
||||
node_id?: string;
|
||||
action?: string;
|
||||
reason?: string;
|
||||
selected_option?: string;
|
||||
completed_steps?: string[];
|
||||
verification_note?: string;
|
||||
free_text?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
import type { ArtifactsResult, ArtifactEntry } from '../api';
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
document: '📄', code: '💻', data: '📊', config: '⚙️', other: '📁',
|
||||
};
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
export default function ArtifactList({ taskId, isCompleted }: { taskId: string; isCompleted: boolean }) {
|
||||
const [data, setData] = useState<ArtifactsResult | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.artifacts(taskId).then(setData).catch(() => {});
|
||||
}, [taskId]);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const hasDeliverable = !!data.deliverable;
|
||||
const nodeCount = data.nodes.length;
|
||||
|
||||
if (!hasDeliverable && nodeCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--line)', paddingTop: 8, marginTop: 8 }}>
|
||||
{/* 最终交付物 */}
|
||||
{hasDeliverable && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--acc)' }}>📦 最终交付</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<a className="btn-small" href={api.artifactDownloadUrl(taskId, data.deliverable!.path)} download
|
||||
style={{ borderColor: 'var(--acc)', color: 'var(--acc)', textDecoration: 'none', fontSize: 10 }}>
|
||||
📄 {formatSize(data.deliverable!.size)} ⬇
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 节点产出折叠 */}
|
||||
{nodeCount > 0 && (
|
||||
<div>
|
||||
<div onClick={() => setOpen(!open)} style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', userSelect: 'none' }}>
|
||||
<span style={{ fontSize: 11 }}>{open ? '▼' : '▶'}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>📁 {nodeCount}个节点产出</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{data.nodes.map((node) => (
|
||||
<div key={node.node_id} style={{ padding: '4px 0 4px 12px', fontSize: 11, color: 'var(--muted)' }}>
|
||||
<span>{node.status === 'done' ? '✅' : '🔄'}</span>{' '}
|
||||
<span style={{ color: 'var(--fg)' }}>{node.name}</span>
|
||||
{node.artifacts.map((a) => (
|
||||
<span key={a.path} style={{ marginLeft: 8 }}>
|
||||
{TYPE_ICONS[a.type]} {a.name} ({formatSize(a.size)})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
import type { ArtifactsResult, ArtifactEntry, ArtifactPreviewResult } from '../api';
|
||||
import { useStore } from '../store';
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
document: '📄',
|
||||
code: '💻',
|
||||
data: '📊',
|
||||
config: '⚙️',
|
||||
other: '📁',
|
||||
};
|
||||
|
||||
const AGENT_EMOJI: Record<string, string> = {
|
||||
'pangtong-fujunshi': '🐦',
|
||||
'simayi-challenger': '🦅',
|
||||
'jiangwei-infra': '🔧',
|
||||
'guanyu-dev': '⚔️',
|
||||
'zhangfei-dev': '💪',
|
||||
'zhaoyun-data': '📊',
|
||||
};
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
// ── 预览模态框 ──
|
||||
|
||||
function ArtifactPreviewModal({
|
||||
taskId,
|
||||
artifact,
|
||||
onClose,
|
||||
}: {
|
||||
taskId: string;
|
||||
artifact: ArtifactEntry;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [preview, setPreview] = useState<ArtifactPreviewResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const toast = useStore((s) => s.toast);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artifact.previewable) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
api.artifactPreview(taskId, artifact.path)
|
||||
.then(setPreview)
|
||||
.catch(() => toast('预览加载失败', 'err'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [taskId, artifact.path]);
|
||||
|
||||
return (
|
||||
<div className="modal-bg open" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ position: 'relative', maxWidth: 700 }}>
|
||||
<button className="modal-close" onClick={onClose}>✕</button>
|
||||
<div style={{ padding: '12px 20px', borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>{TYPE_ICONS[artifact.type] || '📁'}</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 700 }}>{artifact.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{formatSize(artifact.size)}</span>
|
||||
<a
|
||||
href={api.artifactDownloadUrl(taskId, artifact.path)}
|
||||
download
|
||||
style={{ marginLeft: 'auto', fontSize: 11, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--acc)', color: 'var(--acc)', textDecoration: 'none' }}
|
||||
>⬇ 下载</a>
|
||||
</div>
|
||||
<div style={{ padding: 20, maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{loading && <div style={{ color: 'var(--muted)', textAlign: 'center' }}>加载中...</div>}
|
||||
{!loading && preview && !preview.previewable && (
|
||||
<div style={{ textAlign: 'center', color: 'var(--muted)', padding: 40 }}>
|
||||
<div style={{ fontSize: 24, marginBottom: 8 }}>📁</div>
|
||||
<div>{preview.message || '文件过大或不可预览,请下载查看'}</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && preview?.content && (
|
||||
preview.render_as === 'markdown' ? (
|
||||
<div className="md-preview" dangerouslySetInnerHTML={{ __html: simpleMarkdown(preview.content) }} />
|
||||
) : preview.render_as === 'table' ? (
|
||||
<pre style={{ fontSize: 12, overflow: 'auto' }}>{preview.content}</pre>
|
||||
) : (
|
||||
<pre style={{ background: '#0d1117', padding: 12, borderRadius: 8, fontSize: 12, overflow: 'auto', color: '#e6edf3', fontFamily: 'monospace' }}>{preview.content}</pre>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 简易 Markdown 渲染 ──
|
||||
|
||||
function simpleMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/^### (.+)$/gm, '<h3 style="font-size:14px;font-weight:700;margin:8px 0 4px">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 style="font-size:15px;font-weight:700;margin:10px 0 6px">$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1 style="font-size:17px;font-weight:700;margin:12px 0 8px">$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
||||
.replace(/`([^`]+)`/g, '<code style="background:var(--panel2);padding:1px 4px;border-radius:3px;font-size:11px">$1</code>')
|
||||
.replace(/\n/g, '<br/>');
|
||||
}
|
||||
|
||||
// ── 主面板 ──
|
||||
|
||||
export default function ArtifactPanel({ taskId, isCompleted }: { taskId: string; isCompleted: boolean }) {
|
||||
const [data, setData] = useState<ArtifactsResult | null>(null);
|
||||
const [previewTarget, setPreviewTarget] = useState<ArtifactEntry | null>(null);
|
||||
const toast = useStore((s) => s.toast);
|
||||
|
||||
useEffect(() => {
|
||||
api.artifacts(taskId)
|
||||
.then(setData)
|
||||
.catch(() => {});
|
||||
}, [taskId]);
|
||||
|
||||
if (!data) return <div style={{ color: 'var(--muted)', textAlign: 'center', padding: 20 }}>加载中...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 最终交付物 */}
|
||||
<div style={{ marginBottom: 16, padding: 16, borderRadius: 10, border: '1px solid var(--acc)', background: 'rgba(201,162,39,0.04)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 16 }}>📋</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--acc)' }}>最终交付物</span>
|
||||
</div>
|
||||
{data.deliverable ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: 10, background: 'var(--panel2)', borderRadius: 8 }}>
|
||||
<span style={{ fontSize: 20 }}>{TYPE_ICONS[data.deliverable.type]}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{data.deliverable.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{formatSize(data.deliverable.size)}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="btn-small" style={{ borderColor: 'var(--acc)', color: 'var(--acc)' }} onClick={() => setPreviewTarget(data.deliverable!)}>预览</button>
|
||||
<a className="btn-small" href={api.artifactDownloadUrl(taskId, data.deliverable.path)} download style={{ borderColor: 'var(--acc)', color: 'var(--acc)', textDecoration: 'none' }}>下载</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', padding: 8 }}>
|
||||
{isCompleted ? '无交付物' : '最终交付物将在任务完成后生成'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 节点产出 */}
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--muted)', marginBottom: 10 }}>📁 节点产出</div>
|
||||
{data.nodes.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', textAlign: 'center', padding: 20 }}>暂无节点产出</div>
|
||||
) : data.nodes.map((node) => (
|
||||
<div key={node.node_id} style={{ marginBottom: 8, padding: 12, borderRadius: 10, background: 'var(--panel)', border: '1px solid var(--line)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<span>{node.status === 'done' ? '✅' : node.status === 'waiting_human' ? '🛐' : '🔄'}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{node.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{AGENT_EMOJI[node.agent_id] || ''} {node.agent_id}</span>
|
||||
<span className={`tag tag-${node.status === 'done' ? 'done' : node.status === 'waiting_human' ? 'waiting' : 'exec'}`} style={{ marginLeft: 'auto' }}>
|
||||
{node.status}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{node.artifacts.map((a) => (
|
||||
<div key={a.path} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', background: 'var(--panel2)', borderRadius: 6 }}>
|
||||
<span>{TYPE_ICONS[a.type] || '📁'}</span>
|
||||
<span style={{ fontSize: 12 }}>{a.name}</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{formatSize(a.size)}</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 3 }}>
|
||||
{a.previewable && (
|
||||
<button className="btn-small" style={{ fontSize: 10, padding: '2px 6px' }} onClick={() => setPreviewTarget(a)}>预览</button>
|
||||
)}
|
||||
<a className="btn-small" href={api.artifactDownloadUrl(taskId, a.path)} download style={{ fontSize: 10, padding: '2px 6px', textDecoration: 'none' }}>下载</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 预览模态框 */}
|
||||
{previewTarget && (
|
||||
<ArtifactPreviewModal taskId={taskId} artifact={previewTarget} onClose={() => setPreviewTarget(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import { useStore } from '../store';
|
||||
import type { CheckpointInfo, HumanInputCheckpoint } from '../api';
|
||||
|
||||
const AGENT_EMOJI: Record<string, string> = {
|
||||
'pangtong-fujunshi': '🐦', 'simayi-challenger': '🦅', 'jiangwei-infra': '🔧',
|
||||
'guanyu-dev': '⚔️', 'zhangfei-dev': '💪', 'zhaoyun-data': '📊',
|
||||
};
|
||||
|
||||
// ── Decision Checkpoint ──
|
||||
|
||||
function DecisionCheckpoint({
|
||||
taskId,
|
||||
info,
|
||||
onDone,
|
||||
}: {
|
||||
taskId: string;
|
||||
info: CheckpointInfo;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const cp = info.checkpoint;
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const toast = useStore((s) => s.toast);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selected) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.humanInputRespond(taskId, {
|
||||
node_id: info.node_id,
|
||||
action: 'decide',
|
||||
selected_option: selected,
|
||||
reason: note,
|
||||
});
|
||||
if (r.ok) { toast('✅ 已御批', 'ok'); onDone(); }
|
||||
else toast(r.error || '操作失败', 'err');
|
||||
} catch { toast('服务器连接失败', 'err'); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 14, padding: 12, background: 'var(--panel2)', borderRadius: 8 }}>
|
||||
{cp.description}
|
||||
</div>
|
||||
{/* 关联产出物 */}
|
||||
{cp.artifacts?.length ? (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}>📦 关联产出物</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{cp.artifacts.map((a, i) => (
|
||||
<a key={i} href={api.artifactDownloadUrl(taskId, a.path)} target="_blank" rel="noreferrer"
|
||||
style={{ fontSize: 10, padding: '2px 8px', borderRadius: 4, border: '1px solid var(--line)', color: 'var(--fg)', textDecoration: 'none' }}>
|
||||
📄 {a.name} ↗
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 方案 */}
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--muted)', marginBottom: 8 }}>📋 备选方案</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 14, flexWrap: 'wrap' }}>
|
||||
{cp.options?.map((opt) => (
|
||||
<div key={opt.id}
|
||||
onClick={() => setSelected(opt.id)}
|
||||
style={{
|
||||
flex: '1 1 180px', padding: 14, borderRadius: 10, cursor: 'pointer',
|
||||
border: `2px solid ${selected === opt.id ? 'var(--acc)' : 'var(--line)'}`,
|
||||
background: selected === opt.id ? 'rgba(201,162,39,0.08)' : 'var(--panel)',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
{opt.recommended && (
|
||||
<div style={{ float: 'right', background: '#22c55e', color: '#fff', fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 3 }}>荐</div>
|
||||
)}
|
||||
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 4 }}>{opt.label}</div>
|
||||
{opt.description && <div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 6 }}>{opt.description}</div>}
|
||||
{opt.pros?.map((p, i) => <div key={i} style={{ fontSize: 11, color: '#22c55e' }}>👍 {p}</div>)}
|
||||
{opt.cons?.map((c, i) => <div key={i} style={{ fontSize: 11, color: '#ef4444' }}>👎 {c}</div>)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 兜底自由输入 */}
|
||||
{(!cp.options || cp.options.length === 0) && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}>无预定义方案,请直接输入决策:</div>
|
||||
<textarea style={{ width: '100%', height: 60, background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 6, padding: 8, color: 'var(--fg)', fontSize: 12 }}
|
||||
value={note} onChange={(e) => setNote(e.target.value)} placeholder="输入你的决策..." />
|
||||
</div>
|
||||
)}
|
||||
{/* 御批备注 */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}>📝 御批备注(可选)</div>
|
||||
<textarea style={{ width: '100%', height: 48, background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 6, padding: 8, color: 'var(--fg)', fontSize: 12 }}
|
||||
value={note} onChange={(e) => setNote(e.target.value)} placeholder="记录决策理由..." />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button className="btn-primary" disabled={!selected || loading} onClick={handleSubmit}>
|
||||
🎯 依此方案施行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Verify Checkpoint ──
|
||||
|
||||
function VerifyCheckpoint({
|
||||
taskId,
|
||||
info,
|
||||
onDone,
|
||||
}: {
|
||||
taskId: string;
|
||||
info: CheckpointInfo;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const cp = info.checkpoint;
|
||||
const [reason, setReason] = useState('');
|
||||
const [showReject, setShowReject] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const toast = useStore((s) => s.toast);
|
||||
|
||||
const handleAction = async (action: string) => {
|
||||
if (action === 'reject' && !showReject) { setShowReject(true); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.humanInputRespond(taskId, {
|
||||
node_id: info.node_id,
|
||||
action,
|
||||
reason,
|
||||
});
|
||||
if (r.ok) { toast(action === 'approve' ? '✅ 已御批' : '❌ 已驳回', 'ok'); onDone(); }
|
||||
else toast(r.error || '操作失败', 'err');
|
||||
} catch { toast('服务器连接失败', 'err'); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 12, padding: 12, background: 'var(--panel2)', borderRadius: 8 }}>
|
||||
{cp.description}
|
||||
</div>
|
||||
{/* 自动核验 */}
|
||||
{cp.autoCheckResults?.length ? (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', marginBottom: 6 }}>自动核验结果</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{cp.autoCheckResults.map((r, i) => (
|
||||
<span key={i} style={{ fontSize: 11, padding: '3px 10px', borderRadius: 4, background: r.passed ? '#22c55e22' : '#ef444422', color: r.passed ? '#22c55e' : '#ef4444' }}>
|
||||
{r.passed ? '✅' : '❌'} {r.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 人工核验步骤 */}
|
||||
{cp.verificationSteps?.length ? (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', marginBottom: 6 }}>人工核验步骤</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.8, paddingLeft: 16, borderLeft: '2px solid var(--line)' }}>
|
||||
{cp.verificationSteps.map((s, i) => <div key={i}>{i + 1}. {s}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 关联产出物 */}
|
||||
{cp.artifacts?.length ? (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}>📦 关联产出物</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{cp.artifacts.map((a, i) => (
|
||||
<a key={i} href={api.artifactDownloadUrl(taskId, a.path)} target="_blank" rel="noreferrer"
|
||||
style={{ fontSize: 10, padding: '2px 8px', borderRadius: 4, border: '1px solid var(--line)', color: 'var(--fg)', textDecoration: 'none' }}>
|
||||
📄 {a.name} ↗
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 驳回输入 */}
|
||||
{showReject && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}>驳回缘由</div>
|
||||
<textarea style={{ width: '100%', height: 60, background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 6, padding: 8, color: 'var(--fg)', fontSize: 12 }}
|
||||
value={reason} onChange={(e) => setReason(e.target.value)} placeholder="请说明驳回原因..." />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button className="btn-ghost" style={{ color: '#ef4444', borderColor: '#ef444444' }} onClick={() => handleAction('reject')} disabled={loading}>
|
||||
❌ 驳回另议
|
||||
</button>
|
||||
<button className="btn-primary" style={{ background: 'linear-gradient(135deg, #22c55e, #16a34a)' }} onClick={() => handleAction('approve')} disabled={loading}>
|
||||
✅ 御批依准施行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Action Checkpoint ──
|
||||
|
||||
function ActionCheckpoint({
|
||||
taskId,
|
||||
info,
|
||||
onDone,
|
||||
}: {
|
||||
taskId: string;
|
||||
info: CheckpointInfo;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const cp = info.checkpoint;
|
||||
const steps = cp.actionSteps || [];
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
|
||||
const [note, setNote] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const toast = useStore((s) => s.toast);
|
||||
|
||||
const toggleStep = (id: string) => {
|
||||
setCompletedSteps((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const allDone = completedSteps.size >= steps.length;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!allDone) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.humanInputRespond(taskId, {
|
||||
node_id: info.node_id,
|
||||
action: 'complete',
|
||||
completed_steps: Array.from(completedSteps),
|
||||
verification_note: note,
|
||||
});
|
||||
if (r.ok) { toast('✅ 已确认', 'ok'); onDone(); }
|
||||
else toast(r.error || '操作失败', 'err');
|
||||
} catch { toast('服务器连接失败', 'err'); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 12, padding: 12, background: 'var(--panel2)', borderRadius: 8 }}>
|
||||
{cp.description}
|
||||
</div>
|
||||
{/* 进度条 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||
<div style={{ flex: 1, background: 'var(--panel2)', borderRadius: 4, height: 6, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 4, background: '#f59e0b', transition: 'width 0.3s', width: `${steps.length ? (completedSteps.size / steps.length * 100) : 0}%` }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{completedSteps.size}/{steps.length} 步骤</span>
|
||||
</div>
|
||||
{/* 步骤列表 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 14 }}>
|
||||
{steps.map((s) => (
|
||||
<div key={s.id} onClick={() => toggleStep(s.id)} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: 10, background: 'var(--panel2)', borderRadius: 8, cursor: 'pointer' }}>
|
||||
<div style={{
|
||||
width: 20, height: 20, borderRadius: 4, border: `2px solid ${completedSteps.has(s.id) ? '#22c55e' : 'var(--line)'}`,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, flexShrink: 0,
|
||||
background: completedSteps.has(s.id) ? '#22c55e' : 'transparent', color: '#fff',
|
||||
}}>{completedSteps.has(s.id) ? '✓' : ''}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, textDecoration: completedSteps.has(s.id) ? 'line-through' : 'none' }}>{s.description}</div>
|
||||
{s.command && (
|
||||
<pre style={{ marginTop: 4, fontSize: 11, padding: '4px 8px', background: '#0d1117', borderRadius: 4, color: '#e6edf3', fontFamily: 'monospace' }}>{s.command}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 验证命令 */}
|
||||
{cp.verificationCommand && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}>验证命令</div>
|
||||
<pre style={{ fontSize: 11, padding: '6px 10px', background: '#0d1117', borderRadius: 6, color: '#e6edf3', fontFamily: 'monospace' }}>{cp.verificationCommand}</pre>
|
||||
</div>
|
||||
)}
|
||||
{/* 验证说明 */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}>验证结果说明</div>
|
||||
<input style={{ width: '100%', background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 6, padding: '6px 10px', color: 'var(--fg)', fontSize: 12 }}
|
||||
value={note} onChange={(e) => setNote(e.target.value)} placeholder="如:返回 200 OK,服务正常运行" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button className="btn-primary" disabled={!allDone || loading} onClick={handleSubmit} title={allDone ? '' : '请完成所有步骤后提交'}>
|
||||
✅ 臣已办妥,继续执行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 主面板 ──
|
||||
|
||||
export default function CheckpointPanel({
|
||||
taskId,
|
||||
checkpoints,
|
||||
onDone,
|
||||
}: {
|
||||
taskId: string;
|
||||
checkpoints: CheckpointInfo[];
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const info = checkpoints[activeIdx];
|
||||
if (!info) return null;
|
||||
|
||||
const cp = info.checkpoint;
|
||||
const typeIcon = cp.type === 'verify' ? '🔍' : cp.type === 'decision' ? '🎯' : '🔧';
|
||||
const typeColor = cp.type === 'verify' ? '#6a9eff' : cp.type === 'decision' ? '#818cf8' : '#f59e0b';
|
||||
const typeLabel = cp.type === 'verify' ? '验证 Checkpoint' : cp.type === 'decision' ? '决策 Checkpoint' : '执行 Checkpoint';
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 多节点 Tab */}
|
||||
{checkpoints.length > 1 && (
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--line)', marginBottom: 12 }}>
|
||||
{checkpoints.map((c, i) => (
|
||||
<button key={c.node_id}
|
||||
className={`tab-btn ${i === activeIdx ? 'active' : ''}`}
|
||||
onClick={() => setActiveIdx(i)}
|
||||
style={{ fontSize: 12, color: i === activeIdx ? typeColor : 'var(--muted)', borderBottomColor: i === activeIdx ? typeColor : 'transparent' }}
|
||||
>🛐 {c.node_name}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型头部 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: '50%', background: `${typeColor}22`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16 }}>{typeIcon}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: typeColor }}>{typeLabel}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>节点 {info.node_name}({AGENT_EMOJI[info.agent_id] || ''} {info.agent_id})</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>{cp.title}</div>
|
||||
|
||||
{/* 按类型渲染 */}
|
||||
{cp.type === 'verify' && <VerifyCheckpoint taskId={taskId} info={info} onDone={onDone} />}
|
||||
{cp.type === 'decision' && <DecisionCheckpoint taskId={taskId} info={info} onDone={onDone} />}
|
||||
{cp.type === 'action' && <ActionCheckpoint taskId={taskId} info={info} onDone={onDone} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
message: string;
|
||||
okLabel: string;
|
||||
okClass?: string;
|
||||
onOk: (reason: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({ title, message, okLabel, okClass, onOk, onCancel }: Props) {
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
return (
|
||||
<div className="confirm-bg open" onClick={onCancel}>
|
||||
<div className="confirm-box" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="confirm-title" dangerouslySetInnerHTML={{ __html: title }} />
|
||||
<div className="confirm-msg" dangerouslySetInnerHTML={{ __html: message }} />
|
||||
<textarea
|
||||
className="confirm-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="输入原因(可留空)"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="confirm-btns">
|
||||
<button className="btn btn-g" onClick={onCancel}>取消</button>
|
||||
<button className={`btn btn-action ${okClass || ''}`} onClick={() => onOk(reason)}>
|
||||
{okLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useStore, isEdict } from '../store';
|
||||
|
||||
export default function CourtCeremony() {
|
||||
const liveStatus = useStore((s) => s.liveStatus);
|
||||
const [show, setShow] = useState(false);
|
||||
const [out, setOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const lastOpen = localStorage.getItem('openclaw_court_date');
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
const pref = JSON.parse(localStorage.getItem('openclaw_court_pref') || '{"enabled":true}');
|
||||
if (!pref.enabled || lastOpen === today) return;
|
||||
localStorage.setItem('openclaw_court_date', today);
|
||||
setShow(true);
|
||||
const timer = setTimeout(() => skip(), 3500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const skip = () => {
|
||||
setOut(true);
|
||||
setTimeout(() => setShow(false), 500);
|
||||
};
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const tasks = liveStatus?.tasks || [];
|
||||
const jjc = tasks.filter(isEdict);
|
||||
const pending = jjc.filter((t) => !['Done', 'Cancelled'].includes(t.state)).length;
|
||||
const done = jjc.filter((t) => t.state === 'Done').length;
|
||||
const overdue = jjc.filter(
|
||||
(t) => t.state !== 'Done' && t.state !== 'Cancelled' && t.eta && new Date(t.eta.replace(' ', 'T')) < new Date()
|
||||
).length;
|
||||
|
||||
const d = new Date();
|
||||
const days = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
const dateStr = `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 · ${days[d.getDay()]}曜日`;
|
||||
|
||||
return (
|
||||
<div className={`ceremony-bg${out ? ' out' : ''}`} onClick={skip}>
|
||||
<div className="crm-glow" />
|
||||
<div className="crm-line1 in">🏛 军议开始</div>
|
||||
<div className="crm-line2 in">各将军就位,商议军情</div>
|
||||
<div className="crm-line3 in">
|
||||
待办 {pending} 件 · 已完成 {done} 件{overdue > 0 && ` · ⚠ 超期 ${overdue} 件`}
|
||||
</div>
|
||||
<div className="crm-date in">{dateStr}</div>
|
||||
<div className="crm-skip" style={{ fontSize: '16px', opacity: 0.6, marginTop: '40px' }}>⚡ 点击任意处跳过(自动消失 3s)</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
/**
|
||||
* 军议大厅 — 多将军实时讨论可视化组件
|
||||
*
|
||||
* 灵感来自 nvwa 项目的故事剧场 + 协作工坊 + 虚拟生活
|
||||
* 功能:
|
||||
* - 可视化朝堂布局,将军站位
|
||||
* - 实时群聊讨论,将军各抒己见
|
||||
* - 皇帝(用户)随时发言参与
|
||||
* - 天命降临(上帝视角)改变讨论走向
|
||||
* - 命运骰子:随机事件增加趣味性
|
||||
* - 自动推进 / 手动推进
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useStore, DEPTS } from '../store';
|
||||
import { api } from '../api';
|
||||
|
||||
// ── 常量 ──
|
||||
|
||||
const OFFICIAL_COLORS: Record<string, string> = {
|
||||
taizi: '#e8a040', zhongshu: '#a07aff', menxia: '#6a9eff', shangshu: '#2ecc8a',
|
||||
libu: '#f5c842', hubu: '#ff9a6a', bingbu: '#ff5270', xingbu: '#cc4444',
|
||||
gongbu: '#44aaff', libu_hr: '#9b59b6',
|
||||
};
|
||||
|
||||
const EMOTION_EMOJI: Record<string, string> = {
|
||||
neutral: '', confident: '😏', worried: '😟', angry: '😤',
|
||||
thinking: '🤔', amused: '😄', happy: '😊',
|
||||
};
|
||||
|
||||
const COURT_POSITIONS: Record<string, { x: number; y: number }> = {
|
||||
// 左列
|
||||
zhongshu: { x: 15, y: 25 }, menxia: { x: 15, y: 45 }, shangshu: { x: 15, y: 65 },
|
||||
// 右列
|
||||
libu: { x: 85, y: 20 }, hubu: { x: 85, y: 35 }, bingbu: { x: 85, y: 50 },
|
||||
xingbu: { x: 85, y: 65 }, gongbu: { x: 85, y: 80 },
|
||||
// 中间
|
||||
taizi: { x: 50, y: 20 }, libu_hr: { x: 50, y: 80 },
|
||||
};
|
||||
|
||||
interface CourtMessage {
|
||||
type: string;
|
||||
content: string;
|
||||
official_id?: string;
|
||||
official_name?: string;
|
||||
emotion?: string;
|
||||
action?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface CourtSession {
|
||||
session_id: string;
|
||||
topic: string;
|
||||
officials: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
role: string;
|
||||
personality: string;
|
||||
speaking_style: string;
|
||||
}>;
|
||||
messages: CourtMessage[];
|
||||
round: number;
|
||||
phase: string;
|
||||
}
|
||||
|
||||
export default function CourtDiscussion() {
|
||||
// Phase: setup | session
|
||||
const [phase, setPhase] = useState<'setup' | 'session'>('setup');
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [topic, setTopic] = useState('');
|
||||
const [session, setSession] = useState<CourtSession | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [autoPlay, setAutoPlay] = useState(false);
|
||||
const autoPlayRef = useRef(false);
|
||||
|
||||
// 皇帝发言
|
||||
const [userInput, setUserInput] = useState('');
|
||||
// 天命降临
|
||||
const [showDecree, setShowDecree] = useState(false);
|
||||
const [decreeInput, setDecreeInput] = useState('');
|
||||
const [decreeFlash, setDecreeFlash] = useState(false);
|
||||
// 命运骰子
|
||||
const [diceRolling, setDiceRolling] = useState(false);
|
||||
const [diceResult, setDiceResult] = useState<string | null>(null);
|
||||
// 活跃说话将军
|
||||
const [speakingId, setSpeakingId] = useState<string | null>(null);
|
||||
// 将军情绪
|
||||
const [emotions, setEmotions] = useState<Record<string, string>>({});
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const toast = useStore((s) => s.toast);
|
||||
const liveStatus = useStore((s) => s.liveStatus);
|
||||
|
||||
// 自动滚到底部
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [session?.messages?.length]);
|
||||
|
||||
// 自动推进
|
||||
useEffect(() => {
|
||||
autoPlayRef.current = autoPlay;
|
||||
}, [autoPlay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoPlay || !session || loading) return;
|
||||
const timer = setInterval(() => {
|
||||
if (autoPlayRef.current && !loading) {
|
||||
handleAdvance();
|
||||
}
|
||||
}, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoPlay, session, loading]);
|
||||
|
||||
// ── 切换将军选中 ──
|
||||
const toggleOfficial = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else if (next.size < 8) next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ── 开始议政 ──
|
||||
const handleStart = async () => {
|
||||
if (!topic.trim() || selectedIds.size < 2 || loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.courtDiscussStart(topic, Array.from(selectedIds));
|
||||
if (!res.ok) throw new Error(res.error || '启动失败');
|
||||
setSession(res as unknown as CourtSession);
|
||||
setPhase('session');
|
||||
} catch (e: unknown) {
|
||||
toast((e as Error).message || '启动失败', 'err');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 推进讨论 ──
|
||||
const handleAdvance = useCallback(async (userMsg?: string, decree?: string) => {
|
||||
if (!session || loading) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await api.courtDiscussAdvance(session.session_id, userMsg, decree);
|
||||
if (!res.ok) throw new Error(res.error || '推进失败');
|
||||
|
||||
// 更新 session messages(追加新消息)
|
||||
setSession((prev) => {
|
||||
if (!prev) return prev;
|
||||
const newMsgs: CourtMessage[] = [];
|
||||
|
||||
if (userMsg) {
|
||||
newMsgs.push({ type: 'emperor', content: userMsg, timestamp: Date.now() / 1000 });
|
||||
}
|
||||
if (decree) {
|
||||
newMsgs.push({ type: 'decree', content: decree, timestamp: Date.now() / 1000 });
|
||||
}
|
||||
|
||||
const aiMsgs = (res.new_messages || []).map((m: Record<string, string>) => ({
|
||||
type: 'official',
|
||||
official_id: m.official_id,
|
||||
official_name: m.name,
|
||||
content: m.content,
|
||||
emotion: m.emotion,
|
||||
action: m.action,
|
||||
timestamp: Date.now() / 1000,
|
||||
}));
|
||||
|
||||
if (res.scene_note) {
|
||||
newMsgs.push({ type: 'scene_note', content: res.scene_note, timestamp: Date.now() / 1000 });
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
round: res.round ?? prev.round + 1,
|
||||
messages: [...prev.messages, ...newMsgs, ...aiMsgs],
|
||||
};
|
||||
});
|
||||
|
||||
// 动画:依次高亮说话的将军
|
||||
const aiMsgs = res.new_messages || [];
|
||||
if (aiMsgs.length > 0) {
|
||||
const emotionMap: Record<string, string> = {};
|
||||
let idx = 0;
|
||||
const cycle = () => {
|
||||
if (idx < aiMsgs.length) {
|
||||
setSpeakingId(aiMsgs[idx].official_id);
|
||||
emotionMap[aiMsgs[idx].official_id] = aiMsgs[idx].emotion || 'neutral';
|
||||
idx++;
|
||||
setTimeout(cycle, 1200);
|
||||
} else {
|
||||
setSpeakingId(null);
|
||||
}
|
||||
};
|
||||
cycle();
|
||||
setEmotions((prev) => ({ ...prev, ...emotionMap }));
|
||||
}
|
||||
} catch {
|
||||
// silently
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [session, loading]);
|
||||
|
||||
// ── 皇帝发言 ──
|
||||
const handleEmperor = () => {
|
||||
const msg = userInput.trim();
|
||||
if (!msg) return;
|
||||
setUserInput('');
|
||||
handleAdvance(msg);
|
||||
};
|
||||
|
||||
// ── 天命降临 ──
|
||||
const handleDecree = () => {
|
||||
const msg = decreeInput.trim();
|
||||
if (!msg) return;
|
||||
setDecreeInput('');
|
||||
setShowDecree(false);
|
||||
setDecreeFlash(true);
|
||||
setTimeout(() => setDecreeFlash(false), 800);
|
||||
handleAdvance(undefined, msg);
|
||||
};
|
||||
|
||||
// ── 命运骰子 ──
|
||||
const handleDice = async () => {
|
||||
if (loading || diceRolling) return;
|
||||
setDiceRolling(true);
|
||||
setDiceResult(null);
|
||||
|
||||
// 滚动动画
|
||||
let count = 0;
|
||||
const timer = setInterval(async () => {
|
||||
count++;
|
||||
setDiceResult('🎲 命运轮转中...');
|
||||
if (count >= 6) {
|
||||
clearInterval(timer);
|
||||
try {
|
||||
const res = await api.courtDiscussFate();
|
||||
const event = res.event || '边疆急报传来';
|
||||
setDiceResult(event);
|
||||
setDiceRolling(false);
|
||||
// 自动作为天命降临注入
|
||||
handleAdvance(undefined, `【命运骰子】${event}`);
|
||||
} catch {
|
||||
setDiceResult('命运之力暂时无法触及');
|
||||
setDiceRolling(false);
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// ── 结束议政 ──
|
||||
const handleConclude = async () => {
|
||||
if (!session) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.courtDiscussConclude(session.session_id);
|
||||
if (res.summary) {
|
||||
setSession((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
phase: 'concluded',
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{ type: 'system', content: `📋 军议大厅结束 — ${res.summary}`, timestamp: Date.now() / 1000 },
|
||||
],
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
}
|
||||
setAutoPlay(false);
|
||||
} catch {
|
||||
toast('结束失败', 'err');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 重置 ──
|
||||
const handleReset = () => {
|
||||
if (session) {
|
||||
api.courtDiscussDestroy(session.session_id).catch(() => {});
|
||||
}
|
||||
setPhase('setup');
|
||||
setSession(null);
|
||||
setAutoPlay(false);
|
||||
setEmotions({});
|
||||
setSpeakingId(null);
|
||||
setDiceResult(null);
|
||||
};
|
||||
|
||||
// ── 预设议题(从当前旨意中提取)──
|
||||
const activeEdicts = (liveStatus?.tasks || []).filter(
|
||||
(t) => /^JJC-/i.test(t.id) && !['Done', 'Cancelled'].includes(t.state),
|
||||
);
|
||||
|
||||
const presetTopics = [
|
||||
...activeEdicts.slice(0, 3).map((t) => ({
|
||||
text: `讨论旨意 ${t.id}:${t.title}`,
|
||||
taskId: t.id,
|
||||
icon: '📜',
|
||||
})),
|
||||
{ text: '讨论系统架构优化方案', taskId: '', icon: '🏗️' },
|
||||
{ text: '评估当前项目进展和风险', taskId: '', icon: '📊' },
|
||||
{ text: '制定下周工作计划', taskId: '', icon: '📋' },
|
||||
{ text: '紧急问题:线上Bug排查方案', taskId: '', icon: '🚨' },
|
||||
];
|
||||
|
||||
// ═══════════════════
|
||||
// 渲染:设置页
|
||||
// ═══════════════════
|
||||
|
||||
if (phase === 'setup') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center py-4">
|
||||
<h2 className="text-xl font-bold bg-gradient-to-r from-amber-400 to-purple-400 bg-clip-text text-transparent">
|
||||
🏛 军议大厅
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--muted)] mt-1">
|
||||
择臣上殿,围绕议题展开讨论 · 陛下可随时发言或降下天意改变走向
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 选择将军 */}
|
||||
<div className="bg-[var(--panel)] rounded-xl p-4 border border-[var(--line)]">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm font-semibold">👔 选择参朝将军</span>
|
||||
<span className="text-xs text-[var(--muted)]">({selectedIds.size}/8,至少2位)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
||||
{DEPTS.map((d) => {
|
||||
const active = selectedIds.has(d.id);
|
||||
const color = OFFICIAL_COLORS[d.id] || '#6a9eff';
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
onClick={() => toggleOfficial(d.id)}
|
||||
className="p-2.5 rounded-lg border transition-all text-left"
|
||||
style={{
|
||||
borderColor: active ? color + '80' : 'var(--line)',
|
||||
background: active ? color + '15' : 'var(--panel2)',
|
||||
boxShadow: active ? `0 0 12px ${color}20` : 'none',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-lg">{d.emoji}</span>
|
||||
<div>
|
||||
<div className="text-xs font-semibold" style={{ color: active ? color : 'var(--text)' }}>
|
||||
{d.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--muted)]">{d.role}</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span
|
||||
className="ml-auto w-4 h-4 rounded-full flex items-center justify-center text-[10px] text-white"
|
||||
style={{ background: color }}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 议题 */}
|
||||
<div className="bg-[var(--panel)] rounded-xl p-4 border border-[var(--line)]">
|
||||
<div className="text-sm font-semibold mb-2">📜 设定议题</div>
|
||||
{presetTopics.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{presetTopics.map((p, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setTopic(p.text)}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg border border-[var(--line)] hover:border-[var(--acc)] hover:text-[var(--acc)] transition-colors"
|
||||
style={{
|
||||
background: topic === p.text ? 'var(--acc)' + '18' : 'transparent',
|
||||
borderColor: topic === p.text ? 'var(--acc)' : undefined,
|
||||
color: topic === p.text ? 'var(--acc)' : undefined,
|
||||
}}
|
||||
>
|
||||
{p.icon} {p.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="w-full bg-[var(--panel2)] rounded-lg p-3 text-sm border border-[var(--line)] focus:border-[var(--acc)] outline-none resize-none"
|
||||
rows={2}
|
||||
placeholder="或自定义议题..."
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 功能特性标签 */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[
|
||||
'👑 皇帝发言', '⚡ 天命降临', '🎲 命运骰子',
|
||||
'🔄 自动推进', '📜 讨论记录',
|
||||
].map((tag) => (
|
||||
<span key={tag} className="text-[10px] px-2 py-1 rounded-full border border-[var(--line)] text-[var(--muted)]">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 开始按钮 */}
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={selectedIds.size < 2 || !topic.trim() || loading}
|
||||
className="w-full py-3 rounded-xl font-semibold text-sm transition-all border-0"
|
||||
style={{
|
||||
background:
|
||||
selectedIds.size >= 2 && topic.trim()
|
||||
? 'linear-gradient(135deg, #6a9eff, #a07aff)'
|
||||
: 'var(--panel2)',
|
||||
color: selectedIds.size >= 2 && topic.trim() ? '#fff' : 'var(--muted)',
|
||||
opacity: loading ? 0.6 : 1,
|
||||
cursor: selectedIds.size >= 2 && topic.trim() && !loading ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
{loading ? '召集中...' : `🏛 开始朝议(${selectedIds.size}位上殿)`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════
|
||||
// 渲染:议政进行中
|
||||
// ═══════════════════
|
||||
|
||||
const officials = session?.officials || [];
|
||||
const messages = session?.messages || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 顶部控制栏 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2 bg-[var(--panel)] rounded-xl px-4 py-2 border border-[var(--line)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold">🏛 军议大厅</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[var(--acc)]20 text-[var(--acc)] border border-[var(--acc)]30">
|
||||
第{session?.round || 0}轮
|
||||
</span>
|
||||
{session?.phase === 'concluded' && (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-800">
|
||||
已结束
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setShowDecree(!showDecree)}
|
||||
className="text-xs px-2.5 py-1 rounded-lg border border-amber-600/40 text-amber-400 hover:bg-amber-900/20 transition"
|
||||
title="天命降临 — 上帝视角干预"
|
||||
>
|
||||
⚡ 天命
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDice}
|
||||
disabled={diceRolling || loading}
|
||||
className="text-xs px-2.5 py-1 rounded-lg border border-purple-600/40 text-purple-400 hover:bg-purple-900/20 transition"
|
||||
title="命运骰子 — 随机事件"
|
||||
>
|
||||
🎲 {diceRolling ? '...' : '骰子'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAutoPlay(!autoPlay)}
|
||||
className={`text-xs px-2.5 py-1 rounded-lg border transition ${autoPlay
|
||||
? 'border-green-600/40 text-green-400 bg-green-900/20'
|
||||
: 'border-[var(--line)] text-[var(--muted)] hover:text-[var(--text)]'
|
||||
}`}
|
||||
>
|
||||
{autoPlay ? '⏸ 暂停' : '▶ 自动'}
|
||||
</button>
|
||||
{session?.phase !== 'concluded' && (
|
||||
<button
|
||||
onClick={handleConclude}
|
||||
className="text-xs px-2.5 py-1 rounded-lg border border-[var(--line)] text-[var(--muted)] hover:text-[var(--warn)] hover:border-[var(--warn)]40 transition"
|
||||
>
|
||||
📋 散朝
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="text-xs px-2 py-1 rounded-lg border border-red-900/40 text-red-400/70 hover:text-red-400 transition"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 天命降临面板 */}
|
||||
{showDecree && (
|
||||
<div
|
||||
className="bg-gradient-to-br from-amber-950/40 to-purple-950/30 rounded-xl p-4 border border-amber-700/30"
|
||||
style={{ animation: 'fadeIn .3s' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-amber-400">⚡ 天命降临 — 上帝视角</span>
|
||||
<button onClick={() => setShowDecree(false)} className="text-xs text-[var(--muted)]">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-amber-300/60 mb-2">
|
||||
降下天意改变讨论走向,所有将军将对此做出反应
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={decreeInput}
|
||||
onChange={(e) => setDecreeInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleDecree()}
|
||||
placeholder="例如:突然发现预算多出一倍..."
|
||||
className="flex-1 bg-black/30 rounded-lg px-3 py-1.5 text-sm border border-amber-800/40 outline-none focus:border-amber-600"
|
||||
/>
|
||||
<button
|
||||
onClick={handleDecree}
|
||||
disabled={!decreeInput.trim()}
|
||||
className="px-4 py-1.5 rounded-lg bg-gradient-to-r from-amber-600 to-purple-600 text-white text-xs font-semibold disabled:opacity-40"
|
||||
>
|
||||
降旨
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 命运骰子结果 */}
|
||||
{diceResult && (
|
||||
<div
|
||||
className="bg-purple-950/40 rounded-lg px-3 py-2 border border-purple-700/30 text-xs text-purple-300 flex items-center gap-2"
|
||||
style={{ animation: 'fadeIn .3s' }}
|
||||
>
|
||||
<span className="text-lg">🎲</span>
|
||||
{diceResult}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 天命降临闪光效果 */}
|
||||
{decreeFlash && (
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none z-50"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(255,200,50,0.3), transparent 70%)',
|
||||
animation: 'fadeOut .8s forwards',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 议题 */}
|
||||
<div className="text-xs text-center text-[var(--muted)] py-1">
|
||||
📜 {session?.topic || ''}
|
||||
</div>
|
||||
|
||||
{/* 主内容:朝堂布局 + 聊天记录 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-3">
|
||||
{/* 左侧:朝堂可视化 */}
|
||||
<div className="bg-[var(--panel)] rounded-xl p-3 border border-[var(--line)] relative overflow-hidden min-h-[320px]">
|
||||
{/* 龙椅 */}
|
||||
<div className="text-center mb-2">
|
||||
<div className="inline-block px-3 py-1 rounded-lg bg-gradient-to-b from-amber-800/40 to-amber-950/40 border border-amber-700/30">
|
||||
<span className="text-lg">👑</span>
|
||||
<div className="text-[10px] text-amber-400/80">龙 椅</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 将军站位 */}
|
||||
<div className="relative" style={{ minHeight: 250 }}>
|
||||
{/* 左列标签 */}
|
||||
<div className="absolute left-0 top-0 text-[9px] text-[var(--muted)] opacity-50">三省</div>
|
||||
<div className="absolute right-0 top-0 text-[9px] text-[var(--muted)] opacity-50">六部</div>
|
||||
|
||||
{officials.map((o) => {
|
||||
const pos = COURT_POSITIONS[o.id] || { x: 50, y: 50 };
|
||||
const color = OFFICIAL_COLORS[o.id] || '#6a9eff';
|
||||
const isSpeaking = speakingId === o.id;
|
||||
const emotion = emotions[o.id] || 'neutral';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={o.id}
|
||||
className="absolute transition-all duration-500"
|
||||
style={{
|
||||
left: `${pos.x}%`,
|
||||
top: `${pos.y}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
{/* 说话光圈 */}
|
||||
{isSpeaking && (
|
||||
<div
|
||||
className="absolute -inset-2 rounded-full"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${color}40, transparent)`,
|
||||
animation: 'pulse 1s infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 头像 */}
|
||||
<div
|
||||
className="relative w-10 h-10 rounded-full flex items-center justify-center text-lg border-2 transition-all"
|
||||
style={{
|
||||
borderColor: isSpeaking ? color : color + '40',
|
||||
background: isSpeaking ? color + '30' : color + '10',
|
||||
transform: isSpeaking ? 'scale(1.2)' : 'scale(1)',
|
||||
boxShadow: isSpeaking ? `0 0 16px ${color}50` : 'none',
|
||||
}}
|
||||
>
|
||||
{o.emoji}
|
||||
{/* 情绪气泡 */}
|
||||
{EMOTION_EMOJI[emotion] && (
|
||||
<span
|
||||
className="absolute -top-1 -right-1 text-xs"
|
||||
style={{ animation: 'bounceIn .3s' }}
|
||||
>
|
||||
{EMOTION_EMOJI[emotion]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 名字 */}
|
||||
<div
|
||||
className="text-[9px] text-center mt-0.5 whitespace-nowrap"
|
||||
style={{ color: isSpeaking ? color : 'var(--muted)' }}
|
||||
>
|
||||
{o.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:聊天记录 */}
|
||||
<div className="bg-[var(--panel)] rounded-xl border border-[var(--line)] flex flex-col" style={{ maxHeight: 500 }}>
|
||||
{/* 消息列表 */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2" style={{ minHeight: 200 }}>
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} msg={msg} officials={officials} />
|
||||
))}
|
||||
{loading && (
|
||||
<div className="text-xs text-[var(--muted)] text-center py-2" style={{ animation: 'pulse 1.5s infinite' }}>
|
||||
🏛 群臣正在思考...
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* 皇帝输入栏 */}
|
||||
{session?.phase !== 'concluded' && (
|
||||
<div className="border-t border-[var(--line)] p-2 flex gap-2">
|
||||
<input
|
||||
value={userInput}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleEmperor()}
|
||||
placeholder="朕有话说..."
|
||||
className="flex-1 bg-[var(--panel2)] rounded-lg px-3 py-1.5 text-sm border border-[var(--line)] outline-none focus:border-amber-600"
|
||||
/>
|
||||
<button
|
||||
onClick={handleEmperor}
|
||||
disabled={!userInput.trim() || loading}
|
||||
className="px-4 py-1.5 rounded-lg text-xs font-semibold border-0 disabled:opacity-40"
|
||||
style={{
|
||||
background: userInput.trim() ? 'linear-gradient(135deg, #e8a040, #f5c842)' : 'var(--panel2)',
|
||||
color: userInput.trim() ? '#000' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
👑 发言
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAdvance()}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 rounded-lg text-xs border border-[var(--acc)]40 text-[var(--acc)] hover:bg-[var(--acc)]10 disabled:opacity-40 transition"
|
||||
>
|
||||
▶ 下一轮
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 消息气泡 ──
|
||||
|
||||
function MessageBubble({
|
||||
msg,
|
||||
officials,
|
||||
}: {
|
||||
msg: CourtMessage;
|
||||
officials: Array<{ id: string; name: string; emoji: string }>;
|
||||
}) {
|
||||
const color = OFFICIAL_COLORS[msg.official_id || ''] || '#6a9eff';
|
||||
const official = officials.find((o) => o.id === msg.official_id);
|
||||
|
||||
if (msg.type === 'system') {
|
||||
return (
|
||||
<div className="text-center text-[10px] text-[var(--muted)] py-1 border-b border-[var(--line)] border-dashed">
|
||||
{msg.content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.type === 'scene_note') {
|
||||
return (
|
||||
<div className="text-center text-[10px] text-purple-400/80 py-1 italic">
|
||||
✦ {msg.content} ✦
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.type === 'emperor') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[80%] bg-gradient-to-br from-amber-900/40 to-amber-800/20 rounded-xl px-3 py-2 border border-amber-700/30">
|
||||
<div className="text-[10px] text-amber-400 mb-0.5">👑 皇帝</div>
|
||||
<div className="text-sm">{msg.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.type === 'decree') {
|
||||
return (
|
||||
<div className="text-center py-2">
|
||||
<div className="inline-block bg-gradient-to-r from-amber-900/30 via-purple-900/30 to-amber-900/30 rounded-lg px-4 py-2 border border-amber-600/30">
|
||||
<div className="text-xs text-amber-400 font-bold">⚡ 天命降临</div>
|
||||
<div className="text-sm mt-0.5">{msg.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 将军消息
|
||||
return (
|
||||
<div className="flex gap-2 items-start" style={{ animation: 'fadeIn .4s' }}>
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-sm flex-shrink-0 border"
|
||||
style={{ borderColor: color + '60', background: color + '15' }}
|
||||
>
|
||||
{official?.emoji || '💬'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className="text-[11px] font-semibold" style={{ color }}>
|
||||
{msg.official_name || '将军'}
|
||||
</span>
|
||||
{msg.emotion && EMOTION_EMOJI[msg.emotion] && (
|
||||
<span className="text-xs">{EMOTION_EMOJI[msg.emotion]}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed">
|
||||
{msg.content?.split(/(\*[^*]+\*)/).map((part, i) => {
|
||||
if (part.startsWith('*') && part.endsWith('*')) {
|
||||
return (
|
||||
<span key={i} className="text-[var(--muted)] italic text-xs">
|
||||
{part.slice(1, -1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
import { useState } from 'react';
|
||||
import { useStore, isEdict, isArchived, getPipeStatus, stateLabel, deptColor, PIPE, STATE_LABEL } from '../store';
|
||||
import ArtifactList from './ArtifactList';
|
||||
|
||||
/** Format ISO timestamp to relative duration string */
|
||||
function formatDuration(iso: string): string {
|
||||
try {
|
||||
const then = new Date(iso).getTime();
|
||||
const now = Date.now();
|
||||
const diffMs = now - then;
|
||||
if (diffMs < 0) return '刚刚';
|
||||
const mins = Math.floor(diffMs / 60000);
|
||||
if (mins < 1) return '刚刚';
|
||||
if (mins < 60) return `${mins}分钟`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const remainMins = mins % 60;
|
||||
if (hrs < 24) return `${hrs}小时${remainMins ? remainMins + '分' : ''}`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}天${hrs % 24 ? (hrs % 24) + '小时' : ''}`;
|
||||
} catch { return ''; }
|
||||
}
|
||||
import { api, type Task } from '../api';
|
||||
|
||||
// 排序权重
|
||||
const STATE_ORDER: Record<string, number> = {
|
||||
created: 0, planning: 1, challenging: 2, assigned: 3,
|
||||
executing: 4, paused: 4.5, reviewing: 5,
|
||||
completed: 8, failed: 7, cancelled: 9, escalated: 6,
|
||||
// legacy
|
||||
Doing: 4, Review: 5, Assigned: 3, Menxia: 1, Zhongshu: 2,
|
||||
Taizi: 0, Inbox: 0, Blocked: 6, Next: 0, Done: 8, Cancelled: 9,
|
||||
};
|
||||
|
||||
// 状态筛选定义
|
||||
const STATUS_FILTERS = [
|
||||
{ key: 'all', label: '全部', icon: '📋' },
|
||||
{ key: 'planning', label: '待拆解', icon: '🐦' },
|
||||
{ key: 'challenging', label: '审核中', icon: '🦅' },
|
||||
{ key: 'executing', label: '执行中', icon: '⚔️' },
|
||||
{ key: 'completed', label: '已完成', icon: '🏆' },
|
||||
{ key: 'failed', label: '失败', icon: '❌' },
|
||||
{ key: 'paused', label: '暂停', icon: '⏸️' },
|
||||
{ key: 'cancelled', label: '已取消', icon: '🚫' },
|
||||
];
|
||||
|
||||
// 状态到筛选key的映射
|
||||
function stateToFilterKey(state: string, task: Task): string {
|
||||
const meta = task.sourceMeta || {};
|
||||
// moziplus 用 status + plan_status,edict 用 state + sourceMeta
|
||||
const s = task.status || state; // 优先用 moziplus status
|
||||
const planStatus = task.plan_status || (meta.plan_status as string) || '';
|
||||
if (s === 'planning' && planStatus === 'challenging') return 'challenging';
|
||||
if (s === 'planning') return 'planning';
|
||||
if (s === 'executing') return 'executing';
|
||||
if (s === 'reviewing') return 'executing'; // reviewing is a sub-state of executing
|
||||
if (s === 'completed' || s === 'Done') return 'completed';
|
||||
if (s === 'failed') return 'failed';
|
||||
if (s === 'paused') return 'paused';
|
||||
if (s === 'cancelled') return 'cancelled';
|
||||
return s;
|
||||
}
|
||||
|
||||
function MiniPipe({ task }: { task: Task }) {
|
||||
const stages = getPipeStatus(task);
|
||||
return (
|
||||
<div className="ec-pipe">
|
||||
{stages.map((s, i) => (
|
||||
<span key={s.key} style={{ display: 'contents' }}>
|
||||
<div className={`ep-node ${s.status}`}>
|
||||
<div className="ep-icon">{s.icon}</div>
|
||||
<div className="ep-name">{s.dept}</div>
|
||||
</div>
|
||||
{i < stages.length - 1 && <div className="ep-arrow">›</div>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取任务最近事件的描述 */
|
||||
function getLatestEvent(task: Task): string | null {
|
||||
// 优先取 flow_log 最后一条的 remark
|
||||
if (task.flow_log && task.flow_log.length > 0) {
|
||||
const last = task.flow_log[task.flow_log.length - 1];
|
||||
if (last.remark) return last.remark;
|
||||
}
|
||||
// 次选取 activity 最后一条的 text
|
||||
if (task.activity && task.activity.length > 0) {
|
||||
const last = task.activity[task.activity.length - 1];
|
||||
if (last.text) return last.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function EdictCard({ task }: { task: Task }) {
|
||||
const setModalTaskId = useStore((s) => s.setModalTaskId);
|
||||
const toast = useStore((s) => s.toast);
|
||||
const loadAll = useStore((s) => s.loadAll);
|
||||
|
||||
const hb = task.heartbeat || { status: 'unknown', label: '⚪' };
|
||||
const stCls = 'st-' + (task.state || '');
|
||||
const deptCls = 'dt-' + (task.org || '').replace(/\s/g, '');
|
||||
const curStage = PIPE.find((_, i) => getPipeStatus(task)[i].status === 'active');
|
||||
const todos = task.todos || [];
|
||||
const todoDone = todos.filter((x) => x.status === 'completed').length;
|
||||
const todoTotal = todos.length;
|
||||
const taskState = task.status || task.state || '';
|
||||
// 前端本地跟 ACTION_GUARDS 同样的映射表(不调 API,卡片列表性能要求)
|
||||
const ACTION_GUARDS: Record<string, string[]> = {
|
||||
pause: ['planning', 'executing'],
|
||||
resume: ['paused', 'cancelled'],
|
||||
cancel: ['created', 'planning', 'executing', 'paused', 'escalated', 'failed'],
|
||||
retry: ['failed'],
|
||||
escalate: ['executing', 'failed'],
|
||||
rollback: ['escalated'],
|
||||
steer: ['executing'],
|
||||
};
|
||||
const isAllowed = (action: string) => {
|
||||
if (taskState === 'cancelling' || taskState === 'pausing') return false; // BUG-6: 中间态禁止操作
|
||||
const allowed = ACTION_GUARDS[action] || [];
|
||||
return allowed.includes(taskState);
|
||||
};
|
||||
const canStop = isAllowed('pause');
|
||||
const canResume = isAllowed('resume');
|
||||
const canCancel = isAllowed('cancel');
|
||||
const archived = isArchived(task);
|
||||
const isBlocked = task.block && task.block !== '无' && task.block !== '-';
|
||||
const isCompleted = ['completed', 'Done'].includes(taskState);
|
||||
const latestEvent = getLatestEvent(task);
|
||||
|
||||
const handleAction = async (action: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (action === 'stop' || action === 'cancel') {
|
||||
const reason = prompt(action === 'stop' ? '请输入暂停原因:' : '请输入取消原因:');
|
||||
if (reason === null) return;
|
||||
try {
|
||||
const r = await api.taskAction(task.id, action, reason);
|
||||
if (r.ok) { toast(r.message || '操作成功'); loadAll(); }
|
||||
else toast(r.error || '操作失败', 'err');
|
||||
} catch { toast('服务器连接失败', 'err'); }
|
||||
} else if (action === 'resume') {
|
||||
try {
|
||||
const r = await api.taskAction(task.id, 'resume', '恢复执行');
|
||||
if (r.ok) { toast(r.message || '已恢复'); loadAll(); }
|
||||
else toast(r.error || '操作失败', 'err');
|
||||
} catch { toast('服务器连接失败', 'err'); }
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const r = await api.archiveTask(task.id, !task.archived);
|
||||
if (r.ok) { toast(r.message || '操作成功'); loadAll(); }
|
||||
else toast(r.error || '操作失败', 'err');
|
||||
} catch { toast('服务器连接失败', 'err'); }
|
||||
};
|
||||
|
||||
// M3: 检测 waiting_human 节点
|
||||
const waitingNodes = (task.nodes || []).filter((n: any) => n.status === 'waiting_human');
|
||||
const hasWaitingHuman = waitingNodes.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`edict-card${archived ? ' archived' : ''}${hasWaitingHuman ? ' checkpoint-card' : ''}`}
|
||||
onClick={() => setModalTaskId(task.id)}
|
||||
style={hasWaitingHuman ? { border: '2px solid #f59e0b', animation: 'pulse-border 2s ease-in-out infinite' } : undefined}
|
||||
>
|
||||
<MiniPipe task={task} />
|
||||
<div className="ec-id">{task.id}</div>
|
||||
<div className="ec-title">{task.title || '(无标题)'}</div>
|
||||
<div className="ec-meta">
|
||||
<span className={`tag ${stCls}`}>{stateLabel(task)}</span>
|
||||
{task.org && <span className={`tag ${deptCls}`}>{task.org}</span>}
|
||||
{task.updated_at && (
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>
|
||||
⏱ {formatDuration(task.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
{curStage && (
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>
|
||||
当前: <b style={{ color: deptColor(curStage.dept) }}>{curStage.dept} · {curStage.action}</b>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{task.now && task.now !== '-' && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', lineHeight: 1.5, marginBottom: 6 }}>
|
||||
{task.now.substring(0, 80)}
|
||||
</div>
|
||||
)}
|
||||
{(task.review_round || 0) > 0 && (
|
||||
<div style={{ fontSize: 11, marginBottom: 6 }}>
|
||||
{Array.from({ length: task.review_round || 0 }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: 'inline-block', width: 14, height: 14, borderRadius: '50%',
|
||||
background: i < (task.review_round || 0) - 1 ? '#1a3a6a22' : 'var(--acc)22',
|
||||
border: `1px solid ${i < (task.review_round || 0) - 1 ? '#2a4a8a' : 'var(--acc)'}`,
|
||||
fontSize: 9, textAlign: 'center', lineHeight: '13px', marginRight: 2,
|
||||
color: i < (task.review_round || 0) - 1 ? '#4a6aaa' : 'var(--acc)',
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
))}
|
||||
<span style={{ color: 'var(--muted)', fontSize: 10 }}>第 {task.review_round} 轮磋商</span>
|
||||
</div>
|
||||
)}
|
||||
{todoTotal > 0 && (
|
||||
<div className="ec-todo-bar">
|
||||
<span>📋 {todoDone}/{todoTotal}</span>
|
||||
<div className="ec-todo-track">
|
||||
<div className="ec-todo-fill" style={{ width: `${Math.round((todoDone / todoTotal) * 100)}%` }} />
|
||||
</div>
|
||||
<span>{todoDone === todoTotal ? '✅ 全部完成' : '🔄 进行中'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ec-footer">
|
||||
<span className={`hb ${hb.status}`}>{hb.label}</span>
|
||||
{isBlocked && (
|
||||
<span className="tag" style={{ borderColor: '#ff527044', color: 'var(--danger)', background: '#200a10' }}>
|
||||
🚫 {task.block}
|
||||
</span>
|
||||
)}
|
||||
{taskState === 'cancelling' && (
|
||||
<div className="tag" style={{ background: '#ff527022', color: '#ff5270', borderColor: '#ff527044' }}>
|
||||
⏳ 正在取消(等待当前节点完成...)
|
||||
</div>
|
||||
)}
|
||||
{taskState === 'pausing' && (
|
||||
<div className="tag" style={{ background: '#ffd70022', color: '#ffd700', borderColor: '#ffd70044' }}>
|
||||
⏳ 正在暂停(等待当前节点完成...)
|
||||
</div>
|
||||
)}
|
||||
{task.eta && task.eta !== '-' && (
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>📅 {task.eta}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 最近事件 */}
|
||||
{latestEvent && (
|
||||
<div style={{
|
||||
fontSize: 10, color: '#8899aa', lineHeight: 1.4, marginTop: 4,
|
||||
padding: '3px 6px', background: '#1a1f2e', borderRadius: 4,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
💬 {latestEvent.substring(0, 100)}
|
||||
</div>
|
||||
)}
|
||||
{/* M3: waiting_human 通知 */}
|
||||
{hasWaitingHuman && (
|
||||
<div style={{ marginTop: 6, background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.2)', borderRadius: 8, padding: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span>🛐</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#f59e0b' }}>等待御批({waitingNodes.length})</span>
|
||||
</div>
|
||||
{waitingNodes.slice(0, 2).map((n: any) => (
|
||||
<div key={n.node_id} style={{ fontSize: 11, color: 'var(--fg)' }}>
|
||||
🎯 {n.name || n.node_id}({n.agent_id})
|
||||
</div>
|
||||
))}
|
||||
<button className="btn-small" style={{ marginTop: 4, borderColor: '#f59e0b', color: '#f59e0b' }} onClick={(e) => { e.stopPropagation(); setModalTaskId(task.id); }}>⚡ 立即处理</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ec-actions" onClick={(e) => e.stopPropagation()}>
|
||||
{canStop && (
|
||||
<button className="mini-act" onClick={(e) => handleAction('stop', e)}>⏸ 暂停</button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<button className="mini-act danger" onClick={(e) => handleAction('cancel', e)}>🚫 取消</button>
|
||||
)}
|
||||
{canResume && (
|
||||
<button className="mini-act" onClick={(e) => handleAction('resume', e)}>▶ 恢复</button>
|
||||
)}
|
||||
{isCompleted && !task.archived && (
|
||||
<button className="mini-act" onClick={handleArchive}>📦 归档</button>
|
||||
)}
|
||||
{task.archived && (
|
||||
<button className="mini-act" onClick={handleArchive}>📤 取消归档</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* M3: 成果物摘要(已完成 + 执行中任务) */}
|
||||
{(isCompleted || taskState === 'executing') && (
|
||||
<ArtifactList taskId={task.id} isCompleted={isCompleted} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EdictBoard() {
|
||||
const liveStatus = useStore((s) => s.liveStatus);
|
||||
const edictFilter = useStore((s) => s.edictFilter);
|
||||
const setEdictFilter = useStore((s) => s.setEdictFilter);
|
||||
const statusFilter = useStore((s) => s.statusFilter);
|
||||
const setStatusFilter = useStore((s) => s.setStatusFilter);
|
||||
const searchQuery = useStore((s) => s.searchQuery);
|
||||
const setSearchQuery = useStore((s) => s.setSearchQuery);
|
||||
const toast = useStore((s) => s.toast);
|
||||
const loadAll = useStore((s) => s.loadAll);
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
|
||||
const tasks = liveStatus?.tasks || [];
|
||||
const allEdicts = tasks.filter(isEdict);
|
||||
const activeEdicts = allEdicts.filter((t) => !isArchived(t));
|
||||
const archivedEdicts = allEdicts.filter((t) => isArchived(t));
|
||||
|
||||
// Step 1: archive filter
|
||||
let edicts: Task[];
|
||||
if (edictFilter === 'active') edicts = activeEdicts;
|
||||
else if (edictFilter === 'archived') edicts = archivedEdicts;
|
||||
else edicts = allEdicts;
|
||||
|
||||
// Step 2: status filter
|
||||
if (statusFilter !== 'all') {
|
||||
edicts = edicts.filter((t) => stateToFilterKey(t.state, t) === statusFilter);
|
||||
}
|
||||
|
||||
// Step 3: search filter
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
edicts = edicts.filter((t) =>
|
||||
(t.title || '').toLowerCase().includes(q) ||
|
||||
(t.id || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
edicts.sort((a, b) => (STATE_ORDER[a.state] ?? 9) - (STATE_ORDER[b.state] ?? 9));
|
||||
|
||||
const unArchivedDone = allEdicts.filter((t) => !t.archived && ['completed', 'Done', 'Cancelled', 'cancelled'].includes(t.status || t.state));
|
||||
|
||||
const handleArchiveAll = async () => {
|
||||
if (!confirm('将所有已完成/已取消的任务移入归档?')) return;
|
||||
try {
|
||||
const r = await api.archiveAllDone();
|
||||
if (r.ok) { toast(`📦 ${r.count || 0} 道任务已归档`); loadAll(); }
|
||||
else toast(r.error || '批量归档失败', 'err');
|
||||
} catch { toast('服务器连接失败', 'err'); }
|
||||
};
|
||||
|
||||
const handleScan = async () => {
|
||||
try {
|
||||
const r = await api.schedulerScan();
|
||||
if (r.ok) toast(`🧭 引擎巡检完成:${r.count || 0} 个动作`);
|
||||
else toast(r.error || '巡检失败', 'err');
|
||||
loadAll();
|
||||
} catch { toast('服务器连接失败', 'err'); }
|
||||
};
|
||||
|
||||
// Count per status for badges
|
||||
const statusCounts: Record<string, number> = { all: 0 };
|
||||
for (const t of (edictFilter === 'active' ? activeEdicts : edictFilter === 'archived' ? archivedEdicts : allEdicts)) {
|
||||
statusCounts.all++;
|
||||
const key = stateToFilterKey(t.state, t);
|
||||
statusCounts[key] = (statusCounts[key] || 0) + 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Archive Bar */}
|
||||
<div className="archive-bar">
|
||||
<span className="ab-label">筛选:</span>
|
||||
{(['active', 'archived', 'all'] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
className={`ab-btn ${edictFilter === f ? 'active' : ''}`}
|
||||
onClick={() => setEdictFilter(f)}
|
||||
>
|
||||
{f === 'active' ? '活跃' : f === 'archived' ? '归档' : '全部'}
|
||||
</button>
|
||||
))}
|
||||
{unArchivedDone.length > 0 && (
|
||||
<button className="ab-btn" onClick={handleArchiveAll}>📦 一键归档</button>
|
||||
)}
|
||||
<span className="ab-count">
|
||||
活跃 {activeEdicts.length} · 归档 {archivedEdicts.length} · 共 {allEdicts.length}
|
||||
</span>
|
||||
<button className="ab-scan" onClick={handleScan}>🧭 引擎巡检</button>
|
||||
</div>
|
||||
|
||||
{/* Status Filter + Search Bar */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 0', flexWrap: 'wrap',
|
||||
}}>
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setStatusFilter(f.key)}
|
||||
style={{
|
||||
padding: '3px 10px', borderRadius: 6, fontSize: 11,
|
||||
border: `1px solid ${statusFilter === f.key ? 'var(--acc)' : '#2a3550'}`,
|
||||
background: statusFilter === f.key ? 'var(--acc)22' : '#161b2e',
|
||||
color: statusFilter === f.key ? 'var(--acc)' : '#8899aa',
|
||||
cursor: 'pointer', transition: 'all 0.15s',
|
||||
display: 'flex', alignItems: 'center', gap: 3,
|
||||
}}
|
||||
>
|
||||
<span>{f.icon}</span>
|
||||
<span>{f.label}</span>
|
||||
{(statusCounts[f.key] || 0) > 0 && (
|
||||
<span style={{
|
||||
fontSize: 10, background: statusFilter === f.key ? 'var(--acc)' : '#2a3550',
|
||||
color: statusFilter === f.key ? '#000' : '#8899aa',
|
||||
borderRadius: 8, padding: '0 4px', minWidth: 16, textAlign: 'center',
|
||||
}}>
|
||||
{statusCounts[f.key]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div style={{ marginLeft: 'auto', position: 'relative' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索任务标题..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
padding: '4px 10px 4px 28px', borderRadius: 6, fontSize: 12,
|
||||
border: '1px solid #2a3550', background: '#161b2e', color: '#c0ccdd',
|
||||
width: 200, outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<span style={{
|
||||
position: 'absolute', left: 8, top: '50%', transform: 'translateY(-50%)',
|
||||
fontSize: 12, color: '#556', pointerEvents: 'none',
|
||||
}}>🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Batch Actions Bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
|
||||
background: '#1a2540', border: '1px solid var(--acc)', borderRadius: 8,
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: 'var(--acc)', fontWeight: 600 }}>
|
||||
☑ 已选 {selectedIds.size} 道
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-g"
|
||||
style={{ fontSize: 11, padding: '4px 12px' }}
|
||||
disabled={batchLoading}
|
||||
onClick={async () => {
|
||||
setBatchLoading(true);
|
||||
let count = 0;
|
||||
for (const id of selectedIds) {
|
||||
try { await api.taskAction(id, 'archive', ''); count++; } catch { /* skip */ }
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
setBatchLoading(false);
|
||||
toast(`📦 已归档 ${count} 道军令`, 'ok');
|
||||
loadAll();
|
||||
}}
|
||||
>
|
||||
{batchLoading ? '⟳' : '📦 批量归档'}
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
style={{ fontSize: 11, padding: '4px 12px', background: '#ff527022', color: '#ff5270', border: '1px solid #ff527044' }}
|
||||
disabled={batchLoading}
|
||||
onClick={async () => {
|
||||
setBatchLoading(true);
|
||||
let count = 0;
|
||||
for (const id of selectedIds) {
|
||||
try { await api.taskAction(id, 'cancel', '批量取消'); count++; } catch { /* skip */ }
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
setBatchLoading(false);
|
||||
toast(`🚫 已取消 ${count} 道军令`, 'ok');
|
||||
loadAll();
|
||||
}}
|
||||
>
|
||||
{batchLoading ? '⟳' : '🚫 批量取消'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-g"
|
||||
style={{ fontSize: 11, padding: '4px 8px', marginLeft: 'auto' }}
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
取消选择
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
<div className="edict-grid">
|
||||
{edicts.length === 0 ? (
|
||||
<div className="empty" style={{ gridColumn: '1/-1' }}>
|
||||
暂无任务<br />
|
||||
<small style={{ fontSize: 11, marginTop: 6, display: 'block', color: 'var(--muted)' }}>
|
||||
通过编排台提交任务,庞统分析后拆解为执行计划
|
||||
</small>
|
||||
</div>
|
||||
) : (
|
||||
edicts.map((t) => (
|
||||
<div key={t.id} style={{ position: 'relative' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(t.id)}
|
||||
onChange={() => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(t.id)) next.delete(t.id); else next.add(t.id);
|
||||
setSelectedIds(next);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute', top: 8, left: 8, zIndex: 2,
|
||||
width: 16, height: 16, cursor: 'pointer',
|
||||
accentColor: 'var(--acc)',
|
||||
}}
|
||||
/>
|
||||
<EdictCard task={t} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 全局搜索 — Ctrl+K 快捷键触发
|
||||
* 搜索范围:军令、将军、战法
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useStore } from '../store';
|
||||
|
||||
export default function GlobalSearch({ onClose }: { onClose: () => void }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const liveStatus = useStore((s) => s.liveStatus);
|
||||
const officialsData = useStore((s) => s.officialsData);
|
||||
const setModalTaskId = useStore((s) => s.setModalTaskId);
|
||||
|
||||
const tasks = liveStatus?.tasks || [];
|
||||
const officials = officialsData?.officials || [];
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const matchedTasks = q ? tasks.filter((t) =>
|
||||
t.id.toLowerCase().includes(q) ||
|
||||
t.title.toLowerCase().includes(q)
|
||||
).slice(0, 8) : [];
|
||||
|
||||
const matchedAgents = q ? officials.filter((o) =>
|
||||
o.role.toLowerCase().includes(q) ||
|
||||
o.id.toLowerCase().includes(q)
|
||||
).slice(0, 5) : [];
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="confirm-bg open" onClick={onClose}>
|
||||
<div className="confirm-box" style={{ maxWidth: 560 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 14 }}>🔍</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索军令、将军、战法..."
|
||||
style={{
|
||||
flex: 1, background: 'var(--panel2)', border: '1px solid var(--line)',
|
||||
borderRadius: 8, padding: '8px 12px', fontSize: 14, color: 'var(--text)', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)' }}>ESC 关闭</span>
|
||||
</div>
|
||||
|
||||
{matchedTasks.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', marginBottom: 6 }}>📜 军令</div>
|
||||
{matchedTasks.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '6px 10px', borderRadius: 6, cursor: 'pointer', border: '1px solid transparent' }}
|
||||
onClick={() => { setModalTaskId(t.id); onClose(); }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--panel2)'; e.currentTarget.style.borderColor = 'var(--line)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
>
|
||||
<span style={{ fontSize: 10, color: 'var(--acc)', fontWeight: 600 }}>{t.id}</span>
|
||||
<span style={{ flex: 1, fontSize: 12 }}>{t.title.substring(0, 40)}</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{t.state}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{matchedAgents.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', marginBottom: 6 }}>⚔️ 将军</div>
|
||||
{matchedAgents.map((o) => (
|
||||
<div
|
||||
key={o.id}
|
||||
style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '6px 10px', borderRadius: 6 }}
|
||||
>
|
||||
<span>{o.emoji}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600 }}>{o.role}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{o.model_short}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q && matchedTasks.length === 0 && matchedAgents.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', padding: '8px 0', textAlign: 'center' }}>
|
||||
未找到「{query}」相关结果
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!q && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', textAlign: 'center', padding: '8px 0' }}>
|
||||
输入关键词搜索军令、将军
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useState } from 'react';
|
||||
import { useStore, isEdict, STATE_LABEL } from '../store';
|
||||
import type { Task, FlowEntry, TaskNode } from '../api';
|
||||
|
||||
export default function MemorialPanel() {
|
||||
const liveStatus = useStore((s) => s.liveStatus);
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [detailTask, setDetailTask] = useState<Task | null>(null);
|
||||
const toast = useStore((s) => s.toast);
|
||||
|
||||
const tasks = liveStatus?.tasks || [];
|
||||
|
||||
const isTerminal = (t: Task) => {
|
||||
const st = t.status || t.state || '';
|
||||
return ['Done', 'Cancelled', 'completed', 'cancelled', 'failed'].includes(st);
|
||||
};
|
||||
const matchFilter = (t: Task, f: string) => {
|
||||
if (f === 'all') return true;
|
||||
const st = t.status || t.state || '';
|
||||
if (f === 'Done') return ['Done', 'completed'].includes(st);
|
||||
if (f === 'Cancelled') return ['Cancelled', 'cancelled'].includes(st);
|
||||
if (f === 'failed') return st === 'failed';
|
||||
return st === f;
|
||||
};
|
||||
let mems = tasks.filter((t) => isEdict(t) && isTerminal(t));
|
||||
if (filter !== 'all') mems = mems.filter((t) => matchFilter(t, filter));
|
||||
|
||||
const exportMemorial = (t: Task) => {
|
||||
const fl = t.flow_log || [];
|
||||
let md = `# 📜 奏折 · ${t.title}\n\n`;
|
||||
md += `- **任务编号**: ${t.id}\n`;
|
||||
md += `- **状态**: ${t.status || t.state}\n`;
|
||||
md += `- **负责部门**: ${t.org}\n`;
|
||||
if (fl.length) {
|
||||
const startAt = fl[0].at ? fl[0].at.substring(0, 19).replace('T', ' ') : '未知';
|
||||
const endAt = fl[fl.length - 1].at ? fl[fl.length - 1].at.substring(0, 19).replace('T', ' ') : '未知';
|
||||
md += `- **开始时间**: ${startAt}\n`;
|
||||
md += `- **完成时间**: ${endAt}\n`;
|
||||
}
|
||||
md += `\n## 流转记录\n\n`;
|
||||
for (const f of fl) {
|
||||
md += `- **${f.from}** → **${f.to}** \n ${f.remark} \n _${(f.at || '').substring(0, 19)}_\n\n`;
|
||||
}
|
||||
if (t.output && t.output !== '-') md += `## 产出物\n\n\`${t.output}\`\n`;
|
||||
navigator.clipboard.writeText(md).then(
|
||||
() => toast('✅ 奏折已复制为 Markdown', 'ok'),
|
||||
() => toast('复制失败', 'err')
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filter */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)' }}>筛选:</span>
|
||||
{[
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'Done', label: '✅ 已完成' },
|
||||
{ key: 'Cancelled', label: '🚫 已取消' },
|
||||
{ key: 'failed', label: '❌ 已失败' },
|
||||
].map((f) => (
|
||||
<span
|
||||
key={f.key}
|
||||
className={`sess-filter${filter === f.key ? ' active' : ''}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="mem-list">
|
||||
{!mems.length ? (
|
||||
<div className="mem-empty">暂无奏折 — 任务完成后自动生成</div>
|
||||
) : (
|
||||
mems.map((t) => {
|
||||
const fl = t.flow_log || [];
|
||||
const depts = [...new Set(fl.map((f) => f.from).concat(fl.map((f) => f.to)).filter((x) => x && x !== '皇上' && x !== '主公'))];
|
||||
const firstAt = fl.length ? (fl[0].at || '').substring(0, 16).replace('T', ' ') : '';
|
||||
const lastAt = fl.length ? (fl[fl.length - 1].at || '').substring(0, 16).replace('T', ' ') : '';
|
||||
const stVal = t.status || t.state || '';
|
||||
const stIcon = ['Done', 'completed'].includes(stVal) ? '✅' : ['Cancelled', 'cancelled'].includes(stVal) ? '🚫' : '❌';
|
||||
return (
|
||||
<div className="mem-card" key={t.id} onClick={() => setDetailTask(t)}>
|
||||
<div className="mem-icon">📜</div>
|
||||
<div className="mem-info">
|
||||
<div className="mem-title">
|
||||
{stIcon} {t.title || t.id}
|
||||
</div>
|
||||
<div className="mem-sub">
|
||||
{t.id} · {t.org || ''} · 流转 {fl.length} 步
|
||||
</div>
|
||||
<div className="mem-tags">
|
||||
{depts.slice(0, 5).map((d) => (
|
||||
<span className="mem-tag" key={d}>{d}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mem-right">
|
||||
<span className="mem-date">{firstAt}</span>
|
||||
{lastAt !== firstAt && <span className="mem-date">{lastAt}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{detailTask && (
|
||||
<MemorialDetailModal task={detailTask} onClose={() => setDetailTask(null)} onExport={exportMemorial} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemorialDetailModal({
|
||||
task: t,
|
||||
onClose,
|
||||
onExport,
|
||||
}: {
|
||||
task: Task;
|
||||
onClose: () => void;
|
||||
onExport: (t: Task) => void;
|
||||
}) {
|
||||
const fl = t.flow_log || [];
|
||||
const st = t.status || t.state || 'Unknown';
|
||||
const stIcon = ['Done', 'completed'].includes(st) ? '✅' : ['Cancelled', 'cancelled'].includes(st) ? '🚫' : st === 'failed' ? '❌' : '🔄';
|
||||
const depts = [...new Set(fl.map((f) => f.from).concat(fl.map((f) => f.to)).filter((x) => x && x !== '皇上' && x !== '主公'))];
|
||||
|
||||
// Reconstruct phases — support both edict and moziplus terminology
|
||||
const originLog: FlowEntry[] = [];
|
||||
const planLog: FlowEntry[] = [];
|
||||
const reviewLog: FlowEntry[] = [];
|
||||
const execLog: FlowEntry[] = [];
|
||||
const resultLog: FlowEntry[] = [];
|
||||
for (const f of fl) {
|
||||
if (f.from === '皇上' || f.from === '主公') originLog.push(f);
|
||||
else if (f.to === '中书省' || f.from === '中书省' || f.to === '庞统' || f.from === '庞统') planLog.push(f);
|
||||
else if (f.to === '门下省' || f.from === '门下省' || f.to === '司马懿' || f.from === '司马懿') reviewLog.push(f);
|
||||
else if (f.remark && (f.remark.includes('完成') || f.remark.includes('回奏') || f.remark.includes('交付'))) resultLog.push(f);
|
||||
else execLog.push(f);
|
||||
}
|
||||
|
||||
const renderPhase = (title: string, icon: string, items: FlowEntry[]) => {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 10 }}>
|
||||
{icon} {title}
|
||||
</div>
|
||||
<div className="md-timeline">
|
||||
{items.map((f, i) => {
|
||||
const dotCls = f.remark?.includes('✅') ? 'green' : f.remark?.includes('驳') ? 'red' : '';
|
||||
return (
|
||||
<div className="md-tl-item" key={i}>
|
||||
<div className={`md-tl-dot ${dotCls}`} />
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'baseline' }}>
|
||||
<span className="md-tl-from">{f.from}</span>
|
||||
<span className="md-tl-to">→ {f.to}</span>
|
||||
</div>
|
||||
<div className="md-tl-remark">{f.remark}</div>
|
||||
<div className="md-tl-time">{(f.at || '').substring(0, 19).replace('T', ' ')}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Extract nodes from task.nodes
|
||||
const nodes = t.nodes || [];
|
||||
|
||||
// Extract challenges from sourceMeta.challenges
|
||||
const challenges = (t.sourceMeta?.challenges || []) as Array<{ verdict?: string; feedback?: string }>;
|
||||
|
||||
return (
|
||||
<div className="modal-bg open" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>✕</button>
|
||||
<div className="modal-body">
|
||||
<div style={{ fontSize: 11, color: 'var(--acc)', fontWeight: 700, letterSpacing: '.04em', marginBottom: 4 }}>{t.id}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 6 }}>{stIcon} {t.title || t.id}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18, flexWrap: 'wrap' }}>
|
||||
<span className={`tag st-${st}`}>{STATE_LABEL[st] || st}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{t.org}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>流转 {fl.length} 步</span>
|
||||
{depts.map((d) => (
|
||||
<span className="mem-tag" key={d}>{d}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{t.now && (
|
||||
<div style={{ background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 8, padding: '10px 14px', marginBottom: 18, fontSize: 12, color: 'var(--muted)' }}>
|
||||
{t.now}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderPhase('主公下令', '👑', originLog)}
|
||||
{renderPhase('庞统拆解', '📋', planLog)}
|
||||
{renderPhase('司马懿审核', '🔍', reviewLog)}
|
||||
{renderPhase('将军执行', '⚔️', execLog)}
|
||||
{renderPhase('汇总交付', '📨', resultLog)}
|
||||
|
||||
{/* 各营回报 — 节点产出预览 */}
|
||||
{(() => {
|
||||
if (nodes.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginTop: 18, paddingTop: 14, borderTop: '1px solid var(--line)' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 10 }}>🏕️ 各营回报</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{nodes.map((n: TaskNode, i: number) => {
|
||||
const isDone = ['completed', 'Done', 'done'].includes(n.status);
|
||||
const isFailed = ['failed', 'Failed'].includes(n.status);
|
||||
const icon = isDone ? '✅' : isFailed ? '❌' : '🔄';
|
||||
return (
|
||||
<div key={n.node_id || i} style={{ background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 8, padding: '10px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span>{icon}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600 }}>{n.name || n.node_id}</span>
|
||||
{n.agent_id && <span style={{ fontSize: 11, color: 'var(--muted)' }}>· {n.agent_id}</span>}
|
||||
<span className="mem-tag" style={{ marginLeft: 'auto' }}>{n.status}</span>
|
||||
</div>
|
||||
{isDone && n.output_summary && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4, lineHeight: 1.5 }}>
|
||||
{n.output_summary.length > 200 ? n.output_summary.substring(0, 200) + '…' : n.output_summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 磋商实录 — 挑战记录 */}
|
||||
{(() => {
|
||||
if (challenges.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginTop: 18, paddingTop: 14, borderTop: '1px solid var(--line)' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 10 }}>⚔️ 磋商实录</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{challenges.map((c: { verdict?: string; feedback?: string }, i: number) => {
|
||||
const verdictIcon = c.verdict === 'pass' ? '✅' : c.verdict === 'fail' ? '❌' : '🔄';
|
||||
return (
|
||||
<div key={i} style={{ background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 8, padding: '10px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span>{verdictIcon}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600 }}>第 {i + 1} 轮</span>
|
||||
<span className="mem-tag">{c.verdict}</span>
|
||||
</div>
|
||||
{c.feedback && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4, lineHeight: 1.5 }}>
|
||||
{c.feedback.length > 200 ? c.feedback.substring(0, 200) + '…' : c.feedback}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{t.output && t.output !== '-' && (
|
||||
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--line)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, marginBottom: 4 }}>📦 产出物</div>
|
||||
<code style={{ fontSize: 11, wordBreak: 'break-all' }}>{t.output}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-g" onClick={() => onExport(t)} style={{ fontSize: 12, padding: '6px 16px' }}>
|
||||
📋 复制奏折
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useStore } from '../store';
|
||||
import { api } from '../api';
|
||||
|
||||
const FALLBACK_MODELS = [
|
||||
{ id: 'anthropic/claude-sonnet-4-6', l: 'Claude Sonnet 4.6', p: 'Anthropic' },
|
||||
{ id: 'anthropic/claude-opus-4-5', l: 'Claude Opus 4.5', p: 'Anthropic' },
|
||||
{ id: 'anthropic/claude-haiku-3-5', l: 'Claude Haiku 3.5', p: 'Anthropic' },
|
||||
{ id: 'openai/gpt-4o', l: 'GPT-4o', p: 'OpenAI' },
|
||||
{ id: 'openai/gpt-4o-mini', l: 'GPT-4o Mini', p: 'OpenAI' },
|
||||
{ id: 'google/gemini-2.5-pro', l: 'Gemini 2.5 Pro', p: 'Google' },
|
||||
{ id: 'copilot/claude-sonnet-4', l: 'Claude Sonnet 4', p: 'Copilot' },
|
||||
{ id: 'copilot/claude-opus-4.5', l: 'Claude Opus 4.5', p: 'Copilot' },
|
||||
{ id: 'copilot/gpt-4o', l: 'GPT-4o', p: 'Copilot' },
|
||||
{ id: 'copilot/gemini-2.5-pro', l: 'Gemini 2.5 Pro', p: 'Copilot' },
|
||||
];
|
||||
|
||||
const CHANNELS = [
|
||||
{ id: 'feishu', label: '飞书 Feishu' },
|
||||
{ id: 'telegram', label: 'Telegram' },
|
||||
{ id: 'wecom', label: '企业微信 WeCom' },
|
||||
{ id: 'discord', label: 'Discord' },
|
||||
{ id: 'slack', label: 'Slack' },
|
||||
{ id: 'signal', label: 'Signal' },
|
||||
{ id: 'tui', label: 'TUI (终端)' },
|
||||
];
|
||||
|
||||
export default function ModelConfig() {
|
||||
const agentConfig = useStore((s) => s.agentConfig);
|
||||
const changeLog = useStore((s) => s.changeLog);
|
||||
const loadAgentConfig = useStore((s) => s.loadAgentConfig);
|
||||
const toast = useStore((s) => s.toast);
|
||||
|
||||
const [selMap, setSelMap] = useState<Record<string, string>>({});
|
||||
const [statusMap, setStatusMap] = useState<Record<string, { cls: string; text: string }>>({});
|
||||
const [channelSel, setChannelSel] = useState('feishu');
|
||||
const [channelStatus, setChannelStatus] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadAgentConfig();
|
||||
}, [loadAgentConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agentConfig?.agents) {
|
||||
const m: Record<string, string> = {};
|
||||
agentConfig.agents.forEach((ag) => {
|
||||
m[ag.id] = ag.model;
|
||||
});
|
||||
setSelMap(m);
|
||||
}
|
||||
if (agentConfig?.dispatchChannel) {
|
||||
setChannelSel(agentConfig.dispatchChannel);
|
||||
}
|
||||
}, [agentConfig]);
|
||||
|
||||
if (!agentConfig?.agents) {
|
||||
return <div className="empty" style={{ gridColumn: '1/-1' }}>⚠️ 请先启动本地服务器</div>;
|
||||
}
|
||||
|
||||
const models = agentConfig.knownModels?.length
|
||||
? agentConfig.knownModels.map((m) => ({ id: m.id, l: m.label, p: m.provider }))
|
||||
: FALLBACK_MODELS;
|
||||
|
||||
const handleSelect = (agentId: string, val: string) => {
|
||||
setSelMap((p) => ({ ...p, [agentId]: val }));
|
||||
};
|
||||
|
||||
const resetMC = (agentId: string) => {
|
||||
const ag = agentConfig.agents.find((a) => a.id === agentId);
|
||||
if (ag) setSelMap((p) => ({ ...p, [agentId]: ag.model }));
|
||||
};
|
||||
|
||||
const applyModel = async (agentId: string) => {
|
||||
const model = selMap[agentId];
|
||||
if (!model) return;
|
||||
setStatusMap((p) => ({ ...p, [agentId]: { cls: 'pending', text: '⟳ 提交中…' } }));
|
||||
try {
|
||||
const r = await api.setModel(agentId, model);
|
||||
if (r.ok) {
|
||||
setStatusMap((p) => ({ ...p, [agentId]: { cls: 'ok', text: '✅ 已提交,Gateway 重启中(约5秒)' } }));
|
||||
toast(agentId + ' 模型已更改', 'ok');
|
||||
setTimeout(() => loadAgentConfig(), 5500);
|
||||
} else {
|
||||
setStatusMap((p) => ({ ...p, [agentId]: { cls: 'err', text: '❌ ' + (r.error || '错误') } }));
|
||||
}
|
||||
} catch {
|
||||
setStatusMap((p) => ({ ...p, [agentId]: { cls: 'err', text: '❌ 无法连接服务器' } }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="model-grid">
|
||||
{agentConfig.agents.map((ag) => {
|
||||
const sel = selMap[ag.id] || ag.model;
|
||||
const changed = sel !== ag.model;
|
||||
const st = statusMap[ag.id];
|
||||
return (
|
||||
<div className="mc-card" key={ag.id}>
|
||||
<div className="mc-top">
|
||||
<span className="mc-emoji">{ag.emoji || '🏛️'}</span>
|
||||
<div>
|
||||
<div className="mc-name">
|
||||
{ag.label}{' '}
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{ag.id}</span>
|
||||
</div>
|
||||
<div className="mc-role">{ag.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mc-cur">
|
||||
当前: <b>{ag.model}</b>
|
||||
</div>
|
||||
<select className="msel" value={sel} onChange={(e) => handleSelect(ag.id, e.target.value)}>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.l} ({m.p})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mc-btns">
|
||||
<button className="btn btn-p" disabled={!changed} onClick={() => applyModel(ag.id)}>
|
||||
应用
|
||||
</button>
|
||||
<button className="btn btn-g" onClick={() => resetMC(ag.id)}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
{st && <div className={`mc-st ${st.cls}`}>{st.text}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Dispatch Channel 配置 */}
|
||||
<div style={{ marginTop: 24, marginBottom: 8 }}>
|
||||
<div className="sec-title">派发渠道</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 0' }}>
|
||||
<select className="msel" value={channelSel} onChange={(e) => setChannelSel(e.target.value)}
|
||||
style={{ maxWidth: 220 }}>
|
||||
{CHANNELS.map((ch) => (
|
||||
<option key={ch.id} value={ch.id}>{ch.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-p" disabled={channelSel === (agentConfig?.dispatchChannel || 'feishu')}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const r = await api.setDispatchChannel(channelSel);
|
||||
if (r.ok) { setChannelStatus('✅ 已保存'); toast('派发渠道已切换', 'ok'); loadAgentConfig(); }
|
||||
else setChannelStatus('❌ ' + (r.error || '失败'));
|
||||
} catch { setChannelStatus('❌ 无法连接'); }
|
||||
setTimeout(() => setChannelStatus(''), 3000);
|
||||
}}>应用</button>
|
||||
{channelStatus && <span style={{ fontSize: 12, color: channelStatus.startsWith('✅') ? 'var(--success)' : 'var(--danger)' }}>{channelStatus}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>自动派发时使用的 OpenClaw 通知渠道(需已在 openclaw.json 中配置对应 channel)</div>
|
||||
</div>
|
||||
|
||||
{/* Change Log */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div className="sec-title">变更日志</div>
|
||||
<div className="cl-list">
|
||||
{!changeLog?.length ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', padding: '8px 0' }}>暂无变更</div>
|
||||
) : (
|
||||
[...changeLog]
|
||||
.reverse()
|
||||
.slice(0, 15)
|
||||
.map((e, i) => (
|
||||
<div className="cl-row" key={i}>
|
||||
<span className="cl-t">{(e.at || '').substring(0, 16).replace('T', ' ')}</span>
|
||||
<span className="cl-a">{e.agentId}</span>
|
||||
<span className="cl-c">
|
||||
<b>{e.oldModel}</b> → <b>{e.newModel}</b>
|
||||
{e.rolledBack && (
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--danger)',
|
||||
fontSize: 10,
|
||||
border: '1px solid #ff527044',
|
||||
padding: '1px 5px',
|
||||
borderRadius: 3,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
⚠ 已回滚
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useStore, DEPTS, isEdict, stateLabel } from '../store';
|
||||
import { api, type OfficialInfo } from '../api';
|
||||
|
||||
// ── Agent name mapping (P1-08) ──
|
||||
|
||||
const AGENT_NAMES: Record<string, string> = {
|
||||
'zhangfei-dev': '张飞 翼德',
|
||||
'guanyu-dev': '关羽 云长',
|
||||
'zhaoyun-data': '赵云 子龙',
|
||||
'jiangwei-infra': '姜维 伯约',
|
||||
'simayi-challenger': '司马懿 仲达',
|
||||
'pangtong-fujunshi': '庞统 士元',
|
||||
};
|
||||
|
||||
const AGENT_EMOJI: Record<string, string> = {
|
||||
'zhangfei-dev': '⚔️',
|
||||
'guanyu-dev': '🗡️',
|
||||
'zhaoyun-data': '🏹',
|
||||
'jiangwei-infra': '🛡️',
|
||||
'simayi-challenger': '🦅',
|
||||
'pangtong-fujunshi': '🐦',
|
||||
};
|
||||
|
||||
const AGENT_DEPT: Record<string, string> = {
|
||||
'zhangfei-dev': '张飞',
|
||||
'guanyu-dev': '关羽',
|
||||
'zhaoyun-data': '赵云',
|
||||
'jiangwei-infra': '姜维',
|
||||
'simayi-challenger': '司马懿',
|
||||
'pangtong-fujunshi': '庞统',
|
||||
};
|
||||
|
||||
// ── Stats card definitions (P1-09) ──
|
||||
|
||||
interface StatCardDef {
|
||||
icon: string;
|
||||
label: string;
|
||||
color: string;
|
||||
bg: string;
|
||||
border: string;
|
||||
states: string[];
|
||||
}
|
||||
|
||||
const STAT_CARDS: StatCardDef[] = [
|
||||
{ icon: '📋', label: '总任务', color: '#dde4f8', bg: '#0f1219', border: '#1c223688', states: ['__all__'] },
|
||||
{ icon: '⚔️', label: '执行中', color: '#6a9eff', bg: '#0a1428', border: '#6a9eff44', states: ['executing', 'Doing'] },
|
||||
{ icon: '✅', label: '已完成', color: '#2ecc8a', bg: '#0a2018', border: '#2ecc8a44', states: ['completed', 'Done'] },
|
||||
{ icon: '⏳', label: '待审批', color: '#f5c842', bg: '#201a08', border: '#f5c84244', states: ['challenging', 'Review'] },
|
||||
{ icon: '❌', label: '失败', color: '#ff5270', bg: '#200a10', border: '#ff527044', states: ['failed'] },
|
||||
{ icon: '⏸️', label: '暂停', color: '#5a6b92', bg: '#141824', border: '#5a6b9244', states: ['paused'] },
|
||||
];
|
||||
|
||||
export default function MonitorPanel() {
|
||||
const liveStatus = useStore((s) => s.liveStatus);
|
||||
const agentsStatusData = useStore((s) => s.agentsStatusData);
|
||||
const officialsData = useStore((s) => s.officialsData);
|
||||
const loadAgentsStatus = useStore((s) => s.loadAgentsStatus);
|
||||
const setModalTaskId = useStore((s) => s.setModalTaskId);
|
||||
const setActiveTab = useStore((s) => s.setActiveTab);
|
||||
const toast = useStore((s) => s.toast);
|
||||
|
||||
// P3-11: engine scan state
|
||||
const [scanLoading, setScanLoading] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<{ zombies: number; actions: { taskId: string; action: string; stalledSec?: number }[] } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadAgentsStatus();
|
||||
}, [loadAgentsStatus]);
|
||||
|
||||
const tasks = liveStatus?.tasks || [];
|
||||
const allEdicts = tasks.filter((t) => isEdict(t));
|
||||
const activeTasks = allEdicts.filter((t) => t.state !== 'completed' && t.state !== 'Done' && t.state !== 'cancelled');
|
||||
|
||||
// ── Compute stats ──
|
||||
function countStates(states: string[]): number {
|
||||
if (states.includes('__all__')) return allEdicts.length;
|
||||
return allEdicts.filter((t) => states.includes(t.state)).length;
|
||||
}
|
||||
|
||||
// ── Build task lookup per dept ──
|
||||
const deptActiveTasks: Record<string, typeof activeTasks> = {};
|
||||
for (const t of activeTasks) {
|
||||
const dept = t.org || '';
|
||||
if (!deptActiveTasks[dept]) deptActiveTasks[dept] = [];
|
||||
deptActiveTasks[dept].push(t);
|
||||
}
|
||||
|
||||
// Build official map
|
||||
const offMap: Record<string, OfficialInfo> = {};
|
||||
if (officialsData?.officials) {
|
||||
officialsData.officials.forEach((o) => { offMap[o.id] = o; });
|
||||
}
|
||||
|
||||
// Agent wake
|
||||
const handleWake = async (agentId: string) => {
|
||||
try {
|
||||
const r = await api.agentWake(agentId);
|
||||
toast(r.message || '唤醒指令已发出');
|
||||
setTimeout(() => loadAgentsStatus(), 30000);
|
||||
} catch { toast('唤醒失败', 'err'); }
|
||||
};
|
||||
|
||||
const handleWakeAll = async () => {
|
||||
if (!agentsStatusData) return;
|
||||
const toWake = agentsStatusData.agents.filter(
|
||||
(a) => a.id !== 'main' && a.status !== 'running' && a.status !== 'unconfigured'
|
||||
);
|
||||
if (!toWake.length) { toast('所有 Agent 均已在线'); return; }
|
||||
toast(`正在唤醒 ${toWake.length} 个 Agent...`);
|
||||
for (const a of toWake) {
|
||||
try { await api.agentWake(a.id); } catch { /* ignore */ }
|
||||
}
|
||||
toast(`${toWake.length} 个唤醒指令已发出,30秒后刷新状态`);
|
||||
setTimeout(() => loadAgentsStatus(), 30000);
|
||||
};
|
||||
|
||||
// Click stat card → jump to tasks tab
|
||||
const handleStatClick = (card: StatCardDef) => {
|
||||
setActiveTab('tasks');
|
||||
};
|
||||
|
||||
// P3-11: engine scan handler
|
||||
const handleSchedulerScan = async () => {
|
||||
setScanLoading(true);
|
||||
setScanResult(null);
|
||||
try {
|
||||
const r = await api.schedulerScan();
|
||||
const zombies = r.actions?.length || 0;
|
||||
setScanResult({ zombies, actions: (r.actions || []).map((a) => ({ taskId: a.taskId, action: a.action, stalledSec: a.stalledSec })) });
|
||||
toast(r.message || (zombies > 0 ? `发现 ${zombies} 个僵尸任务` : '一切正常'));
|
||||
} catch {
|
||||
toast('巡营扫描失败', 'err');
|
||||
} finally {
|
||||
setScanLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// P3-12: compute context pressure per agent from officialsData
|
||||
const contextPressureMap: Record<string, number> = {};
|
||||
if (officialsData?.officials) {
|
||||
for (const o of officialsData.officials) {
|
||||
const used = o.tokens_in + o.tokens_out;
|
||||
const pct = Math.min(100, Math.round((used / 200000) * 100));
|
||||
contextPressureMap[o.id] = pct;
|
||||
}
|
||||
}
|
||||
const avgPressure = Object.keys(contextPressureMap).length > 0
|
||||
? Math.round(Object.values(contextPressureMap).reduce((a, b) => a + b, 0) / Object.keys(contextPressureMap).length)
|
||||
: 0;
|
||||
|
||||
// Agent Status Panel data
|
||||
const asData = agentsStatusData;
|
||||
const filtered = asData?.agents?.filter((a) => a.id !== 'main') || [];
|
||||
const running = filtered.filter((a) => a.status === 'running').length;
|
||||
const idle = filtered.filter((a) => a.status === 'idle').length;
|
||||
const offline = filtered.filter((a) => a.status === 'offline').length;
|
||||
const unconf = filtered.filter((a) => a.status === 'unconfigured').length;
|
||||
const gw = asData?.gateway;
|
||||
const gwCls = gw?.probe ? 'ok' : gw?.alive ? 'warn' : 'err';
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ══ P1-09: 今日军报统计卡片 ══ */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
|
||||
gap: 10,
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
{STAT_CARDS.map((card) => {
|
||||
const value = countStates(card.states);
|
||||
return (
|
||||
<div
|
||||
key={card.label}
|
||||
onClick={() => handleStatClick(card)}
|
||||
style={{
|
||||
background: card.bg,
|
||||
border: `1px solid ${card.border}`,
|
||||
borderRadius: 12,
|
||||
padding: '14px 16px',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform .12s, border-color .15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.transform = 'translateY(-2px)';
|
||||
(e.currentTarget as HTMLDivElement).style.borderColor = card.color;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.transform = 'translateY(0)';
|
||||
(e.currentTarget as HTMLDivElement).style.borderColor = card.border;
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: '#5a6b92', marginBottom: 6 }}>{card.icon} {card.label}</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: card.color, lineHeight: 1 }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* P3-12: 粮草告急 context pressure card */}
|
||||
{(() => {
|
||||
const pColor = avgPressure > 80 ? '#ff5270' : avgPressure > 50 ? '#f5c842' : '#2ecc8a';
|
||||
const pBg = avgPressure > 80 ? '#200a10' : avgPressure > 50 ? '#201a08' : '#0a2018';
|
||||
const pBorder = avgPressure > 80 ? '#ff527044' : avgPressure > 50 ? '#f5c84244' : '#2ecc8a44';
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: pBg,
|
||||
border: `1px solid ${pBorder}`,
|
||||
borderRadius: 12,
|
||||
padding: '14px 16px',
|
||||
transition: 'transform .12s, border-color .15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.transform = 'translateY(-2px)';
|
||||
(e.currentTarget as HTMLDivElement).style.borderColor = pColor;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.transform = 'translateY(0)';
|
||||
(e.currentTarget as HTMLDivElement).style.borderColor = pBorder;
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: '#5a6b92', marginBottom: 6 }}>🌾 粮草</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: pColor, lineHeight: 1 }}>{avgPressure}%</div>
|
||||
<div style={{ marginTop: 8, height: 6, background: '#0e1320', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${avgPressure}%`,
|
||||
background: pColor,
|
||||
borderRadius: 3,
|
||||
transition: 'width .3s, background .3s',
|
||||
...(avgPressure > 80 ? { animation: 'pulse 1s infinite' } : {}),
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* ══ P1-08: Agent 在线状态面板(增强) ══ */}
|
||||
{asData && asData.ok && (
|
||||
<div className="as-panel">
|
||||
<div className="as-header">
|
||||
<span className="as-title">🔌 Agent 在线状态</span>
|
||||
<span className={`as-gw ${gwCls}`}>Gateway: {gw?.status || '未知'}</span>
|
||||
<button className="btn-refresh" onClick={() => loadAgentsStatus()} style={{ marginLeft: 8 }}>
|
||||
🔄 刷新
|
||||
</button>
|
||||
{(offline + unconf > 0) && (
|
||||
<button className="btn-refresh" onClick={handleWakeAll} style={{ marginLeft: 4, borderColor: 'var(--warn)', color: 'var(--warn)' }}>
|
||||
⚡ 全部唤醒
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agent cards grid */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
||||
gap: 10,
|
||||
}}>
|
||||
{filtered.map((a) => {
|
||||
const isRunning = a.status === 'running';
|
||||
const deptLabel = AGENT_DEPT[a.id] || '';
|
||||
const myTasks = deptActiveTasks[deptLabel] || [];
|
||||
const currentTask = myTasks[0]; // first active task
|
||||
const emoji = AGENT_EMOJI[a.id] || '🤖';
|
||||
const name = AGENT_NAMES[a.id] || a.label;
|
||||
const canWake = a.status !== 'running' && a.status !== 'unconfigured' && gw?.alive;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
style={{
|
||||
background: 'var(--panel2)',
|
||||
border: `1px solid ${isRunning ? '#6a9eff66' : '#1c2236'}`,
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
position: 'relative',
|
||||
transition: 'border-color .15s, background .15s',
|
||||
...(isRunning ? { animation: 'pulse 2s ease-in-out infinite' } : {}),
|
||||
}}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: isRunning ? '#2ecc8a' : a.status === 'idle' ? '#4a5568' : '#ff5270',
|
||||
boxShadow: isRunning ? '0 0 6px #2ecc8a88' : undefined,
|
||||
animation: isRunning || a.status === 'offline' ? 'pulse 1.5s infinite' : undefined,
|
||||
}} />
|
||||
|
||||
<div style={{ textAlign: 'center', marginBottom: 6 }}>
|
||||
<div style={{ fontSize: 26 }}>{emoji}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, marginTop: 4 }}>{name}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
|
||||
{isRunning ? '🔵 忙碌' : a.status === 'idle' ? '🟢 空闲' : a.status === 'offline' ? '🔴 离线' : '⚪ 未配置'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current task info */}
|
||||
{isRunning && currentTask ? (
|
||||
<div style={{
|
||||
marginTop: 8,
|
||||
padding: '6px 8px',
|
||||
background: '#0a1228',
|
||||
borderRadius: 6,
|
||||
borderLeft: '2px solid var(--acc)',
|
||||
cursor: 'pointer',
|
||||
}} onClick={() => setModalTaskId(currentTask.id)}>
|
||||
<div style={{ fontSize: 10, color: 'var(--acc)', fontWeight: 700, marginBottom: 2 }}>
|
||||
{currentTask.id}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text)',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{currentTask.title || '(无标题)'}
|
||||
</div>
|
||||
{currentTask.now && currentTask.now !== '-' && (
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
|
||||
📍 {currentTask.now.substring(0, 30)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isRunning ? (
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', textAlign: 'center', marginTop: 6 }}>执行中…</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', textAlign: 'center', marginTop: 6 }}>待命中</div>
|
||||
)}
|
||||
|
||||
{/* Wake button */}
|
||||
{canWake && (
|
||||
<div style={{ textAlign: 'center', marginTop: 6 }}>
|
||||
<button className="as-wake-btn" onClick={(e) => { e.stopPropagation(); handleWake(a.id); }}>
|
||||
⚡ 唤醒
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* P3-12: per-agent context pressure bar */}
|
||||
{(() => {
|
||||
const pressure = contextPressureMap[a.id];
|
||||
if (pressure === undefined) return null;
|
||||
const barColor = pressure > 80 ? '#ff5270' : pressure > 50 ? '#f5c842' : '#2ecc8a';
|
||||
return (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 9, color: 'var(--muted)', marginBottom: 2 }}>
|
||||
<span>🌾 上下文</span>
|
||||
<span style={{ color: barColor }}>{pressure}%</span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: '#0e1320', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${pressure}%`,
|
||||
background: barColor,
|
||||
borderRadius: 2,
|
||||
transition: 'width .3s',
|
||||
...(pressure > 80 ? { animation: 'pulse 1s infinite' } : {}),
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="as-summary">
|
||||
<span><span className="as-dot running" style={{ position: 'static', width: 8, height: 8 }} /> {running} 运行中</span>
|
||||
<span><span className="as-dot idle" style={{ position: 'static', width: 8, height: 8 }} /> {idle} 待命</span>
|
||||
{offline > 0 && <span><span className="as-dot offline" style={{ position: 'static', width: 8, height: 8 }} /> {offline} 离线</span>}
|
||||
{unconf > 0 && <span><span className="as-dot unconfigured" style={{ position: 'static', width: 8, height: 8 }} /> {unconf} 未配置</span>}
|
||||
<span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--muted)' }}>
|
||||
检测于 {(asData.checkedAt || '').substring(11, 19)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ Duty Grid (existing) ══ */}
|
||||
<div className="duty-grid">
|
||||
{DEPTS.map((d) => {
|
||||
const myTasks = activeTasks.filter((t) => t.org === d.label);
|
||||
const isActive = myTasks.some((t) => t.state === 'Doing' || t.state === 'executing');
|
||||
const isBlocked = myTasks.some((t) => t.state === 'Blocked');
|
||||
const off = offMap[d.id];
|
||||
const hb = off?.heartbeat || { status: 'idle', label: '⚪' };
|
||||
const dotCls = isBlocked ? 'blocked' : isActive ? 'busy' : hb.status === 'active' ? 'active' : 'idle';
|
||||
const statusText = isBlocked ? '⚠️ 阻塞' : isActive ? '⚙️ 执行中' : hb.status === 'active' ? '🟢 活跃' : '⚪ 候命';
|
||||
const cardCls = isBlocked ? 'blocked-card' : isActive ? 'active-card' : '';
|
||||
|
||||
return (
|
||||
<div key={d.id} className={`duty-card ${cardCls}`}>
|
||||
<div className="dc-hdr">
|
||||
<span className="dc-emoji">{d.emoji}</span>
|
||||
<div className="dc-info">
|
||||
<div className="dc-name">{d.label}</div>
|
||||
<div className="dc-role">{d.role} · {d.rank}</div>
|
||||
</div>
|
||||
<div className="dc-status">
|
||||
<span className={`dc-dot ${dotCls}`} />
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dc-body">
|
||||
{myTasks.length > 0 ? (
|
||||
myTasks.map((t) => (
|
||||
<div key={t.id} className="dc-task" onClick={() => setModalTaskId(t.id)}>
|
||||
<div className="dc-task-id">{t.id}</div>
|
||||
<div className="dc-task-title">{t.title || '(无标题)'}</div>
|
||||
{t.now && t.now !== '-' && (
|
||||
<div className="dc-task-now">{t.now.substring(0, 70)}</div>
|
||||
)}
|
||||
<div className="dc-task-meta">
|
||||
<span className={`tag st-${t.state}`}>{stateLabel(t)}</span>
|
||||
{t.block && t.block !== '无' && (
|
||||
<span className="tag" style={{ borderColor: '#ff527044', color: 'var(--danger)' }}>🚫{t.block}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="dc-idle">
|
||||
<span style={{ fontSize: 20 }}>🪭</span>
|
||||
<span>候命中</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="dc-footer">
|
||||
<span className="dc-model">🤖 {off?.model_short || '待配置'}</span>
|
||||
{off?.last_active && <span className="dc-la">⏰ {off.last_active}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ══ P3-11: 引擎巡营 / 操作区 ══ */}
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
background: 'var(--panel2)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 12,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: scanResult ? 12 : 0 }}>
|
||||
<button
|
||||
className="btn btn-g"
|
||||
onClick={handleSchedulerScan}
|
||||
disabled={scanLoading}
|
||||
style={{ minWidth: 120 }}
|
||||
>
|
||||
{scanLoading ? '⏳ 巡营中...' : '🔍 引擎巡营'}
|
||||
</button>
|
||||
{scanResult && (
|
||||
<span style={{ fontSize: 12, color: scanResult.zombies > 0 ? '#ff5270' : '#2ecc8a' }}>
|
||||
{scanResult.zombies > 0
|
||||
? `⚠️ 发现 ${scanResult.zombies} 个僵尸任务`
|
||||
: '✅ 一切正常'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scanResult && scanResult.zombies > 0 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}>
|
||||
{scanResult.actions.map((act, i) => (
|
||||
<div key={i} style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text)',
|
||||
padding: '4px 8px',
|
||||
background: '#0e1320',
|
||||
borderRadius: 6,
|
||||
borderLeft: '2px solid #ff5270',
|
||||
}}>
|
||||
<span style={{ color: '#ff5270', fontWeight: 700 }}>{act.taskId}</span>
|
||||
<span style={{ color: 'var(--muted)', marginLeft: 8 }}>{act.action}</span>
|
||||
{act.stalledSec != null && (
|
||||
<span style={{ color: '#f5c842', marginLeft: 8 }}>卡住 {Math.round(act.stalledSec / 60)} 分钟</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useStore } from '../store';
|
||||
import { api } from '../api';
|
||||
import type { SubConfig, MorningNewsItem } from '../api';
|
||||
|
||||
const CAT_META: Record<string, { icon: string; color: string; desc: string }> = {
|
||||
'政治': { icon: '🏛️', color: '#6a9eff', desc: '全球政治动态' },
|
||||
'军事': { icon: '⚔️', color: '#ff5270', desc: '军事与冲突' },
|
||||
'经济': { icon: '💹', color: '#2ecc8a', desc: '经济与市场' },
|
||||
'AI大模型': { icon: '🤖', color: '#a07aff', desc: 'AI与大模型进展' },
|
||||
};
|
||||
|
||||
const DEFAULT_CATS = ['政治', '军事', '经济', 'AI大模型'];
|
||||
|
||||
export default function MorningPanel() {
|
||||
const morningBrief = useStore((s) => s.morningBrief);
|
||||
const subConfig = useStore((s) => s.subConfig);
|
||||
const loadMorning = useStore((s) => s.loadMorning);
|
||||
const loadSubConfig = useStore((s) => s.loadSubConfig);
|
||||
const toast = useStore((s) => s.toast);
|
||||
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [localConfig, setLocalConfig] = useState<SubConfig | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshLabel, setRefreshLabel] = useState('⟳ 立即采集');
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadMorning();
|
||||
}, [loadMorning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (subConfig) setLocalConfig(JSON.parse(JSON.stringify(subConfig)));
|
||||
}, [subConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshNews = async () => {
|
||||
setRefreshing(true);
|
||||
setRefreshLabel('⟳ 采集中…');
|
||||
let lastDate: string | null = null;
|
||||
try {
|
||||
lastDate = morningBrief?.generated_at || null;
|
||||
} catch { /* */ }
|
||||
|
||||
try {
|
||||
await api.refreshMorning();
|
||||
toast('采集已触发,自动检测更新中…', 'ok');
|
||||
let count = 0;
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = setInterval(async () => {
|
||||
count++;
|
||||
if (count > 24) {
|
||||
clearInterval(pollRef.current!);
|
||||
pollRef.current = null;
|
||||
setRefreshing(false);
|
||||
setRefreshLabel('⟳ 立即采集');
|
||||
toast('采集超时,请重试', 'err');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const fresh = await api.morningBrief();
|
||||
if (fresh.generated_at && fresh.generated_at !== lastDate) {
|
||||
clearInterval(pollRef.current!);
|
||||
pollRef.current = null;
|
||||
setRefreshing(false);
|
||||
setRefreshLabel('⟳ 立即采集');
|
||||
loadMorning();
|
||||
toast('✅ 天下要闻已更新', 'ok');
|
||||
} else {
|
||||
setRefreshLabel(`⟳ 采集中… (${count * 5}s)`);
|
||||
}
|
||||
} catch { /* */ }
|
||||
}, 5000);
|
||||
} catch {
|
||||
toast('触发失败', 'err');
|
||||
setRefreshing(false);
|
||||
setRefreshLabel('⟳ 立即采集');
|
||||
}
|
||||
};
|
||||
|
||||
// Config helpers
|
||||
const toggleCat = (name: string) => {
|
||||
if (!localConfig) return;
|
||||
const cats = [...(localConfig.categories || [])];
|
||||
const existing = cats.find((c) => c.name === name);
|
||||
if (existing) existing.enabled = !existing.enabled;
|
||||
else cats.push({ name, enabled: true });
|
||||
setLocalConfig({ ...localConfig, categories: cats });
|
||||
};
|
||||
|
||||
const addKeyword = (kw: string) => {
|
||||
if (!localConfig || !kw) return;
|
||||
const kws = [...(localConfig.keywords || [])];
|
||||
if (!kws.includes(kw)) kws.push(kw);
|
||||
setLocalConfig({ ...localConfig, keywords: kws });
|
||||
};
|
||||
|
||||
const removeKeyword = (i: number) => {
|
||||
if (!localConfig) return;
|
||||
const kws = [...(localConfig.keywords || [])];
|
||||
kws.splice(i, 1);
|
||||
setLocalConfig({ ...localConfig, keywords: kws });
|
||||
};
|
||||
|
||||
const addFeed = (name: string, url: string, category: string) => {
|
||||
if (!localConfig || !name || !url) {
|
||||
toast('请填写源名称和URL', 'err');
|
||||
return;
|
||||
}
|
||||
const feeds = [...(localConfig.custom_feeds || [])];
|
||||
feeds.push({ name, url, category });
|
||||
setLocalConfig({ ...localConfig, custom_feeds: feeds });
|
||||
};
|
||||
|
||||
const removeFeed = (i: number) => {
|
||||
if (!localConfig) return;
|
||||
const feeds = [...(localConfig.custom_feeds || [])];
|
||||
feeds.splice(i, 1);
|
||||
setLocalConfig({ ...localConfig, custom_feeds: feeds });
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!localConfig) return;
|
||||
try {
|
||||
const r = await api.saveMorningConfig(localConfig);
|
||||
if (r.ok) {
|
||||
toast('订阅配置已保存', 'ok');
|
||||
loadSubConfig();
|
||||
} else {
|
||||
toast(r.error || '保存失败', 'err');
|
||||
}
|
||||
} catch {
|
||||
toast('服务器连接失败', 'err');
|
||||
}
|
||||
};
|
||||
|
||||
const enabledSet = localConfig
|
||||
? new Set((localConfig.categories || []).filter((c) => c.enabled).map((c) => c.name))
|
||||
: new Set(DEFAULT_CATS);
|
||||
const userKws = (localConfig?.keywords || []).map((k) => k.toLowerCase());
|
||||
|
||||
const cats = morningBrief?.categories || {};
|
||||
const dateStr = morningBrief?.date
|
||||
? morningBrief.date.replace(/(\d{4})(\d{2})(\d{2})/, '$1年$2月$3日')
|
||||
: '';
|
||||
const totalNews = Object.values(cats).flat().length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 4 }}>🌅 天下要闻</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)' }}>
|
||||
{dateStr && `${dateStr} | `}
|
||||
{morningBrief?.generated_at && `采集于 ${morningBrief.generated_at} | `}
|
||||
共 {totalNews} 条要闻
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-g"
|
||||
onClick={() => setShowConfig(!showConfig)}
|
||||
style={{ fontSize: 12, padding: '6px 14px' }}
|
||||
>
|
||||
⚙ 订阅配置
|
||||
</button>
|
||||
<button
|
||||
className="tpl-go"
|
||||
disabled={refreshing}
|
||||
onClick={refreshNews}
|
||||
style={{ fontSize: 12, padding: '6px 14px' }}
|
||||
>
|
||||
{refreshLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscription Config */}
|
||||
{showConfig && localConfig && (
|
||||
<SubConfigPanel
|
||||
config={localConfig}
|
||||
enabledSet={enabledSet}
|
||||
onToggleCat={toggleCat}
|
||||
onAddKeyword={addKeyword}
|
||||
onRemoveKeyword={removeKeyword}
|
||||
onAddFeed={addFeed}
|
||||
onRemoveFeed={removeFeed}
|
||||
onSave={saveConfig}
|
||||
onSetWebhook={(v) => setLocalConfig({ ...localConfig, feishu_webhook: v })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* News */}
|
||||
{!Object.keys(cats).length ? (
|
||||
<div className="mb-empty">暂无数据,点击右上角「立即采集」获取今日简报</div>
|
||||
) : (
|
||||
<div className="mb-cats">
|
||||
{Object.entries(cats).map(([cat, items]) => {
|
||||
if (!enabledSet.has(cat)) return null;
|
||||
const meta = CAT_META[cat] || { icon: '📰', color: 'var(--acc)', desc: cat };
|
||||
const scored = (items as MorningNewsItem[])
|
||||
.map((item) => {
|
||||
const text = ((item.title || '') + (item.summary || '')).toLowerCase();
|
||||
const kwHits = userKws.filter((k) => text.includes(k)).length;
|
||||
return { ...item, _kwHits: kwHits };
|
||||
})
|
||||
.sort((a, b) => b._kwHits - a._kwHits);
|
||||
|
||||
return (
|
||||
<div className="mb-cat" key={cat}>
|
||||
<div className="mb-cat-hdr">
|
||||
<span className="mb-cat-icon">{meta.icon}</span>
|
||||
<span className="mb-cat-name" style={{ color: meta.color }}>{cat}</span>
|
||||
<span className="mb-cat-cnt">{scored.length} 条</span>
|
||||
</div>
|
||||
<div className="mb-news-list">
|
||||
{!scored.length ? (
|
||||
<div className="mb-empty" style={{ padding: 16 }}>暂无新闻</div>
|
||||
) : (
|
||||
scored.map((item, i) => {
|
||||
const hasImg = !!(item.image && item.image.startsWith('http'));
|
||||
return (
|
||||
<div
|
||||
className="mb-card"
|
||||
key={i}
|
||||
onClick={() => window.open(item.link, '_blank')}
|
||||
>
|
||||
<div className="mb-img">
|
||||
{hasImg ? (
|
||||
<img
|
||||
src={item.image}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
loading="lazy"
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<span>{meta.icon}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-info">
|
||||
<div className="mb-headline">
|
||||
{item.title}
|
||||
{item._kwHits > 0 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
padding: '1px 5px',
|
||||
borderRadius: 999,
|
||||
background: '#a07aff22',
|
||||
color: '#a07aff',
|
||||
border: '1px solid #a07aff44',
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
⭐ 关注
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-summary">{item.summary || item.desc || ''}</div>
|
||||
<div className="mb-meta">
|
||||
<span className="mb-source">📡 {item.source || ''}</span>
|
||||
{item.pub_date && (
|
||||
<span className="mb-time">{item.pub_date.substring(0, 16)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubConfigPanel({
|
||||
config,
|
||||
enabledSet,
|
||||
onToggleCat,
|
||||
onAddKeyword,
|
||||
onRemoveKeyword,
|
||||
onAddFeed,
|
||||
onRemoveFeed,
|
||||
onSave,
|
||||
onSetWebhook,
|
||||
}: {
|
||||
config: SubConfig;
|
||||
enabledSet: Set<string>;
|
||||
onToggleCat: (name: string) => void;
|
||||
onAddKeyword: (kw: string) => void;
|
||||
onRemoveKeyword: (i: number) => void;
|
||||
onAddFeed: (name: string, url: string, cat: string) => void;
|
||||
onRemoveFeed: (i: number) => void;
|
||||
onSave: () => void;
|
||||
onSetWebhook: (v: string) => void;
|
||||
}) {
|
||||
const [newKw, setNewKw] = useState('');
|
||||
const [feedName, setFeedName] = useState('');
|
||||
const [feedUrl, setFeedUrl] = useState('');
|
||||
const [feedCat, setFeedCat] = useState(DEFAULT_CATS[0]);
|
||||
|
||||
const allCats = [...DEFAULT_CATS];
|
||||
(config.categories || []).forEach((c) => {
|
||||
if (!allCats.includes(c.name)) allCats.push(c.name);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="sub-config" style={{ marginBottom: 20, padding: 16, background: 'var(--panel2)', borderRadius: 12, border: '1px solid var(--line)' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>⚙ 订阅配置</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>订阅分类</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{allCats.map((cat) => {
|
||||
const meta = CAT_META[cat] || { icon: '📰', color: 'var(--acc)', desc: cat };
|
||||
const on = enabledSet.has(cat);
|
||||
return (
|
||||
<div
|
||||
key={cat}
|
||||
className={`sub-cat ${on ? 'active' : ''}`}
|
||||
onClick={() => onToggleCat(cat)}
|
||||
style={{ cursor: 'pointer', padding: '6px 12px', borderRadius: 8, border: `1px solid ${on ? 'var(--acc)' : 'var(--line)'}`, display: 'flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<span>{meta.icon}</span>
|
||||
<span style={{ fontSize: 12 }}>{cat}</span>
|
||||
{on && <span style={{ fontSize: 10, color: 'var(--ok)' }}>✓</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keywords */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>关注关键词</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 6 }}>
|
||||
{(config.keywords || []).map((kw, i) => (
|
||||
<span key={i} className="sub-kw" style={{ fontSize: 11, padding: '2px 8px', borderRadius: 4, background: 'var(--bg)', border: '1px solid var(--line)' }}>
|
||||
{kw}
|
||||
<span style={{ cursor: 'pointer', marginLeft: 4, color: 'var(--danger)' }} onClick={() => onRemoveKeyword(i)}>✕</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newKw}
|
||||
onChange={(e) => setNewKw(e.target.value)}
|
||||
placeholder="输入关键词"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { onAddKeyword(newKw.trim()); setNewKw(''); } }}
|
||||
style={{ flex: 1, padding: '6px 10px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 6, color: 'var(--text)', fontSize: 12, outline: 'none' }}
|
||||
/>
|
||||
<button className="btn btn-g" onClick={() => { onAddKeyword(newKw.trim()); setNewKw(''); }} style={{ fontSize: 11, padding: '4px 12px' }}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Feeds */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>自定义信息源</div>
|
||||
{(config.custom_feeds || []).map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4, fontSize: 11 }}>
|
||||
<span style={{ fontWeight: 600 }}>{f.name}</span>
|
||||
<span style={{ color: 'var(--muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.url}</span>
|
||||
<span style={{ color: 'var(--acc)' }}>{f.category}</span>
|
||||
<span style={{ cursor: 'pointer', color: 'var(--danger)' }} onClick={() => onRemoveFeed(i)}>✕</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
|
||||
<input placeholder="源名称" value={feedName} onChange={(e) => setFeedName(e.target.value)}
|
||||
style={{ width: 100, padding: '6px 8px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 6, color: 'var(--text)', fontSize: 11, outline: 'none' }} />
|
||||
<input placeholder="RSS / URL" value={feedUrl} onChange={(e) => setFeedUrl(e.target.value)}
|
||||
style={{ flex: 1, padding: '6px 8px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 6, color: 'var(--text)', fontSize: 11, outline: 'none' }} />
|
||||
<select value={feedCat} onChange={(e) => setFeedCat(e.target.value)}
|
||||
style={{ padding: '6px 8px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 6, color: 'var(--text)', fontSize: 11, outline: 'none' }}>
|
||||
{allCats.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-g" onClick={() => { onAddFeed(feedName, feedUrl, feedCat); setFeedName(''); setFeedUrl(''); }} style={{ fontSize: 11, padding: '4px 12px' }}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feishu Webhook */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>飞书 Webhook</div>
|
||||
<input
|
||||
type="text"
|
||||
value={config.feishu_webhook || ''}
|
||||
onChange={(e) => onSetWebhook(e.target.value)}
|
||||
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
|
||||
style={{ width: '100%', padding: '8px 10px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 6, color: 'var(--text)', fontSize: 12, outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button className="tpl-go" onClick={onSave} style={{ fontSize: 12, padding: '6px 16px' }}>
|
||||
💾 保存配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 通知中心 — 烽火台
|
||||
* 显示系统通知、审批请求、告警信息
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useStore } from '../store';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: 'info' | 'warning' | 'success' | 'error';
|
||||
title: string;
|
||||
message: string;
|
||||
time: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
const TYPE_STYLES: Record<string, { icon: string; color: string }> = {
|
||||
info: { icon: 'ℹ️', color: '#6a9eff' },
|
||||
warning: { icon: '⚠️', color: '#f5c842' },
|
||||
success: { icon: '✅', color: '#2ecc8a' },
|
||||
error: { icon: '🚨', color: '#ff5270' },
|
||||
};
|
||||
|
||||
export default function NotificationCenter({ onClose }: { onClose: () => void }) {
|
||||
const [notifications] = useState<Notification[]>([]);
|
||||
const unread = notifications.filter((n) => !n.read).length;
|
||||
|
||||
return (
|
||||
<div className="confirm-bg open" onClick={onClose}>
|
||||
<div className="confirm-box" style={{ maxWidth: 400, maxHeight: 500 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700 }}>🔔 烽火台</span>
|
||||
{unread > 0 && (
|
||||
<span style={{ fontSize: 10, padding: '2px 8px', borderRadius: 10, background: '#ff5270', color: '#fff', fontWeight: 600 }}>
|
||||
{unread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<div style={{ fontSize: 28, marginBottom: 8 }}>🔕</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)' }}>暂无烽火(通知功能待后端支持)</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 380, overflow: 'auto' }}>
|
||||
{notifications.map((n) => {
|
||||
const style = TYPE_STYLES[n.type] || TYPE_STYLES.info;
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
style={{
|
||||
padding: '10px 12px', borderRadius: 8,
|
||||
background: 'var(--panel2)', border: `1px solid ${n.read ? 'var(--line)' : style.color + '44'}`,
|
||||
opacity: n.read ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
||||
<span>{style.icon}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600 }}>{n.title}</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--muted)' }}>{n.time}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{n.message}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useStore, STATE_LABEL } from '../store';
|
||||
|
||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||
|
||||
export default function OfficialPanel() {
|
||||
const officialsData = useStore((s) => s.officialsData);
|
||||
const selectedOfficial = useStore((s) => s.selectedOfficial);
|
||||
const setSelectedOfficial = useStore((s) => s.setSelectedOfficial);
|
||||
const loadOfficials = useStore((s) => s.loadOfficials);
|
||||
const setModalTaskId = useStore((s) => s.setModalTaskId);
|
||||
|
||||
useEffect(() => {
|
||||
loadOfficials();
|
||||
}, [loadOfficials]);
|
||||
|
||||
if (!officialsData?.officials) {
|
||||
return <div className="empty">⚠️ 请确保本地服务器已启动</div>;
|
||||
}
|
||||
|
||||
const offs = officialsData.officials;
|
||||
const totals = officialsData.totals || { tasks_done: 0, cost_cny: 0 };
|
||||
const maxTk = Math.max(...offs.map((o) => o.tokens_in + o.tokens_out + o.cache_read + o.cache_write), 1);
|
||||
|
||||
// Active officials
|
||||
const alive = offs.filter((o) => o.heartbeat?.status === 'active');
|
||||
|
||||
// Selected official detail
|
||||
const sel = offs.find((o) => o.id === (selectedOfficial || offs[0]?.id));
|
||||
const selId = sel?.id || offs[0]?.id;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Activity banner */}
|
||||
{alive.length > 0 && (
|
||||
<div className="off-activity">
|
||||
<span>🟢 当前活跃:</span>
|
||||
{alive.map((o) => (
|
||||
<span key={o.id} style={{ fontSize: 12 }}>{o.emoji} {o.role}</span>
|
||||
))}
|
||||
<span style={{ color: 'var(--muted)', fontSize: 11, marginLeft: 'auto' }}>其余将军待命</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI Row */}
|
||||
<div className="off-kpi">
|
||||
<div className="kpi">
|
||||
<div className="kpi-v" style={{ color: 'var(--acc)' }}>{offs.length}</div>
|
||||
<div className="kpi-l">在职将军</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="kpi-v" style={{ color: '#f5c842' }}>{totals.tasks_done || 0}</div>
|
||||
<div className="kpi-l">累计完成军令</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="kpi-v" style={{ color: (totals.cost_cny || 0) > 20 ? 'var(--warn)' : 'var(--ok)' }}>
|
||||
¥{totals.cost_cny || 0}
|
||||
</div>
|
||||
<div className="kpi-l">累计费用(含缓存)</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="kpi-v" style={{ fontSize: 16, paddingTop: 4 }}>{officialsData.top_official || '—'}</div>
|
||||
<div className="kpi-l">功绩第一</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layout: Ranklist + Detail */}
|
||||
<div className="off-layout">
|
||||
{/* Left: Ranklist */}
|
||||
<div className="off-ranklist">
|
||||
<div className="orl-hdr">将军功绩榜</div>
|
||||
{offs.map((o) => {
|
||||
const hb = o.heartbeat || { status: 'idle' };
|
||||
return (
|
||||
<div
|
||||
key={o.id}
|
||||
className={`orl-item${selId === o.id ? ' selected' : ''}`}
|
||||
onClick={() => setSelectedOfficial(o.id)}
|
||||
>
|
||||
<span style={{ minWidth: 24, textAlign: 'center' }}>
|
||||
{o.merit_rank <= 3 ? MEDALS[o.merit_rank - 1] : '#' + o.merit_rank}
|
||||
</span>
|
||||
<span>{o.emoji}</span>
|
||||
<span style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700 }}>{o.role}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)' }}>{o.label} · {o.model_short || o.model}</div>
|
||||
</span>
|
||||
<span style={{ fontSize: 11, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
|
||||
<span>{o.merit_score}分</span>
|
||||
{(() => {
|
||||
const failed = o.tasks_failed ?? 0;
|
||||
const total = o.tasks_done + o.tasks_active + failed;
|
||||
const rate = total > 0 ? Math.round((o.tasks_done / total) * 100) : -1;
|
||||
if (rate < 0) return null;
|
||||
const color = rate > 80 ? '#2ecc8a' : rate >= 50 ? '#f5c842' : '#ff5270';
|
||||
return <span style={{ fontSize: 9, color }}>成功率 {rate}%</span>;
|
||||
})()}
|
||||
{o.cost_cny > 0 && <span style={{ fontSize: 9, color: 'var(--muted)' }}>¥{o.cost_cny}</span>}
|
||||
</span>
|
||||
<span className={`dc-dot ${hb.status}`} style={{ width: 8, height: 8 }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: Detail */}
|
||||
<div className="off-detail">
|
||||
{sel ? (
|
||||
<OfficialDetail official={sel} maxTk={maxTk} onOpenTask={setModalTaskId} />
|
||||
) : (
|
||||
<div className="empty">选择左侧将军查看详情</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 锦囊在 OfficialDetail 内部渲染 */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OfficialDetail({
|
||||
official: o,
|
||||
maxTk,
|
||||
onOpenTask,
|
||||
}: {
|
||||
official: NonNullable<ReturnType<typeof useStore.getState>['officialsData']>['officials'][0];
|
||||
maxTk: number;
|
||||
onOpenTask: (id: string) => void;
|
||||
}) {
|
||||
const hb = o.heartbeat || { status: 'idle', label: '⚪ 待命' };
|
||||
const totTk = o.tokens_in + o.tokens_out + o.cache_read + o.cache_write;
|
||||
const edicts = o.participated_edicts || [];
|
||||
|
||||
const tkBars = [
|
||||
{ l: '输入', v: o.tokens_in, color: '#6a9eff' },
|
||||
{ l: '输出', v: o.tokens_out, color: '#a07aff' },
|
||||
{ l: '缓存读', v: o.cache_read, color: '#2ecc8a' },
|
||||
{ l: '缓存写', v: o.cache_write, color: '#f5c842' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center', marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 40 }}>{o.emoji}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 800 }}>{o.role}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)' }}>
|
||||
{o.label} · <span style={{ color: 'var(--acc)' }}>{o.model_short || o.model}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
|
||||
🏅 {o.rank} · 功绩分 {o.merit_score}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className={`hb ${hb.status}`} style={{ marginBottom: 4 }}>{hb.label}</div>
|
||||
{o.last_active && <div style={{ fontSize: 10, color: 'var(--muted)' }}>活跃 {o.last_active}</div>}
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
|
||||
{o.sessions} 个会话 · {o.messages} 条消息
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Merit Stats */}
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div className="sec-title">功绩统计</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--ok)' }}>{o.tasks_done}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)' }}>完成军令</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--warn)' }}>{o.tasks_active}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)' }}>执行中</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--acc)' }}>{o.flow_participations}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)' }}>参与军令</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Bars */}
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div className="sec-title">Token 消耗</div>
|
||||
{tkBars.map((b) => (
|
||||
<div key={b.l} style={{ marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, marginBottom: 2 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>{b.l}</span>
|
||||
<span>{b.v.toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: '#0e1320', borderRadius: 3 }}>
|
||||
<div style={{ height: '100%', width: `${maxTk > 0 ? Math.round((b.v / maxTk) * 100) : 0}%`, background: b.color, borderRadius: 3 }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cost */}
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div className="sec-title">累计费用</div>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<span style={{ fontSize: 12, color: o.cost_cny > 10 ? 'var(--danger)' : o.cost_cny > 3 ? 'var(--warn)' : 'var(--ok)' }}>
|
||||
<b>¥{o.cost_cny}</b> 人民币
|
||||
</span>
|
||||
<span style={{ fontSize: 12 }}><b>${o.cost_usd}</b> 美元</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>总计 {totTk.toLocaleString()} tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Participated Edicts */}
|
||||
<div>
|
||||
<div className="sec-title">参与军令({edicts.length} 道)</div>
|
||||
{edicts.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', padding: '8px 0' }}>暂无军令记录</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{edicts.map((e) => {
|
||||
const stRaw = e.state || e.status || '';
|
||||
const stLabel = STATE_LABEL[stRaw] || stRaw || '未知';
|
||||
const stKey = stRaw;
|
||||
return (
|
||||
<div
|
||||
key={e.id}
|
||||
style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '6px 8px', borderRadius: 6, cursor: 'pointer', border: '1px solid var(--line)' }}
|
||||
onClick={() => onOpenTask(e.id)}
|
||||
>
|
||||
<span style={{ fontSize: 10, color: 'var(--acc)', fontWeight: 700 }}>{e.id}</span>
|
||||
<span style={{ flex: 1, fontSize: 12 }}>{e.title.substring(0, 35)}</span>
|
||||
<span className={`tag st-${stKey}`} style={{ fontSize: 10 }}>{stLabel}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 锦囊(MEMORY.md)*/}
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<div className="sec-title">🎒 锦囊</div>
|
||||
<div style={{ background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 8, padding: '10px 14px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)' }}>
|
||||
{o.memorySize ? `📜 锦囊已备 (${o.memorySize} 字)` : '📜 锦囊状态未知'}
|
||||
{o.memoryUpdatedAt && <span style={{ marginLeft: 8 }}>· 最后修订 {o.memoryUpdatedAt}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-g"
|
||||
style={{ fontSize: 11, padding: '4px 12px' }}
|
||||
onClick={() => { /* TODO: 锦囊翻阅待后端支持 */ }}
|
||||
>
|
||||
📖 翻阅锦囊
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useStore, isEdict, STATE_LABEL, timeAgo } from '../store';
|
||||
import type { Task } from '../api';
|
||||
import { useState } from 'react';
|
||||
import { formatDashboardTime } from '../time';
|
||||
|
||||
// Agent maps built from agentConfig
|
||||
function useAgentMaps() {
|
||||
const cfg = useStore((s) => s.agentConfig);
|
||||
const emojiMap: Record<string, string> = {};
|
||||
const labelMap: Record<string, string> = {};
|
||||
if (cfg?.agents) {
|
||||
cfg.agents.forEach((a) => {
|
||||
emojiMap[a.id] = a.emoji || '🏛️';
|
||||
labelMap[a.id] = a.label || a.id;
|
||||
});
|
||||
}
|
||||
return { emojiMap, labelMap };
|
||||
}
|
||||
|
||||
function extractAgent(t: Task): string {
|
||||
const m = (t.id || '').match(/^OC-(\w+)-/);
|
||||
if (m) return m[1];
|
||||
return (t.org || '').replace(/省|部/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function humanTitle(t: Task, labelMap: Record<string, string>): string {
|
||||
let title = t.title || '';
|
||||
if (title === 'heartbeat 会话' || title === 'heartbeat 传令') return '💓 心跳检测';
|
||||
const m = title.match(/^agent:(\w+):(\w+)/);
|
||||
if (m) {
|
||||
const agLabel = labelMap[m[1]] || m[1];
|
||||
if (m[2] === 'main') return agLabel + ' · 主传令';
|
||||
if (m[2] === 'subagent') return agLabel + ' · 子任务执行';
|
||||
if (m[2] === 'cron') return agLabel + ' · 定时任务';
|
||||
return agLabel + ' · ' + m[2];
|
||||
}
|
||||
return title.replace(/ 会话$/, '').replace(/ 传令$/, '') || t.id;
|
||||
}
|
||||
|
||||
function channelLabel(t: Task): { icon: string; text: string } {
|
||||
const now = t.now || '';
|
||||
if (now.includes('feishu/direct')) return { icon: '💬', text: '飞书对话' };
|
||||
if (now.includes('feishu')) return { icon: '💬', text: '飞书' };
|
||||
if (now.includes('webchat')) return { icon: '🌐', text: 'WebChat' };
|
||||
if (now.includes('cron')) return { icon: '⏰', text: '定时' };
|
||||
if (now.includes('direct')) return { icon: '📨', text: '直连' };
|
||||
return { icon: '📡', text: '传令' };
|
||||
}
|
||||
|
||||
function lastMessage(t: Task): string {
|
||||
const acts = t.activity || [];
|
||||
for (let i = acts.length - 1; i >= 0; i--) {
|
||||
const a = acts[i];
|
||||
if (a.kind === 'assistant') {
|
||||
let txt = a.text || '';
|
||||
if (txt.startsWith('NO_REPLY') || txt.startsWith('Reasoning:')) continue;
|
||||
txt = txt.replace(/\[\[.*?\]\]/g, '').replace(/\*\*/g, '').replace(/^#+\s/gm, '').trim();
|
||||
return txt.substring(0, 50) + (txt.length > 50 ? '…' : '');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Compute active dot color from heartbeat + state */
|
||||
function activeDot(t: Task): { color: string; title: string } {
|
||||
const hb = t.heartbeat;
|
||||
const st = t.status || t.state;
|
||||
if (hb?.status === 'active') return { color: '#2ecc8a', title: '执行中' };
|
||||
if (hb?.status === 'warn') return { color: '#f5c842', title: '警告' };
|
||||
if (hb?.status === 'stalled') return { color: '#ff5270', title: '卡顿' };
|
||||
if (st && !['Done', 'Cancelled', 'completed', 'cancelled'].includes(st))
|
||||
return { color: '#2ecc8a', title: '进行中' };
|
||||
return { color: '#6b7280', title: '空闲' };
|
||||
}
|
||||
|
||||
/** Compute duration from activity timestamps */
|
||||
function computeDuration(t: Task): string | null {
|
||||
const acts = t.activity || [];
|
||||
if (acts.length < 2) return null;
|
||||
const first = acts[0].at;
|
||||
const last = acts[acts.length - 1].at;
|
||||
if (!first || !last) return null;
|
||||
const ms = new Date(typeof last === 'number' ? last : last).getTime()
|
||||
- new Date(typeof first === 'number' ? first : first).getTime();
|
||||
if (ms < 0) return null;
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) return `${sec}秒`;
|
||||
const min = Math.floor(sec / 60);
|
||||
const remSec = sec % 60;
|
||||
if (min < 60) return `${min}分${remSec}秒`;
|
||||
const hr = Math.floor(min / 60);
|
||||
const remMin = min % 60;
|
||||
return `${hr}时${remMin}分`;
|
||||
}
|
||||
|
||||
export default function SessionsPanel() {
|
||||
const liveStatus = useStore((s) => s.liveStatus);
|
||||
const sessFilter = useStore((s) => s.sessFilter);
|
||||
const setSessFilter = useStore((s) => s.setSessFilter);
|
||||
const { emojiMap, labelMap } = useAgentMaps();
|
||||
const [detailTask, setDetailTask] = useState<Task | null>(null);
|
||||
|
||||
const tasks = liveStatus?.tasks || [];
|
||||
const sessions = tasks.filter((t) => !isEdict(t));
|
||||
|
||||
let filtered = sessions;
|
||||
if (sessFilter === 'active') filtered = sessions.filter((t) => {
|
||||
const st = t.status || t.state;
|
||||
return st !== 'Done' && st !== 'Cancelled' && st !== 'completed' && st !== 'cancelled';
|
||||
});
|
||||
else if (sessFilter !== 'all') filtered = sessions.filter((t) => extractAgent(t) === sessFilter);
|
||||
|
||||
// Unique agents for filter tabs
|
||||
const agentIds = [...new Set(sessions.map(extractAgent))];
|
||||
|
||||
// Count active sessions
|
||||
const activeCount = sessions.filter((t) => {
|
||||
const st = t.status || t.state;
|
||||
return st !== 'Done' && st !== 'Cancelled' && st !== 'completed' && st !== 'cancelled';
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)', marginRight: 4 }}>📡 传令</span>
|
||||
{[
|
||||
{ key: 'all', label: `全部 (${sessions.length})` },
|
||||
{ key: 'active', label: `活跃 (${activeCount})` },
|
||||
...agentIds.slice(0, 8).map((id) => ({
|
||||
key: id,
|
||||
label: `${emojiMap[id] || ''} ${labelMap[id] || id}`,
|
||||
})),
|
||||
].map((f) => (
|
||||
<span
|
||||
key={f.key}
|
||||
className={`sess-filter${sessFilter === f.key ? ' active' : ''}`}
|
||||
onClick={() => setSessFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="sess-grid">
|
||||
{!filtered.length ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--muted)', padding: 24, textAlign: 'center', gridColumn: '1/-1' }}>
|
||||
暂无传令数据
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((t) => {
|
||||
const agent = extractAgent(t);
|
||||
const emoji = emojiMap[agent] || '🏛️';
|
||||
const agLabel = labelMap[agent] || t.org || agent;
|
||||
const ch = channelLabel(t);
|
||||
const title = humanTitle(t, labelMap);
|
||||
const msg = lastMessage(t);
|
||||
const sm = t.sourceMeta || {};
|
||||
const totalTk = (sm as Record<string, unknown>).totalTokens as number | undefined;
|
||||
const updatedAt = t.eta || t.updatedAt || '';
|
||||
const dot = activeDot(t);
|
||||
const st = t.status || t.state || 'Unknown';
|
||||
|
||||
return (
|
||||
<div className="sess-card" key={t.id} onClick={() => setDetailTask(t)}>
|
||||
<div className="sc-top">
|
||||
<span className="sc-emoji">{emoji}</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className="sc-agent">{agLabel}</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)', background: 'var(--panel2)', padding: '2px 6px', borderRadius: 4 }}>
|
||||
{ch.icon} {ch.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 8, height: 8, borderRadius: '50%', display: 'inline-block',
|
||||
background: dot.color,
|
||||
boxShadow: dot.color === '#2ecc8a' ? '0 0 6px #2ecc8a88' : 'none',
|
||||
}}
|
||||
title={dot.title}
|
||||
/>
|
||||
<span className={`tag st-${st}`} style={{ fontSize: 10 }}>{STATE_LABEL[st] || st}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sc-title">{title}</div>
|
||||
{msg && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', lineHeight: 1.5, marginBottom: 8, borderLeft: '2px solid var(--line)', paddingLeft: 8, maxHeight: 40, overflow: 'hidden' }}>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
<div className="sc-meta">
|
||||
{totalTk ? <span style={{ fontSize: 10, color: 'var(--muted)' }}>🪙 {totalTk.toLocaleString()} tokens</span> : null}
|
||||
{updatedAt ? <span className="sc-time">{timeAgo(updatedAt)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session Detail Modal */}
|
||||
{detailTask && (
|
||||
<SessionDetailModal task={detailTask} labelMap={labelMap} emojiMap={emojiMap} onClose={() => setDetailTask(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionDetailModal({
|
||||
task: t,
|
||||
labelMap,
|
||||
emojiMap,
|
||||
onClose,
|
||||
}: {
|
||||
task: Task;
|
||||
labelMap: Record<string, string>;
|
||||
emojiMap: Record<string, string>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const agent = extractAgent(t);
|
||||
const emoji = emojiMap[agent] || '🏛️';
|
||||
const agLabel = labelMap[agent] || t.org || agent;
|
||||
const title = humanTitle(t, labelMap);
|
||||
const ch = channelLabel(t);
|
||||
const dot = activeDot(t);
|
||||
const sm = t.sourceMeta || {};
|
||||
const acts = t.activity || [];
|
||||
const st = t.status || t.state || 'Unknown';
|
||||
const duration = computeDuration(t);
|
||||
|
||||
const totalTokens = (sm as Record<string, unknown>).totalTokens as number | undefined;
|
||||
const inputTokens = (sm as Record<string, unknown>).inputTokens as number | undefined;
|
||||
const outputTokens = (sm as Record<string, unknown>).outputTokens as number | undefined;
|
||||
const cachedTokens = (sm as Record<string, unknown>).cacheTokens as number | undefined
|
||||
?? (sm as Record<string, unknown>).cache_read as number | undefined;
|
||||
|
||||
return (
|
||||
<div className="modal-bg open" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>✕</button>
|
||||
<div className="modal-body">
|
||||
<div style={{ fontSize: 11, color: 'var(--acc)', fontWeight: 700, letterSpacing: '.04em', marginBottom: 4 }}>{t.id}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 6 }}>
|
||||
{emoji} {title}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18, flexWrap: 'wrap' }}>
|
||||
<span
|
||||
style={{
|
||||
width: 10, height: 10, borderRadius: '50%', display: 'inline-block',
|
||||
background: dot.color,
|
||||
boxShadow: dot.color === '#2ecc8a' ? '0 0 6px #2ecc8a88' : 'none',
|
||||
}}
|
||||
title={dot.title}
|
||||
/>
|
||||
<span className={`tag st-${st}`}>{STATE_LABEL[st] || st}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{ch.icon} {ch.text}</span>
|
||||
{duration && (
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', background: 'var(--panel2)', padding: '2px 8px', borderRadius: 4 }}>
|
||||
⏱ {duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agent Info */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18, padding: '10px 14px', background: 'var(--panel2)', borderRadius: 10, border: '1px solid var(--line)' }}>
|
||||
<span style={{ fontSize: 24 }}>{emoji}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700 }}>{agLabel}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>传令官 · {agent}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 18, flexWrap: 'wrap' }}>
|
||||
{totalTokens != null && (
|
||||
<div style={{ background: 'var(--panel2)', padding: '10px 16px', borderRadius: 8, fontSize: 12, border: '1px solid var(--line)' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--acc)' }}>{totalTokens.toLocaleString()}</div>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>总 Tokens</div>
|
||||
</div>
|
||||
)}
|
||||
{inputTokens != null && (
|
||||
<div style={{ background: 'var(--panel2)', padding: '10px 16px', borderRadius: 8, fontSize: 12, border: '1px solid var(--line)' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700 }}>{inputTokens.toLocaleString()}</div>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>输入</div>
|
||||
</div>
|
||||
)}
|
||||
{outputTokens != null && (
|
||||
<div style={{ background: 'var(--panel2)', padding: '10px 16px', borderRadius: 8, fontSize: 12, border: '1px solid var(--line)' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700 }}>{outputTokens.toLocaleString()}</div>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>输出</div>
|
||||
</div>
|
||||
)}
|
||||
{cachedTokens != null && (
|
||||
<div style={{ background: 'var(--panel2)', padding: '10px 16px', borderRadius: 8, fontSize: 12, border: '1px solid var(--line)' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#2ecc8a' }}>{cachedTokens.toLocaleString()}</div>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>缓存</div>
|
||||
</div>
|
||||
)}
|
||||
{acts.length > 0 && (
|
||||
<div style={{ background: 'var(--panel2)', padding: '10px 16px', borderRadius: 8, fontSize: 12, border: '1px solid var(--line)' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700 }}>{acts.length}</div>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>活动条目</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Activity Timeline */}
|
||||
<div style={{ fontSize: 12, fontWeight: 700, marginBottom: 8 }}>
|
||||
📋 传令时间线 <span style={{ fontWeight: 400, color: 'var(--muted)' }}>({acts.length} 条)</span>
|
||||
</div>
|
||||
<div style={{ maxHeight: 400, overflowY: 'auto', border: '1px solid var(--line)', borderRadius: 10, background: 'var(--panel2)' }}>
|
||||
{!acts.length ? (
|
||||
<div style={{ padding: 16, color: 'var(--muted)', fontSize: 12, textAlign: 'center' }}>暂无活动记录</div>
|
||||
) : (
|
||||
acts.slice().reverse().map((a, i) => {
|
||||
const kind = a.kind || '';
|
||||
const kIcon = kind === 'assistant' ? '🤖' : kind === 'tool' ? '🔧' : kind === 'user' ? '👤' : '📝';
|
||||
const kLabel = kind === 'assistant' ? '回复' : kind === 'tool' ? '工具' : kind === 'user' ? '用户' : '事件';
|
||||
let txt = (a.text || '').replace(/\[\[.*?\]\]/g, '').replace(/\*\*/g, '').trim();
|
||||
if (txt.length > 200) txt = txt.substring(0, 200) + '…';
|
||||
const time = formatDashboardTime(a.at as string | number | undefined, { showSeconds: true });
|
||||
return (
|
||||
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)', fontSize: 12, lineHeight: 1.5 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
|
||||
<span>{kIcon}</span>
|
||||
<span style={{ fontWeight: 600, fontSize: 11 }}>{kLabel}</span>
|
||||
<span style={{ color: 'var(--muted)', fontSize: 10, marginLeft: 'auto' }}>{time}</span>
|
||||
</div>
|
||||
{txt && <div style={{ color: 'var(--muted)' }}>{txt}</div>}
|
||||
{a.tools && a.tools.length > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{a.tools.map((tool, ti) => (
|
||||
<span key={ti} style={{ fontSize: 10, background: 'var(--panel)', border: '1px solid var(--line)', padding: '2px 6px', borderRadius: 4, marginRight: 4, color: 'var(--acc)' }}>
|
||||
{tool.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{t.output && t.output !== '-' && (
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 12, wordBreak: 'break-all', borderTop: '1px solid var(--line)', paddingTop: 8 }}>
|
||||
📂 {t.output}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* 城防设置 — 接线状态、安全防务、版本更新、数据源配置
|
||||
* 三国术语:设置→城防、连接→接线、配置→防务
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { api, AgentsStatusData } from '../api';
|
||||
|
||||
interface ServiceCheckResult {
|
||||
name: string;
|
||||
url: string;
|
||||
online: boolean;
|
||||
detail: string;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const [tab, setTab] = useState<'connections' | 'security' | 'version' | 'logs'>('connections');
|
||||
|
||||
// 接线状态巡检
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkResults, setCheckResults] = useState<ServiceCheckResult[]>([]);
|
||||
const [lastInspection, setLastInspection] = useState<string>('');
|
||||
|
||||
const runInspection = useCallback(async () => {
|
||||
setChecking(true);
|
||||
const results: ServiceCheckResult[] = [];
|
||||
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
||||
|
||||
// 1. Gateway 状态
|
||||
let gatewayOnline = false;
|
||||
let gatewayUrl = 'http://192.168.2.153:18789';
|
||||
let gatewayVersion = '-';
|
||||
try {
|
||||
const status: AgentsStatusData = await api.agentsStatus();
|
||||
gatewayOnline = status.gateway.alive;
|
||||
gatewayVersion = status.gateway.status || '-';
|
||||
gatewayUrl = status.ok ? gatewayUrl : gatewayUrl;
|
||||
} catch {
|
||||
gatewayOnline = false;
|
||||
}
|
||||
results.push({
|
||||
name: 'OpenClaw Gateway',
|
||||
url: gatewayUrl,
|
||||
online: gatewayOnline,
|
||||
detail: gatewayOnline ? `在线 · ${gatewayVersion}` : '离线',
|
||||
checkedAt: now,
|
||||
});
|
||||
|
||||
// 2. moziplus 后端
|
||||
let backendOnline = false;
|
||||
try {
|
||||
await api.agentsStatus();
|
||||
backendOnline = true;
|
||||
} catch {
|
||||
backendOnline = false;
|
||||
}
|
||||
results.push({
|
||||
name: 'moziplus 后端',
|
||||
url: 'http://192.168.2.154:8088',
|
||||
online: backendOnline,
|
||||
detail: backendOnline ? '在线' : '离线',
|
||||
checkedAt: now,
|
||||
});
|
||||
|
||||
setCheckResults(results);
|
||||
setLastInspection(now);
|
||||
setChecking(false);
|
||||
}, []);
|
||||
|
||||
// 静态接线列表
|
||||
const connections = [
|
||||
{ name: 'moziplus 后端', url: 'http://localhost:8088', status: 'connected', note: 'moziplus daemon' },
|
||||
{ name: 'OpenClaw Gateway', url: 'http://192.168.2.153:18789', status: 'connected', note: '主 Gateway' },
|
||||
{ name: 'NAS 回测服务', url: 'http://192.168.2.154:8088', status: 'connected', note: 'Docker 回测' },
|
||||
{ name: 'Gitee 远程仓库', url: 'git@gitee.com:cfdaily/sanguo_moziplus.git', status: 'connected', note: 'Git 同步' },
|
||||
{ name: 'Redis(M3)', url: '-', status: 'pending', note: 'M3 阶段接入实时推送' },
|
||||
{ name: '飞书推送(M3)', url: '-', status: 'pending', note: 'M3 阶段接入通知推送' },
|
||||
];
|
||||
|
||||
// 安全防务
|
||||
const risks = [
|
||||
{ level: 'info' as const, title: 'API 无鉴权', desc: 'Dashboard API 默认无 token 鉴权,本地运行无风险', action: 'M3 考虑加入 token 验证' },
|
||||
{ level: 'ok' as const, title: 'SSH 密钥正常', desc: 'Gitee SSH 密钥配置正确,Git 同步正常', action: '' },
|
||||
{ level: 'ok' as const, title: '数据库备份', desc: 'SQLite 数据库自动备份已启用', action: '' },
|
||||
{ level: 'warn' as const, title: 'Python 3.9 版本较旧', desc: 'Python 3.9.6 不支持 X | None 语法,需 from __future__ import annotations', action: '考虑升级到 Python 3.10+' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 城防子页签 */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 20 }}>
|
||||
{[
|
||||
{ key: 'connections' as const, label: '🔌 接线状态' },
|
||||
{ key: 'security' as const, label: '🛡️ 安全防务' },
|
||||
{ key: 'version' as const, label: '📦 版本更新' },
|
||||
{ key: 'logs' as const, label: '📋 城防日志' },
|
||||
].map((t) => (
|
||||
<button key={t.key} className={`btn ${tab === t.key ? 'btn-primary' : ''}`} onClick={() => setTab(t.key)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ========== 接线状态 ========== */}
|
||||
{tab === 'connections' && (
|
||||
<div>
|
||||
{/* 巡视城防 */}
|
||||
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18, marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14 }}>🏰 接线状态巡检</span>
|
||||
<button
|
||||
className="btn btn-action"
|
||||
onClick={runInspection}
|
||||
disabled={checking}
|
||||
style={{ opacity: checking ? 0.6 : 1, cursor: checking ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
{checking ? '⏳ 巡视中…' : '🔍 巡视城防'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastInspection && (
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 10 }}>
|
||||
最后巡视:{lastInspection}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkResults.length > 0 && (
|
||||
<div>
|
||||
{checkResults.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '12px 0',
|
||||
borderBottom: i < checkResults.length - 1 ? '1px solid var(--line)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
background: r.online ? '#2ecc8a' : '#ff5270',
|
||||
boxShadow: `0 0 6px ${r.online ? '#2ecc8a66' : '#ff527066'}`,
|
||||
display: 'inline-block', flexShrink: 0,
|
||||
}} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{r.name}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace' }}>{r.url}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<span style={{
|
||||
fontSize: 10, padding: '2px 8px', borderRadius: 4,
|
||||
background: r.online ? '#0a2018' : '#200a0a',
|
||||
color: r.online ? '#2ecc8a' : '#ff5270',
|
||||
border: `1px solid ${r.online ? '#2ecc8a44' : '#ff527044'}`,
|
||||
}}>
|
||||
{r.detail}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 巡视摘要 */}
|
||||
<div style={{
|
||||
marginTop: 12, padding: '10px 14px', borderRadius: 8,
|
||||
background: checkResults.every(r => r.online) ? '#0a2018' : '#200a0a',
|
||||
border: `1px solid ${checkResults.every(r => r.online) ? '#2ecc8a44' : '#ff527044'}`,
|
||||
fontSize: 12,
|
||||
color: checkResults.every(r => r.online) ? '#2ecc8a' : '#ff5270',
|
||||
}}>
|
||||
{checkResults.every(r => r.online)
|
||||
? `✅ 全部城门正常 (${checkResults.length}/${checkResults.length})`
|
||||
: `⚠️ ${checkResults.filter(r => !r.online).length} 处城门告急 (${checkResults.filter(r => r.online).length}/${checkResults.length} 正常)`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkResults.length === 0 && !checking && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', textAlign: 'center', padding: '16px 0' }}>
|
||||
点击「🔍 巡视城防」检查各服务接线状态
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 接线列表 */}
|
||||
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14 }}>
|
||||
<div style={{ padding: '12px 18px 0', fontSize: 12, fontWeight: 700, color: 'var(--muted)' }}>接线清单</div>
|
||||
{connections.map((c, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 18px', borderBottom: i < connections.length - 1 ? '1px solid #0e1320' : 'none' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{c.name}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace' }}>{c.url}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 10, padding: '2px 8px', borderRadius: 4, background: c.status === 'connected' ? '#0a2018' : '#201a08', color: c.status === 'connected' ? 'var(--ok)' : 'var(--warn)', border: `1px solid ${c.status === 'connected' ? '#2ecc8a44' : '#f5c84244'}` }}>
|
||||
{c.status === 'connected' ? '✅ 已接线' : '⏳ 待接入'}
|
||||
</span>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>{c.note}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 安全防务 ========== */}
|
||||
{tab === 'security' && (
|
||||
<div>
|
||||
{risks.map((r, i) => (
|
||||
<div key={i} style={{ background: 'var(--panel)', border: `1px solid ${r.level === 'warn' ? '#f5c84244' : r.level === 'ok' ? '#2ecc8a44' : '#6a9eff44'}`, borderRadius: 12, padding: 14, marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>
|
||||
{r.level === 'ok' && '✅ '}{r.level === 'warn' && '⚠️ '}{r.level === 'info' && 'ℹ️ '}
|
||||
{r.title}
|
||||
</span>
|
||||
<span style={{ fontSize: 9, padding: '1px 6px', borderRadius: 3, background: r.level === 'ok' ? '#0a2018' : r.level === 'warn' ? '#201a08' : '#0a1428', color: r.level === 'ok' ? 'var(--ok)' : r.level === 'warn' ? 'var(--warn)' : 'var(--acc)' }}>
|
||||
{r.level === 'ok' ? '正常' : r.level === 'warn' ? '警告' : '提示'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: r.action ? 6 : 0 }}>{r.desc}</div>
|
||||
{r.action && <div style={{ fontSize: 11, color: 'var(--acc)' }}>→ {r.action}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 版本更新 ========== */}
|
||||
{tab === 'version' && (
|
||||
<div>
|
||||
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 4 }}>moziplus</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 800 }}>v0.4b</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)' }}>M2 开发中</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 4 }}>Dashboard</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 800 }}>v1.0</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)' }}>基于 Edict 前端</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 4 }}>OpenClaw</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 800 }}>latest</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--ok)' }}>✅ 最新版本</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 4 }}>Node.js</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 800 }}>v22</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--ok)' }}>✅ 运行正常</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18, marginTop: 12 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10 }}>📋 更新通道</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', lineHeight: 1.8 }}>
|
||||
<div>📦 安装方式:git clone + PM2</div>
|
||||
<div>🔄 更新方式:git pull + npm install + pm2 restart</div>
|
||||
<div>📝 远程仓库:git@gitee.com:cfdaily/sanguo_moziplus.git</div>
|
||||
<div>🔀 分支:main(稳定)→ develop(开发)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* ========== 城防日志 ========== */}
|
||||
{tab === 'logs' && (
|
||||
<div>
|
||||
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>📋 操作日志</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
|
||||
记录系统关键操作和事件(功能待后端支持,当前显示本地日志)
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{JSON.parse(localStorage.getItem('opLogs') || '[]').slice(-20).reverse().map((log: { time: string; action: string; detail: string }, i: number) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 10, alignItems: 'baseline', padding: '6px 0', borderBottom: '1px solid var(--line)' }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)', width: 140, flexShrink: 0 }}>{log.time}</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--acc)', width: 80 }}>{log.action}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{log.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
{JSON.parse(localStorage.getItem('opLogs') || '[]').length === 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', padding: 8 }}>暂无日志记录</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useStore } from '../store';
|
||||
import { api, RemoteSkillItem } from '../api';
|
||||
|
||||
// 社区知名 Skills 源快选列表
|
||||
const COMMUNITY_SOURCES = [
|
||||
{
|
||||
label: 'obra/superpowers',
|
||||
emoji: '⚡',
|
||||
stars: '66.9k',
|
||||
desc: '完整开发工作流战法集',
|
||||
skills: [
|
||||
{ name: 'brainstorming', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/brainstorming/SKILL.md' },
|
||||
{ name: 'test-driven-development', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/test-driven-development/SKILL.md' },
|
||||
{ name: 'systematic-debugging', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/systematic-debugging/SKILL.md' },
|
||||
{ name: 'subagent-driven-development', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/subagent-driven-development/SKILL.md' },
|
||||
{ name: 'writing-plans', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/writing-plans/SKILL.md' },
|
||||
{ name: 'executing-plans', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/executing-plans/SKILL.md' },
|
||||
{ name: 'requesting-code-review', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/requesting-code-review/SKILL.md' },
|
||||
{ name: 'root-cause-tracing', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/root-cause-tracing/SKILL.md' },
|
||||
{ name: 'verification-before-completion', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/verification-before-completion/SKILL.md' },
|
||||
{ name: 'dispatching-parallel-agents', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/dispatching-parallel-agents/SKILL.md' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'anthropics/skills',
|
||||
emoji: '🏛️',
|
||||
stars: '官方',
|
||||
desc: 'Anthropic 官方战法库',
|
||||
skills: [
|
||||
{ name: 'docx', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/docx/SKILL.md' },
|
||||
{ name: 'pdf', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/pdf/SKILL.md' },
|
||||
{ name: 'xlsx', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/xlsx/SKILL.md' },
|
||||
{ name: 'pptx', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/pptx/SKILL.md' },
|
||||
{ name: 'mcp-builder', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/mcp-builder/SKILL.md' },
|
||||
{ name: 'frontend-design', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/frontend-design/SKILL.md' },
|
||||
{ name: 'web-artifacts-builder', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/web-artifacts-builder/SKILL.md' },
|
||||
{ name: 'webapp-testing', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/webapp-testing/SKILL.md' },
|
||||
{ name: 'algorithmic-art', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/algorithmic-art/SKILL.md' },
|
||||
{ name: 'canvas-design', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/canvas-design/SKILL.md' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'ComposioHQ/awesome-claude-skills',
|
||||
emoji: '🌐',
|
||||
stars: '39.2k',
|
||||
desc: '100+ 武林秘籍',
|
||||
skills: [
|
||||
{ name: 'github-integration', url: 'https://raw.githubusercontent.com/ComposioHQ/awesome-claude-skills/master/github-integration/SKILL.md' },
|
||||
{ name: 'data-analysis', url: 'https://raw.githubusercontent.com/ComposioHQ/awesome-claude-skills/master/data-analysis/SKILL.md' },
|
||||
{ name: 'code-review', url: 'https://raw.githubusercontent.com/ComposioHQ/awesome-claude-skills/master/code-review/SKILL.md' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function SkillsConfig() {
|
||||
const agentConfig = useStore((s) => s.agentConfig);
|
||||
const loadAgentConfig = useStore((s) => s.loadAgentConfig);
|
||||
const toast = useStore((s) => s.toast);
|
||||
|
||||
// 自家战法状态
|
||||
const [skillModal, setSkillModal] = useState<{ agentId: string; name: string; content: string; path: string } | null>(null);
|
||||
const [addForm, setAddForm] = useState<{ agentId: string; agentLabel: string } | null>(null);
|
||||
const [formData, setFormData] = useState({ name: '', desc: '', trigger: '' });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// 主 Tab 切换
|
||||
const [activeTab, setActiveTab] = useState<'local' | 'remote'>('local');
|
||||
|
||||
// 江湖战法状态
|
||||
const [remoteSkills, setRemoteSkills] = useState<RemoteSkillItem[]>([]);
|
||||
const [remoteLoading, setRemoteLoading] = useState(false);
|
||||
const [addRemoteForm, setAddRemoteForm] = useState(false);
|
||||
const [remoteFormData, setRemoteFormData] = useState({ agentId: '', skillName: '', sourceUrl: '', description: '' });
|
||||
const [remoteSubmitting, setRemoteSubmitting] = useState(false);
|
||||
const [updatingSkill, setUpdatingSkill] = useState<string | null>(null);
|
||||
const [removingSkill, setRemovingSkill] = useState<string | null>(null);
|
||||
const [quickPickSource, setQuickPickSource] = useState<(typeof COMMUNITY_SOURCES)[0] | null>(null);
|
||||
const [quickPickAgent, setQuickPickAgent] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadAgentConfig();
|
||||
}, [loadAgentConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'remote') loadRemoteSkills();
|
||||
}, [activeTab]);
|
||||
|
||||
const loadRemoteSkills = async () => {
|
||||
setRemoteLoading(true);
|
||||
try {
|
||||
const r = await api.remoteSkillsList();
|
||||
if (r.ok) setRemoteSkills(r.remoteSkills || []);
|
||||
} catch {
|
||||
toast('江湖战法列表加载失败', 'err');
|
||||
}
|
||||
setRemoteLoading(false);
|
||||
};
|
||||
|
||||
const openSkill = async (agentId: string, skillName: string) => {
|
||||
setSkillModal({ agentId, name: skillName, content: '⟳ 加载中…', path: '' });
|
||||
try {
|
||||
const r = await api.skillContent(agentId, skillName);
|
||||
if (r.ok) {
|
||||
setSkillModal({ agentId, name: skillName, content: r.content || '', path: r.path || '' });
|
||||
} else {
|
||||
setSkillModal({ agentId, name: skillName, content: '❌ ' + (r.error || '无法读取'), path: '' });
|
||||
}
|
||||
} catch {
|
||||
setSkillModal({ agentId, name: skillName, content: '❌ 服务器连接失败', path: '' });
|
||||
}
|
||||
};
|
||||
|
||||
const openAddForm = (agentId: string, agentLabel: string) => {
|
||||
setAddForm({ agentId, agentLabel });
|
||||
setFormData({ name: '', desc: '', trigger: '' });
|
||||
};
|
||||
|
||||
const submitAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!addForm || !formData.name) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const r = await api.addSkill(addForm.agentId, formData.name, formData.desc, formData.trigger);
|
||||
if (r.ok) {
|
||||
toast(`✅ 战法 ${formData.name} 已修炼到 ${addForm.agentLabel}`, 'ok');
|
||||
setAddForm(null);
|
||||
loadAgentConfig();
|
||||
} else {
|
||||
toast(r.error || '添加失败', 'err');
|
||||
}
|
||||
} catch {
|
||||
toast('服务器连接失败', 'err');
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const submitAddRemote = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const { agentId, skillName, sourceUrl, description } = remoteFormData;
|
||||
if (!agentId || !skillName || !sourceUrl) return;
|
||||
setRemoteSubmitting(true);
|
||||
try {
|
||||
const r = await api.addRemoteSkill(agentId, skillName, sourceUrl, description);
|
||||
if (r.ok) {
|
||||
toast(`✅ 江湖战法 ${skillName} 已添加到 ${agentId}`, 'ok');
|
||||
setAddRemoteForm(false);
|
||||
setRemoteFormData({ agentId: '', skillName: '', sourceUrl: '', description: '' });
|
||||
loadRemoteSkills();
|
||||
loadAgentConfig();
|
||||
} else {
|
||||
toast(r.error || '添加失败', 'err');
|
||||
}
|
||||
} catch {
|
||||
toast('服务器连接失败', 'err');
|
||||
}
|
||||
setRemoteSubmitting(false);
|
||||
};
|
||||
|
||||
const handleUpdate = async (skill: RemoteSkillItem) => {
|
||||
const key = `${skill.agentId}/${skill.skillName}`;
|
||||
setUpdatingSkill(key);
|
||||
try {
|
||||
const r = await api.updateRemoteSkill(skill.agentId, skill.skillName);
|
||||
if (r.ok) {
|
||||
toast(`✅ 战法 ${skill.skillName} 已精进`, 'ok');
|
||||
loadRemoteSkills();
|
||||
} else {
|
||||
toast(r.error || '更新失败', 'err');
|
||||
}
|
||||
} catch {
|
||||
toast('服务器连接失败', 'err');
|
||||
}
|
||||
setUpdatingSkill(null);
|
||||
};
|
||||
|
||||
const handleRemove = async (skill: RemoteSkillItem) => {
|
||||
const key = `${skill.agentId}/${skill.skillName}`;
|
||||
setRemovingSkill(key);
|
||||
try {
|
||||
const r = await api.removeRemoteSkill(skill.agentId, skill.skillName);
|
||||
if (r.ok) {
|
||||
toast(`🗑️ 战法 ${skill.skillName} 已废弃`, 'ok');
|
||||
loadRemoteSkills();
|
||||
loadAgentConfig();
|
||||
} else {
|
||||
toast(r.error || '移除失败', 'err');
|
||||
}
|
||||
} catch {
|
||||
toast('服务器连接失败', 'err');
|
||||
}
|
||||
setRemovingSkill(null);
|
||||
};
|
||||
|
||||
const handleQuickImport = async (skillUrl: string, skillName: string) => {
|
||||
if (!quickPickAgent) { toast('请先选择目标 Agent', 'err'); return; }
|
||||
try {
|
||||
const r = await api.addRemoteSkill(quickPickAgent, skillName, skillUrl, '');
|
||||
if (r.ok) {
|
||||
toast(`✅ ${skillName} → ${quickPickAgent}`, 'ok');
|
||||
loadRemoteSkills();
|
||||
loadAgentConfig();
|
||||
} else {
|
||||
toast(r.error || '导入失败', 'err');
|
||||
}
|
||||
} catch {
|
||||
toast('服务器连接失败', 'err');
|
||||
}
|
||||
};
|
||||
|
||||
if (!agentConfig?.agents) {
|
||||
return <div className="empty">无法加载</div>;
|
||||
}
|
||||
|
||||
// ── 自家战法面板 ──
|
||||
const localPanel = (
|
||||
<div>
|
||||
<div className="skills-grid">
|
||||
{agentConfig.agents.map((ag) => (
|
||||
<div className="sk-card" key={ag.id}>
|
||||
<div className="sk-hdr">
|
||||
<span className="sk-emoji">{ag.emoji || '🏛️'}</span>
|
||||
<span className="sk-name">{ag.label}</span>
|
||||
<span className="sk-cnt">{(ag.skills || []).length} 套战法</span>
|
||||
</div>
|
||||
<div className="sk-list">
|
||||
{!(ag.skills || []).length ? (
|
||||
<div className="sk-empty">暂无 Skills</div>
|
||||
) : (
|
||||
(ag.skills || []).map((sk) => (
|
||||
<div className="sk-item" key={sk.name} onClick={() => openSkill(ag.id, sk.name)}>
|
||||
<span className="si-name">📦 {sk.name}</span>
|
||||
<span className="si-desc">{sk.description || '无描述'}</span>
|
||||
<span className="si-arrow">›</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="sk-add" onClick={() => openAddForm(ag.id, ag.label)}>
|
||||
+ 自创战法
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── 江湖战法面板 ──
|
||||
const remotePanel = (
|
||||
<div>
|
||||
{/* 操作栏 */}
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button
|
||||
style={{ padding: '8px 18px', background: 'var(--acc)', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 13 }}
|
||||
onClick={() => { setAddRemoteForm(true); setQuickPickSource(null); }}
|
||||
>
|
||||
+ 添加江湖战法
|
||||
</button>
|
||||
<button
|
||||
style={{ padding: '8px 14px', background: 'transparent', color: 'var(--acc)', border: '1px solid var(--acc)', borderRadius: 8, cursor: 'pointer', fontSize: 12 }}
|
||||
onClick={loadRemoteSkills}
|
||||
>
|
||||
⟳ 刷新列表
|
||||
</button>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 4 }}>
|
||||
共 {remoteSkills.length} 个江湖战法
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 武林秘籍区 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--muted)', letterSpacing: '.06em', marginBottom: 10 }}>
|
||||
🌐 武林秘籍 — 一键导入
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{COMMUNITY_SOURCES.map((src) => (
|
||||
<div
|
||||
key={src.label}
|
||||
onClick={() => setQuickPickSource(quickPickSource?.label === src.label ? null : src)}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
background: quickPickSource?.label === src.label ? '#0d1f45' : 'var(--panel)',
|
||||
border: `1px solid ${quickPickSource?.label === src.label ? 'var(--acc)' : 'var(--line)'}`,
|
||||
borderRadius: 10,
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
transition: 'all .15s',
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: 6 }}>{src.emoji}</span>
|
||||
<b style={{ color: 'var(--text)' }}>{src.label}</b>
|
||||
<span style={{ marginLeft: 6, color: '#f0b429', fontSize: 11 }}>★ {src.stars}</span>
|
||||
<span style={{ marginLeft: 8, color: 'var(--muted)' }}>{src.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{quickPickSource && (
|
||||
<div style={{ marginTop: 14, background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 12, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600 }}>目标 Agent:</span>
|
||||
<select
|
||||
value={quickPickAgent}
|
||||
onChange={(e) => setQuickPickAgent(e.target.value)}
|
||||
style={{ padding: '6px 10px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 6, color: 'var(--text)', fontSize: 12 }}
|
||||
>
|
||||
<option value="">— 选择 Agent —</option>
|
||||
{agentConfig.agents.map((ag) => (
|
||||
<option key={ag.id} value={ag.id}>{ag.emoji} {ag.label} ({ag.id})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 8 }}>
|
||||
{quickPickSource.skills.map((sk) => {
|
||||
const alreadyAdded = remoteSkills.some((r) => r.skillName === sk.name && r.agentId === quickPickAgent);
|
||||
return (
|
||||
<div
|
||||
key={sk.name}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 12px', background: 'var(--panel2)', borderRadius: 8,
|
||||
border: '1px solid var(--line)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600 }}>📦 {sk.name}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', wordBreak: 'break-all', maxWidth: 180 }}>{sk.url.split('/').slice(-2).join('/')}</div>
|
||||
</div>
|
||||
{alreadyAdded ? (
|
||||
<span style={{ fontSize: 10, color: '#4caf88', fontWeight: 600 }}>✓ 已导入</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleQuickImport(sk.url, sk.name)}
|
||||
style={{ padding: '4px 10px', background: 'var(--acc)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 11, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
导入
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 已添加的江湖战法列表 */}
|
||||
{remoteLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--muted)', fontSize: 13 }}>⟳ 加载中…</div>
|
||||
) : remoteSkills.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', background: 'var(--panel)', borderRadius: 12, border: '1px dashed var(--line)' }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 10 }}>🌐</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--muted)' }}>尚无江湖战法</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 6 }}>从武林秘籍快速导入,或手动添加 URL</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{remoteSkills.map((sk) => {
|
||||
const key = `${sk.agentId}/${sk.skillName}`;
|
||||
const isUpdating = updatingSkill === key;
|
||||
const isRemoving = removingSkill === key;
|
||||
const agInfo = agentConfig.agents.find((a) => a.id === sk.agentId);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 12, padding: '14px 18px',
|
||||
display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700 }}>📦 {sk.skillName}</span>
|
||||
<span style={{
|
||||
fontSize: 10, padding: '2px 8px', borderRadius: 999,
|
||||
background: sk.status === 'valid' ? '#0d3322' : '#3d1111',
|
||||
color: sk.status === 'valid' ? '#4caf88' : '#ff5270',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{sk.status === 'valid' ? '✓ 有效' : '✗ 文件丢失'}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', background: 'var(--panel2)', padding: '2px 8px', borderRadius: 6 }}>
|
||||
{agInfo?.emoji} {agInfo?.label || sk.agentId}
|
||||
</span>
|
||||
</div>
|
||||
{sk.description && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 4 }}>{sk.description}</div>
|
||||
)}
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<span>🔗 <a href={sk.sourceUrl} target="_blank" rel="noreferrer" style={{ color: 'var(--acc)', textDecoration: 'none' }}>{sk.sourceUrl.length > 60 ? sk.sourceUrl.slice(0, 60) + '…' : sk.sourceUrl}</a></span>
|
||||
<span>📅 {sk.lastUpdated ? sk.lastUpdated.slice(0, 10) : sk.addedAt?.slice(0, 10)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => openSkill(sk.agentId, sk.skillName)}
|
||||
style={{ padding: '6px 12px', background: 'transparent', color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 6, cursor: 'pointer', fontSize: 11 }}
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdate(sk)}
|
||||
disabled={isUpdating}
|
||||
style={{ padding: '6px 12px', background: 'transparent', color: 'var(--acc)', border: '1px solid var(--acc)', borderRadius: 6, cursor: 'pointer', fontSize: 11 }}
|
||||
>
|
||||
{isUpdating ? '⟳' : '更新'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemove(sk)}
|
||||
disabled={isRemoving}
|
||||
style={{ padding: '6px 12px', background: 'transparent', color: '#ff5270', border: '1px solid #ff5270', borderRadius: 6, cursor: 'pointer', fontSize: 11 }}
|
||||
>
|
||||
{isRemoving ? '⟳' : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 主 Tab 切换 */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--line)', paddingBottom: 0 }}>
|
||||
{[
|
||||
{ key: 'local', label: '🏛️ 自家战法', count: agentConfig.agents.reduce((n, a) => n + (a.skills?.length || 0), 0) },
|
||||
{ key: 'remote', label: '🌐 江湖战法', count: remoteSkills.length },
|
||||
].map((t) => (
|
||||
<div
|
||||
key={t.key}
|
||||
onClick={() => setActiveTab(t.key as 'local' | 'remote')}
|
||||
style={{
|
||||
padding: '8px 18px', cursor: 'pointer', fontSize: 13, borderRadius: '8px 8px 0 0',
|
||||
fontWeight: activeTab === t.key ? 700 : 400,
|
||||
background: activeTab === t.key ? 'var(--panel)' : 'transparent',
|
||||
color: activeTab === t.key ? 'var(--text)' : 'var(--muted)',
|
||||
border: activeTab === t.key ? '1px solid var(--line)' : '1px solid transparent',
|
||||
borderBottom: activeTab === t.key ? '1px solid var(--panel)' : '1px solid transparent',
|
||||
position: 'relative', bottom: -1,
|
||||
transition: 'all .15s',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
{t.count > 0 && (
|
||||
<span style={{ marginLeft: 6, fontSize: 10, padding: '1px 6px', borderRadius: 999, background: '#1a2040', color: 'var(--acc)' }}>
|
||||
{t.count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'local' ? localPanel : remotePanel}
|
||||
|
||||
{/* Skill Content Modal */}
|
||||
{skillModal && (
|
||||
<div className="modal-bg open" onClick={() => setSkillModal(null)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={() => setSkillModal(null)}>✕</button>
|
||||
<div className="modal-body">
|
||||
<div style={{ fontSize: 11, color: 'var(--acc)', fontWeight: 700, letterSpacing: '.04em', marginBottom: 4 }}>
|
||||
{skillModal.agentId.toUpperCase()}
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 16 }}>📦 {skillModal.name}</div>
|
||||
<div className="sk-modal-body">
|
||||
<div className="sk-md" style={{ whiteSpace: 'pre-wrap', fontSize: 12, lineHeight: 1.7 }}>
|
||||
{skillModal.content}
|
||||
</div>
|
||||
{skillModal.path && (
|
||||
<div className="sk-path" style={{ fontSize: 10, color: 'var(--muted)', marginTop: 12 }}>
|
||||
📂 {skillModal.path}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 本地 Add Skill Form Modal */}
|
||||
{addForm && (
|
||||
<div className="modal-bg open" onClick={() => setAddForm(null)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={() => setAddForm(null)}>✕</button>
|
||||
<div className="modal-body">
|
||||
<div style={{ fontSize: 11, color: 'var(--acc)', fontWeight: 700, letterSpacing: '.04em', marginBottom: 4 }}>
|
||||
为 {addForm.agentLabel} 自创战法
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 18 }}>+ 新增 Skill</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--panel2)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
marginBottom: 18,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.7,
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<b style={{ color: 'var(--text)' }}>📋 Skill 规范说明</b>
|
||||
<br />
|
||||
• 战法名使用<b style={{ color: 'var(--text)' }}>小写英文 + 连字符</b>
|
||||
<br />
|
||||
• 创建后会生成模板文件 SKILL.md
|
||||
<br />
|
||||
• 战法在将军接到相关军令时<b style={{ color: 'var(--text)' }}>自动触发</b>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submitAdd} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}>
|
||||
战法名称 <span style={{ color: '#ff5270' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="如 data-analysis, code-review"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData((p) => ({ ...p, name: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))
|
||||
}
|
||||
style={{ width: '100%', padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--text)', fontSize: 13, outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}>战法描述</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="一句话说明用途"
|
||||
value={formData.desc}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, desc: e.target.value }))}
|
||||
style={{ width: '100%', padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--text)', fontSize: 13, outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}>触发条件(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="何时触发此战法"
|
||||
value={formData.trigger}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, trigger: e.target.value }))}
|
||||
style={{ width: '100%', padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--text)', fontSize: 13, outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
<button type="button" className="btn btn-g" onClick={() => setAddForm(null)} style={{ padding: '8px 20px' }}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
style={{ padding: '8px 20px', fontSize: 13, background: 'var(--acc)', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600 }}
|
||||
>
|
||||
{submitting ? '⟳ 修炼中…' : '📦 创建战法'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 远程 Add Remote Skill Modal */}
|
||||
{addRemoteForm && (
|
||||
<div className="modal-bg open" onClick={() => setAddRemoteForm(false)}>
|
||||
<div className="modal" style={{ maxWidth: 520 }} onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={() => setAddRemoteForm(false)}>✕</button>
|
||||
<div className="modal-body">
|
||||
<div style={{ fontSize: 11, color: '#a07aff', fontWeight: 700, letterSpacing: '.04em', marginBottom: 4 }}>
|
||||
江湖战法管理
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 18 }}>🌐 添加江湖战法</div>
|
||||
|
||||
<div style={{ background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 10, padding: 12, marginBottom: 18, fontSize: 11, color: 'var(--muted)', lineHeight: 1.7 }}>
|
||||
支持 GitHub Raw URL,如:<br />
|
||||
<code style={{ color: 'var(--acc)', fontSize: 10 }}>https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/brainstorming/SKILL.md</code>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submitAddRemote} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}>目标 Agent <span style={{ color: '#ff5270' }}>*</span></label>
|
||||
<select
|
||||
required
|
||||
value={remoteFormData.agentId}
|
||||
onChange={(e) => setRemoteFormData((p) => ({ ...p, agentId: e.target.value }))}
|
||||
style={{ width: '100%', padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--text)', fontSize: 13 }}
|
||||
>
|
||||
<option value="">— 选择 Agent —</option>
|
||||
{agentConfig.agents.map((ag) => (
|
||||
<option key={ag.id} value={ag.id}>{ag.emoji} {ag.label} ({ag.id})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}>战法名称 <span style={{ color: '#ff5270' }}>*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="如 brainstorming, code-review"
|
||||
value={remoteFormData.skillName}
|
||||
onChange={(e) => setRemoteFormData((p) => ({ ...p, skillName: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))}
|
||||
style={{ width: '100%', padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--text)', fontSize: 13, outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}>源 URL <span style={{ color: '#ff5270' }}>*</span></label>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://raw.githubusercontent.com/..."
|
||||
value={remoteFormData.sourceUrl}
|
||||
onChange={(e) => setRemoteFormData((p) => ({ ...p, sourceUrl: e.target.value }))}
|
||||
style={{ width: '100%', padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--text)', fontSize: 12, outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}>描述(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="一句话说明用途"
|
||||
value={remoteFormData.description}
|
||||
onChange={(e) => setRemoteFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
style={{ width: '100%', padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--text)', fontSize: 13, outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
<button type="button" className="btn btn-g" onClick={() => setAddRemoteForm(false)} style={{ padding: '8px 20px' }}>取消</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={remoteSubmitting}
|
||||
style={{ padding: '8px 20px', fontSize: 13, background: '#a07aff', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600 }}
|
||||
>
|
||||
{remoteSubmitting ? '⟳ 下载中…' : '🌐 添加江湖战法'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
import { useState } from 'react';
|
||||
import { useStore, TEMPLATES, TPL_CATS } from '../store';
|
||||
import type { Template } from '../store';
|
||||
import { api } from '../api';
|
||||
|
||||
export default function TemplatePanel() {
|
||||
const tplCatFilter = useStore((s) => s.tplCatFilter);
|
||||
const setTplCatFilter = useStore((s) => s.setTplCatFilter);
|
||||
const toast = useStore((s) => s.toast);
|
||||
const loadAll = useStore((s) => s.loadAll);
|
||||
|
||||
const [formTpl, setFormTpl] = useState<Template | null>(null);
|
||||
const [formVals, setFormVals] = useState<Record<string, string>>({});
|
||||
const [previewCmd, setPreviewCmd] = useState('');
|
||||
|
||||
let tpls = TEMPLATES;
|
||||
if (tplCatFilter !== '全部') tpls = tpls.filter((t) => t.cat === tplCatFilter);
|
||||
|
||||
const openForm = (tpl: Template) => {
|
||||
const vals: Record<string, string> = {};
|
||||
tpl.params.forEach((p) => {
|
||||
vals[p.key] = p.default || '';
|
||||
});
|
||||
setFormVals(vals);
|
||||
setFormTpl(tpl);
|
||||
setPreviewCmd('');
|
||||
};
|
||||
|
||||
const buildCmd = (tpl: Template) => {
|
||||
let cmd = tpl.command;
|
||||
for (const p of tpl.params) {
|
||||
cmd = cmd.replace(new RegExp('\\{' + p.key + '\\}', 'g'), formVals[p.key] || p.default || '');
|
||||
}
|
||||
return cmd;
|
||||
};
|
||||
|
||||
const preview = () => {
|
||||
if (!formTpl) return;
|
||||
setPreviewCmd(buildCmd(formTpl));
|
||||
};
|
||||
|
||||
const execute = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formTpl) return;
|
||||
const cmd = buildCmd(formTpl);
|
||||
if (!cmd.trim()) {
|
||||
toast('请填写必填参数', 'err');
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-check (skip in moziplus - Gateway check not applicable)
|
||||
if (!confirm(`确认创建任务?\n\n${cmd.substring(0, 200)}${cmd.length > 200 ? '…' : ''}`)) return;
|
||||
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
for (const p of formTpl.params) {
|
||||
params[p.key] = formVals[p.key] || p.default || '';
|
||||
}
|
||||
const r = await api.createTask({
|
||||
title: `${formTpl.name}: ${Object.values(params).join(' ').substring(0, 80)}`,
|
||||
requirement: cmd,
|
||||
project_root: '/tmp',
|
||||
project_type: 'general',
|
||||
templateId: formTpl.id,
|
||||
params,
|
||||
});
|
||||
const rAny = r as unknown as Record<string, unknown>;
|
||||
if (r.ok || r.taskId || rAny.id) {
|
||||
const tid = r.taskId || String(rAny.id || '');
|
||||
toast(`📜 ${tid} 任务已创建`, 'ok');
|
||||
setFormTpl(null);
|
||||
loadAll();
|
||||
} else {
|
||||
toast(r.error || '创建任务失败', 'err');
|
||||
}
|
||||
} catch {
|
||||
toast('⚠️ 服务器连接失败', 'err');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Category filter */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{TPL_CATS.map((c) => (
|
||||
<span
|
||||
key={c.name}
|
||||
className={`tpl-cat${tplCatFilter === c.name ? ' active' : ''}`}
|
||||
onClick={() => setTplCatFilter(c.name)}
|
||||
>
|
||||
{c.icon} {c.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="tpl-grid">
|
||||
{tpls.map((t) => (
|
||||
<div className="tpl-card" key={t.id}>
|
||||
<div className="tpl-top">
|
||||
<span className="tpl-icon">{t.icon}</span>
|
||||
<span className="tpl-name">{t.name}</span>
|
||||
</div>
|
||||
<div className="tpl-desc">{t.desc}</div>
|
||||
<div className="tpl-footer">
|
||||
{t.depts.map((d) => (
|
||||
<span className="tpl-dept" key={d}>{d}</span>
|
||||
))}
|
||||
<span className="tpl-est">
|
||||
{t.est} · {t.cost}
|
||||
</span>
|
||||
<button className="tpl-go" onClick={() => openForm(t)}>
|
||||
创建任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template Form Modal */}
|
||||
{formTpl && (
|
||||
<div className="modal-bg open" onClick={() => setFormTpl(null)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={() => setFormTpl(null)}>✕</button>
|
||||
<div className="modal-body">
|
||||
<div style={{ fontSize: 11, color: 'var(--acc)', fontWeight: 700, letterSpacing: '.04em', marginBottom: 4 }}>
|
||||
任务模板
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 6 }}>
|
||||
{formTpl.icon} {formTpl.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 18 }}>{formTpl.desc}</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 18, flexWrap: 'wrap' }}>
|
||||
{formTpl.depts.map((d) => (
|
||||
<span className="tpl-dept" key={d}>{d}</span>
|
||||
))}
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 'auto' }}>
|
||||
{formTpl.est} · {formTpl.cost}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form className="tpl-form" onSubmit={execute}>
|
||||
{formTpl.params.map((p) => (
|
||||
<div className="tpl-field" key={p.key}>
|
||||
<label className="tpl-label">
|
||||
{p.label}
|
||||
{p.required && <span style={{ color: '#ff5270' }}> *</span>}
|
||||
</label>
|
||||
{p.type === 'textarea' ? (
|
||||
<textarea
|
||||
className="tpl-input"
|
||||
style={{ minHeight: 80, resize: 'vertical' }}
|
||||
required={p.required}
|
||||
value={formVals[p.key] || ''}
|
||||
onChange={(e) => setFormVals((v) => ({ ...v, [p.key]: e.target.value }))}
|
||||
/>
|
||||
) : p.type === 'select' ? (
|
||||
<select
|
||||
className="tpl-input"
|
||||
value={formVals[p.key] || p.default || ''}
|
||||
onChange={(e) => setFormVals((v) => ({ ...v, [p.key]: e.target.value }))}
|
||||
>
|
||||
{(p.options || []).map((o) => (
|
||||
<option key={o}>{o}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="tpl-input"
|
||||
type="text"
|
||||
required={p.required}
|
||||
value={formVals[p.key] || ''}
|
||||
onChange={(e) => setFormVals((v) => ({ ...v, [p.key]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{previewCmd && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--panel2)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 14,
|
||||
fontSize: 12,
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text)', marginBottom: 6 }}>
|
||||
📜 将提交给编排引擎:
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{previewCmd}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button type="button" className="btn btn-g" onClick={preview} style={{ padding: '8px 16px', fontSize: 12 }}>
|
||||
👁 预览旨意
|
||||
</button>
|
||||
<button type="submit" className="tpl-go" style={{ padding: '8px 20px', fontSize: 13 }}>
|
||||
📜 创建任务
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Self-create Template */}
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<button
|
||||
className="btn btn-g"
|
||||
style={{ fontSize: 12, padding: '6px 16px' }}
|
||||
onClick={() => {
|
||||
const name = prompt('战法名称:');
|
||||
if (!name) return;
|
||||
const desc = prompt('战法描述:') || '';
|
||||
const cmd = prompt('命令模板(用 {参数名} 做占位符):') || '';
|
||||
const customs = JSON.parse(localStorage.getItem('customTemplates') || '[]');
|
||||
customs.push({ id: `custom-${Date.now()}`, name, desc, command: cmd, icon: '✏️', cat: '自创', est: '自定义', cost: '自定义', depts: ['庞统'], params: [] });
|
||||
localStorage.setItem('customTemplates', JSON.stringify(customs));
|
||||
toast(`✏️ 自创战法「${name}」已保存`, 'ok');
|
||||
}}
|
||||
>
|
||||
✏️ 自创军令
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useStore } from '../store';
|
||||
|
||||
export default function Toaster() {
|
||||
const toasts = useStore((s) => s.toasts);
|
||||
if (!toasts.length) return null;
|
||||
|
||||
return (
|
||||
<div className="toaster">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={`toast ${t.type}`}>
|
||||
{t.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 军费总览 — Token 消耗归因、上下文压力、预算状态
|
||||
* 三国术语:军费/各营消耗/兵器消耗/军令消耗/粮草告急
|
||||
*/
|
||||
|
||||
import { useStore } from '../store';
|
||||
import type { OfficialInfo } from '../api';
|
||||
|
||||
export default function UsagePanel() {
|
||||
const officialsData = useStore((s) => s.officialsData);
|
||||
|
||||
// 从真实数据构建归因,fallback 到空数组
|
||||
const officials = officialsData?.officials || [];
|
||||
const totals = officialsData?.totals || { tasks_done: 0, cost_cny: 0 };
|
||||
|
||||
// 各营消耗(按 Agent)
|
||||
const byAgent: Array<{ name: string; emoji: string; tokens: number; cost: number; pct: number }> = [];
|
||||
const totalTokens = officials.reduce((s, o) => s + o.tokens_in + o.tokens_out + o.cache_read + o.cache_write, 0) || 1;
|
||||
for (const o of officials) {
|
||||
const tk = o.tokens_in + o.tokens_out + o.cache_read + o.cache_write;
|
||||
byAgent.push({
|
||||
name: o.role.split(' ')[0],
|
||||
emoji: o.emoji,
|
||||
tokens: tk,
|
||||
cost: o.cost_cny,
|
||||
pct: Math.round((tk / totalTokens) * 100),
|
||||
});
|
||||
}
|
||||
byAgent.sort((a, b) => b.tokens - a.tokens);
|
||||
|
||||
// 兵器消耗(按模型)
|
||||
const modelMap = new Map<string, { tokens: number; cost: number }>();
|
||||
for (const o of officials) {
|
||||
const key = o.model_short || o.model;
|
||||
const tk = o.tokens_in + o.tokens_out + o.cache_read + o.cache_write;
|
||||
const existing = modelMap.get(key) || { tokens: 0, cost: 0 };
|
||||
existing.tokens += tk;
|
||||
existing.cost += o.cost_cny;
|
||||
modelMap.set(key, existing);
|
||||
}
|
||||
const byModel = Array.from(modelMap.entries()).map(([name, data]) => ({
|
||||
name,
|
||||
...data,
|
||||
pct: Math.round((data.tokens / totalTokens) * 100),
|
||||
})).sort((a, b) => b.tokens - a.tokens);
|
||||
|
||||
// 军令消耗(参与军令 top 5)
|
||||
const taskMap = new Map<string, { title: string; tokens: number; cost: number }>();
|
||||
for (const o of officials) {
|
||||
for (const e of o.participated_edicts) {
|
||||
const existing = taskMap.get(e.id) || { title: e.title, tokens: 0, cost: 0 };
|
||||
existing.tokens += o.tokens_in + o.tokens_out;
|
||||
existing.cost += o.cost_cny / Math.max(o.participated_edicts.length, 1);
|
||||
taskMap.set(e.id, existing);
|
||||
}
|
||||
}
|
||||
const byTask = Array.from(taskMap.values())
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.slice(0, 5);
|
||||
|
||||
// 粮草告急(上下文压力)
|
||||
const contextWindows = officials.map((o: OfficialInfo) => {
|
||||
const used = (o.tokens_in + o.tokens_out) / 2000; // 粗估 k tokens
|
||||
const max = 200; // 200k context
|
||||
const pct = Math.round((used / max) * 100);
|
||||
const status = pct > 80 ? 'high' : pct > 50 ? 'warn' : 'ok';
|
||||
return { agent: o.role.split(' ')[0], emoji: o.emoji, used: Math.round(used), max, pct, status };
|
||||
}).sort((a, b) => b.pct - a.pct);
|
||||
|
||||
const totalCost = byAgent.reduce((s, a) => s + a.cost, 0);
|
||||
const highPressure = contextWindows.filter((cw) => cw.status === 'high').length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 军费摘要卡片 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 10, marginBottom: 24 }}>
|
||||
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: 'var(--acc)' }}>{(totalTokens / 1000000).toFixed(1)}M</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>总 Token</div>
|
||||
</div>
|
||||
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: '#f5c842' }}>¥{totalCost.toFixed(1)}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>军费总计</div>
|
||||
</div>
|
||||
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: 'var(--ok)' }}>{totals.tasks_done || 0}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>完成军令</div>
|
||||
</div>
|
||||
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: highPressure > 0 ? 'var(--danger)' : 'var(--ok)' }}>{highPressure}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>粮草告急</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 各营消耗 */}
|
||||
<div style={{ fontWeight: 700, color: 'var(--acc)', marginBottom: 10, fontSize: 14, borderBottom: '1px solid var(--line)', paddingBottom: 6 }}>
|
||||
⚔️ 各营消耗
|
||||
</div>
|
||||
{byAgent.map((a) => (
|
||||
<div key={a.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ width: 24 }}>{a.emoji}</span>
|
||||
<span style={{ width: 50, fontSize: 12 }}>{a.name}</span>
|
||||
<div style={{ flex: 1, height: 10, background: '#0e1320', borderRadius: 5, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${a.pct}%`, background: 'linear-gradient(90deg, var(--acc), #a07aff)', borderRadius: 5, transition: 'width .3s' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', width: 60, textAlign: 'right' }}>{(a.tokens / 1000).toFixed(0)}k</span>
|
||||
<span style={{ fontSize: 11, color: '#f5c842', width: 50, textAlign: 'right' }}>¥{a.cost.toFixed(1)}</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)', width: 30, textAlign: 'right' }}>{a.pct}%</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 兵器消耗 */}
|
||||
<div style={{ fontWeight: 700, color: 'var(--acc)', marginTop: 20, marginBottom: 10, fontSize: 14, borderBottom: '1px solid var(--line)', paddingBottom: 6 }}>
|
||||
🗡️ 兵器消耗
|
||||
</div>
|
||||
{byModel.map((m) => (
|
||||
<div key={m.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 12, width: 160, color: 'var(--text)' }}>{m.name}</span>
|
||||
<div style={{ flex: 1, height: 10, background: '#0e1320', borderRadius: 5, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${m.pct}%`, background: 'linear-gradient(90deg, #2ecc8a, var(--acc))', borderRadius: 5 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', width: 60, textAlign: 'right' }}>{(m.tokens / 1000).toFixed(0)}k</span>
|
||||
<span style={{ fontSize: 11, color: '#f5c842', width: 50, textAlign: 'right' }}>¥{m.cost.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 军令消耗 Top 5 */}
|
||||
<div style={{ fontWeight: 700, color: 'var(--acc)', marginTop: 20, marginBottom: 10, fontSize: 14, borderBottom: '1px solid var(--line)', paddingBottom: 6 }}>
|
||||
📜 军令消耗(前5)
|
||||
</div>
|
||||
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 12 }}>
|
||||
{byTask.length === 0 ? (
|
||||
<div style={{ padding: 14, fontSize: 12, color: 'var(--muted)' }}>暂无军令数据</div>
|
||||
) : (
|
||||
byTask.map((t, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 14px', borderBottom: i < byTask.length - 1 ? '1px solid #0e1320' : 'none' }}>
|
||||
<span style={{ fontSize: 12 }}>{t.title}</span>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{(t.tokens / 1000).toFixed(0)}k tokens</span>
|
||||
<span style={{ fontSize: 11, color: '#f5c842' }}>¥{t.cost.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 粮草告急 */}
|
||||
<div style={{ fontWeight: 700, color: 'var(--acc)', marginTop: 20, marginBottom: 10, fontSize: 14, borderBottom: '1px solid var(--line)', paddingBottom: 6 }}>
|
||||
🌾 粮草告急
|
||||
</div>
|
||||
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 12 }}>
|
||||
{contextWindows.map((cw, i) => (
|
||||
<div key={cw.agent} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', borderBottom: i < contextWindows.length - 1 ? '1px solid #0e1320' : 'none' }}>
|
||||
<span style={{ width: 20 }}>{cw.emoji}</span>
|
||||
<span style={{ width: 50, fontSize: 12 }}>{cw.agent}</span>
|
||||
<div style={{ flex: 1, height: 6, background: '#0e1320', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${cw.pct}%`,
|
||||
borderRadius: 3,
|
||||
background: cw.status === 'high' ? '#ff5270' : cw.status === 'warn' ? '#f5c842' : '#2ecc8a',
|
||||
}} />
|
||||
</div>
|
||||
<span style={{ fontSize: 10, color: cw.status === 'high' ? '#ff5270' : cw.status === 'warn' ? '#f5c842' : 'var(--muted)' }}>
|
||||
{cw.used}k / {cw.max}k {cw.status === 'high' && '⚠️'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ══ 三省六部总控台 · CSS 变量 ══ */
|
||||
:root {
|
||||
--bg: #07090f;
|
||||
--panel: #0f1219;
|
||||
--panel2: #141824;
|
||||
--line: #1c2236;
|
||||
--text: #dde4f8;
|
||||
--muted: #5a6b92;
|
||||
--ok: #2ecc8a;
|
||||
--warn: #f5c842;
|
||||
--danger: #ff5270;
|
||||
--acc: #6a9eff;
|
||||
--acc2: #a07aff;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "PingFang SC", Inter, -apple-system, "Segoe UI", sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #1e2538; border-radius: 4px; }
|
||||
|
||||
/* ══ 布局 ══ */
|
||||
.wrap { max-width: 1400px; margin: 0 auto; padding: 16px; }
|
||||
|
||||
/* ══ HEADER ══ */
|
||||
.hdr { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.logo { font-size: 20px; font-weight: 800; background: linear-gradient(135deg, #6a9eff, #a07aff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.sub-text { font-size: 11px; color: var(--muted); }
|
||||
.hdr-r { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.chip { font-size: 11px; padding: 3px 9px; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); color: var(--muted); }
|
||||
.chip.ok { border-color: #2ecc8a44; color: var(--ok); }
|
||||
.chip.warn { border-color: #f5c84244; color: var(--warn); }
|
||||
.chip.err { border-color: #ff527044; color: var(--danger); }
|
||||
.btn-refresh { font-size: 11px; padding: 4px 10px; border: 1px solid var(--acc); border-radius: 6px; background: transparent; color: var(--acc); cursor: pointer; }
|
||||
.btn-refresh:hover { background: #0a1228; }
|
||||
|
||||
/* ══ TABS ══ */
|
||||
.tabs { display: flex; gap: 2px; margin-bottom: 18px; border-bottom: 1px solid var(--line); overflow-x: auto; }
|
||||
.tab { font-size: 13px; padding: 8px 16px; border-radius: 8px 8px 0 0; cursor: pointer; color: var(--muted); border: 1px solid transparent; border-bottom: none; white-space: nowrap; position: relative; bottom: -1px; transition: all .15s; user-select: none; }
|
||||
.tab:hover { color: var(--text); background: var(--panel); }
|
||||
.tab.active { color: var(--text); background: var(--panel); border-color: var(--line); font-weight: 600; }
|
||||
.tbadge { font-size: 10px; padding: 1px 5px; border-radius: 999px; background: #1a2040; color: var(--acc); margin-left: 4px; }
|
||||
|
||||
/* ══ 旨意看板卡片 ══ */
|
||||
.edict-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }
|
||||
.edict-card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px; cursor: pointer; transition: border-color .15s, transform .1s, box-shadow .15s; }
|
||||
.edict-card:hover { border-color: var(--acc); transform: translateY(-2px); box-shadow: 0 4px 20px rgba(106,158,255,.1); }
|
||||
.edict-card.archived { opacity: .55; border-style: dashed; }
|
||||
.edict-card.archived:hover { opacity: .85; }
|
||||
|
||||
.ec-pipe { display: flex; align-items: center; gap: 0; margin-bottom: 14px; overflow-x: auto; padding-bottom: 2px; }
|
||||
.ep-node { display: flex; flex-direction: column; align-items: center; gap: 1px; padding: 5px 8px; border-radius: 6px; flex-shrink: 0; min-width: 52px; }
|
||||
.ep-node.done { background: #0a2018; }
|
||||
.ep-node.active { background: #0f1a38; border: 1px solid var(--acc); }
|
||||
.ep-node.pending { opacity: .3; }
|
||||
.ep-icon { font-size: 14px; }
|
||||
.ep-name { font-size: 9px; color: var(--muted); white-space: nowrap; }
|
||||
.ep-node.done .ep-name { color: var(--ok); }
|
||||
.ep-node.active .ep-name { color: var(--acc); font-weight: 700; }
|
||||
.ep-arrow { font-size: 10px; color: #1c2236; padding: 0 1px; flex-shrink: 0; }
|
||||
|
||||
.ec-id { font-size: 10px; color: var(--acc); font-weight: 700; letter-spacing: .04em; margin-bottom: 5px; }
|
||||
.ec-title { font-size: 15px; font-weight: 700; line-height: 1.4; margin-bottom: 10px; color: var(--text); }
|
||||
.ec-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 8px; }
|
||||
.tag { font-size: 10px; padding: 2px 7px; border-radius: 4px; border: 1px solid; display: inline-block; white-space: nowrap; }
|
||||
|
||||
/* state colors */
|
||||
.st-Inbox { border-color: #3a4a7a44; color: #7a9aff; background: #0a1028; }
|
||||
.st-Taizi { border-color: #e8a04044; color: #e8a040; background: #281a08; }
|
||||
.st-Zhongshu { border-color: #a07aff44; color: #a07aff; background: #110a28; }
|
||||
.st-Menxia { border-color: #ff9a6a44; color: #ff9a6a; background: #280f0a; }
|
||||
.st-Assigned, .st-Doing { border-color: #6a9eff44; color: #6a9eff; background: #0a1428; }
|
||||
.st-Review { border-color: #f5c84244; color: #f5c842; background: #201a08; }
|
||||
.st-Done { border-color: #2ecc8a44; color: var(--ok); background: #0a2018; }
|
||||
.st-Blocked { border-color: #ff527044; color: var(--danger); background: #200a10; }
|
||||
.st-Cancelled { border-color: #88888844; color: #888; background: #1a1a1a; }
|
||||
.st-Next { border-color: #4a9adf44; color: #4a9adf; background: #0a1424; }
|
||||
.st-Pending { border-color: #3a4a7a44; color: #7a9aff; background: #0a1028; }
|
||||
|
||||
/* dept colors */
|
||||
.dt-中书省 { border-color: #a07aff44; color: #a07aff; background: #1a0f38; }
|
||||
.dt-门下省 { border-color: #6a9eff44; color: #6a9eff; background: #0f1a38; }
|
||||
.dt-尚书省 { border-color: #6aef9a44; color: #6aef9a; background: #0a2018; }
|
||||
.dt-礼部 { border-color: #f5c84244; color: #f5c842; background: #201a08; }
|
||||
.dt-户部 { border-color: #ff9a6a44; color: #ff9a6a; background: #28100a; }
|
||||
.dt-兵部 { border-color: #ff527044; color: #ff5270; background: #280a10; }
|
||||
.dt-刑部 { border-color: #cc444444; color: #cc4444; background: #280808; }
|
||||
.dt-工部 { border-color: #44aaff44; color: #44aaff; background: #081828; }
|
||||
|
||||
.ec-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; flex-wrap: wrap; gap: 6px; }
|
||||
.hb { font-size: 10px; padding: 2px 7px; border-radius: 999px; border: 1px solid var(--line); }
|
||||
.hb.active { border-color: #2ecc8a44; color: var(--ok); }
|
||||
.hb.warn { border-color: #f5c84244; color: var(--warn); }
|
||||
.hb.stalled { border-color: #ff527044; color: var(--danger); animation: pulse 1.5s infinite; }
|
||||
.hb.unknown { color: var(--muted); }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
|
||||
|
||||
/* ══ TASK DETAIL MODAL ══ */
|
||||
.modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 100; backdrop-filter: blur(3px); overflow-y: auto; display: flex; align-items: flex-start; justify-content: center; padding: 40px 16px; }
|
||||
.modal { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; width: 100%; max-width: 760px; padding: 28px; position: relative; box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
.modal-close { position: absolute; top: 16px; right: 16px; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 8px; cursor: pointer; font-size: 18px; color: var(--muted); background: none; border: none; }
|
||||
.modal-close:hover { background: var(--panel2); color: var(--text); }
|
||||
.modal-id { font-size: 11px; color: var(--acc); font-weight: 700; letter-spacing: .04em; margin-bottom: 6px; }
|
||||
.modal-title { font-size: 22px; font-weight: 800; line-height: 1.3; margin-bottom: 18px; }
|
||||
|
||||
.m-pipe { display: flex; align-items: stretch; gap: 0; overflow-x: auto; padding: 16px; background: var(--panel2); border-radius: 12px; margin-bottom: 20px; }
|
||||
.mp-stage { display: flex; align-items: center; flex-shrink: 0; }
|
||||
.mp-node { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 10px 14px; border-radius: 10px; min-width: 80px; position: relative; }
|
||||
.mp-node.done { background: #0a2018; border: 1px solid #2ecc8a44; }
|
||||
.mp-node.active { background: #0f1838; border: 2px solid var(--acc); box-shadow: 0 0 14px rgba(106,158,255,.2); }
|
||||
.mp-node.pending { opacity: .25; border: 1px dashed var(--line); }
|
||||
.mp-icon { font-size: 22px; }
|
||||
.mp-dept { font-size: 12px; font-weight: 700; margin-top: 2px; }
|
||||
.mp-node.done .mp-dept { color: var(--ok); }
|
||||
.mp-node.active .mp-dept { color: var(--acc); }
|
||||
.mp-node.pending .mp-dept { color: var(--muted); }
|
||||
.mp-action { font-size: 10px; color: var(--muted); margin-top: 1px; }
|
||||
.mp-node.active .mp-action { color: #6a9eff88; }
|
||||
.mp-done-tick { position: absolute; top: -6px; right: -6px; width: 16px; height: 16px; background: var(--ok); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; color: #000; font-weight: 700; }
|
||||
.mp-arrow { color: #1c2236; font-size: 18px; padding: 0 6px; margin-top: -10px; }
|
||||
|
||||
.cur-stage { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: #0a1228; border: 1px solid var(--acc); border-radius: 10px; margin-bottom: 18px; }
|
||||
.cs-icon { font-size: 24px; }
|
||||
.cs-info .cs-dept { font-size: 16px; font-weight: 700; color: var(--acc); }
|
||||
.cs-info .cs-action { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||||
.cs-hb { margin-left: auto; }
|
||||
|
||||
.m-section { margin-bottom: 18px; }
|
||||
.m-sec-label { font-size: 11px; font-weight: 700; color: var(--muted); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--line); }
|
||||
.fl-timeline { display: flex; flex-direction: column; gap: 0; position: relative; }
|
||||
.fl-timeline::before { content: ''; position: absolute; left: 60px; top: 0; bottom: 0; width: 1px; background: var(--line); }
|
||||
.fl-item { display: flex; gap: 0; position: relative; padding: 8px 0; }
|
||||
.fl-time { min-width: 60px; font-size: 10px; color: var(--muted); text-align: right; padding-right: 14px; flex-shrink: 0; padding-top: 3px; }
|
||||
.fl-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 3px; position: relative; z-index: 1; }
|
||||
.fl-content { padding-left: 12px; flex: 1; }
|
||||
.fl-who { font-size: 12px; margin-bottom: 2px; }
|
||||
.fl-who .from { font-weight: 700; }
|
||||
.fl-who .to { font-weight: 700; }
|
||||
.fl-rem { font-size: 11px; color: var(--muted); line-height: 1.5; }
|
||||
|
||||
.m-rows { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.m-row { background: var(--panel2); border-radius: 8px; padding: 10px 12px; }
|
||||
.mr-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 3px; }
|
||||
.mr-val { font-size: 13px; font-weight: 600; word-break: break-all; }
|
||||
|
||||
/* ══ 省部调度 ══ */
|
||||
.duty-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 12px; }
|
||||
.duty-card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; transition: border-color .15s; }
|
||||
.duty-card:hover { border-color: #2e3d6a; }
|
||||
.duty-card.active-card { border-color: var(--acc); }
|
||||
.duty-card.blocked-card { border-color: #ff527055; }
|
||||
.dc-hdr { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: var(--panel2); border-bottom: 1px solid var(--line); }
|
||||
.dc-emoji { font-size: 22px; }
|
||||
.dc-info { flex: 1; }
|
||||
.dc-name { font-size: 14px; font-weight: 800; }
|
||||
.dc-role { font-size: 10px; color: var(--muted); }
|
||||
.dc-status { display: flex; align-items: center; gap: 5px; font-size: 11px; }
|
||||
.dc-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dc-dot.active { background: var(--ok); }
|
||||
.dc-dot.busy { background: var(--warn); animation: pulse 1.5s infinite; }
|
||||
.dc-dot.blocked { background: var(--danger); animation: pulse 1s infinite; }
|
||||
.dc-dot.idle { background: #2a3a5a; }
|
||||
.dc-body { padding: 14px 16px; }
|
||||
.dc-idle { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; padding: 6px 0; }
|
||||
.dc-task { display: flex; flex-direction: column; gap: 6px; cursor: pointer; padding: 6px; border-radius: 8px; border: 1px solid var(--line); margin-bottom: 6px; }
|
||||
.dc-task:hover { border-color: var(--acc); }
|
||||
.dc-task-id { font-size: 10px; color: var(--acc); font-weight: 700; letter-spacing: .04em; }
|
||||
.dc-task-title { font-size: 14px; font-weight: 700; color: var(--text); line-height: 1.3; }
|
||||
.dc-task-now { font-size: 12px; color: var(--muted); line-height: 1.5; margin-top: 2px; }
|
||||
.dc-task-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
|
||||
.dc-footer { padding: 8px 16px; border-top: 1px solid var(--line); display: flex; align-items: center; gap: 8px; background: var(--panel2); }
|
||||
.dc-model { font-size: 10px; color: var(--muted); }
|
||||
.dc-la { font-size: 10px; color: var(--muted); margin-left: auto; }
|
||||
|
||||
/* ══ Agent Status Panel ══ */
|
||||
.as-panel { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px 18px; margin-bottom: 16px; }
|
||||
.as-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.as-title { font-size: 13px; font-weight: 700; }
|
||||
.as-gw { font-size: 11px; padding: 3px 10px; border-radius: 999px; margin-left: auto; }
|
||||
.as-gw.ok { background: #0a2018; border: 1px solid #2ecc8a44; color: var(--ok); }
|
||||
.as-gw.err { background: #200a10; border: 1px solid #ff527044; color: var(--danger); }
|
||||
.as-gw.warn { background: #201a08; border: 1px solid #f5c84244; color: var(--warn); }
|
||||
.as-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; }
|
||||
.as-card { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 10px; text-align: center; cursor: pointer; transition: border-color .15s, background .15s; position: relative; }
|
||||
.as-card:hover { border-color: var(--acc); background: #0a1228; }
|
||||
.as-dot { position: absolute; top: 6px; right: 6px; width: 8px; height: 8px; border-radius: 50%; }
|
||||
.as-dot.running { background: #2ecc8a; box-shadow: 0 0 6px #2ecc8a88; animation: pulse 1.5s infinite; }
|
||||
.as-dot.idle { background: #4a5568; }
|
||||
.as-dot.offline { background: #ff5270; animation: pulse 1.2s infinite; }
|
||||
.as-dot.unconfigured { background: #6b7280; }
|
||||
.as-wake-btn { font-size: 10px; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--acc); color: var(--acc); background: transparent; cursor: pointer; margin-top: 6px; transition: background .15s; }
|
||||
.as-wake-btn:hover { background: var(--acc); color: #fff; }
|
||||
.as-summary { font-size: 11px; color: var(--muted); display: flex; gap: 12px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--line); }
|
||||
|
||||
/* ══ Task Actions ══ */
|
||||
.task-actions { display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap; }
|
||||
.btn-action { font-size: 12px; padding: 7px 16px; border-radius: 8px; border: none; cursor: pointer; font-weight: 700; transition: all .15s; }
|
||||
.btn-stop { background: #ff527022; color: #ff5270; border: 1px solid #ff527044; }
|
||||
.btn-stop:hover { background: #ff527044; }
|
||||
.btn-cancel-action { background: #88888822; color: #888; border: 1px solid #88888844; }
|
||||
.btn-cancel-action:hover { background: #88888844; }
|
||||
.btn-resume { background: #2ecc8a22; color: #2ecc8a; border: 1px solid #2ecc8a44; }
|
||||
.btn-resume:hover { background: #2ecc8a44; }
|
||||
|
||||
/* ══ Scheduler Panel ══ */
|
||||
.sched-section { margin-bottom: 18px; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
|
||||
.sched-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
|
||||
.sched-title { font-size: 11px; font-weight: 700; letter-spacing: .06em; color: var(--acc); }
|
||||
.sched-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
|
||||
.sched-kpi { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
|
||||
.sched-kpi .k { font-size: 10px; color: var(--muted); margin-bottom: 2px; }
|
||||
.sched-kpi .v { font-size: 13px; font-weight: 700; }
|
||||
.sched-btn { font-size: 11px; padding: 5px 10px; border-radius: 6px; border: 1px solid var(--line); background: transparent; color: var(--muted); cursor: pointer; transition: all .15s; }
|
||||
.sched-btn:hover { border-color: var(--acc); color: var(--text); }
|
||||
|
||||
/* ══ Todo List ══ */
|
||||
.todo-section { margin-bottom: 18px; }
|
||||
.todo-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||
.todo-progress { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
|
||||
.todo-bar { width: 120px; height: 6px; background: #0e1320; border-radius: 3px; overflow: hidden; }
|
||||
.todo-bar-fill { height: 100%; border-radius: 3px; background: var(--ok); transition: width .3s; }
|
||||
.todo-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.todo-item { display: flex; flex-direction: column; background: var(--panel2); border-radius: 8px; font-size: 12px; transition: opacity .15s; }
|
||||
.todo-item.done { opacity: .55; }
|
||||
.todo-item .t-row { display: flex; align-items: center; gap: 8px; padding: 7px 10px; }
|
||||
.todo-item .t-icon { font-size: 14px; flex-shrink: 0; }
|
||||
.todo-item .t-title { flex: 1; color: var(--text); }
|
||||
.todo-item.done .t-title { text-decoration: line-through; color: var(--muted); }
|
||||
.todo-item .t-status { font-size: 10px; padding: 2px 6px; border-radius: 4px; }
|
||||
.todo-item .t-status.s-done { color: var(--ok); background: #0a2018; border: 1px solid #2ecc8a44; }
|
||||
.todo-item .t-status.s-progress { color: var(--acc); background: #0a1228; border: 1px solid #6a9eff44; }
|
||||
.todo-item .t-status.s-notstarted { color: var(--muted); background: var(--panel); border: 1px solid var(--line); }
|
||||
|
||||
.ec-todo-bar { display: flex; align-items: center; gap: 6px; font-size: 10px; color: var(--muted); margin-top: 6px; }
|
||||
.ec-todo-track { flex: 1; max-width: 80px; height: 4px; background: #0e1320; border-radius: 2px; overflow: hidden; }
|
||||
.ec-todo-fill { height: 100%; background: var(--ok); border-radius: 2px; }
|
||||
|
||||
.ec-actions { display: flex; gap: 4px; margin-top: 8px; }
|
||||
.ec-actions .mini-act { font-size: 10px; padding: 3px 8px; border-radius: 5px; border: 1px solid var(--line); background: transparent; cursor: pointer; color: var(--muted); transition: all .12s; }
|
||||
.ec-actions .mini-act:hover { border-color: var(--acc); color: var(--text); }
|
||||
.ec-actions .mini-act.danger:hover { border-color: #ff5270; color: #ff5270; }
|
||||
|
||||
/* ══ Archive Bar ══ */
|
||||
.archive-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.ab-label { font-size: 12px; color: var(--muted); margin-right: 4px; }
|
||||
.ab-btn { font-size: 11px; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--line); background: transparent; cursor: pointer; color: var(--muted); transition: all .15s; font-weight: 600; }
|
||||
.ab-btn:hover { border-color: var(--acc); color: var(--text); }
|
||||
.ab-btn.active { border-color: var(--acc); color: var(--acc); background: #0f1a38; }
|
||||
.ab-count { font-size: 10px; color: var(--muted); margin-left: auto; }
|
||||
.ab-scan { font-size: 11px; padding: 4px 12px; border-radius: 6px; border: 1px solid #6a9eff44; background: transparent; cursor: pointer; color: var(--acc); font-weight: 600; transition: all .15s; }
|
||||
.ab-scan:hover { background: #0a1228; border-color: var(--acc); }
|
||||
.ab-scan-status { font-size: 10px; color: var(--muted); }
|
||||
|
||||
/* ══ Live Activity ══ */
|
||||
.la-section { margin-bottom: 18px; }
|
||||
.la-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||
.la-title { font-size: 11px; font-weight: 700; color: var(--acc); letter-spacing: .06em; }
|
||||
.la-log { max-height: 320px; overflow-y: auto; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
|
||||
.la-entry { display: flex; gap: 8px; align-items: flex-start; padding: 5px 8px; border-radius: 6px; line-height: 1.5; word-break: break-all; }
|
||||
.la-entry:hover { background: rgba(106,158,255,.04); }
|
||||
.la-empty { text-align: center; color: var(--muted); padding: 20px; font-size: 12px; }
|
||||
|
||||
/* ══ Model Config ══ */
|
||||
.model-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
||||
.mc-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
|
||||
.mc-top { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.mc-emoji { font-size: 22px; }
|
||||
.mc-name { font-size: 15px; font-weight: 700; }
|
||||
.mc-role { font-size: 11px; color: var(--muted); }
|
||||
.mc-cur { font-size: 11px; color: var(--muted); margin-bottom: 8px; }
|
||||
.mc-cur b { color: var(--text); }
|
||||
.msel { width: 100%; background: var(--panel2); border: 1px solid var(--line); border-radius: 7px; color: var(--text); padding: 7px 10px; font-size: 12px; outline: none; cursor: pointer; }
|
||||
.msel:focus { border-color: var(--acc); }
|
||||
.mc-btns { display: flex; gap: 6px; margin-top: 8px; }
|
||||
.btn { font-size: 12px; padding: 6px 14px; border-radius: 7px; border: none; cursor: pointer; font-weight: 600; }
|
||||
.btn-p { background: var(--acc); color: #000; }
|
||||
.btn-p:hover { filter: brightness(1.15); }
|
||||
.btn-p:disabled { background: #2a3a6a; color: var(--muted); cursor: not-allowed; }
|
||||
.btn-g { background: transparent; border: 1px solid var(--line); color: var(--muted); }
|
||||
.btn-g:hover { border-color: #2e3d6a; color: var(--text); }
|
||||
.cl-wrap { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
|
||||
.cl-title { font-size: 12px; font-weight: 700; color: var(--muted); letter-spacing: .05em; text-transform: uppercase; margin-bottom: 10px; }
|
||||
.cl-row { display: flex; gap: 10px; font-size: 11px; padding: 5px 0; border-bottom: 1px solid var(--line); }
|
||||
.cl-row:last-child { border-bottom: none; }
|
||||
|
||||
/* ══ Skills Config ══ */
|
||||
.skills-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
||||
.sk-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
|
||||
.sk-hdr { display: flex; align-items: center; gap: 9px; padding: 11px 14px; background: var(--panel2); border-bottom: 1px solid var(--line); }
|
||||
.sk-list { padding: 10px; }
|
||||
.sk-item { display: flex; gap: 8px; padding: 8px 10px; border-radius: 7px; font-size: 12px; margin-bottom: 3px; cursor: pointer; border: 1px solid transparent; transition: all .12s; }
|
||||
.sk-item:hover { background: var(--panel2); border-color: var(--line); }
|
||||
|
||||
/* ══ Sessions ══ */
|
||||
.sess-filters { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; align-items: center; }
|
||||
.sess-filter { font-size: 11px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line); background: var(--panel); color: var(--muted); cursor: pointer; transition: all .12s; }
|
||||
.sess-filter:hover { border-color: var(--acc); color: var(--text); }
|
||||
.sess-filter.active { border-color: var(--acc); color: var(--acc); background: #0a1228; }
|
||||
.sess-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 10px; }
|
||||
.sess-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px; transition: border-color .12s; cursor: pointer; }
|
||||
.sess-card:hover { border-color: #2e3d6a; }
|
||||
|
||||
/* ══ Officials ══ */
|
||||
.off-activity { display: flex; align-items: center; gap: 8px; padding: 8px 14px; background: #0a1228; border: 1px solid #1a2a4a; border-radius: 10px; margin-bottom: 14px; font-size: 12px; flex-wrap: wrap; }
|
||||
.off-kpi { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 16px; }
|
||||
.kpi { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; }
|
||||
.kpi-v { font-size: 24px; font-weight: 800; margin-bottom: 3px; }
|
||||
.kpi-l { font-size: 11px; color: var(--muted); }
|
||||
.off-layout { display: grid; grid-template-columns: 260px 1fr; gap: 14px; }
|
||||
@media (max-width: 700px) { .off-layout { grid-template-columns: 1fr; } .off-kpi { grid-template-columns: repeat(2, 1fr); } }
|
||||
.off-ranklist { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
|
||||
.orl-hdr { padding: 10px 14px; background: var(--panel2); border-bottom: 1px solid var(--line); font-size: 11px; font-weight: 700; color: var(--muted); letter-spacing: .06em; text-transform: uppercase; }
|
||||
.orl-item { display: flex; align-items: center; gap: 10px; padding: 10px 14px; cursor: pointer; border-bottom: 1px solid var(--line); transition: background .1s; }
|
||||
.orl-item:last-child { border-bottom: none; }
|
||||
.orl-item:hover { background: var(--panel2); }
|
||||
.orl-item.selected { background: #0a1228; border-left: 3px solid var(--acc); }
|
||||
.off-detail { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 22px; min-height: 400px; }
|
||||
|
||||
/* ══ Memorials ══ */
|
||||
.mem-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.mem-card { display: flex; gap: 14px; align-items: flex-start; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; cursor: pointer; transition: border-color .12s; }
|
||||
.mem-card:hover { border-color: var(--acc); }
|
||||
|
||||
/* ══ Templates ══ */
|
||||
.tpl-cats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.tpl-cat { font-size: 12px; padding: 6px 14px; border-radius: 999px; border: 1px solid var(--line); background: var(--panel); color: var(--muted); cursor: pointer; transition: all .12s; }
|
||||
.tpl-cat:hover { border-color: var(--acc); color: var(--text); }
|
||||
.tpl-cat.active { border-color: var(--acc); color: var(--acc); background: #0a1228; }
|
||||
.tpl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 10px; }
|
||||
.tpl-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; transition: border-color .12s; cursor: pointer; display: flex; flex-direction: column; }
|
||||
.tpl-card:hover { border-color: var(--acc); }
|
||||
|
||||
/* ══ Morning Brief ══ */
|
||||
.mb-hdr { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18px; flex-wrap: wrap; gap: 10px; }
|
||||
.mb-title { font-size: 20px; font-weight: 800; background: linear-gradient(135deg, #f5c842, #ff9a4a); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.mb-sub { font-size: 12px; color: var(--muted); margin-top: 3px; }
|
||||
.mb-cats { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||
.mb-cat { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
|
||||
.mb-cat-hdr { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--line); }
|
||||
.mb-news-list { padding: 10px; }
|
||||
.mb-card { display: flex; gap: 12px; padding: 10px 8px; border-radius: 10px; margin-bottom: 6px; cursor: pointer; transition: background .12s; border-bottom: 1px solid var(--line); }
|
||||
.mb-card:last-child { border-bottom: none; }
|
||||
.mb-card:hover { background: var(--panel2); }
|
||||
|
||||
/* ══ Court Ceremony ══ */
|
||||
.ceremony-bg { position: fixed; inset: 0; z-index: 9999; background: #07090f; display: flex; flex-direction: column; align-items: center; justify-content: center; opacity: 0; animation: crmFadeIn .6s ease forwards; cursor: pointer; }
|
||||
.ceremony-bg.out { animation: crmFadeOut .5s ease forwards; }
|
||||
.crm-glow { position: absolute; width: 400px; height: 400px; border-radius: 50%; background: radial-gradient(circle, rgba(106,158,255,.08), transparent 70%); animation: crmPulse 3s ease-in-out infinite; }
|
||||
.crm-line1 { font-family: 'Noto Serif SC', serif; font-size: 52px; font-weight: 900; color: #dde4f8; letter-spacing: .15em; opacity: 0; transform: translateY(20px); }
|
||||
.crm-line2 { font-family: 'Noto Serif SC', serif; font-size: 22px; font-weight: 700; color: var(--acc); letter-spacing: .2em; margin-top: 12px; opacity: 0; transform: translateY(15px); }
|
||||
.crm-line3 { font-size: 14px; color: var(--muted); margin-top: 24px; opacity: 0; letter-spacing: .05em; }
|
||||
.crm-date { font-size: 12px; color: #2a3555; margin-top: 40px; opacity: 0; letter-spacing: .08em; }
|
||||
.crm-skip { font-size: 11px; color: #2a3555; margin-top: 18px; opacity: 0; animation: crmChar .4s 2.5s forwards; }
|
||||
.crm-line1.in { animation: crmSlideUp .6s .3s ease forwards; }
|
||||
.crm-line2.in { animation: crmSlideUp .5s 1.1s ease forwards; }
|
||||
.crm-line3.in { animation: crmSlideUp .5s 1.6s ease forwards; }
|
||||
.crm-date.in { animation: crmChar .4s 2s ease forwards; }
|
||||
@keyframes crmFadeIn { to { opacity: 1; } }
|
||||
@keyframes crmFadeOut { to { opacity: 0; pointer-events: none; } }
|
||||
@keyframes crmSlideUp { to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes crmChar { to { opacity: 1; } }
|
||||
@keyframes crmPulse { 0%,100% { transform: scale(1); opacity: .5; } 50% { transform: scale(1.1); opacity: .8; } }
|
||||
|
||||
/* ══ Confirm Dialog ══ */
|
||||
.confirm-bg { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 200; backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; }
|
||||
.confirm-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 24px; max-width: 420px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
.confirm-title { font-size: 16px; font-weight: 700; margin-bottom: 8px; }
|
||||
.confirm-msg { font-size: 13px; color: var(--muted); margin-bottom: 14px; line-height: 1.5; }
|
||||
.confirm-input { width: 100%; background: var(--panel2); border: 1px solid var(--line); border-radius: 7px; color: var(--text); padding: 8px 10px; font-size: 12px; outline: none; margin-bottom: 14px; }
|
||||
.confirm-input:focus { border-color: var(--acc); }
|
||||
.confirm-btns { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
|
||||
/* ══ Toast ══ */
|
||||
.toaster { position: fixed; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 8px; z-index: 300; pointer-events: none; }
|
||||
.toast { font-size: 13px; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel); color: var(--text); box-shadow: 0 4px 20px rgba(0,0,0,.4); animation: tin .2s; max-width: 320px; pointer-events: auto; }
|
||||
.toast.ok { border-color: #2ecc8a55; background: #0a1a10; }
|
||||
.toast.err { border-color: #ff527055; background: #200a10; }
|
||||
@keyframes tin { from { transform: translateX(40px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
|
||||
/* ══ Subscription Config ══ */
|
||||
.sub-config { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px; margin-bottom: 18px; }
|
||||
.sub-section { margin-bottom: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.sub-section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
|
||||
.sub-sec-title { font-size: 13px; font-weight: 700; margin-bottom: 10px; }
|
||||
.sub-input { background: var(--panel2); border: 1px solid var(--line); border-radius: 7px; color: var(--text); padding: 7px 10px; font-size: 12px; outline: none; min-width: 0; }
|
||||
.sub-input:focus { border-color: var(--acc); }
|
||||
|
||||
/* ══ Skills: missing sub-classes ══ */
|
||||
.si-name { font-weight: 600; min-width: 100px; }
|
||||
.si-desc { color: var(--muted); flex: 1; line-height: 1.4; }
|
||||
.si-arrow { color: var(--muted); font-size: 14px; opacity: .3; transition: opacity .12s; }
|
||||
.sk-item:hover .si-arrow { opacity: 1; }
|
||||
.sk-emoji { font-size: 18px; }
|
||||
.sk-name { font-size: 14px; font-weight: 700; }
|
||||
.sk-cnt { font-size: 11px; color: var(--muted); margin-left: auto; }
|
||||
.sk-empty { font-size: 12px; color: var(--muted); padding: 12px; text-align: center; opacity: .6; }
|
||||
.sk-add { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 8px; font-size: 12px; color: var(--acc); cursor: pointer; border-top: 1px solid var(--line); transition: background .12s; }
|
||||
.sk-add:hover { background: var(--panel2); }
|
||||
|
||||
/* skill detail modal */
|
||||
.sk-modal-body { max-height: 70vh; overflow-y: auto; }
|
||||
.sk-md { font-size: 13px; line-height: 1.7; color: var(--text); }
|
||||
.sk-md h1, .sk-md h2, .sk-md h3 { margin: 16px 0 8px; color: var(--text); }
|
||||
.sk-md h1 { font-size: 18px; }
|
||||
.sk-md h2 { font-size: 15px; border-bottom: 1px solid var(--line); padding-bottom: 6px; }
|
||||
.sk-md h3 { font-size: 13px; }
|
||||
.sk-md p { margin: 6px 0; }
|
||||
.sk-md ul, .sk-md ol { padding-left: 20px; margin: 6px 0; }
|
||||
.sk-md li { margin: 3px 0; }
|
||||
.sk-md code { font-size: 11px; background: var(--panel2); padding: 2px 6px; border-radius: 4px; font-family: monospace; }
|
||||
.sk-md pre { background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 12px; overflow-x: auto; margin: 8px 0; }
|
||||
.sk-md pre code { background: none; padding: 0; }
|
||||
.sk-md table { width: 100%; border-collapse: collapse; font-size: 12px; margin: 8px 0; }
|
||||
.sk-md th, .sk-md td { padding: 6px 10px; border: 1px solid var(--line); text-align: left; }
|
||||
.sk-md th { background: var(--panel2); }
|
||||
.sk-md hr { border: none; border-top: 1px solid var(--line); margin: 14px 0; }
|
||||
.sk-path { font-size: 10px; color: var(--muted); padding: 8px 0; word-break: break-all; border-top: 1px solid var(--line); margin-top: 12px; }
|
||||
|
||||
/* ══ Sessions: missing sub-classes ══ */
|
||||
.sc-top { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
.sc-emoji { font-size: 20px; }
|
||||
.sc-agent { font-size: 13px; font-weight: 700; }
|
||||
.sc-org { font-size: 11px; color: var(--muted); }
|
||||
.sc-title { font-size: 13px; font-weight: 600; margin-bottom: 6px; line-height: 1.4; }
|
||||
.sc-now { font-size: 11px; color: var(--muted); line-height: 1.5; margin-bottom: 6px; }
|
||||
.sc-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.sc-id { font-size: 10px; color: var(--acc); font-weight: 600; }
|
||||
.sc-time { font-size: 10px; color: var(--muted); margin-left: auto; }
|
||||
|
||||
/* ══ Model Config: missing sub-classes ══ */
|
||||
.mc-st { font-size: 11px; margin-top: 6px; padding: 4px 8px; border-radius: 5px; display: none; }
|
||||
.mc-st.ok { display: block; background: #0a2018; color: var(--ok); border: 1px solid #2ecc8a44; }
|
||||
.mc-st.err { display: block; background: #200a10; color: var(--danger); border: 1px solid #ff527044; }
|
||||
.mc-st.pending { display: block; background: #0a1228; color: var(--acc); border: 1px solid #6a9eff44; }
|
||||
.cl-t { color: var(--muted); min-width: 115px; }
|
||||
.cl-a { color: var(--acc); min-width: 80px; }
|
||||
.cl-c { color: var(--muted); }
|
||||
.cl-c b { color: var(--text); }
|
||||
.cl-list { display: flex; flex-direction: column; }
|
||||
|
||||
/* ══ Memorial: missing sub-classes ══ */
|
||||
.mem-icon { font-size: 28px; flex-shrink: 0; margin-top: 2px; }
|
||||
.mem-info { flex: 1; min-width: 0; }
|
||||
.mem-title { font-size: 14px; font-weight: 700; margin-bottom: 4px; }
|
||||
.mem-sub { font-size: 11px; color: var(--muted); line-height: 1.5; }
|
||||
.mem-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
|
||||
.mem-tag { font-size: 10px; padding: 2px 8px; border-radius: 4px; background: var(--panel2); color: var(--muted); border: 1px solid var(--line); }
|
||||
.mem-right { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; }
|
||||
.mem-date { font-size: 10px; color: var(--muted); }
|
||||
.mem-cost { font-size: 10px; color: var(--acc); }
|
||||
.mem-empty { text-align: center; padding: 40px; color: var(--muted); font-size: 13px; }
|
||||
|
||||
/* memorial detail timeline */
|
||||
.md-timeline { position: relative; padding-left: 24px; margin: 16px 0; }
|
||||
.md-timeline::before { content: ''; position: absolute; left: 7px; top: 0; bottom: 0; width: 2px; background: var(--line); }
|
||||
.md-tl-item { position: relative; margin-bottom: 14px; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.md-tl-item:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
|
||||
.md-tl-dot { position: absolute; left: -20px; top: 3px; width: 10px; height: 10px; border-radius: 50%; background: var(--acc); border: 2px solid var(--bg); }
|
||||
.md-tl-dot.green { background: var(--ok); }
|
||||
.md-tl-dot.yellow { background: var(--warn); }
|
||||
.md-tl-dot.red { background: var(--danger); }
|
||||
.md-tl-from { font-size: 11px; font-weight: 700; color: var(--acc); }
|
||||
.md-tl-to { font-size: 11px; color: var(--muted); }
|
||||
.md-tl-remark { font-size: 12px; margin-top: 3px; line-height: 1.5; }
|
||||
.md-tl-time { font-size: 10px; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
/* ══ Template: missing sub-classes ══ */
|
||||
.tpl-top { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.tpl-icon { font-size: 24px; }
|
||||
.tpl-name { font-size: 14px; font-weight: 700; }
|
||||
.tpl-pop { font-size: 10px; color: var(--muted); margin-left: auto; }
|
||||
.tpl-desc { font-size: 12px; color: var(--muted); line-height: 1.5; margin-bottom: 10px; flex: 1; }
|
||||
.tpl-footer { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.tpl-dept { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: var(--panel2); color: var(--acc); }
|
||||
.tpl-est { font-size: 10px; color: var(--muted); margin-left: auto; }
|
||||
.tpl-go { font-size: 11px; padding: 5px 14px; border-radius: 6px; background: var(--acc); color: #fff; border: none; cursor: pointer; font-weight: 600; margin-left: 8px; transition: opacity .12s; }
|
||||
.tpl-go:hover { opacity: .85; }
|
||||
.tpl-form { margin-top: 18px; }
|
||||
.tpl-field { margin-bottom: 14px; }
|
||||
.tpl-label { font-size: 12px; font-weight: 600; display: block; margin-bottom: 6px; }
|
||||
.tpl-input { width: 100%; padding: 10px 12px; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; color: var(--text); font-size: 13px; outline: none; }
|
||||
|
||||
/* ══ Morning Brief: missing sub-classes ══ */
|
||||
.mb-img { width: 72px; height: 52px; border-radius: 7px; object-fit: cover; flex-shrink: 0; background: var(--panel2); display: flex; align-items: center; justify-content: center; font-size: 22px; overflow: hidden; }
|
||||
.mb-img img { width: 100%; height: 100%; object-fit: cover; border-radius: 7px; }
|
||||
.mb-info { flex: 1; min-width: 0; }
|
||||
.mb-headline { font-size: 13px; font-weight: 700; line-height: 1.4; margin-bottom: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.mb-summary { font-size: 11px; color: var(--muted); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.mb-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }
|
||||
.mb-source { font-size: 10px; color: var(--acc); }
|
||||
.mb-time { font-size: 10px; color: var(--muted); }
|
||||
.mb-cat-icon { font-size: 20px; }
|
||||
.mb-cat-name { font-size: 14px; font-weight: 800; }
|
||||
.mb-cat-cnt { font-size: 11px; color: var(--muted); margin-left: auto; }
|
||||
.mb-empty { text-align: center; padding: 30px; color: var(--muted); font-size: 13px; }
|
||||
.mb-loading { display: flex; align-items: center; justify-content: center; padding: 60px; color: var(--muted); font-size: 14px; gap: 10px; }
|
||||
|
||||
/* ══ Live Activity: missing sub-classes ══ */
|
||||
.la-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--ok); margin-right: 4px; animation: pulse 1.5s infinite; }
|
||||
.la-dot.idle { background: var(--muted); animation: none; }
|
||||
.la-agent { font-size: 11px; color: var(--muted); }
|
||||
.la-icon { flex-shrink: 0; font-size: 13px; margin-top: 1px; }
|
||||
.la-body { flex: 1; min-width: 0; }
|
||||
.la-time { font-size: 10px; color: var(--muted); flex-shrink: 0; min-width: 44px; text-align: right; }
|
||||
.la-assistant { color: var(--text); }
|
||||
.la-thinking { color: #a07aff; font-style: italic; opacity: .75; }
|
||||
.la-tool { color: #44aaff; }
|
||||
.la-tool-result { color: var(--muted); font-size: 11px; }
|
||||
.la-tool-result.ok { color: var(--ok); }
|
||||
.la-tool-result.err { color: var(--danger); }
|
||||
.la-user { color: var(--warn); }
|
||||
.la-tool-name { font-weight: 700; margin-right: 4px; }
|
||||
.la-trunc { color: var(--muted); font-size: 10px; opacity: .6; }
|
||||
.la-flow-wrap { display: flex; flex-direction: column; gap: 6px; }
|
||||
.la-groups { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
|
||||
.la-group { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
|
||||
.la-group-hd { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; border-bottom: 1px solid var(--line); font-size: 11px; color: var(--muted); }
|
||||
.la-group-hd .name { font-weight: 700; color: var(--text); }
|
||||
.la-group-bd { display: flex; flex-direction: column; gap: 4px; padding: 6px; }
|
||||
|
||||
/* ══ Scheduler: missing sub-classes ══ */
|
||||
.sched-status { font-size: 10px; color: var(--muted); }
|
||||
.sched-line { font-size: 11px; color: var(--muted); display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.sched-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.sched-btn.warn:hover { border-color: #f5c842; color: #f5c842; }
|
||||
.sched-btn.danger:hover { border-color: #ff5270; color: #ff5270; }
|
||||
|
||||
/* ══ Task Actions: missing btn-cancel ══ */
|
||||
.btn-cancel { background: #88888822; color: #888; border: 1px solid #88888844; }
|
||||
.btn-cancel:hover { background: #88888844; }
|
||||
.btn-action:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
/* ══ Todo: missing sub-classes ══ */
|
||||
.todo-detail { display: none; padding: 4px 10px 10px 36px; font-size: 11px; line-height: 1.6; color: var(--text); white-space: pre-wrap; word-break: break-word; border-top: 1px solid var(--line); margin: 0 6px; opacity: .85; }
|
||||
.todo-item.expanded .todo-detail { display: block; }
|
||||
.todo-item .t-expand { color: var(--muted); font-size: 10px; transition: transform .2s; flex-shrink: 0; }
|
||||
.todo-item.expanded .t-expand { transform: rotate(90deg); }
|
||||
.todo-item .t-id { color: var(--muted); font-size: 10px; min-width: 20px; }
|
||||
.todo-item.has-detail .t-row { cursor: pointer; }
|
||||
|
||||
/* ══ Officials: missing sub-classes ══ */
|
||||
.act-label { color: var(--muted); flex-shrink: 0; }
|
||||
.act-dot { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 999px; background: #0f1a38; border: 1px solid #1e2e50; margin: 2px; }
|
||||
.act-dot.alive { border-color: #2ecc8a44; background: #0a2018; color: var(--ok); }
|
||||
.act-dot.warn { border-color: #f5c84244; background: #201a08; color: var(--warn); }
|
||||
.act-dot.idle { color: var(--muted); }
|
||||
.orl-medal { font-size: 16px; min-width: 20px; text-align: center; }
|
||||
.orl-emoji { font-size: 18px; }
|
||||
.orl-name { flex: 1; }
|
||||
.orl-role { font-size: 12px; font-weight: 700; }
|
||||
.orl-org { font-size: 10px; color: var(--muted); }
|
||||
.orl-score { font-size: 11px; font-weight: 700; color: var(--acc); }
|
||||
.orl-hbdot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.orl-hbdot.active { background: var(--ok); }
|
||||
.orl-hbdot.warn { background: var(--warn); }
|
||||
.orl-hbdot.stalled { background: var(--danger); animation: pulse 1.2s infinite; }
|
||||
.orl-hbdot.idle { background: #2a3a5a; }
|
||||
.od-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--muted); font-size: 13px; min-height: 200px; }
|
||||
.od-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
|
||||
.od-emoji { font-size: 40px; }
|
||||
.od-name { font-size: 22px; font-weight: 800; }
|
||||
.od-role { font-size: 13px; color: var(--muted); margin-top: 2px; }
|
||||
.od-rank-badge { font-size: 11px; padding: 3px 9px; border-radius: 999px; border: 1px solid #f5c84244; color: #f5c842; background: #201a08; margin-top: 4px; display: inline-block; }
|
||||
.od-hb { margin-left: auto; text-align: right; }
|
||||
.od-section { margin-bottom: 18px; }
|
||||
.od-sec-title { font-size: 10px; font-weight: 700; color: var(--muted); letter-spacing: .07em; text-transform: uppercase; margin-bottom: 10px; }
|
||||
.od-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.ods { background: var(--panel2); border-radius: 8px; padding: 10px; text-align: center; }
|
||||
.ods-v { font-size: 20px; font-weight: 800; }
|
||||
.ods-l { font-size: 10px; color: var(--muted); margin-top: 2px; }
|
||||
.tbar { margin-bottom: 7px; }
|
||||
.tbar-hdr { display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 3px; }
|
||||
.tbar-label { color: var(--muted); }
|
||||
.tbar-val { font-weight: 600; }
|
||||
.tbar-track { height: 6px; background: #0e1320; border-radius: 3px; overflow: hidden; }
|
||||
.tbar-fill { height: 100%; border-radius: 3px; }
|
||||
.od-cost-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.cost-chip { font-size: 12px; padding: 5px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel2); }
|
||||
.cost-chip b { font-size: 15px; }
|
||||
.cost-chip.hi { border-color: #ff527044; }
|
||||
.cost-chip.md { border-color: #f5c84244; }
|
||||
.cost-chip.lo { border-color: #2ecc8a44; }
|
||||
.od-edict-list { display: flex; flex-direction: column; gap: 5px; }
|
||||
.oe-item { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: var(--panel2); border-radius: 7px; font-size: 12px; cursor: pointer; }
|
||||
.oe-item:hover { background: #141c30; }
|
||||
.oe-id { font-size: 10px; color: var(--acc); font-weight: 700; min-width: 110px; }
|
||||
.oe-title { flex: 1; color: var(--text); }
|
||||
.oe-state { font-size: 10px; }
|
||||
.kpi-v.gold { color: #f5c842; }
|
||||
.kpi-v.green { color: var(--ok); }
|
||||
.kpi-v.blue { color: var(--acc); }
|
||||
.kpi-v.warn { color: var(--warn); }
|
||||
|
||||
/* ══ Subscription: missing sub-classes ══ */
|
||||
.sub-cats { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.sub-cat { display: flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel2); cursor: pointer; transition: all .15s; user-select: none; }
|
||||
.sub-cat:hover { border-color: var(--acc); }
|
||||
.sub-cat.active { border-color: var(--ok); background: #0a2018; }
|
||||
.sub-cat .sc-check { width: 16px; height: 16px; border-radius: 4px; border: 1px solid var(--line); display: flex; align-items: center; justify-content: center; font-size: 10px; transition: all .15s; }
|
||||
.sub-cat.active .sc-check { background: var(--ok); border-color: var(--ok); color: #000; }
|
||||
.sub-cat .sc-label { font-size: 12px; font-weight: 600; }
|
||||
.sub-cat .sc-count { font-size: 10px; color: var(--muted); }
|
||||
.sub-kw-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.sub-kw { display: flex; align-items: center; gap: 4px; padding: 4px 8px 4px 10px; border-radius: 999px; background: #0f1a38; border: 1px solid #1e2e50; font-size: 11px; color: var(--acc); }
|
||||
.sub-kw .kw-del { cursor: pointer; opacity: .5; font-size: 13px; padding: 0 2px; }
|
||||
.sub-kw .kw-del:hover { opacity: 1; color: var(--danger); }
|
||||
.sub-feed-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.sub-feed { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--panel2); border-radius: 7px; font-size: 12px; }
|
||||
.sub-feed .sf-name { font-weight: 600; min-width: 80px; color: var(--acc); }
|
||||
.sub-feed .sf-url { flex: 1; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sub-feed .sf-cat { font-size: 10px; padding: 2px 6px; border-radius: 4px; border: 1px solid var(--line); }
|
||||
.sub-feed .sf-del { cursor: pointer; color: var(--muted); font-size: 14px; }
|
||||
.sub-feed .sf-del:hover { color: var(--danger); }
|
||||
|
||||
/* ══ Agent Status: missing sub-classes ══ */
|
||||
.as-card .as-emoji { font-size: 22px; margin-bottom: 3px; }
|
||||
.as-card .as-label { font-size: 12px; font-weight: 700; }
|
||||
.as-card .as-role { font-size: 10px; color: var(--muted); }
|
||||
.as-card .as-status { font-size: 10px; margin-top: 4px; }
|
||||
.as-card .as-time { font-size: 9px; color: var(--muted); margin-top: 2px; }
|
||||
.as-wake-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.as-refresh { font-size: 11px; padding: 4px 12px; border-radius: 8px; border: 1px solid var(--line); color: var(--muted); background: transparent; cursor: pointer; transition: background .15s; }
|
||||
.as-refresh:hover { background: var(--panel2); color: var(--text); }
|
||||
.as-wake-all { font-size: 11px; padding: 4px 12px; border-radius: 8px; border: 1px solid var(--warn); color: var(--warn); background: transparent; cursor: pointer; transition: background .15s; margin-left: 6px; }
|
||||
.as-wake-all:hover { background: var(--warn); color: #fff; }
|
||||
.as-summary span { display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
/* ══ Archive Bar: missing sub-classes ══ */
|
||||
.archive-bar .ab-archive-all { font-size: 11px; padding: 4px 12px; border-radius: 6px; border: 1px solid #2ecc8a44; background: transparent; cursor: pointer; color: var(--ok); font-weight: 600; transition: all .15s; }
|
||||
.archive-bar .ab-archive-all:hover { background: #0a2018; border-color: var(--ok); }
|
||||
.archive-bar .ab-scan-detail { font-size: 11px; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); background: transparent; cursor: pointer; color: var(--muted); font-weight: 600; transition: all .15s; }
|
||||
.archive-bar .ab-scan-detail:hover { border-color: var(--acc); color: var(--text); }
|
||||
.archive-bar .ab-scan-detail.active { border-color: var(--acc); color: var(--acc); background: #0f1a38; }
|
||||
.archive-bar .ab-scan-copy { font-size: 11px; padding: 4px 10px; border-radius: 6px; border: 1px solid #2ecc8a44; background: transparent; cursor: pointer; color: var(--ok); font-weight: 600; transition: all .15s; }
|
||||
.archive-bar .ab-scan-copy:hover { background: #0a2018; border-color: var(--ok); }
|
||||
|
||||
/* scan detail panel */
|
||||
.global-scan-detail { display: none; margin-top: -4px; margin-bottom: 12px; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
|
||||
.global-scan-detail.open { display: block; }
|
||||
.global-scan-detail .gs-empty { font-size: 11px; color: var(--muted); }
|
||||
.global-scan-detail .gs-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.global-scan-detail .gs-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; background: var(--panel); border: 1px solid var(--line); font-size: 11px; }
|
||||
.global-scan-detail .gs-tag { font-size: 10px; border-radius: 10px; padding: 2px 8px; font-weight: 700; border: 1px solid var(--line); color: var(--muted); }
|
||||
.global-scan-detail .gs-tag.retry { color: var(--acc); border-color: #6a9eff55; }
|
||||
.global-scan-detail .gs-tag.escalate { color: #f5c842; border-color: #f5c84255; }
|
||||
.global-scan-detail .gs-tag.rollback { color: #ff5270; border-color: #ff527055; }
|
||||
.global-scan-detail .gs-task { font-weight: 700; color: var(--text); }
|
||||
.global-scan-detail .gs-meta { color: var(--muted); }
|
||||
.global-scan-detail .gs-hint { margin-top: 8px; font-size: 10px; color: var(--muted); }
|
||||
|
||||
/* ══ Confirm: missing sub-classes ══ */
|
||||
.confirm-reason { width: 100%; background: var(--panel2); border: 1px solid var(--line); border-radius: 7px; color: var(--text); padding: 8px 10px; font-size: 12px; outline: none; margin-bottom: 14px; resize: vertical; min-height: 60px; }
|
||||
.confirm-reason:focus { border-color: var(--acc); }
|
||||
|
||||
/* ══ General ══ */
|
||||
.empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 13px; }
|
||||
.sec-title { font-size: 12px; font-weight: 700; color: var(--muted); letter-spacing: .07em; text-transform: uppercase; margin-bottom: 12px; }
|
||||
code { font-size: 11px; background: var(--panel2); padding: 2px 6px; border-radius: 4px; font-family: monospace; }
|
||||
|
||||
/* ══ 朝堂议政动画 ══ */
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
|
||||
@keyframes pulse { 0%, 100% { opacity: .6; } 50% { opacity: 1; } }
|
||||
@keyframes bounceIn { 0% { transform: scale(0); } 60% { transform: scale(1.3); } 100% { transform: scale(1); } }
|
||||
|
||||
/* M3: checkpoint pulse */
|
||||
@keyframes pulse-border { 0%, 100% { border-color: rgba(245, 158, 11, 0.4); } 50% { border-color: rgba(245, 158, 11, 0.9); } }
|
||||
|
||||
/* M3: Button styles */
|
||||
.btn-primary {
|
||||
padding: 8px 18px; border-radius: 8px; border: none; font-size: 13px; font-weight: 600;
|
||||
background: linear-gradient(135deg, var(--acc), #c9a227); color: #1a1a2e; cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary:hover { filter: brightness(1.1); }
|
||||
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; filter: none; }
|
||||
.btn-ghost {
|
||||
padding: 8px 18px; border-radius: 8px; font-size: 13px; font-weight: 600;
|
||||
background: transparent; color: var(--fg); border: 1px solid var(--line); cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-ghost:hover { background: var(--panel2); }
|
||||
.btn-small {
|
||||
padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 600;
|
||||
background: transparent; color: var(--fg); border: 1px solid var(--line); cursor: pointer;
|
||||
transition: all 0.15s; text-decoration: none; display: inline-block;
|
||||
}
|
||||
.btn-small:hover { background: var(--panel2); }
|
||||
.tab-btn {
|
||||
padding: 8px 16px; font-size: 13px; font-weight: 600; border: none;
|
||||
background: none; cursor: pointer; transition: all 0.15s;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.tab-btn:hover { background: var(--panel2); }
|
||||
.tag-done { background: #22c55e22; color: #22c55e; border: 1px solid #22c55e44; padding: 1px 6px; border-radius: 4px; font-size: 10px; }
|
||||
.tag-exec { background: var(--acc)22; color: var(--acc); border: 1px solid var(--acc)44; padding: 1px 6px; border-radius: 4px; font-size: 10px; }
|
||||
.tag-waiting { background: #f59e0b22; color: #f59e0b; border: 1px solid #f59e0b44; padding: 1px 6px; border-radius: 4px; font-size: 10px; }
|
||||
@@ -1,110 +0,0 @@
|
||||
// AI Briefing 页面
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import * as api from '../api';
|
||||
import type { Task, Project } from '../types';
|
||||
|
||||
export function Briefing() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<string>('');
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [briefing, setBriefing] = useState<string>('');
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.listProjects();
|
||||
setProjects(data);
|
||||
if (data.length > 0 && !selectedProject) {
|
||||
setSelectedProject(data[0].id);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [selectedProject]);
|
||||
|
||||
useEffect(() => { loadProjects(); }, [loadProjects]);
|
||||
|
||||
const generateBriefing = useCallback(async () => {
|
||||
if (!selectedProject) return;
|
||||
try {
|
||||
const data = await api.listTasks(selectedProject);
|
||||
setTasks(data);
|
||||
|
||||
// 本地生成简要汇报
|
||||
const done = data.filter(t => t.status === 'done');
|
||||
const working = data.filter(t => t.status === 'working');
|
||||
const failed = data.filter(t => t.status === 'failed');
|
||||
const pending = data.filter(t => t.status === 'pending');
|
||||
|
||||
const lines = [
|
||||
`📊 项目日报`,
|
||||
``,
|
||||
`总任务数: ${data.length}`,
|
||||
`✅ 已完成: ${done.length}`,
|
||||
`🔄 进行中: ${working.length}`,
|
||||
`⏳ 待处理: ${pending.length}`,
|
||||
`❌ 失败: ${failed.length}`,
|
||||
``,
|
||||
];
|
||||
|
||||
if (working.length > 0) {
|
||||
lines.push(`## 进行中的任务`);
|
||||
working.forEach(t => lines.push(`- [${t.id.substring(0, 8)}] ${t.title} (${t.assignee || '未分配'})`));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
lines.push(`## 失败的任务`);
|
||||
failed.forEach(t => lines.push(`- [${t.id.substring(0, 8)}] ${t.title}`));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (done.length > 0) {
|
||||
lines.push(`## 最近完成`);
|
||||
done.slice(-5).forEach(t => lines.push(`- [${t.id.substring(0, 8)}] ${t.title}`));
|
||||
}
|
||||
|
||||
setBriefing(lines.join('\n'));
|
||||
} catch { /* ignore */ }
|
||||
}, [selectedProject]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="page-title">AI Briefing</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={e => setSelectedProject(e.target.value)}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--line)',
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--fg)',
|
||||
}}
|
||||
>
|
||||
<option value="">选择项目</option>
|
||||
{projects.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-primary" onClick={generateBriefing}>
|
||||
生成汇报
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{briefing && (
|
||||
<div className="card">
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'var(--font)', lineHeight: 1.6 }}>
|
||||
{briefing}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!briefing && (
|
||||
<div className="card">
|
||||
<p style={{ color: 'var(--muted)' }}>选择项目并点击"生成汇报"查看项目状态概览。</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
// 系统配置页面
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useProjects } from '../hooks/useApi';
|
||||
import * as api from '../api';
|
||||
|
||||
export function Config() {
|
||||
const { projects, loading, refresh } = useProjects();
|
||||
const [newId, setNewId] = useState('');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
await api.createProject({ id: newId || undefined, name: newName, description: newDesc || null });
|
||||
setNewId('');
|
||||
setNewName('');
|
||||
setNewDesc('');
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="page-title">系统配置</h2>
|
||||
|
||||
{/* 创建项目 */}
|
||||
<div className="card" style={{ marginBottom: 20 }}>
|
||||
<div className="card-title">创建项目</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="项目ID(可选,自动生成)"
|
||||
value={newId}
|
||||
onChange={e => setNewId(e.target.value)}
|
||||
style={{
|
||||
width: 200,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--line)',
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--fg)',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="项目名称"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--line)',
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--fg)',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="描述(可选)"
|
||||
value={newDesc}
|
||||
onChange={e => setNewDesc(e.target.value)}
|
||||
style={{
|
||||
flex: 2,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--line)',
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--fg)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !newName.trim()}
|
||||
style={{ minWidth: 60 }}
|
||||
>
|
||||
{creating ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="error" style={{ marginTop: 8 }}>{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* 项目列表 */}
|
||||
<div className="card">
|
||||
<div className="card-title">已有项目</div>
|
||||
{loading ? (
|
||||
<div className="loading">加载中...</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>描述</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td style={{ fontFamily: 'monospace' }}>{p.id.substring(0, 8)}</td>
|
||||
<td>{p.name}</td>
|
||||
<td style={{ color: 'var(--muted)' }}>{p.description || '-'}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${p.status === 'active' ? 'done' : 'blocked'}`}>
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
// 全局监控页面
|
||||
|
||||
import React from 'react';
|
||||
import { useDaemonStatus } from '../hooks/useApi';
|
||||
import { useProjects } from '../hooks/useApi';
|
||||
|
||||
export function Monitor() {
|
||||
const { status, refresh: refreshStatus } = useDaemonStatus();
|
||||
const { projects, loading, refresh: refreshProjects } = useProjects();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h2 className="page-title">全局监控</h2>
|
||||
<button className="btn" onClick={() => { refreshStatus(); refreshProjects(); }}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Daemon 状态 */}
|
||||
<div className="stat-grid">
|
||||
<div className="stat-card">
|
||||
<div className="value">{status?.status === 'running' ? '🟢' : '🔴'}</div>
|
||||
<div className="label">Daemon 状态</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="value">{status?.tick_count ?? '-'}</div>
|
||||
<div className="label">Tick 计数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="value">{status?.active_projects ?? '-'}</div>
|
||||
<div className="label">活跃项目</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="value">{status?.uptime_seconds ? Math.floor(status.uptime_seconds / 60) + 'm' : '-'}</div>
|
||||
<div className="label">运行时间</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 项目列表 */}
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>项目列表</h3>
|
||||
{loading ? (
|
||||
<div className="loading">加载中...</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.id.substring(0, 8)}</td>
|
||||
<td>{p.name}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${p.status === 'active' ? 'done' : 'blocked'}`}>
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{p.created_at?.substring(0, 19) ?? '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{projects.length === 0 && (
|
||||
<tr><td colSpan={4} style={{ color: 'var(--muted)' }}>暂无项目</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 最近事件 */}
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, margin: '20px 0 12px' }}>最近事件</h3>
|
||||
<div className="card">
|
||||
<div className="event-log">
|
||||
<p style={{ color: 'var(--muted)' }}>通过 SSE 实时推送事件(连接后显示)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// 任务看板页面
|
||||
|
||||
import React from 'react';
|
||||
import { useTasks } from '../hooks/useApi';
|
||||
import type { Task } from '../types';
|
||||
|
||||
interface Props {
|
||||
projectId: string | null;
|
||||
onSelectTask: (task: Task) => void;
|
||||
}
|
||||
|
||||
const STATUS_ORDER = ['pending', 'working', 'review', 'blocked', 'done', 'failed'];
|
||||
|
||||
export function TaskBoard({ projectId, onSelectTask }: Props) {
|
||||
const { tasks, loading, error, refresh } = useTasks(projectId);
|
||||
|
||||
if (!projectId) {
|
||||
return <div className="loading">请选择一个项目</div>;
|
||||
}
|
||||
|
||||
if (loading) return <div className="loading">加载中...</div>;
|
||||
if (error) return <div className="error">加载失败: {error}</div>;
|
||||
|
||||
// 按状态分组
|
||||
const grouped: Record<string, Task[]> = {};
|
||||
for (const task of tasks) {
|
||||
const status = task.status || 'pending';
|
||||
if (!grouped[status]) grouped[status] = [];
|
||||
grouped[status].push(task);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h2 className="page-title">任务看板</h2>
|
||||
<button className="btn" onClick={refresh}>刷新</button>
|
||||
</div>
|
||||
|
||||
{STATUS_ORDER.map(status => {
|
||||
const statusTasks = grouped[status] || [];
|
||||
if (statusTasks.length === 0) return null;
|
||||
return (
|
||||
<div key={status} style={{ marginBottom: 20 }}>
|
||||
<h3 style={{ fontSize: 14, color: 'var(--muted)', marginBottom: 8, textTransform: 'uppercase' }}>
|
||||
{status} ({statusTasks.length})
|
||||
</h3>
|
||||
<div className="task-board">
|
||||
{statusTasks.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="task-card"
|
||||
onClick={() => onSelectTask(task)}
|
||||
>
|
||||
<div className="title">{task.title}</div>
|
||||
<div className="meta">
|
||||
<span className={`badge badge-${status}`}>{status}</span>
|
||||
{task.assignee && <span>👤 {task.assignee}</span>}
|
||||
{task.risk_level && <span>⚠️ {task.risk_level}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<div className="card">
|
||||
<p style={{ color: 'var(--muted)' }}>暂无任务。通过 API 或 CLI 创建任务。</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* Zustand Store — 三国量化编排台状态管理
|
||||
* 基于 Edict 前端适配 moziplus 后端
|
||||
* HTTP 5s 轮询,无 WebSocket
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
api,
|
||||
type Task,
|
||||
type LiveStatus,
|
||||
type AgentConfig,
|
||||
type OfficialsData,
|
||||
type AgentsStatusData,
|
||||
type MorningBrief,
|
||||
type SubConfig,
|
||||
type ChangeLogEntry,
|
||||
type TaskNode,
|
||||
} from './api';
|
||||
|
||||
// ── Pipeline Definition (PIPE) ──
|
||||
|
||||
export const PIPE = [
|
||||
{ key: 'created', dept: '主公', icon: '👑', action: '提交' },
|
||||
{ key: 'planning', dept: '庞统', icon: '🐦', action: '拆解' },
|
||||
{ key: 'challenge', dept: '司马懿', icon: '🦅', action: '审核' },
|
||||
{ key: 'assigned', dept: '引擎', icon: '📮', action: '派发' },
|
||||
{ key: 'executing', dept: '将军', icon: '⚔️', action: '执行' },
|
||||
{ key: 'reviewing', dept: '司马懿', icon: '🔎', action: '验收' },
|
||||
{ key: 'done', dept: '完成', icon: '🏆', action: '完成' },
|
||||
] as const;
|
||||
|
||||
export const PIPE_STATE_IDX: Record<string, number> = {
|
||||
// moziplus states
|
||||
created: 0,
|
||||
planning: 1,
|
||||
challenging: 2,
|
||||
assigned: 3,
|
||||
executing: 4,
|
||||
cancelling: 4, // BUG-6: 同 executing 位置
|
||||
pausing: 4, // BUG-6: 同 executing 位置
|
||||
paused: 4,
|
||||
reviewing: 5,
|
||||
completed: 6,
|
||||
failed: 4,
|
||||
cancelled: 6,
|
||||
escalated: 4,
|
||||
// legacy edict states (backward compat)
|
||||
Inbox: 0, Menxia: 1, Zhongshu: 2, Assigned: 3,
|
||||
Doing: 4, Review: 5, Done: 6, Blocked: 4, Cancelled: 6, Next: 0,
|
||||
Taizi: 0, Pending: 0,
|
||||
};
|
||||
|
||||
export const DEPT_COLOR: Record<string, string> = {
|
||||
'主公': '#ffd700', '庞统': '#a07aff', '司马懿': '#6a9eff', '引擎': '#6aef9a',
|
||||
'张飞': '#ff5270', '关羽': '#f5c842', '赵云': '#44aaff', '姜维': '#2ecc8a',
|
||||
'诸葛亮': '#e8a040', '交付': '#2ecc8a',
|
||||
};
|
||||
|
||||
export const STATE_LABEL: Record<string, string> = {
|
||||
created: '新建', planning: '规划中', challenging: '审核中',
|
||||
assigned: '已派发', executing: '执行中', paused: '已暂停',
|
||||
cancelling: '取消中', pausing: '暂停中', // BUG-6
|
||||
reviewing: '验收中', completed: '已完成', failed: '已失败',
|
||||
cancelled: '已取消', escalated: '已升级',
|
||||
};
|
||||
|
||||
export function deptColor(d: string): string {
|
||||
return DEPT_COLOR[d] || '#6a9eff';
|
||||
}
|
||||
|
||||
export function stateLabel(t: Task): string {
|
||||
const r = t.review_round || 0;
|
||||
const s = t.status || t.state; // moziplus status or legacy edict state
|
||||
// moziplus states
|
||||
if (s === 'challenging' && r > 1) return `审核中(第${r}轮)`;
|
||||
if (s === 'planning' && r > 0) return `修订方案(第${r}轮)`;
|
||||
// plan_status refinement
|
||||
const planStatus = t.plan_status;
|
||||
if (s === 'planning' && planStatus === 'approved') return '待派发';
|
||||
if (s === 'planning' && planStatus === 'rejected') return '方案驳回';
|
||||
if (s === 'planning' && planStatus === 'challenging') return '审核中';
|
||||
// BUG-6: 中间态标签
|
||||
if (s === 'cancelling') return '⏳ 取消中';
|
||||
if (s === 'pausing') return '⏳ 暂停中';
|
||||
return STATE_LABEL[s] || s;
|
||||
}
|
||||
|
||||
export function isEdict(t: Task): boolean {
|
||||
// moziplus tasks: UUIDs or random short IDs (M2-08); Edict tasks: TSK-xxx
|
||||
const id = t.id || '';
|
||||
return /^TSK-/i.test(id) || /^[0-9a-f]{8}-/i.test(id) || /^[A-Za-z0-9]{10,16}$/.test(id);
|
||||
}
|
||||
|
||||
export function isSession(t: Task): boolean {
|
||||
return /^(OC-|MC-)/i.test(t.id || '');
|
||||
}
|
||||
|
||||
export function isArchived(t: Task): boolean {
|
||||
return !!t.archived;
|
||||
}
|
||||
|
||||
export type PipeStatus = { key: string; dept: string; icon: string; action: string; status: 'done' | 'active' | 'pending' };
|
||||
|
||||
export function getPipeStatus(t: Task): PipeStatus[] {
|
||||
const planStatus = t.plan_status || '';
|
||||
const nodes = t.nodes || [];
|
||||
const s = t.status || t.state; // moziplus status or legacy edict state
|
||||
|
||||
let activeIdx = PIPE_STATE_IDX[s] ?? 4;
|
||||
|
||||
// Refine based on moziplus sub-state
|
||||
if (s === 'planning') {
|
||||
if (planStatus === 'challenging') activeIdx = 2; // 审核
|
||||
else if (planStatus === 'approved') activeIdx = 3; // 派发
|
||||
else if (planStatus === 'rejected') activeIdx = 1; // 回到拆解
|
||||
else activeIdx = 1; // 拆解
|
||||
} else if (s === 'executing') {
|
||||
const hasReviewing = nodes.some((n: TaskNode) => n.status === 'reviewing');
|
||||
activeIdx = hasReviewing ? 5 : 4; // 验收 vs 执行
|
||||
} else if (s === 'failed' || s === 'cancelled') {
|
||||
// 根据实际失败阶段定位进度条
|
||||
if (planStatus === 'pending' || planStatus === 'rejected' || !planStatus) {
|
||||
activeIdx = 1; // 拆解阶段失败/取消
|
||||
} else if (planStatus === 'challenging') {
|
||||
activeIdx = 2; // 审核阶段失败/取消
|
||||
} else if (planStatus === 'approved') {
|
||||
activeIdx = 4; // 执行阶段失败/取消
|
||||
} else {
|
||||
activeIdx = 4; // 默认显示在执行
|
||||
}
|
||||
} else if (s === 'escalated') {
|
||||
activeIdx = 4;
|
||||
} else if (s === 'cancelling' || s === 'pausing') {
|
||||
// BUG-6: 停在执行阶段
|
||||
activeIdx = 4;
|
||||
}
|
||||
|
||||
return PIPE.map((stage, i) => ({
|
||||
...stage,
|
||||
status: (i < activeIdx ? 'done' : i === activeIdx ? 'active' : 'pending') as 'done' | 'active' | 'pending',
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Tabs ──
|
||||
|
||||
export type TabKey =
|
||||
| 'tasks' | 'court' | 'monitor' | 'agents'
|
||||
| 'models' | 'skills' | 'sessions' | 'archives' | 'templates'
|
||||
| 'usage' | 'settings' | 'officials' | 'morning';
|
||||
|
||||
export const TAB_DEFS: { key: TabKey; label: string; icon: string }[] = [
|
||||
{ key: 'tasks', label: '任务看板', icon: '📜' },
|
||||
{ key: 'court', label: '军议大厅', icon: '🏛️' },
|
||||
{ key: 'monitor', label: '编排调度', icon: '🔌' },
|
||||
{ key: 'agents', label: '将军总览', icon: '🤺' },
|
||||
{ key: 'models', label: '模型配置', icon: '🤖' },
|
||||
{ key: 'skills', label: '技能配置', icon: '🎯' },
|
||||
{ key: 'sessions', label: '传令巡哨', icon: '🔭' },
|
||||
{ key: 'usage', label: '花费总览', icon: '💰' },
|
||||
{ key: 'archives', label: '奏折阁', icon: '📜' },
|
||||
{ key: 'templates', label: '任务模板', icon: '📋' },
|
||||
{ key: 'settings', label: '系统设置', icon: '⚙️' },
|
||||
];
|
||||
|
||||
// ── DEPTS for monitor ──
|
||||
|
||||
export const DEPTS = [
|
||||
{ id: 'pangtong', label: '庞统', emoji: '🐦', role: '副军师', rank: '凤雏' },
|
||||
{ id: 'simayi', label: '司马懿', emoji: '🦅', role: '质量总监', rank: '仲达' },
|
||||
{ id: 'zhugeliang', label: '诸葛亮', emoji: '🪶', role: '总军师', rank: '孔明' },
|
||||
{ id: 'zhangfei', label: '张飞', emoji: '⚔️', role: '编码先锋', rank: '翼德' },
|
||||
{ id: 'guanyu', label: '关羽', emoji: '🗡️', role: '风控守将', rank: '云长' },
|
||||
{ id: 'zhaoyun', label: '赵云', emoji: '🏹', role: '数据总管', rank: '子龙' },
|
||||
{ id: 'jiangwei', label: '姜维', emoji: '🛡️', role: '平台总督', rank: '伯约' },
|
||||
];
|
||||
|
||||
// ── Templates ──
|
||||
|
||||
export interface TemplateParam {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'textarea' | 'select';
|
||||
default?: string;
|
||||
required?: boolean;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
cat: string;
|
||||
icon: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
depts: string[];
|
||||
est: string;
|
||||
cost: string;
|
||||
params: TemplateParam[];
|
||||
command: string;
|
||||
}
|
||||
|
||||
export const TEMPLATES: Template[] = [
|
||||
{
|
||||
id: 'tpl-blank', cat: '快速开始', icon: '📜', name: '空白军令',
|
||||
desc: '自由输入需求描述,无预设参数',
|
||||
depts: ['庞统'], est: '自定义', cost: '自定义',
|
||||
params: [
|
||||
{ key: 'requirement', label: '需求描述', type: 'textarea', required: true },
|
||||
],
|
||||
command: '{requirement}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-quant-strategy', cat: '量化策略', icon: '📈', name: '量化策略开发',
|
||||
desc: '从需求分析到回测验证的完整策略开发流程',
|
||||
depts: ['庞统', '张飞', '关羽'], est: '~60分钟', cost: '¥3',
|
||||
params: [
|
||||
{ key: 'strategy_name', label: '策略名称', type: 'text', required: true },
|
||||
{ key: 'universe', label: '股票池', type: 'select', options: ['沪深300', '中证500', '全A'], default: '沪深300' },
|
||||
{ key: 'period', label: '回测区间', type: 'text', default: '2024-01-01 ~ 2025-12-31' },
|
||||
],
|
||||
command: '开发量化策略「{strategy_name}」,股票池:{universe},回测区间:{period}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-data-download', cat: '数据采集', icon: '📊', name: '行情数据下载',
|
||||
desc: '批量下载A股行情数据(日线/分钟线)到NAS',
|
||||
depts: ['赵云'], est: '~30分钟', cost: '¥1',
|
||||
params: [
|
||||
{ key: 'data_type', label: '数据类型', type: 'select', options: ['日线', '1分钟线', '5分钟线', 'Tick'], default: '日线' },
|
||||
{ key: 'symbols', label: '股票代码', type: 'text', default: '沪深300成分股' },
|
||||
{ key: 'date_range', label: '日期范围', type: 'text', required: true },
|
||||
],
|
||||
command: '下载{data_type}数据,股票:{symbols},日期:{date_range}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-backtest', cat: '量化策略', icon: '🔬', name: '策略回测',
|
||||
desc: '对指定策略进行标准化回测,输出绩效报告',
|
||||
depts: ['姜维', '庞统'], est: '~45分钟', cost: '¥2',
|
||||
params: [
|
||||
{ key: 'strategy', label: '策略文件路径', type: 'text', required: true },
|
||||
{ key: 'benchmark', label: '基准', type: 'select', options: ['沪深300', '中证500', '上证50'], default: '沪深300' },
|
||||
],
|
||||
command: '回测策略 {strategy},基准:{benchmark}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-risk-review', cat: '风控审查', icon: '🛡️', name: '风控审查',
|
||||
desc: '审查策略的风控逻辑,评估止损/仓位/滑点设置',
|
||||
depts: ['关羽', '司马懿'], est: '~20分钟', cost: '¥1.5',
|
||||
params: [
|
||||
{ key: 'target', label: '审查目标', type: 'text', required: true },
|
||||
{ key: 'scope', label: '审查范围', type: 'select', options: ['止损规则', '仓位管理', '滑点控制', '全部'], default: '全部' },
|
||||
],
|
||||
command: '对 {target} 进行风控审查,范围:{scope}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-code-impl', cat: '编码开发', icon: '💻', name: '编码实现',
|
||||
desc: '根据设计文档编码实现功能,强调最小实现、可运行优先',
|
||||
depts: ['张飞'], est: '~40分钟', cost: '¥2',
|
||||
params: [
|
||||
{ key: 'design_doc', label: '设计文档路径', type: 'text', required: true },
|
||||
{ key: 'module', label: '模块名称', type: 'text', required: true },
|
||||
],
|
||||
command: '根据 {design_doc} 实现模块 {module}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-deploy', cat: '编码开发', icon: '🚀', name: '环境部署',
|
||||
desc: '部署运行环境,安装依赖,配置服务',
|
||||
depts: ['姜维'], est: '~30分钟', cost: '¥1.5',
|
||||
params: [
|
||||
{ key: 'project', label: '项目名称', type: 'text', required: true },
|
||||
{ key: 'env', label: '部署环境', type: 'select', options: ['Docker', 'NAS', '本地开发'], default: 'Docker' },
|
||||
],
|
||||
command: '部署 {project} 到 {env}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-data-verify', cat: '数据采集', icon: '✅', name: '数据验证',
|
||||
desc: '验证数据完整性、正确性、质量检查',
|
||||
depts: ['赵云'], est: '~15分钟', cost: '¥0.5',
|
||||
params: [
|
||||
{ key: 'data_path', label: '数据路径', type: 'text', required: true },
|
||||
{ key: 'check', label: '检查项', type: 'select', options: ['完整性', '正确性', '异常值', '全部'], default: '全部' },
|
||||
],
|
||||
command: '验证数据 {data_path},检查:{check}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-requirement', cat: '需求分析', icon: '📝', name: '需求分析',
|
||||
desc: '分析用户需求,撰写需求规格文档',
|
||||
depts: ['庞统'], est: '~20分钟', cost: '¥1',
|
||||
params: [
|
||||
{ key: 'req', label: '需求描述', type: 'textarea', required: true },
|
||||
{ key: 'domain', label: '领域', type: 'select', options: ['量化策略', '数据平台', '交易系统', '基础设施'], default: '量化策略' },
|
||||
],
|
||||
command: '分析需求:{req},领域:{domain}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-code-review', cat: '风控审查', icon: '🔍', name: '代码审查',
|
||||
desc: '审查代码质量、逻辑正确性、异常处理',
|
||||
depts: ['司马懿', '关羽'], est: '~25分钟', cost: '¥1.5',
|
||||
params: [
|
||||
{ key: 'repo', label: '仓库/文件路径', type: 'text', required: true },
|
||||
{ key: 'scope', label: '审查范围', type: 'select', options: ['全量', '增量', '指定文件'], default: '增量' },
|
||||
],
|
||||
command: '审查 {repo},范围:{scope}',
|
||||
},
|
||||
];
|
||||
|
||||
export const TPL_CATS = [
|
||||
{ name: '全部', icon: '📋' },
|
||||
{ name: '量化策略', icon: '📈' },
|
||||
{ name: '数据采集', icon: '📊' },
|
||||
{ name: '风控审查', icon: '🛡️' },
|
||||
{ name: '编码开发', icon: '💻' },
|
||||
{ name: '需求分析', icon: '📝' },
|
||||
];
|
||||
|
||||
// ── Main Store ──
|
||||
|
||||
interface AppStore {
|
||||
// Data
|
||||
liveStatus: LiveStatus | null;
|
||||
agentConfig: AgentConfig | null;
|
||||
changeLog: ChangeLogEntry[];
|
||||
officialsData: OfficialsData | null;
|
||||
agentsStatusData: AgentsStatusData | null;
|
||||
morningBrief: MorningBrief | null;
|
||||
subConfig: SubConfig | null;
|
||||
|
||||
// UI State
|
||||
activeTab: TabKey;
|
||||
edictFilter: 'active' | 'archived' | 'all';
|
||||
statusFilter: string;
|
||||
searchQuery: string;
|
||||
sessFilter: string;
|
||||
tplCatFilter: string;
|
||||
selectedOfficial: string | null;
|
||||
modalTaskId: string | null;
|
||||
countdown: number;
|
||||
|
||||
// Toast
|
||||
toasts: { id: number; msg: string; type: 'ok' | 'err' }[];
|
||||
|
||||
// Actions
|
||||
setActiveTab: (tab: TabKey) => void;
|
||||
setEdictFilter: (f: 'active' | 'archived' | 'all') => void;
|
||||
setStatusFilter: (f: string) => void;
|
||||
setSearchQuery: (q: string) => void;
|
||||
setSessFilter: (f: string) => void;
|
||||
setTplCatFilter: (f: string) => void;
|
||||
setSelectedOfficial: (id: string | null) => void;
|
||||
setModalTaskId: (id: string | null) => void;
|
||||
setCountdown: (n: number) => void;
|
||||
toast: (msg: string, type?: 'ok' | 'err') => void;
|
||||
|
||||
// Data fetching
|
||||
loadLive: () => Promise<void>;
|
||||
loadAgentConfig: () => Promise<void>;
|
||||
loadOfficials: () => Promise<void>;
|
||||
loadAgentsStatus: () => Promise<void>;
|
||||
loadMorning: () => Promise<void>;
|
||||
loadSubConfig: () => Promise<void>;
|
||||
loadAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
let _toastId = 0;
|
||||
|
||||
export const useStore = create<AppStore>((set, get) => ({
|
||||
liveStatus: null,
|
||||
agentConfig: null,
|
||||
changeLog: [],
|
||||
officialsData: null,
|
||||
agentsStatusData: null,
|
||||
morningBrief: null,
|
||||
subConfig: null,
|
||||
|
||||
activeTab: 'tasks',
|
||||
edictFilter: 'active',
|
||||
statusFilter: 'all',
|
||||
searchQuery: '',
|
||||
sessFilter: 'all',
|
||||
tplCatFilter: '全部',
|
||||
selectedOfficial: null,
|
||||
modalTaskId: null,
|
||||
countdown: 5,
|
||||
|
||||
toasts: [],
|
||||
|
||||
setActiveTab: (tab) => {
|
||||
set({ activeTab: tab });
|
||||
const s = get();
|
||||
if (['models', 'skills', 'sessions'].includes(tab) && !s.agentConfig) s.loadAgentConfig();
|
||||
if (tab === 'officials' && !s.officialsData) s.loadOfficials();
|
||||
if (tab === 'monitor') s.loadAgentsStatus();
|
||||
if (tab === 'morning' && !s.morningBrief) s.loadMorning();
|
||||
},
|
||||
setEdictFilter: (f) => set({ edictFilter: f }),
|
||||
setStatusFilter: (f) => set({ statusFilter: f }),
|
||||
setSearchQuery: (q) => set({ searchQuery: q }),
|
||||
setSessFilter: (f) => set({ sessFilter: f }),
|
||||
setTplCatFilter: (f) => set({ tplCatFilter: f }),
|
||||
setSelectedOfficial: (id) => set({ selectedOfficial: id }),
|
||||
setModalTaskId: (id) => set({ modalTaskId: id }),
|
||||
setCountdown: (n) => set({ countdown: n }),
|
||||
|
||||
toast: (msg, type = 'ok') => {
|
||||
const id = ++_toastId;
|
||||
set((s) => ({ toasts: [...s.toasts, { id, msg, type }] }));
|
||||
setTimeout(() => {
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
loadLive: async () => {
|
||||
try {
|
||||
const data = await api.liveStatus();
|
||||
set({ liveStatus: data });
|
||||
// Also preload officials for monitor tab
|
||||
const s = get();
|
||||
if (!s.officialsData) {
|
||||
api.officialsStats().then((d) => set({ officialsData: d })).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
},
|
||||
|
||||
loadAgentConfig: async () => {
|
||||
try {
|
||||
const cfg = await api.agentConfig();
|
||||
const log = await api.modelChangeLog();
|
||||
set({ agentConfig: cfg, changeLog: log });
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
},
|
||||
|
||||
loadOfficials: async () => {
|
||||
try {
|
||||
const data = await api.officialsStats();
|
||||
set({ officialsData: data });
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
},
|
||||
|
||||
loadAgentsStatus: async () => {
|
||||
try {
|
||||
const data = await api.agentsStatus();
|
||||
set({ agentsStatusData: data });
|
||||
} catch {
|
||||
set({ agentsStatusData: null });
|
||||
}
|
||||
},
|
||||
|
||||
loadMorning: async () => {
|
||||
try {
|
||||
const [brief, config] = await Promise.all([api.morningBrief(), api.morningConfig()]);
|
||||
set({ morningBrief: brief, subConfig: config });
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
},
|
||||
|
||||
loadSubConfig: async () => {
|
||||
try {
|
||||
const config = await api.morningConfig();
|
||||
set({ subConfig: config });
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
},
|
||||
|
||||
loadAll: async () => {
|
||||
const s = get();
|
||||
await s.loadLive();
|
||||
const tab = s.activeTab;
|
||||
if (['models', 'skills'].includes(tab)) await s.loadAgentConfig();
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Countdown & Polling ──
|
||||
|
||||
let _cdTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function startPolling() {
|
||||
if (_cdTimer) return;
|
||||
useStore.getState().loadAll();
|
||||
_cdTimer = setInterval(() => {
|
||||
const s = useStore.getState();
|
||||
const cd = s.countdown - 1;
|
||||
if (cd <= 0) {
|
||||
s.setCountdown(5);
|
||||
s.loadAll();
|
||||
} else {
|
||||
s.setCountdown(cd);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
export function stopPolling() {
|
||||
if (_cdTimer) {
|
||||
clearInterval(_cdTimer);
|
||||
_cdTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utility ──
|
||||
|
||||
export function esc(s: string | undefined | null): string {
|
||||
if (!s) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function timeAgo(iso: string | undefined): string {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const diff = Date.now() - d.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return '刚刚';
|
||||
if (mins < 60) return mins + '分钟前';
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return hrs + '小时前';
|
||||
return Math.floor(hrs / 24) + '天前';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
/* 墨子+ v2.0 Dashboard 全局样式 */
|
||||
|
||||
:root {
|
||||
--bg: #0a1628;
|
||||
--bg2: #111d33;
|
||||
--panel: #162038;
|
||||
--panel2: #1c2a48;
|
||||
--fg: #e8eaf0;
|
||||
--muted: #7a8ba8;
|
||||
--acc: #4a9eff;
|
||||
--acc2: #6cb6ff;
|
||||
--line: #253350;
|
||||
--ok: #22c55e;
|
||||
--warn: #f59e0b;
|
||||
--err: #ef4444;
|
||||
--purple: #818cf8;
|
||||
--radius: 8px;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
background: var(--bg2);
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 16px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
padding: 0 16px 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--acc);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
transition: all 0.15s;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.nav-item:hover { background: var(--panel); color: var(--fg); }
|
||||
.nav-item.active { background: var(--panel2); color: var(--acc); }
|
||||
|
||||
/* Main content */
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Task board */
|
||||
.task-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.task-card:hover { border-color: var(--acc); }
|
||||
|
||||
.task-card .title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.task-card .meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-pending { background: rgba(74, 158, 255, 0.15); color: var(--acc); }
|
||||
.badge-working { background: rgba(245, 158, 11, 0.15); color: var(--warn); }
|
||||
.badge-review { background: rgba(129, 140, 248, 0.15); color: var(--purple); }
|
||||
.badge-done { background: rgba(34, 197, 94, 0.15); color: var(--ok); }
|
||||
.badge-failed { background: rgba(239, 68, 68, 0.15); color: var(--err); }
|
||||
.badge-blocked { background: rgba(122, 139, 168, 0.15); color: var(--muted); }
|
||||
|
||||
/* Monitor */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--acc);
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tr:hover td { background: var(--panel); }
|
||||
|
||||
/* Button */
|
||||
.btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel2);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover { border-color: var(--acc); }
|
||||
.btn-primary { background: var(--acc); color: #fff; border-color: var(--acc); }
|
||||
.btn-primary:hover { background: var(--acc2); }
|
||||
|
||||
/* Loading / Error */
|
||||
.loading { color: var(--muted); padding: 20px; }
|
||||
.error { color: var(--err); padding: 20px; }
|
||||
|
||||
/* Event log */
|
||||
.event-log {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.event-item .time {
|
||||
color: var(--muted);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.event-item .type {
|
||||
color: var(--acc);
|
||||
margin-right: 8px;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function parseDashboardTimestamp(value: number | string | undefined | null): Date | null {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
|
||||
if (typeof value === 'number') {
|
||||
const ms = Math.abs(value) < 1e12 ? value * 1000 : value;
|
||||
const d = new Date(ms);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
const raw = String(value).trim();
|
||||
if (!raw) return null;
|
||||
|
||||
if (/^\d+(\.\d+)?$/.test(raw)) {
|
||||
return parseDashboardTimestamp(Number(raw));
|
||||
}
|
||||
|
||||
let normalized = raw;
|
||||
if (normalized.includes(' ') && !normalized.includes('T')) {
|
||||
normalized = normalized.replace(' ', 'T');
|
||||
}
|
||||
|
||||
const looksIso = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(normalized);
|
||||
const hasTimezone = /(?:Z|[+\-]\d{2}:\d{2})$/i.test(normalized);
|
||||
if (looksIso && !hasTimezone) {
|
||||
normalized += 'Z';
|
||||
}
|
||||
|
||||
const d = new Date(normalized);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export function formatDashboardTime(
|
||||
value: number | string | undefined | null,
|
||||
{ showSeconds = true }: { showSeconds?: boolean } = {}
|
||||
): string {
|
||||
const d = parseDashboardTimestamp(value);
|
||||
if (!d) return '';
|
||||
const hh = pad2(d.getHours());
|
||||
const mm = pad2(d.getMinutes());
|
||||
if (!showSeconds) return `${hh}:${mm}`;
|
||||
return `${hh}:${mm}:${pad2(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function formatDashboardDateTime(
|
||||
value: number | string | undefined | null,
|
||||
{ showSeconds = true }: { showSeconds?: boolean } = {}
|
||||
): string {
|
||||
const d = parseDashboardTimestamp(value);
|
||||
if (!d) return '';
|
||||
const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
const time = formatDashboardTime(d.getTime(), { showSeconds });
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
Reference in New Issue
Block a user