feat: agent avatars, task tags, search, edit/delete agents, cron readability, timezone fix
- Agent avatars: first-letter + deterministic color avatar in all views - Agent management: edit (name/description) and delete with confirmation - Execution API: validates agent existence, returns 410 if deleted - Task tags: JSON array field, editable with tag pills UI - Task search: by name (?q=) and by tags (?tags=) and by status - Task edit: simplified to name/tags/description only (not agent params) - Cron display: human-readable Chinese (e.g. '每5分钟', '每天 09:00') - Timezone fix: UTC→local via dayjs.utc().local() across all pages - Content area: full width (removed max-width:1200px constraints) - Login page: standalone layout without sidebar
This commit is contained in:
parent
cd41e59afc
commit
b04c0ce321
@ -48,6 +48,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("cron_expression", sa.String(64), nullable=False),
|
sa.Column("cron_expression", sa.String(64), nullable=False),
|
||||||
sa.Column("grace_period", sa.Integer, default=300),
|
sa.Column("grace_period", sa.Integer, default=300),
|
||||||
sa.Column("status", sa.String(32), default="active"),
|
sa.Column("status", sa.String(32), default="active"),
|
||||||
|
sa.Column("tags", sa.Text, nullable=True),
|
||||||
sa.Column("last_run_at", sa.DateTime, nullable=True),
|
sa.Column("last_run_at", sa.DateTime, nullable=True),
|
||||||
sa.Column("last_run_result", sa.String(32), nullable=True),
|
sa.Column("last_run_result", sa.String(32), nullable=True),
|
||||||
sa.Column("next_run_at", sa.DateTime, nullable=True),
|
sa.Column("next_run_at", sa.DateTime, nullable=True),
|
||||||
|
|||||||
@ -59,6 +59,17 @@ async def heartbeat(agent_id: int, body: AgentHeartbeat, db: AsyncSession = Depe
|
|||||||
return AgentOut(**{**agent.__dict__, "task_count": cnt})
|
return AgentOut(**{**agent.__dict__, "task_count": cnt})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{agent_id}", status_code=204)
|
||||||
|
async def delete_agent(agent_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
svc = AgentService(db)
|
||||||
|
agent = await svc.get(agent_id)
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(404, "Agent not found")
|
||||||
|
# Delete all tasks first, then the agent (cascade)
|
||||||
|
await svc.delete(agent_id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
# ── Batch registration: AI agent self-registers with all its tasks ─────
|
# ── Batch registration: AI agent self-registers with all its tasks ─────
|
||||||
|
|
||||||
@router.post("/register-with-tasks", response_model=AgentBatchRegisterResult, status_code=201)
|
@router.post("/register-with-tasks", response_model=AgentBatchRegisterResult, status_code=201)
|
||||||
|
|||||||
@ -20,6 +20,14 @@ async def report_execution(task_id: int, body: ExecutionReport,
|
|||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
|
|
||||||
|
# Verify agent exists and is active
|
||||||
|
agent_svc = AgentService(db)
|
||||||
|
agent = await agent_svc.get(task.agent_id)
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(410, "Agent已被删除,请重新注册")
|
||||||
|
if agent.status != "active":
|
||||||
|
raise HTTPException(403, "Agent已停用,无法汇报")
|
||||||
|
|
||||||
exec_svc = ExecutionService(db)
|
exec_svc = ExecutionService(db)
|
||||||
record = await exec_svc.report_result(
|
record = await exec_svc.report_result(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
|
|||||||
@ -1,13 +1,28 @@
|
|||||||
"""API router — Task management."""
|
"""API router — Task management."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import select, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.schemas import TaskCreate, TaskOut, TaskUpdate
|
from app.schemas import TaskCreate, TaskOut, TaskUpdate
|
||||||
from app.services import TaskService, AgentService
|
from app.services import TaskService, AgentService
|
||||||
|
from app.models import ScheduledTask
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
router = APIRouter(prefix="/api/tasks", tags=["tags"])
|
||||||
|
|
||||||
|
|
||||||
|
def _task_to_out(task, agent_name: str = "") -> dict:
|
||||||
|
"""Convert task model to TaskOut dict, handling tags JSON deserialization."""
|
||||||
|
data = {**task.__dict__}
|
||||||
|
try:
|
||||||
|
data["tags"] = json.loads(data.get("tags", "[]"))
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
data["tags"] = []
|
||||||
|
data["agent_name"] = agent_name
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=TaskOut, status_code=201)
|
@router.post("", response_model=TaskOut, status_code=201)
|
||||||
@ -25,23 +40,48 @@ async def create_task(body: TaskCreate, agent_id: int, db: AsyncSession = Depend
|
|||||||
description=body.description,
|
description=body.description,
|
||||||
grace_period=body.grace_period,
|
grace_period=body.grace_period,
|
||||||
)
|
)
|
||||||
return TaskOut(**{**task.__dict__, "agent_name": agent.name})
|
# Store tags
|
||||||
|
if body.tags:
|
||||||
|
task.tags = json.dumps(body.tags, ensure_ascii=False)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(task)
|
||||||
|
return TaskOut(**_task_to_out(task, agent.name))
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[TaskOut])
|
@router.get("", response_model=list[TaskOut])
|
||||||
async def list_tasks(agent_id: int | None = None, db: AsyncSession = Depends(get_db)):
|
async def list_tasks(
|
||||||
"""List all tasks, optionally filtered by agent_id."""
|
agent_id: int | None = Query(None),
|
||||||
|
q: str | None = Query(None, description="关键词搜索(任务名称)"),
|
||||||
|
tags: str | None = Query(None, description="标签筛选,逗号分隔"),
|
||||||
|
status: str | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all tasks, with optional search and filters."""
|
||||||
svc = TaskService(db)
|
svc = TaskService(db)
|
||||||
agent_svc = AgentService(db)
|
agent_svc = AgentService(db)
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
stmt = select(ScheduledTask)
|
||||||
if agent_id:
|
if agent_id:
|
||||||
tasks = await svc.list_by_agent(agent_id)
|
stmt = stmt.where(ScheduledTask.agent_id == agent_id)
|
||||||
else:
|
if status:
|
||||||
tasks = await svc.list_all()
|
stmt = stmt.where(ScheduledTask.status == status)
|
||||||
result = []
|
if q:
|
||||||
|
stmt = stmt.where(ScheduledTask.name.like(f"%{q}%"))
|
||||||
|
if tags:
|
||||||
|
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
|
for tag in tag_list:
|
||||||
|
stmt = stmt.where(ScheduledTask.tags.like(f'%"{tag}"%'))
|
||||||
|
|
||||||
|
stmt = stmt.order_by(ScheduledTask.id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
tasks = list(result.scalars().all())
|
||||||
|
|
||||||
|
output = []
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
agent = await agent_svc.get(t.agent_id)
|
agent = await agent_svc.get(t.agent_id)
|
||||||
result.append(TaskOut(**{**t.__dict__, "agent_name": agent.name if agent else ""}))
|
output.append(TaskOut(**_task_to_out(t, agent.name if agent else "")))
|
||||||
return result
|
return output
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{task_id}", response_model=TaskOut)
|
@router.get("/{task_id}", response_model=TaskOut)
|
||||||
@ -52,18 +92,29 @@ async def get_task(task_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
agent = await agent_svc.get(task.agent_id)
|
agent = await agent_svc.get(task.agent_id)
|
||||||
return TaskOut(**{**task.__dict__, "agent_name": agent.name if agent else ""})
|
return TaskOut(**_task_to_out(task, agent.name if agent else ""))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{task_id}", response_model=TaskOut)
|
@router.put("/{task_id}", response_model=TaskOut)
|
||||||
async def update_task(task_id: int, body: TaskUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_task(task_id: int, body: TaskUpdate, db: AsyncSession = Depends(get_db)):
|
||||||
svc = TaskService(db)
|
svc = TaskService(db)
|
||||||
agent_svc = AgentService(db)
|
agent_svc = AgentService(db)
|
||||||
task = await svc.update(task_id, **body.model_dump(exclude_none=True))
|
|
||||||
|
# Handle tags separately (JSON serialization)
|
||||||
|
update_data = body.model_dump(exclude_none=True)
|
||||||
|
tags_list = update_data.pop("tags", None)
|
||||||
|
|
||||||
|
task = await svc.update(task_id, **update_data)
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
|
|
||||||
|
if tags_list is not None:
|
||||||
|
task.tags = json.dumps(tags_list, ensure_ascii=False)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(task)
|
||||||
|
|
||||||
agent = await agent_svc.get(task.agent_id)
|
agent = await agent_svc.get(task.agent_id)
|
||||||
return TaskOut(**{**task.__dict__, "agent_name": agent.name if agent else ""})
|
return TaskOut(**_task_to_out(task, agent.name if agent else ""))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{task_id}", status_code=204)
|
@router.delete("/{task_id}", status_code=204)
|
||||||
|
|||||||
@ -18,6 +18,7 @@ class ScheduledTask(Base):
|
|||||||
cron_expression: Mapped[str] = mapped_column(String(64), nullable=False, comment="Cron 表达式, e.g. */5 * * * *")
|
cron_expression: Mapped[str] = mapped_column(String(64), nullable=False, comment="Cron 表达式, e.g. */5 * * * *")
|
||||||
grace_period: Mapped[int] = mapped_column(Integer, default=300, comment="容忍秒数, 超时未执行则告警")
|
grace_period: Mapped[int] = mapped_column(Integer, default=300, comment="容忍秒数, 超时未执行则告警")
|
||||||
status: Mapped[str] = mapped_column(String(32), default="active", comment="active / paused / stopped")
|
status: Mapped[str] = mapped_column(String(32), default="active", comment="active / paused / stopped")
|
||||||
|
tags: Mapped[str | None] = mapped_column(Text, nullable=True, comment="标签 JSON 数组, e.g. [\"数据同步\",\"重要\"]")
|
||||||
|
|
||||||
# 冗余字段方便查询
|
# 冗余字段方便查询
|
||||||
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最近一次执行时间")
|
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最近一次执行时间")
|
||||||
|
|||||||
@ -10,6 +10,7 @@ class TaskCreate(BaseModel):
|
|||||||
description: str = Field(default="")
|
description: str = Field(default="")
|
||||||
cron_expression: str = Field(..., max_length=64)
|
cron_expression: str = Field(..., max_length=64)
|
||||||
grace_period: int = Field(default=300, ge=0)
|
grace_period: int = Field(default=300, ge=0)
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class TaskUpdate(BaseModel):
|
class TaskUpdate(BaseModel):
|
||||||
@ -18,6 +19,7 @@ class TaskUpdate(BaseModel):
|
|||||||
cron_expression: str | None = None
|
cron_expression: str | None = None
|
||||||
grace_period: int | None = None
|
grace_period: int | None = None
|
||||||
status: str | None = None # active / paused / stopped
|
status: str | None = None # active / paused / stopped
|
||||||
|
tags: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class TaskOut(BaseModel):
|
class TaskOut(BaseModel):
|
||||||
@ -29,6 +31,7 @@ class TaskOut(BaseModel):
|
|||||||
cron_expression: str
|
cron_expression: str
|
||||||
grace_period: int
|
grace_period: int
|
||||||
status: str
|
status: str
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
last_run_at: datetime | None = None
|
last_run_at: datetime | None = None
|
||||||
last_run_result: str | None = None
|
last_run_result: str | None = None
|
||||||
next_run_at: datetime | None = None
|
next_run_at: datetime | None = None
|
||||||
|
|||||||
@ -52,3 +52,11 @@ class AgentService:
|
|||||||
stmt = select(func.count(ScheduledTask.id)).where(ScheduledTask.agent_id == agent_id)
|
stmt = select(func.count(ScheduledTask.id)).where(ScheduledTask.agent_id == agent_id)
|
||||||
result = await self.db.execute(stmt)
|
result = await self.db.execute(stmt)
|
||||||
return result.scalar() or 0
|
return result.scalar() or 0
|
||||||
|
|
||||||
|
async def delete(self, agent_id: int) -> bool:
|
||||||
|
agent = await self.get(agent_id)
|
||||||
|
if agent is None:
|
||||||
|
return False
|
||||||
|
await self.db.delete(agent)
|
||||||
|
await self.db.flush()
|
||||||
|
return True
|
||||||
|
|||||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
|
"cronstrue": "^3.14.0",
|
||||||
"dayjs": "^1.11.0",
|
"dayjs": "^1.11.0",
|
||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
"element-plus": "^2.7.0",
|
"element-plus": "^2.7.0",
|
||||||
@ -1079,6 +1080,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cronstrue": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-XnW4vuK/jPJjmTyDWiej1Zq36Od7ITwxaV2O1pzHZuyMVvdy7NAvyvIBzybt+idqSpfqYuoDG7uf/ocGtJVWxA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"cronstrue": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
|||||||
@ -9,14 +9,15 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.4.0",
|
|
||||||
"vue-router": "^4.3.0",
|
|
||||||
"element-plus": "^2.7.0",
|
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
|
"cronstrue": "^3.14.0",
|
||||||
"dayjs": "^1.11.0",
|
"dayjs": "^1.11.0",
|
||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
"vue-echarts": "^6.7.0"
|
"element-plus": "^2.7.0",
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-echarts": "^6.7.0",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
|||||||
@ -42,9 +42,10 @@ export const listAgents = () => api.get('/agents')
|
|||||||
export const getAgent = (id) => api.get(`/agents/${id}`)
|
export const getAgent = (id) => api.get(`/agents/${id}`)
|
||||||
export const createAgent = (data) => api.post('/agents', data)
|
export const createAgent = (data) => api.post('/agents', data)
|
||||||
export const updateAgent = (id, data) => api.put(`/agents/${id}`, data)
|
export const updateAgent = (id, data) => api.put(`/agents/${id}`, data)
|
||||||
|
export const deleteAgent = (id) => api.delete(`/agents/${id}`)
|
||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
export const listTasks = (agentId) => api.get('/tasks', { params: { agent_id: agentId } })
|
export const listTasks = (params) => api.get('/tasks', { params })
|
||||||
export const getTask = (id) => api.get(`/tasks/${id}`)
|
export const getTask = (id) => api.get(`/tasks/${id}`)
|
||||||
export const createTask = (agentId, data) => api.post('/tasks', data, { params: { agent_id: agentId } })
|
export const createTask = (agentId, data) => api.post('/tasks', data, { params: { agent_id: agentId } })
|
||||||
export const updateTask = (id, data) => api.put(`/tasks/${id}`, data)
|
export const updateTask = (id, data) => api.put(`/tasks/${id}`, data)
|
||||||
|
|||||||
@ -230,3 +230,33 @@ html, body, #app {
|
|||||||
border: 1px solid var(--border-color) !important;
|
border: 1px solid var(--border-color) !important;
|
||||||
backdrop-filter: blur(12px) !important;
|
backdrop-filter: blur(12px) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Agent Avatar */
|
||||||
|
.agent-avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
.agent-avatar-sm {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|||||||
@ -3,10 +3,15 @@ import ElementPlus from 'element-plus'
|
|||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import utc from 'dayjs/plugin/utc'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import './assets/style.css'
|
import './assets/style.css'
|
||||||
|
|
||||||
|
// Extend dayjs with UTC plugin for timezone conversion
|
||||||
|
dayjs.extend(utc)
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
// Register all Element Plus icons
|
// Register all Element Plus icons
|
||||||
@ -14,6 +19,118 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
|||||||
app.component(key, component)
|
app.component(key, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global helper: cron expression to readable Chinese
|
||||||
|
app.config.globalProperties.$cron = (expr) => {
|
||||||
|
if (!expr) return ''
|
||||||
|
try {
|
||||||
|
const parts = expr.trim().split(/\s+/)
|
||||||
|
if (parts.length !== 5) return expr
|
||||||
|
const [min, hour, dom, month, dow] = parts
|
||||||
|
|
||||||
|
// Helper: describe a cron field
|
||||||
|
const descField = (val, unit) => {
|
||||||
|
if (val === '*') return `每${unit}`
|
||||||
|
if (val.startsWith('*/')) return `每${val.slice(2)}${unit}`
|
||||||
|
if (val.includes(',')) {
|
||||||
|
const items = val.split(',').map(v => v.padStart(2, '0')).join('分、')
|
||||||
|
return `第 ${items}分`
|
||||||
|
}
|
||||||
|
if (val.includes('-')) {
|
||||||
|
const [from, to] = val.split('-')
|
||||||
|
return `${from.padStart(2,'0')}分到${to.padStart(2,'0')}分`
|
||||||
|
}
|
||||||
|
return `${val.padStart(2,'0')}${unit}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const allStar = (...args) => args.every(v => v === '*')
|
||||||
|
|
||||||
|
// Every minute
|
||||||
|
if (min === '*' && hour === '*' && allStar(dom, month, dow)) return '每分钟'
|
||||||
|
|
||||||
|
// Every N minutes (always)
|
||||||
|
if (min.startsWith('*/') && hour === '*' && allStar(dom, month, dow)) {
|
||||||
|
return `每${min.slice(2)}分钟`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every N hours (at min 0)
|
||||||
|
if (min === '0' && hour.startsWith('*/') && allStar(dom, month, dow)) {
|
||||||
|
return `每${hour.slice(2)}小时`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific minutes every hour
|
||||||
|
if (min !== '*' && !min.startsWith('*/') && hour === '*' && allStar(dom, month, dow)) {
|
||||||
|
if (min.includes(',')) {
|
||||||
|
const items = min.split(',').map(v => v.padStart(2, '0'))
|
||||||
|
return `每小时 ${items.join('、')}分`
|
||||||
|
}
|
||||||
|
return `每小时 第${min.padStart(2,'0')}分钟`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily at specific time(s)
|
||||||
|
if (allStar(dom, month, dow)) {
|
||||||
|
if (min.startsWith('*/')) {
|
||||||
|
const interval = min.slice(2)
|
||||||
|
if (hour.includes('-')) {
|
||||||
|
const [hFrom, hTo] = hour.split('-').map(h => h.padStart(2,'0'))
|
||||||
|
return `每${interval}分钟(${hFrom}:00-${hTo}:00)`
|
||||||
|
}
|
||||||
|
if (hour !== '*') return `每${interval}分钟(${hour.padStart(2,'0')}点)`
|
||||||
|
return `每${interval}分钟`
|
||||||
|
}
|
||||||
|
if (hour.includes(',')) {
|
||||||
|
const times = hour.split(',').map(h => `${h.padStart(2,'0')}:${min.padStart(2,'0')}`)
|
||||||
|
return `每天 ${times.join('、')}`
|
||||||
|
}
|
||||||
|
if (hour.includes('-')) {
|
||||||
|
return `每天 ${hour.split('-')[0].padStart(2,'0')}:${min.padStart(2,'0')} 到 ${hour.split('-')[1].padStart(2,'0')}:${min.padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
return `每天 ${hour.padStart(2,'0')}:${min.padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly
|
||||||
|
const dowNames = ['日', '一', '二', '三', '四', '五', '六', '日']
|
||||||
|
if (allStar(dom, month) && dow !== '*') {
|
||||||
|
if (dow.includes(',')) {
|
||||||
|
const days = dow.split(',').map(d => `周${dowNames[parseInt(d)]}`)
|
||||||
|
return `每${days.join('、')} ${hour.padStart(2,'0')}:${min.padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
if (dow.includes('-')) {
|
||||||
|
const [from, to] = dow.split('-')
|
||||||
|
return `每周${dowNames[parseInt(from)]}到${dowNames[parseInt(to)]} ${hour.padStart(2,'0')}:${min.padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
return `每周${dowNames[parseInt(dow)]} ${hour.padStart(2,'0')}:${min.padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every N minutes within specific hours (e.g., */15 9-18 * * *)
|
||||||
|
if (min.startsWith('*/') && !allStar(dom, month, dow)) {
|
||||||
|
const interval = min.slice(2)
|
||||||
|
let hourDesc = ''
|
||||||
|
if (hour !== '*') hourDesc = descField(hour, '点')
|
||||||
|
return `每${interval}分钟${hourDesc ? ` (${hourDesc})` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return expr
|
||||||
|
} catch {
|
||||||
|
return expr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global helper: agent name → avatar { letter, bg color }
|
||||||
|
const AVATAR_COLORS = [
|
||||||
|
'#667eea', '#22c55e', '#f59e0b', '#ef4444', '#ec4899',
|
||||||
|
'#8b5cf6', '#06b6d4', '#f97316', '#14b8a6', '#a855f7',
|
||||||
|
'#6366f1', '#84cc16', '#e11d48', '#0ea5e9',
|
||||||
|
]
|
||||||
|
app.config.globalProperties.$avatar = (name) => {
|
||||||
|
if (!name) return { letter: '?', bg: AVATAR_COLORS[0] }
|
||||||
|
const letter = name.trim()[0]
|
||||||
|
// Deterministic color from name
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < name.length; i++) hash = ((hash << 5) - hash) + name.charCodeAt(i)
|
||||||
|
const idx = Math.abs(hash) % AVATAR_COLORS.length
|
||||||
|
return { letter, bg: AVATAR_COLORS[idx] }
|
||||||
|
}
|
||||||
|
|
||||||
app.use(ElementPlus, { locale: zhCn })
|
app.use(ElementPlus, { locale: zhCn })
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@ -43,7 +43,10 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="a in agents" :key="a.id">
|
<tr v-for="a in agents" :key="a.id">
|
||||||
<td class="cell-mono">#{{ a.id }}</td>
|
<td class="cell-mono">#{{ a.id }}</td>
|
||||||
<td class="cell-name">{{ a.name }}</td>
|
<td>
|
||||||
|
<span class="agent-avatar" :style="{background: $avatar(a.name).bg}">{{ $avatar(a.name).letter }}</span>
|
||||||
|
<span class="cell-name">{{ a.name }}</span>
|
||||||
|
</td>
|
||||||
<td class="cell-muted">{{ a.description || '—' }}</td>
|
<td class="cell-muted">{{ a.description || '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="tag" :class="a.status === 'active' ? 'tag-green' : 'tag-gray'">
|
<span class="tag" :class="a.status === 'active' ? 'tag-green' : 'tag-gray'">
|
||||||
@ -51,7 +54,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="cell-mono">{{ a.task_count }}</td>
|
<td class="cell-mono">{{ a.task_count }}</td>
|
||||||
<td class="cell-mono">{{ a.last_heartbeat_at ? dayjs(a.last_heartbeat_at).format('MM-DD HH:mm') : '—' }}</td>
|
<td class="cell-mono">{{ a.last_heartbeat_at ? dayjs.utc(a.last_heartbeat_at).local().format('MM-DD HH:mm') : '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<code class="cell-key">{{ a.api_key.substring(0, 16) }}...</code>
|
<code class="cell-key">{{ a.api_key.substring(0, 16) }}...</code>
|
||||||
<button class="btn-icon" @click="copyKey(a.api_key)" title="复制 API Key">
|
<button class="btn-icon" @click="copyKey(a.api_key)" title="复制 API Key">
|
||||||
@ -60,6 +63,8 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<router-link to="/tasks" class="cell-link">任务</router-link>
|
<router-link to="/tasks" class="cell-link">任务</router-link>
|
||||||
|
<button class="cell-link-btn" style="color:var(--accent-1)" @click="showEdit(a)">编辑</button>
|
||||||
|
<button class="cell-link-btn" style="color:var(--danger)" @click="confirmDelete(a)">删除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="agents.length === 0">
|
<tr v-if="agents.length === 0">
|
||||||
@ -70,12 +75,65 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Agent Modal -->
|
||||||
|
<div v-if="editAgent" class="modal-overlay" @click.self="editAgent = null">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4>编辑 Agent</h4>
|
||||||
|
<button class="modal-close" @click="editAgent = null">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Agent 名称</label>
|
||||||
|
<div class="field-avatar-row">
|
||||||
|
<span class="agent-avatar-lg" :style="{background: $avatar(editForm.name).bg}">{{ $avatar(editForm.name).letter }}</span>
|
||||||
|
<input v-model="editForm.name" class="input" placeholder="Agent 名称" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>描述</label>
|
||||||
|
<input v-model="editForm.description" class="input" placeholder="描述" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" @click="editAgent = null">取消</button>
|
||||||
|
<button class="btn-primary" @click="handleEdit">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirm Modal -->
|
||||||
|
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = null">
|
||||||
|
<div class="modal" style="width:400px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4>删除 Agent</h4>
|
||||||
|
<button class="modal-close" @click="deleteTarget = null">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;line-height:1.6">
|
||||||
|
确定要删除 Agent <strong style="color:var(--text-primary)">{{ deleteTarget.name }}</strong> 吗?
|
||||||
|
</p>
|
||||||
|
<p style="color:var(--text-muted);font-size:12px;margin-top:8px">
|
||||||
|
其关联的定时任务也将一并删除。Agent 后续仍可重新注册。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" @click="deleteTarget = null">取消</button>
|
||||||
|
<button class="btn-danger" @click="handleDelete">确认删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { listAgents, getSystemConfig } from '../api/index.js'
|
import { listAgents, updateAgent, deleteAgent, getSystemConfig } from '../api/index.js'
|
||||||
|
|
||||||
const agents = ref([])
|
const agents = ref([])
|
||||||
const tab = ref('curl')
|
const tab = ref('curl')
|
||||||
@ -90,6 +148,40 @@ const loadData = async () => {
|
|||||||
baseUrl.value = cfgRes.data.base_url
|
baseUrl.value = cfgRes.data.base_url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Edit
|
||||||
|
const editAgent = ref(null)
|
||||||
|
const editForm = ref({ name: '', description: '' })
|
||||||
|
const showEdit = (agent) => {
|
||||||
|
editForm.value = { name: agent.name, description: agent.description }
|
||||||
|
editAgent.value = agent
|
||||||
|
}
|
||||||
|
const handleEdit = async () => {
|
||||||
|
try {
|
||||||
|
await updateAgent(editAgent.value.id, editForm.value)
|
||||||
|
editAgent.value = null
|
||||||
|
await loadData()
|
||||||
|
const { ElMessage } = await import('element-plus')
|
||||||
|
ElMessage.success('已更新')
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
const deleteTarget = ref(null)
|
||||||
|
const confirmDelete = (agent) => { deleteTarget.value = agent }
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteAgent(deleteTarget.value.id)
|
||||||
|
deleteTarget.value = null
|
||||||
|
await loadData()
|
||||||
|
const { ElMessage } = await import('element-plus')
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const copyKey = async (key) => {
|
const copyKey = async (key) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(key)
|
await navigator.clipboard.writeText(key)
|
||||||
@ -122,7 +214,7 @@ onMounted(loadData)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.agents-page { max-width: 1200px; }
|
.agents-page {}
|
||||||
.mb-24 { margin-bottom: 24px; }
|
.mb-24 { margin-bottom: 24px; }
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
@ -234,4 +326,91 @@ onMounted(loadData)
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.btn-icon:hover { color: var(--accent-1); }
|
.btn-icon:hover { color: var(--accent-1); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 480px; max-width: 90vw;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.modal-header h4 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
|
||||||
|
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; }
|
||||||
|
.modal-close:hover { color: #fff; }
|
||||||
|
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.modal-footer {
|
||||||
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
|
padding: 16px 20px; border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.field label { font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.field-avatar-row { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: var(--accent-1); }
|
||||||
|
|
||||||
|
.agent-avatar-lg {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 7px 16px; border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
color: #fff; font-size: 13px; font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { opacity: 0.9; }
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 7px 16px; border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary); font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { color: #fff; border-color: rgba(255,255,255,0.15); }
|
||||||
|
.btn-danger {
|
||||||
|
padding: 7px 16px; border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(239,68,68,0.8);
|
||||||
|
color: #fff; font-size: 13px; font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: #ef4444; }
|
||||||
|
|
||||||
|
.cell-link-btn {
|
||||||
|
background: none; border: none;
|
||||||
|
font-size: 13px; cursor: pointer;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.cell-link-btn:hover { text-decoration: underline; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -20,7 +20,7 @@
|
|||||||
{{ a.alert_type === 'missed_run' ? '未按时执行' : '执行失败' }}
|
{{ a.alert_type === 'missed_run' ? '未按时执行' : '执行失败' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="alert-task">{{ a.task_name || '未知任务' }}</span>
|
<span class="alert-task">{{ a.task_name || '未知任务' }}</span>
|
||||||
<span class="alert-time">{{ dayjs(a.created_at).format('MM-DD HH:mm') }}</span>
|
<span class="alert-time">{{ dayjs.utc(a.created_at).local().format('MM-DD HH:mm') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="alert-msg">{{ a.message }}</div>
|
<div class="alert-msg">{{ a.message }}</div>
|
||||||
<div class="alert-bottom">
|
<div class="alert-bottom">
|
||||||
@ -68,7 +68,7 @@ onMounted(loadData)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.alerts-page { max-width: 1200px; }
|
.alerts-page {}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
|
|||||||
@ -117,15 +117,18 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="t in tasks" :key="t.id">
|
<tr v-for="t in tasks" :key="t.id">
|
||||||
<td><span class="cell-badge">{{ t.agent_name }}</span></td>
|
<td>
|
||||||
|
<span class="agent-avatar" :style="{background: $avatar(t.agent_name).bg}">{{ $avatar(t.agent_name).letter }}</span>
|
||||||
|
<span class="cell-badge">{{ t.agent_name }}</span>
|
||||||
|
</td>
|
||||||
<td class="cell-name">{{ t.name }}</td>
|
<td class="cell-name">{{ t.name }}</td>
|
||||||
<td><code class="cell-code">{{ t.cron_expression }}</code></td>
|
<td><code class="cell-code" :title="t.cron_expression">{{ $cron(t.cron_expression) }}</code></td>
|
||||||
<td>
|
<td>
|
||||||
<span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'">
|
<span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'">
|
||||||
{{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }}
|
{{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="cell-mono">{{ t.last_run_at ? dayjs(t.last_run_at).format('MM-DD HH:mm') : '—' }}</td>
|
<td class="cell-mono">{{ t.last_run_at ? dayjs.utc(t.last_run_at).local().format('MM-DD HH:mm') : '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
|
<span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
|
||||||
{{ t.last_run_result === 'success' ? '成功' : '失败' }}
|
{{ t.last_run_result === 'success' ? '成功' : '失败' }}
|
||||||
@ -255,7 +258,6 @@ const copyScript = async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard {
|
.dashboard {
|
||||||
max-width: 1200px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Onboarding Panel ─────────────────────────── */
|
/* ── Onboarding Panel ─────────────────────────── */
|
||||||
|
|||||||
@ -246,7 +246,7 @@ onMounted(loadData)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.settings-page { max-width: 1200px; }
|
.settings-page {}
|
||||||
.mb-24 { margin-bottom: 24px; }
|
.mb-24 { margin-bottom: 24px; }
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
|
|||||||
@ -13,10 +13,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
<div class="detail-item"><span class="detail-label">Agent</span><span class="detail-value">{{ task.agent_name }}</span></div>
|
<div class="detail-item">
|
||||||
<div class="detail-item"><span class="detail-label">Cron</span><code class="detail-code">{{ task.cron_expression }}</code></div>
|
<span class="detail-label">Agent</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
<span class="agent-avatar-sm" :style="{background: $avatar(task.agent_name).bg}">{{ $avatar(task.agent_name).letter }}</span>
|
||||||
|
{{ task.agent_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item"><span class="detail-label">Cron</span><code class="detail-code" :title="task.cron_expression">{{ $cron(task.cron_expression) }}</code></div>
|
||||||
<div class="detail-item"><span class="detail-label">容忍窗口</span><span class="detail-value">{{ task.grace_period }}s</span></div>
|
<div class="detail-item"><span class="detail-label">容忍窗口</span><span class="detail-value">{{ task.grace_period }}s</span></div>
|
||||||
<div class="detail-item"><span class="detail-label">最近运行</span><span class="detail-value mono">{{ task.last_run_at ? dayjs(task.last_run_at).format('YYYY-MM-DD HH:mm:ss') : '—' }}</span></div>
|
<div class="detail-item"><span class="detail-label">最近运行</span><span class="detail-value mono">{{ task.last_run_at ? dayjs.utc(task.last_run_at).local().format('YYYY-MM-DD HH:mm:ss') : '—' }}</span></div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">最近结果</span>
|
<span class="detail-label">最近结果</span>
|
||||||
<span v-if="task.last_run_result" class="tag" :class="task.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
|
<span v-if="task.last_run_result" class="tag" :class="task.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
|
||||||
@ -25,7 +31,7 @@
|
|||||||
<span v-else class="detail-value">—</span>
|
<span v-else class="detail-value">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item"><span class="detail-label">执行次数</span><span class="detail-value mono">{{ task.total_run_count }}</span></div>
|
<div class="detail-item"><span class="detail-label">执行次数</span><span class="detail-value mono">{{ task.total_run_count }}</span></div>
|
||||||
<div class="detail-item"><span class="detail-label">预计下次</span><span class="detail-value mono">{{ task.next_run_at ? dayjs(task.next_run_at).format('YYYY-MM-DD HH:mm:ss') : '待计算' }}</span></div>
|
<div class="detail-item"><span class="detail-label">预计下次</span><span class="detail-value mono">{{ task.next_run_at ? dayjs.utc(task.next_run_at).local().format('YYYY-MM-DD HH:mm:ss') : '待计算' }}</span></div>
|
||||||
<div class="detail-item"><span class="detail-label">描述</span><span class="detail-value">{{ task.description || '无' }}</span></div>
|
<div class="detail-item"><span class="detail-label">描述</span><span class="detail-value">{{ task.description || '无' }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -55,7 +61,7 @@
|
|||||||
{{ e.status === 'success' ? '成功' : e.status === 'failed' ? '失败' : '运行中' }}
|
{{ e.status === 'success' ? '成功' : e.status === 'failed' ? '失败' : '运行中' }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="cell-mono">{{ dayjs(e.started_at).format('MM-DD HH:mm:ss') }}</td>
|
<td class="cell-mono">{{ dayjs.utc(e.started_at).local().format('MM-DD HH:mm:ss') }}</td>
|
||||||
<td class="cell-mono">{{ e.duration_ms ?? '—' }}</td>
|
<td class="cell-mono">{{ e.duration_ms ?? '—' }}</td>
|
||||||
<td class="cell-muted cell-ellipsis">{{ e.result || '—' }}</td>
|
<td class="cell-muted cell-ellipsis">{{ e.result || '—' }}</td>
|
||||||
<td><button class="cell-link-btn" @click="showLog(e)">日志</button></td>
|
<td><button class="cell-link-btn" @click="showLog(e)">日志</button></td>
|
||||||
@ -118,8 +124,6 @@ const showLog = (row) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.task-detail { max-width: 1200px; }
|
|
||||||
|
|
||||||
.back-btn {
|
.back-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -1,29 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="tasks-page">
|
<div class="tasks-page">
|
||||||
|
<!-- Search & Filter Bar -->
|
||||||
<div class="section mb-24">
|
<div class="section mb-24">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h3>定时任务 — 由 AI Agent 自动管理</h3>
|
<h3>所有任务</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboard-body">
|
<div class="search-bar">
|
||||||
<p>Agent 注册后,通过下方 API 汇报任务执行情况,无需手动创建。</p>
|
<div class="search-input-wrap">
|
||||||
<div class="code-block">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
<div class="code-tabs">
|
<input v-model="searchQ" class="search-input" placeholder="搜索任务名称..." @input="loadData" />
|
||||||
<button :class="{ active: tab === 'report' }" @click="tab = 'report'">汇报执行</button>
|
|
||||||
<button :class="{ active: tab === 'register' }" @click="tab = 'register'">注册任务</button>
|
|
||||||
<button :class="{ active: tab === 'batch' }" @click="tab = 'batch'">批量注册</button>
|
|
||||||
</div>
|
|
||||||
<div class="code-body">
|
|
||||||
<pre>{{ codes[tab] }}</pre>
|
|
||||||
</div>
|
|
||||||
<button class="btn-copy" @click="copyCode">复制</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<input v-model="searchTags" class="search-input" style="width:200px" placeholder="标签筛选(逗号分隔)" @input="loadData" />
|
||||||
|
<select v-model="searchStatus" class="search-input" style="width:130px" @change="loadData">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="active">运行中</option>
|
||||||
|
<option value="paused">已暂停</option>
|
||||||
|
<option value="stopped">已停止</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Task list -->
|
<!-- Task list -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h3>所有任务</h3>
|
<h3>任务列表({{ tasks.length }})</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
@ -33,6 +33,7 @@
|
|||||||
<th>Agent</th>
|
<th>Agent</th>
|
||||||
<th>任务名称</th>
|
<th>任务名称</th>
|
||||||
<th>Cron</th>
|
<th>Cron</th>
|
||||||
|
<th>标签</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>最近运行</th>
|
<th>最近运行</th>
|
||||||
<th>结果</th>
|
<th>结果</th>
|
||||||
@ -43,15 +44,22 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="t in tasks" :key="t.id">
|
<tr v-for="t in tasks" :key="t.id">
|
||||||
<td class="cell-mono">#{{ t.id }}</td>
|
<td class="cell-mono">#{{ t.id }}</td>
|
||||||
<td><span class="cell-badge">{{ t.agent_name }}</span></td>
|
<td>
|
||||||
|
<span class="agent-avatar" :style="{background: $avatar(t.agent_name).bg}">{{ $avatar(t.agent_name).letter }}</span>
|
||||||
|
<span class="cell-badge">{{ t.agent_name }}</span>
|
||||||
|
</td>
|
||||||
<td class="cell-name">{{ t.name }}</td>
|
<td class="cell-name">{{ t.name }}</td>
|
||||||
<td><code class="cell-code">{{ t.cron_expression }}</code></td>
|
<td><code class="cell-code" :title="t.cron_expression">{{ $cron(t.cron_expression) }}</code></td>
|
||||||
|
<td>
|
||||||
|
<span v-for="tag in (t.tags || [])" :key="tag" class="tag-pill">{{ tag }}</span>
|
||||||
|
<span v-if="!t.tags || t.tags.length === 0" class="cell-muted">—</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'">
|
<span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'">
|
||||||
{{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }}
|
{{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="cell-mono">{{ t.last_run_at ? dayjs(t.last_run_at).format('MM-DD HH:mm') : '—' }}</td>
|
<td class="cell-mono">{{ t.last_run_at ? dayjs.utc(t.last_run_at).local().format('MM-DD HH:mm') : '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
|
<span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
|
||||||
{{ t.last_run_result === 'success' ? '成功' : '失败' }}
|
{{ t.last_run_result === 'success' ? '成功' : '失败' }}
|
||||||
@ -61,78 +69,121 @@
|
|||||||
<td class="cell-mono">{{ t.total_run_count }}</td>
|
<td class="cell-mono">{{ t.total_run_count }}</td>
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="`/tasks/${t.id}`" class="cell-link">详情</router-link>
|
<router-link :to="`/tasks/${t.id}`" class="cell-link">详情</router-link>
|
||||||
<button class="btn-icon" @click="handleDelete(t)" title="删除">
|
<button class="cell-link-btn" style="color:var(--accent-1);margin-left:6px" @click="showEdit(t)">编辑</button>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
<button class="cell-link-btn" style="color:var(--danger);margin-left:4px" @click="handleDelete(t)">删除</button>
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="tasks.length === 0">
|
<tr v-if="tasks.length === 0">
|
||||||
<td colspan="9" class="cell-empty">暂无任务 — Agent 注册后会自动出现在这里</td>
|
<td colspan="10" class="cell-empty">暂无任务 — Agent 注册后会自动出现在这里</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Task Modal -->
|
||||||
|
<div v-if="editTask" class="modal-overlay" @click.self="editTask = null">
|
||||||
|
<div class="modal modal-wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4>编辑任务</h4>
|
||||||
|
<button class="modal-close" @click="editTask = null">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>任务名称</label>
|
||||||
|
<input v-model="editForm.name" class="input" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>标签(回车添加)</label>
|
||||||
|
<div class="tags-edit">
|
||||||
|
<span v-for="(tag, i) in editForm.tags" :key="i" class="tag-pill">
|
||||||
|
{{ tag }}
|
||||||
|
<button class="tag-remove" @click="editForm.tags.splice(i, 1)">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<input v-model="tagInput" class="tag-input" placeholder="输入标签后回车" @keydown.enter.prevent="addTag" @keydown.,.prevent="addTag" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>描述</label>
|
||||||
|
<input v-model="editForm.description" class="input" placeholder="任务描述(选填)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" @click="editTask = null">取消</button>
|
||||||
|
<button class="btn-primary" @click="handleEdit">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { listTasks, deleteTask, getSystemConfig } from '../api/index.js'
|
import { listTasks, deleteTask, updateTask } from '../api/index.js'
|
||||||
|
|
||||||
const tasks = ref([])
|
const tasks = ref([])
|
||||||
const tab = ref('report')
|
const searchQ = ref('')
|
||||||
const baseUrl = ref(window.location.origin)
|
const searchTags = ref('')
|
||||||
|
const searchStatus = ref('')
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
const [taskRes, cfgRes] = await Promise.all([
|
const params = {}
|
||||||
listTasks(),
|
if (searchQ.value) params.q = searchQ.value
|
||||||
getSystemConfig(),
|
if (searchTags.value) params.tags = searchTags.value
|
||||||
])
|
if (searchStatus.value) params.status = searchStatus.value
|
||||||
tasks.value = taskRes.data
|
const res = await listTasks(params)
|
||||||
baseUrl.value = cfgRes.data.base_url
|
tasks.value = res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit
|
||||||
|
const editTask = ref(null)
|
||||||
|
const editForm = ref({ name: '', description: '', tags: [] })
|
||||||
|
const tagInput = ref('')
|
||||||
|
|
||||||
|
const addTag = () => {
|
||||||
|
const val = tagInput.value.replace(/,/g, '').trim()
|
||||||
|
if (val && !editForm.value.tags.includes(val)) editForm.value.tags.push(val)
|
||||||
|
tagInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const showEdit = (t) => {
|
||||||
|
editForm.value = {
|
||||||
|
name: t.name,
|
||||||
|
description: t.description || '',
|
||||||
|
tags: [...(t.tags || [])],
|
||||||
|
}
|
||||||
|
editTask.value = t
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = async () => {
|
||||||
|
try {
|
||||||
|
await updateTask(editTask.value.id, editForm.value)
|
||||||
|
editTask.value = null
|
||||||
|
await loadData()
|
||||||
|
const { ElMessage } = await import('element-plus')
|
||||||
|
ElMessage.success('已保存')
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (row) => {
|
const handleDelete = async (row) => {
|
||||||
try {
|
try {
|
||||||
await deleteTask(row.id)
|
await deleteTask(row.id)
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (e) {
|
} catch (e) { console.error(e) }
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const codes = computed(() => {
|
|
||||||
const b = baseUrl.value
|
|
||||||
return {
|
|
||||||
report: `curl -X POST ${b}/api/tasks/<TASK_ID>/executions \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-H "Authorization: Bearer <YOUR_API_KEY>" \\
|
|
||||||
-d '{"status":"success","duration_ms":2500,"log":"done"}'`,
|
|
||||||
register: `curl -X POST "${b}/api/tasks?agent_id=<AGENT_ID>" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-H "Authorization: Bearer <YOUR_API_KEY>" \\
|
|
||||||
-d '{"name":"data-sync","cron_expression":"*/5 * * * *","grace_period":300}'`,
|
|
||||||
batch: `curl -X POST ${b}/api/agents/register-with-tasks \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{"name":"agent","tasks":[{"name":"sync","cron_expression":"*/5 * * * *"}]}'`,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const copyCode = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(codes.value[tab.value])
|
|
||||||
const { ElMessage } = await import('element-plus')
|
|
||||||
ElMessage.success('已复制')
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadData)
|
onMounted(loadData)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tasks-page { max-width: 1200px; }
|
.tasks-page {}
|
||||||
.mb-24 { margin-bottom: 24px; }
|
.mb-24 { margin-bottom: 24px; }
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
@ -147,37 +198,32 @@ onMounted(loadData)
|
|||||||
}
|
}
|
||||||
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
|
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
|
||||||
|
|
||||||
.onboard-body { padding: 20px; }
|
/* Search */
|
||||||
.onboard-body > p { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; }
|
.search-bar {
|
||||||
|
display: flex; gap: 10px; padding: 12px 20px;
|
||||||
.code-block {
|
align-items: center;
|
||||||
position: relative;
|
}
|
||||||
background: rgba(0,0,0,0.3);
|
.search-input-wrap {
|
||||||
border-radius: 10px;
|
display: flex; align-items: center; gap: 8px; flex: 1;
|
||||||
overflow: hidden;
|
padding: 0 12px;
|
||||||
}
|
border-radius: 8px;
|
||||||
.code-tabs { display: flex; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
border: 1px solid var(--border-color);
|
||||||
.code-tabs button {
|
background: rgba(255,255,255,0.04);
|
||||||
padding: 8px 16px; background: none; border: none;
|
color: var(--text-muted);
|
||||||
color: rgba(255,255,255,0.4); font-size: 12px; cursor: pointer;
|
}
|
||||||
border-bottom: 2px solid transparent;
|
.search-input-wrap svg { flex-shrink: 0; }
|
||||||
}
|
.search-input {
|
||||||
.code-tabs button.active { color: #a8b4ff; border-bottom-color: #667eea; }
|
flex: 1;
|
||||||
.code-body { padding: 16px; overflow-x: auto; }
|
padding: 8px 0;
|
||||||
.code-body pre {
|
background: none;
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
border: none;
|
||||||
font-size: 12px; line-height: 1.7; color: rgba(255,255,255,0.75);
|
outline: none;
|
||||||
white-space: pre; margin: 0;
|
color: var(--text-primary);
|
||||||
}
|
font-size: 13px;
|
||||||
.btn-copy {
|
}
|
||||||
position: absolute; top: 44px; right: 12px;
|
.search-input::placeholder { color: var(--text-muted); }
|
||||||
padding: 4px 12px; border-radius: 6px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
color: rgba(255,255,255,0.5); font-size: 11px; cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn-copy:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
|
||||||
|
|
||||||
|
/* Table */
|
||||||
.table-wrap { overflow-x: auto; }
|
.table-wrap { overflow-x: auto; }
|
||||||
.data-table { width: 100%; border-collapse: collapse; }
|
.data-table { width: 100%; border-collapse: collapse; }
|
||||||
.data-table th {
|
.data-table th {
|
||||||
@ -204,17 +250,92 @@ onMounted(loadData)
|
|||||||
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
|
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
|
||||||
.cell-muted { color: var(--text-muted); }
|
.cell-muted { color: var(--text-muted); }
|
||||||
.cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; }
|
.cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; }
|
||||||
.cell-link { color: var(--accent-1); text-decoration: none; font-size: 13px; margin-right: 8px; }
|
.cell-link { color: var(--accent-1); text-decoration: none; font-size: 13px; }
|
||||||
.cell-link:hover { text-decoration: underline; }
|
.cell-link:hover { text-decoration: underline; }
|
||||||
|
.cell-link-btn { background: none; border: none; font-size: 12px; cursor: pointer; }
|
||||||
|
.cell-link-btn:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
|
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
|
||||||
.tag-green { background: rgba(34,197,94,0.12); color: #4ade80; }
|
.tag-green { background: rgba(34,197,94,0.12); color: #4ade80; }
|
||||||
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
|
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
|
||||||
.tag-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
|
.tag-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
|
||||||
|
|
||||||
.btn-icon {
|
/* Tag Pills */
|
||||||
background: none; border: none; color: var(--text-muted);
|
.tag-pill {
|
||||||
cursor: pointer; padding: 4px; vertical-align: middle;
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(102,126,234,0.12);
|
||||||
|
color: #a8b4ff;
|
||||||
|
margin-right: 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.btn-icon:hover { color: var(--danger); }
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 480px; max-width: 90vw;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.modal-wide { width: 560px; }
|
||||||
|
.modal-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.modal-header h4 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
|
||||||
|
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; }
|
||||||
|
.modal-close:hover { color: #fff; }
|
||||||
|
.modal-body { padding: 20px; }
|
||||||
|
.modal-footer {
|
||||||
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
|
padding: 16px 20px; border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-fields { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.field label { font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: var(--accent-1); }
|
||||||
|
select.input { cursor: pointer; }
|
||||||
|
|
||||||
|
.tags-edit {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
min-height: 36px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.tag-remove { background: none; border: none; color: inherit; cursor: pointer; padding: 0; margin-left: 2px; opacity: 0.6; }
|
||||||
|
.tag-remove:hover { opacity: 1; }
|
||||||
|
.tag-input {
|
||||||
|
flex: 1; min-width: 100px;
|
||||||
|
background: none; border: none; outline: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.tag-input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.btn-primary { padding: 7px 16px; border-radius: 8px; border: none; background: linear-gradient(135deg,#667eea,#764ba2); color: #fff; font-size: 13px; font-weight: 500; cursor: pointer; }
|
||||||
|
.btn-primary:hover { opacity: 0.9; }
|
||||||
|
.btn-secondary { padding: 7px 16px; border-radius: 8px; border: 1px solid var(--border-color); background: transparent; color: var(--text-secondary); font-size: 13px; cursor: pointer; }
|
||||||
|
.btn-secondary:hover { color: #fff; border-color: rgba(255,255,255,0.15); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user