Compare commits
11 Commits
v2026.07.1
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
46b220d6ca | |
|
|
7346031fc4 | |
|
|
fc8dd6f0c8 | |
|
|
e20189d5f8 | |
|
|
459e19cbb8 | |
|
|
991b7b85c3 | |
|
|
ada95e4e7f | |
|
|
b5e9cfbcd2 | |
|
|
629924c342 | |
|
|
7ea37afc75 | |
|
|
f7bc9fd5fb |
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -1,5 +1,18 @@
|
||||||
# 变更日志
|
# 变更日志
|
||||||
|
|
||||||
|
## 2026-07-13
|
||||||
|
|
||||||
|
- [新增] 统一结单推送:从 worker 轮询 `fault_records` 上移到 API 层事件驱动(PUT [id]/batch 状态跳变触发),新增 `notifyTicketResolved` 与 `notifyTicketCreated` 并列构成完整通知层
|
||||||
|
- [新增] 微信推送独立页面 `/settings/wechat`,与邮件监控完全解耦
|
||||||
|
- [新增] `useDirtyTracker` 共享 Hook(`shared/ui/hooks/`),支持"修改后才显示保存按钮"模式,微信推送页每推送组独立保存、监控页统一 section 保存
|
||||||
|
- [优化] 结单推送月度统计改为 `close_time` 归组,周报/月报按 `isRealFault` 过滤,统一排除 无故障/误判
|
||||||
|
- [修复] 月报第四章跨月时长重复计算 bug(`isOngoing=true` 时不再计入 `ipDurationMap`)
|
||||||
|
- [修复] `audit_logs` 表缺少 `username` 列导致 7月2日至今所有审计日志静默写入失败,补全 4 处 API 路由审计日志缺失
|
||||||
|
- [修复] `getMaskedConfig()` 直接 mutate webhook URL 导致测试发送 `••••••••` 至企业微信(93000 invalid url)
|
||||||
|
- [优化] 故障大类删除「其他」、故障小类删除「无故障」选项(3 处表单统一)
|
||||||
|
- [安全] SSRF 加固:拒绝非规范数字型 IP 写法(整数/十六进制/八进制/短式),防绕过内网黑名单
|
||||||
|
- [重构] worker `checkRecoveryUpdates` 完全移除(78 行),副作用迁移至 `resolution-side-effects.ts`
|
||||||
|
|
||||||
## 2026-07-02
|
## 2026-07-02
|
||||||
|
|
||||||
- [优化] 企业微信推送消息格式全面对齐 zabbix-01 脚本(10 项修复)
|
- [优化] 企业微信推送消息格式全面对齐 zabbix-01 脚本(10 项修复)
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ npm run import # 导入工单
|
||||||
| `src/lib/monitor/mail-monitor.ts` | 邮件监控:IMAP 连接、邮件过滤、解析 |
|
| `src/lib/monitor/mail-monitor.ts` | 邮件监控:IMAP 连接、邮件过滤、解析 |
|
||||||
| `src/lib/monitor/ticket-processor.ts` | 邮件监控:工单创建、故障记录 |
|
| `src/lib/monitor/ticket-processor.ts` | 邮件监控:工单创建、故障记录 |
|
||||||
| `src/lib/monitor/wechat-pusher.ts` | 邮件监控:微信 webhook 推送 |
|
| `src/lib/monitor/wechat-pusher.ts` | 邮件监控:微信 webhook 推送 |
|
||||||
|
| `src/lib/monitor/ticket-notifier.ts` | 邮件监控:建单/结单推送(`notifyTicketCreated` + `notifyTicketResolved`) |
|
||||||
|
| `src/lib/monitor/resolution-side-effects.ts` | 邮件监控:结单副作用(回写 fault_records + 清理 reminder_state) |
|
||||||
| `src/lib/monitor/availability-engine.ts` | 邮件监控:可用性档位计算 |
|
| `src/lib/monitor/availability-engine.ts` | 邮件监控:可用性档位计算 |
|
||||||
| `src/lib/monitor/reminder-engine.ts` | 邮件监控:OEM 诊断/维修提醒 |
|
| `src/lib/monitor/reminder-engine.ts` | 邮件监控:OEM 诊断/维修提醒 |
|
||||||
| `scripts/monitor-worker.ts` | 邮件监控:Worker 独立入口脚本 |
|
| `scripts/monitor-worker.ts` | 邮件监控:Worker 独立入口脚本 |
|
||||||
|
|
@ -126,6 +128,8 @@ npm run import # 导入工单
|
||||||
| POST | `/api/auth/logout` | 登出(清除两个 cookie) |
|
| POST | `/api/auth/logout` | 登出(清除两个 cookie) |
|
||||||
| GET | `/api/auth/me` | 当前用户信息 |
|
| GET | `/api/auth/me` | 当前用户信息 |
|
||||||
| GET | `/api/internal/roles` | 内部 API:返回角色列表(x-internal-key 鉴权) |
|
| GET | `/api/internal/roles` | 内部 API:返回角色列表(x-internal-key 鉴权) |
|
||||||
|
| GET | `/api/internal/users` | 内部 API:返回用户列表(x-internal-key 鉴权) |
|
||||||
|
| POST | `/api/internal/users` | 内部 API:OA 同步用户角色(x-internal-key 鉴权) |
|
||||||
|
|
||||||
### 工单
|
### 工单
|
||||||
|
|
||||||
|
|
@ -165,6 +169,7 @@ npm run import # 导入工单
|
||||||
| PUT | `/api/monitor/settings` | 更新配置(支持分模块保存) |
|
| PUT | `/api/monitor/settings` | 更新配置(支持分模块保存) |
|
||||||
| POST | `/api/monitor/test-mail` | 测试 IMAP 连接 |
|
| POST | `/api/monitor/test-mail` | 测试 IMAP 连接 |
|
||||||
| POST | `/api/monitor/test-wechat` | 测试微信 webhook |
|
| POST | `/api/monitor/test-wechat` | 测试微信 webhook |
|
||||||
|
| 前端页面 | `/settings/wechat` | webhook 独立管理页(微信推送配置) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,13 @@ services:
|
||||||
- issue-reports:/app/reports
|
- issue-reports:/app/reports
|
||||||
# .next 目录从主机挂载,npm run build 后直接生效,无需重建镜像
|
# .next 目录从主机挂载,npm run build 后直接生效,无需重建镜像
|
||||||
- ./.next:/app/.next
|
- ./.next:/app/.next
|
||||||
# 运行时从 LLDAP 容器动态读取 admin 密码
|
# 运行时从 LLDAP 容器动态读取 admin 密码(已迁移至环境变量注入)
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/issue.db
|
- DATABASE_PATH=/app/data/issue.db
|
||||||
- JWT_SECRET=${JWT_SECRET:-oa-shared-jwt-secret-tlyq-2026}
|
- JWT_SECRET=${JWT_SECRET:-oa-shared-jwt-secret-tlyq-2026}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
|
- LLDAP_ADMIN_PASSWORD=${LLDAP_ADMIN_PASSWORD}
|
||||||
- ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api}
|
- ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api}
|
||||||
- ASSETS_API_KEY=${ASSETS_API_KEY}
|
- ASSETS_API_KEY=${ASSETS_API_KEY}
|
||||||
- ALLOWED_API_KEYS=${ALLOWED_API_KEYS}
|
- ALLOWED_API_KEYS=${ALLOWED_API_KEYS}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Card } from '@/components/ui'
|
import { Card } from '@/components/ui'
|
||||||
import { Plus, Trash2, ChevronDown, ChevronUp, Search, RefreshCw } from 'lucide-react'
|
import { Search, RefreshCw } from 'lucide-react'
|
||||||
|
import { useDirtyTracker } from '@shared/ui/hooks/useDirtyTracker'
|
||||||
|
|
||||||
interface MonitorStatus {
|
interface MonitorStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|
@ -17,24 +18,10 @@ interface MonitorStatus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebhookConfig {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
enabled: boolean
|
|
||||||
params: {
|
|
||||||
interval_seconds: number
|
|
||||||
push_delay_ms: number
|
|
||||||
silent_start_hour: number
|
|
||||||
silent_end_hour: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MonitorConfig {
|
interface MonitorConfig {
|
||||||
monitor: { enabled: boolean; interval_seconds: number; push_delay_ms: number; silent_start_hour: number; silent_end_hour: number }
|
monitor: { enabled: boolean; interval_seconds: number; push_delay_ms: number; silent_start_hour: number; silent_end_hour: number }
|
||||||
mail: { address: string; imap_server: string; imap_port: number; pop3_server: string; pop3_port: number; smtp_server: string; smtp_port: number; password: string }
|
mail: { address: string; imap_server: string; imap_port: number; pop3_server: string; pop3_port: number; smtp_server: string; smtp_port: number; password: string }
|
||||||
filter: { subject_keywords: string[]; sender_email: string; oem_repair_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }; oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string } }
|
filter: { subject_keywords: string[]; sender_email: string; oem_repair_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }; oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string } }
|
||||||
wechat: { webhooks: WebhookConfig[] }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScanState {
|
interface ScanState {
|
||||||
|
|
@ -62,25 +49,18 @@ interface ScanState {
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SectionKey = 'mail' | 'filter' | 'wechat' | 'monitor'
|
type SectionKey = 'mail' | 'filter' | 'monitor'
|
||||||
|
|
||||||
function generateId() {
|
|
||||||
return 'wh_' + Math.random().toString(36).slice(2, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MonitorSettingsPage() {
|
export default function MonitorSettingsPage() {
|
||||||
const [status, setStatus] = useState<MonitorStatus | null>(null)
|
const [status, setStatus] = useState<MonitorStatus | null>(null)
|
||||||
const [config, setConfig] = useState<MonitorConfig | null>(null)
|
const [config, setConfig] = useState<MonitorConfig | null>(null)
|
||||||
const [originalConfig, setOriginalConfig] = useState<MonitorConfig | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const { isDirty, markClean } = useDirtyTracker()
|
||||||
const [saving, setSaving] = useState<SectionKey | null>(null)
|
const [saving, setSaving] = useState<SectionKey | null>(null)
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
const [newKeyword, setNewKeyword] = useState('')
|
const [newKeyword, setNewKeyword] = useState('')
|
||||||
const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null)
|
const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null)
|
||||||
const [sectionMessage, setSectionMessage] = useState<Partial<Record<SectionKey, { type: 'success' | 'error'; text: string }>>>({})
|
const [sectionMessage, setSectionMessage] = useState<Partial<Record<SectionKey, { type: 'success' | 'error'; text: string }>>>({})
|
||||||
const [expandedWebhooks, setExpandedWebhooks] = useState<Set<string>>(new Set())
|
|
||||||
const [testingWebhook, setTestingWebhook] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// 立即检查冷却状态
|
// 立即检查冷却状态
|
||||||
const [triggerCooldown, setTriggerCooldown] = useState(0)
|
const [triggerCooldown, setTriggerCooldown] = useState(0)
|
||||||
const cooldownTimerRef = useRef<NodeJS.Timeout | null>(null)
|
const cooldownTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
@ -233,7 +213,9 @@ export default function MonitorSettingsPage() {
|
||||||
]).then(([s, c, scan, history]) => {
|
]).then(([s, c, scan, history]) => {
|
||||||
setStatus(s)
|
setStatus(s)
|
||||||
setConfig(c)
|
setConfig(c)
|
||||||
setOriginalConfig(JSON.parse(JSON.stringify(c)))
|
markClean('mail', { mail: c.mail })
|
||||||
|
markClean('filter', { filter: c.filter })
|
||||||
|
markClean('monitor', { monitor: c.monitor })
|
||||||
setScanState(scan)
|
setScanState(scan)
|
||||||
setScanHistory(history)
|
setScanHistory(history)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
@ -373,25 +355,6 @@ export default function MonitorSettingsPage() {
|
||||||
setSectionMessage(prev => ({ ...prev, mail: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } }))
|
setSectionMessage(prev => ({ ...prev, mail: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTestWebhook = async (webhookId: string) => {
|
|
||||||
if (!config) return
|
|
||||||
const wh = config.wechat.webhooks.find(w => w.id === webhookId)
|
|
||||||
if (!wh || !wh.url) return
|
|
||||||
setTestingWebhook(webhookId)
|
|
||||||
const res = await fetch('/api/monitor/test-wechat', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ webhook_url: wh.url }),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
setTestingWebhook(null)
|
|
||||||
setSectionMessage(prev => ({
|
|
||||||
...prev,
|
|
||||||
wechat: { type: data.success ? 'success' : 'error', text: data.success ? `「${wh.name}」测试成功` : `「${wh.name}」${data.error}` },
|
|
||||||
}))
|
|
||||||
setTimeout(() => setSectionMessage(prev => ({ ...prev, wechat: undefined })), 5000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSectionSave = async (section: SectionKey) => {
|
const handleSectionSave = async (section: SectionKey) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
setSaving(section)
|
setSaving(section)
|
||||||
|
|
@ -401,7 +364,7 @@ export default function MonitorSettingsPage() {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setSaving(null)
|
setSaving(null)
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setOriginalConfig(prev => prev ? { ...prev, [section]: JSON.parse(JSON.stringify(config[section])) } : prev)
|
markClean(section, { [section]: config[section] })
|
||||||
setSectionMessage(prev => ({ ...prev, [section]: { type: 'success', text: '已保存' } }))
|
setSectionMessage(prev => ({ ...prev, [section]: { type: 'success', text: '已保存' } }))
|
||||||
} else {
|
} else {
|
||||||
setSectionMessage(prev => ({ ...prev, [section]: { type: 'error', text: data.error } }))
|
setSectionMessage(prev => ({ ...prev, [section]: { type: 'error', text: data.error } }))
|
||||||
|
|
@ -420,57 +383,12 @@ export default function MonitorSettingsPage() {
|
||||||
setConfig({ ...config, filter: { ...config.filter, subject_keywords: config.filter.subject_keywords.filter((_, i) => i !== index) } })
|
setConfig({ ...config, filter: { ...config.filter, subject_keywords: config.filter.subject_keywords.filter((_, i) => i !== index) } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const addWebhook = () => {
|
|
||||||
if (!config) return
|
|
||||||
const newWh: WebhookConfig = {
|
|
||||||
id: generateId(),
|
|
||||||
name: `推送 ${config.wechat.webhooks.length + 1}`,
|
|
||||||
url: '',
|
|
||||||
enabled: true,
|
|
||||||
params: { interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
|
|
||||||
}
|
|
||||||
const webhooks = [...config.wechat.webhooks, newWh]
|
|
||||||
setConfig({ ...config, wechat: { ...config.wechat, webhooks } })
|
|
||||||
setExpandedWebhooks(prev => new Set(prev).add(newWh.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeWebhook = (id: string) => {
|
|
||||||
if (!config) return
|
|
||||||
if (config.wechat.webhooks.length <= 1) return
|
|
||||||
setConfig({ ...config, wechat: { ...config.wechat, webhooks: config.wechat.webhooks.filter(w => w.id !== id) } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateWebhook = (id: string, updates: Partial<WebhookConfig>) => {
|
|
||||||
if (!config) return
|
|
||||||
const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, ...updates } : w)
|
|
||||||
setConfig({ ...config, wechat: { ...config.wechat, webhooks } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateWebhookParams = (id: string, paramUpdates: Partial<WebhookConfig['params']>) => {
|
|
||||||
if (!config) return
|
|
||||||
const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, params: { ...w.params, ...paramUpdates } } : w)
|
|
||||||
setConfig({ ...config, wechat: { ...config.wechat, webhooks } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleWebhookExpand = (id: string) => {
|
|
||||||
setExpandedWebhooks(prev => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id)
|
|
||||||
else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSectionChanged(original: MonitorConfig | null, current: MonitorConfig | null, section: SectionKey): boolean {
|
|
||||||
if (!original || !current) return false
|
|
||||||
return JSON.stringify(original[section]) !== JSON.stringify(current[section])
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputClass = (path: string, changed = false) =>
|
const inputClass = (path: string, changed = false) =>
|
||||||
`w-full px-3 py-2 border rounded-lg text-sm transition-colors ${changed ? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600' : 'border-slate-300 dark:border-slate-600'}`
|
`w-full px-3 py-2 border rounded-lg text-sm transition-colors ${changed ? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600' : 'border-slate-300 dark:border-slate-600'}`
|
||||||
|
|
||||||
const SectionSaveButton = ({ section }: { section: SectionKey }) => {
|
const SectionSaveButton = ({ section }: { section: SectionKey }) => {
|
||||||
const changed = hasSectionChanged(originalConfig, config, section)
|
if (!config) return null
|
||||||
|
const changed = isDirty(section, { [section]: config[section] })
|
||||||
if (!changed) return null
|
if (!changed) return null
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex items-center gap-3">
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
|
@ -945,121 +863,6 @@ export default function MonitorSettingsPage() {
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 微信推送 - 多 Webhook */}
|
|
||||||
<Card className="p-5">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">微信推送</h2>
|
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">每个推送组可独立启用/禁用,并配置独立的运行参数</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={addWebhook} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm font-medium">
|
|
||||||
<Plus size={14} />添加推送组
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sectionMessage.wechat && (
|
|
||||||
<div className={`mb-4 p-3 rounded-lg text-sm ${sectionMessage.wechat.type === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}`}>
|
|
||||||
{sectionMessage.wechat.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{config.wechat.webhooks.map((wh, index) => (
|
|
||||||
<div key={wh.id} className={`border rounded-lg transition-colors ${wh.enabled ? 'border-slate-200 dark:border-slate-700' : 'border-slate-200 dark:border-slate-700 opacity-60'}`}>
|
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleWebhookExpand(wh.id)}
|
|
||||||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
|
||||||
>
|
|
||||||
{expandedWebhooks.has(wh.id) ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
||||||
</button>
|
|
||||||
<span className="text-sm font-medium text-slate-500 dark:text-slate-400 w-8">#{index + 1}</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={wh.name}
|
|
||||||
onChange={e => updateWebhook(wh.id, { name: e.target.value })}
|
|
||||||
className="flex-1 px-2 py-1 text-sm border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-400 rounded text-slate-900 dark:text-slate-100 font-medium"
|
|
||||||
placeholder="推送组名称"
|
|
||||||
/>
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={wh.enabled}
|
|
||||||
onChange={e => updateWebhook(wh.id, { enabled: e.target.checked })}
|
|
||||||
className="sr-only peer"
|
|
||||||
/>
|
|
||||||
<div className="w-9 h-5 bg-slate-200 peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-slate-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-slate-600 peer-checked:bg-blue-500"></div>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
onClick={() => handleTestWebhook(wh.id)}
|
|
||||||
disabled={!wh.url || testingWebhook === wh.id}
|
|
||||||
className="px-3 py-1 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-xs disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{testingWebhook === wh.id ? '测试中...' : '测试'}
|
|
||||||
</button>
|
|
||||||
{config.wechat.webhooks.length > 1 && (
|
|
||||||
<button onClick={() => removeWebhook(wh.id)} className="p-1 text-slate-400 hover:text-red-500 rounded" title="删除">
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expandedWebhooks.has(wh.id) && (
|
|
||||||
<div className="px-4 pb-4 space-y-4 border-t border-slate-100 dark:border-slate-700/50 pt-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Webhook 地址</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={wh.url}
|
|
||||||
onChange={e => updateWebhook(wh.id, { url: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
|
||||||
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">运行参数</label>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-slate-500 mb-1">检查间隔(秒)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={wh.params.interval_seconds}
|
|
||||||
onChange={e => updateWebhookParams(wh.id, { interval_seconds: parseInt(e.target.value) || 60 })}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
|
||||||
min={10} max={3600}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-slate-500 mb-1">静默期开始(时)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={wh.params.silent_start_hour}
|
|
||||||
onChange={e => updateWebhookParams(wh.id, { silent_start_hour: parseInt(e.target.value) || 0 })}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
|
||||||
min={0} max={23}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-slate-500 mb-1">静默期结束(时)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={wh.params.silent_end_hour}
|
|
||||||
onChange={e => updateWebhookParams(wh.id, { silent_end_hour: parseInt(e.target.value) || 7 })}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
|
||||||
min={0} max={23}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SectionSaveButton section="wechat" />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 全局运行参数 */}
|
{/* 全局运行参数 */}
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<h2 className="text-lg font-semibold mb-4">全局运行参数</h2>
|
<h2 className="text-lg font-semibold mb-4">全局运行参数</h2>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Card } from '@/components/ui'
|
||||||
|
import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { useDirtyTracker } from '@shared/ui/hooks/useDirtyTracker'
|
||||||
|
|
||||||
|
interface WebhookConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
enabled: boolean
|
||||||
|
params: {
|
||||||
|
interval_seconds: number
|
||||||
|
push_delay_ms: number
|
||||||
|
silent_start_hour: number
|
||||||
|
silent_end_hour: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
return 'wh_' + Math.random().toString(36).slice(2, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WechatSettingsPage() {
|
||||||
|
const [config, setConfig] = useState<{ wechat: { webhooks: WebhookConfig[] } } | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const { isDirty, markClean, removeItem } = useDirtyTracker()
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null)
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
|
const [expandedWebhooks, setExpandedWebhooks] = useState<Set<string>>(new Set())
|
||||||
|
const [testingWebhook, setTestingWebhook] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/monitor/settings')
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) throw new Error('加载失败')
|
||||||
|
return r.json()
|
||||||
|
})
|
||||||
|
.then(data => setConfig({ wechat: data.wechat }))
|
||||||
|
.catch(err => setMessage({ type: 'error', text: '加载配置失败: ' + err.message }))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 保存单个推送组
|
||||||
|
const handleSaveSingle = async (id: string) => {
|
||||||
|
if (!config) return
|
||||||
|
const wh = config.wechat.webhooks.find(w => w.id === id)
|
||||||
|
if (!wh) return
|
||||||
|
setSavingId(id)
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/monitor/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ wechat: { webhooks: [wh] } }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || '保存失败')
|
||||||
|
markClean(id, wh)
|
||||||
|
setMessage({ type: 'success', text: `「${wh.name}」保存成功` })
|
||||||
|
setTimeout(() => setMessage(null), 3000)
|
||||||
|
} catch (err) {
|
||||||
|
setMessage({ type: 'error', text: `「${wh.name}」保存失败: ${err instanceof Error ? err.message : String(err)}` })
|
||||||
|
} finally {
|
||||||
|
setSavingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 webhook
|
||||||
|
const handleTestWebhook = async (webhookId: string) => {
|
||||||
|
if (!config) return
|
||||||
|
const wh = config.wechat.webhooks.find(w => w.id === webhookId)
|
||||||
|
if (!wh || !wh.url) return
|
||||||
|
setTestingWebhook(webhookId)
|
||||||
|
const res = await fetch('/api/monitor/test-wechat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ webhook_url: wh.url }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
setTestingWebhook(null)
|
||||||
|
setMessage({ type: data.success ? 'success' : 'error', text: data.success ? `「${wh.name}」测试成功` : `「${wh.name}」${data.error}` })
|
||||||
|
setTimeout(() => setMessage(null), 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增 webhook
|
||||||
|
const addWebhook = () => {
|
||||||
|
if (!config) return
|
||||||
|
const newWh: WebhookConfig = {
|
||||||
|
id: generateId(),
|
||||||
|
name: `推送 ${config.wechat.webhooks.length + 1}`,
|
||||||
|
url: '',
|
||||||
|
enabled: true,
|
||||||
|
params: { interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
|
||||||
|
}
|
||||||
|
const webhooks = [...config.wechat.webhooks, newWh]
|
||||||
|
setConfig({ wechat: { ...config.wechat, webhooks } })
|
||||||
|
setExpandedWebhooks(prev => new Set(prev).add(newWh.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 webhook(至少保留 1 个)
|
||||||
|
const removeWebhook = (id: string) => {
|
||||||
|
if (!config) return
|
||||||
|
if (config.wechat.webhooks.length <= 1) return
|
||||||
|
setConfig({ wechat: { ...config.wechat, webhooks: config.wechat.webhooks.filter(w => w.id !== id) } })
|
||||||
|
removeItem(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 webhook 基本字段
|
||||||
|
const updateWebhook = (id: string, updates: Partial<WebhookConfig>) => {
|
||||||
|
if (!config) return
|
||||||
|
const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, ...updates } : w)
|
||||||
|
setConfig({ wechat: { ...config.wechat, webhooks } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 webhook 高级参数
|
||||||
|
const updateWebhookParams = (id: string, paramUpdates: Partial<WebhookConfig['params']>) => {
|
||||||
|
if (!config) return
|
||||||
|
const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, params: { ...w.params, ...paramUpdates } } : w)
|
||||||
|
setConfig({ wechat: { ...config.wechat, webhooks } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开/折叠 webhook
|
||||||
|
const toggleWebhookExpand = (id: string) => {
|
||||||
|
setExpandedWebhooks(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="p-6 text-slate-500 dark:text-slate-400">加载中...</div>
|
||||||
|
if (!config) return <div className="p-6 text-slate-500 dark:text-slate-400">加载失败</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">微信推送配置</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 mt-1">管理企业微信 Webhook 推送,每个推送组可独立启用/禁用并配置运行参数</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 全局消息提示 */}
|
||||||
|
{message && (
|
||||||
|
<div className={`p-3 rounded-lg text-sm ${message.type === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}`}>
|
||||||
|
{message.text}
|
||||||
|
<button onClick={() => setMessage(null)} className="float-right font-bold">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Webhook 列表 */}
|
||||||
|
<Card className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">推送组</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">每个推送组可独立启用/禁用,并配置独立的运行参数</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={addWebhook}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Plus size={14} />添加推送组
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{config.wechat.webhooks.map((wh, index) => (
|
||||||
|
<div
|
||||||
|
key={wh.id}
|
||||||
|
className={`border rounded-lg transition-colors ${wh.enabled ? 'border-slate-200 dark:border-slate-700' : 'border-slate-200 dark:border-slate-700 opacity-60'}`}
|
||||||
|
>
|
||||||
|
{/* 主行:序号 + 名称 + 启停 + 测试 + 删除 */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleWebhookExpand(wh.id)}
|
||||||
|
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
{expandedWebhooks.has(wh.id) ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400 w-8">#{index + 1}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={wh.name}
|
||||||
|
onChange={e => updateWebhook(wh.id, { name: e.target.value })}
|
||||||
|
className="flex-1 px-2 py-1 text-sm border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-400 rounded text-slate-900 dark:text-slate-100 font-medium"
|
||||||
|
placeholder="推送组名称"
|
||||||
|
/>
|
||||||
|
{/* 启用/停用 toggle */}
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={wh.enabled}
|
||||||
|
onChange={e => updateWebhook(wh.id, { enabled: e.target.checked })}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-9 h-5 bg-slate-200 peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-slate-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-slate-600 peer-checked:bg-blue-500"></div>
|
||||||
|
</label>
|
||||||
|
{/* 测试按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleTestWebhook(wh.id)}
|
||||||
|
disabled={!wh.url || testingWebhook === wh.id}
|
||||||
|
className="px-3 py-1 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-xs disabled:opacity-50 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
{testingWebhook === wh.id ? '测试中...' : '测试'}
|
||||||
|
</button>
|
||||||
|
{/* 删除按钮(至少保留 1 个) */}
|
||||||
|
{config.wechat.webhooks.length > 1 && (
|
||||||
|
<button onClick={() => removeWebhook(wh.id)} className="p-1 text-slate-400 hover:text-red-500 rounded" title="删除">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 展开的高级参数 */}
|
||||||
|
{expandedWebhooks.has(wh.id) && (
|
||||||
|
<div className="px-4 pb-4 space-y-4 border-t border-slate-100 dark:border-slate-700/50 pt-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">Webhook 地址</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={wh.url}
|
||||||
|
onChange={e => updateWebhook(wh.id, { url: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
|
||||||
|
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-slate-700 dark:text-slate-300">运行参数</label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">检查间隔(秒)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={wh.params.interval_seconds}
|
||||||
|
onChange={e => updateWebhookParams(wh.id, { interval_seconds: parseInt(e.target.value) || 60 })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
|
||||||
|
min={10} max={3600}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">推送延迟(毫秒)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={wh.params.push_delay_ms}
|
||||||
|
onChange={e => updateWebhookParams(wh.id, { push_delay_ms: parseInt(e.target.value) || 2000 })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
|
||||||
|
min={0} max={30000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">静默期开始(时)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={wh.params.silent_start_hour}
|
||||||
|
onChange={e => updateWebhookParams(wh.id, { silent_start_hour: parseInt(e.target.value) || 0 })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
|
||||||
|
min={0} max={23}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">静默期结束(时)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={wh.params.silent_end_hour}
|
||||||
|
onChange={e => updateWebhookParams(wh.id, { silent_end_hour: parseInt(e.target.value) || 7 })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
|
||||||
|
min={0} max={23}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isDirty(wh.id, wh) && (
|
||||||
|
<div className="flex justify-end pt-2 border-t border-slate-100 dark:border-slate-700/50">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveSingle(wh.id)}
|
||||||
|
disabled={savingId === wh.id}
|
||||||
|
className="px-3 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 text-xs font-medium"
|
||||||
|
>
|
||||||
|
{savingId === wh.id ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,79 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useState } from 'react'
|
// 登录页 — 引用共享组件
|
||||||
|
import LoginPage from '@shared/ui/login-page'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function Page() {
|
||||||
const [username, setUsername] = useState('')
|
return <LoginPage siteName="IT工单跟踪系统" redirectPath="/dashboard" />
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [showLdapForm, setShowLdapForm] = useState(false)
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault(); setError(''); setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) })
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) { setError(data.error || '登录失败'); return }
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
|
||||||
const redirect = params.get('redirect')
|
|
||||||
const dest = (redirect && redirect.startsWith('/')) ? redirect : '/dashboard'
|
|
||||||
window.location.href = dest
|
|
||||||
} catch { setError('网络错误') } finally { setLoading(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSsoLogin() {
|
|
||||||
window.location.href = '/api/auth/login/oidc'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 px-4">
|
|
||||||
<div className="w-full max-w-sm bg-white dark:bg-slate-900 rounded-lg border border-blue-200/50 dark:border-blue-500/20 shadow-lg p-8">
|
|
||||||
<h1 className="text-2xl font-bold text-center mb-6 text-slate-900 dark:text-white">IT 工单跟踪系统</h1>
|
|
||||||
{error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>}
|
|
||||||
|
|
||||||
{!showLdapForm ? (
|
|
||||||
<>
|
|
||||||
<button onClick={handleSsoLogin} className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors duration-200 mb-3">
|
|
||||||
统一认证登录
|
|
||||||
</button>
|
|
||||||
<p className="text-center text-xs text-slate-400 mb-3">通过 SSO 统一身份认证</p>
|
|
||||||
<p className="text-center mb-2">
|
|
||||||
<button onClick={() => { window.location.href = '/api/auth/login/oidc?switch=1' }} className="text-xs text-slate-400 hover:text-slate-600 underline">
|
|
||||||
使用其他账号登录
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
<p className="text-center">
|
|
||||||
<button onClick={() => setShowLdapForm(true)} className="text-xs text-slate-400 hover:text-slate-600 underline">
|
|
||||||
使用 LDAP 直接登录
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">用户名</label>
|
|
||||||
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="请输入用户名"
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">密码</label>
|
|
||||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="请输入密码"
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
|
|
||||||
</div>
|
|
||||||
<button type="submit" disabled={loading}
|
|
||||||
className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200">
|
|
||||||
{loading ? '登录中...' : '登录'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<p className="text-center mt-3">
|
|
||||||
<button onClick={() => setShowLdapForm(false)} className="text-xs text-slate-400 hover:text-slate-600 underline">
|
|
||||||
返回统一认证登录
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { cookies } from 'next/headers'
|
||||||
import { getDb } from '@/lib/db'
|
import { getDb } from '@/lib/db'
|
||||||
import { verifyToken } from '@/lib/auth'
|
import { verifyToken } from '@/lib/auth'
|
||||||
import { checkPermission } from '@/lib/permissions'
|
import { checkPermission } from '@/lib/permissions'
|
||||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
import { writeAuditLog, getClientIP, diffObjects } from '@/lib/audit'
|
||||||
|
|
||||||
async function getSession() {
|
async function getSession() {
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
|
|
@ -20,7 +20,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const existing = getDb().prepare('SELECT id FROM api_keys WHERE id = ?').get(id)
|
type ApiKeyRow = { id: number; name: string; permissions: string; expires_at: string | null; is_active: number }
|
||||||
|
const existing = getDb().prepare<[string | number], ApiKeyRow>('SELECT id, name, permissions, expires_at, is_active FROM api_keys WHERE id = ?').get(id)
|
||||||
if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 })
|
if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -35,6 +36,20 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||||
is_active !== undefined ? (is_active ? 1 : 0) : 1,
|
is_active !== undefined ? (is_active ? 1 : 0) : 1,
|
||||||
id
|
id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
const changes = diffObjects(existing as unknown as Record<string, unknown>, body)
|
||||||
|
if (Object.keys(changes).length > 0) {
|
||||||
|
writeAuditLog({
|
||||||
|
userId: session.id,
|
||||||
|
action: 'update',
|
||||||
|
entityType: 'api_key',
|
||||||
|
entityId: Number(id),
|
||||||
|
details: { changes },
|
||||||
|
ipAddress: getClientIP(request),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '更新失败'
|
const msg = e instanceof Error ? e.message : '更新失败'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login)
|
// GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login)
|
||||||
import { NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
||||||
|
|
||||||
|
|
@ -10,7 +11,7 @@ function getSiteUrl(): string {
|
||||||
return process.env.NEXT_PUBLIC_SITE_URL || 'https://issue.tlyq.ai'
|
return process.env.NEXT_PUBLIC_SITE_URL || 'https://issue.tlyq.ai'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清除 tlyq_session + session cookie → 302 跳转 /login */
|
/** 清除 tlyq_session + session + oidc_id_token + session_issue cookie → 302 跳转 /login */
|
||||||
function logoutResponse(): NextResponse {
|
function logoutResponse(): NextResponse {
|
||||||
const response = NextResponse.redirect(new URL('/login', getSiteUrl()))
|
const response = NextResponse.redirect(new URL('/login', getSiteUrl()))
|
||||||
response.cookies.set('tlyq_session', '', {
|
response.cookies.set('tlyq_session', '', {
|
||||||
|
|
@ -18,8 +19,37 @@ function logoutResponse(): NextResponse {
|
||||||
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
|
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
|
||||||
})
|
})
|
||||||
response.cookies.set('session', '', { path: '/', maxAge: 0 })
|
response.cookies.set('session', '', { path: '/', maxAge: 0 })
|
||||||
|
response.cookies.set('oidc_id_token', '', {
|
||||||
|
httpOnly: true, secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
|
||||||
|
})
|
||||||
|
response.cookies.set('session_issue', '', { path: '/', maxAge: 0 })
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() { return logoutResponse() }
|
export async function GET() { return logoutResponse() }
|
||||||
export async function POST() { return logoutResponse() }
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
// 从 session cookie 提取用户名用于审计
|
||||||
|
let username = 'anonymous'
|
||||||
|
try {
|
||||||
|
const token =
|
||||||
|
request.cookies.get('tlyq_session')?.value ||
|
||||||
|
request.cookies.get('session_issue')?.value
|
||||||
|
if (token) {
|
||||||
|
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString())
|
||||||
|
username = payload.username || 'unknown'
|
||||||
|
}
|
||||||
|
} catch { /* 解析失败,使用默认值 */ }
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: null,
|
||||||
|
username,
|
||||||
|
action: 'logout',
|
||||||
|
entityType: 'auth',
|
||||||
|
details: { message: '用户登出' },
|
||||||
|
ipAddress: getClientIP(request),
|
||||||
|
})
|
||||||
|
|
||||||
|
return logoutResponse()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
// GET /api/internal/users — 供 OA 查询 issue 用户列表
|
||||||
|
// POST /api/internal/users — 供 OA 同步用户角色
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
|
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const key = request.headers.get('x-internal-key')
|
||||||
|
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const users = db.prepare(
|
||||||
|
'SELECT username, display_name, role FROM users WHERE is_active = 1 ORDER BY username'
|
||||||
|
).all() as { username: string; display_name: string; role: string }[]
|
||||||
|
return NextResponse.json({ users })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const key = request.headers.get('x-internal-key')
|
||||||
|
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { username, displayName, role } = body
|
||||||
|
if (!username) {
|
||||||
|
return NextResponse.json({ error: 'username 必填' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const VALID_ROLES = ['admin', 'editor', 'viewer']
|
||||||
|
const safeRole = VALID_ROLES.includes(role) ? role : 'viewer'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO users (username, password_hash, display_name, role, is_active, created_at, updated_at)
|
||||||
|
VALUES (?, '', ?, ?, 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))
|
||||||
|
ON CONFLICT(username) DO UPDATE SET role = ?, display_name = ?,
|
||||||
|
updated_at = datetime('now', '+8 hours')`
|
||||||
|
).run(username, displayName || username, safeRole, safeRole, displayName || username)
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: '同步失败' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,11 +5,39 @@ import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getMaskedConfig, updateMonitorConfig } from '@/lib/monitor/settings-manager'
|
import { getMaskedConfig, updateMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import net from 'net'
|
||||||
|
|
||||||
// 主机名格式校验(纵深防御):只允许合法主机名/IP 字符,拒绝 URL/路径/空格等,
|
// 拒绝内网/回环/链路本地/CGNAT 的 IPv4 字面量(SSRF 纵深防御,防收信指向内网主机)
|
||||||
// 防止误配或配置被篡改后连接到任意主机(SSRF 面收窄)
|
function isPrivateIPv4(host: string): boolean {
|
||||||
const isValidHost = (v: unknown): boolean =>
|
if (!net.isIPv4(host)) return false
|
||||||
typeof v === 'string' && v.length > 0 && v.length <= 253 && /^[a-zA-Z0-9.-]+$/.test(v)
|
const p = host.split('.').map(Number)
|
||||||
|
if (p[0] === 0 || p[0] === 10 || p[0] === 127) return true // 0/8, 10/8, 回环 127/8
|
||||||
|
if (p[0] === 169 && p[1] === 254) return true // 链路本地 169.254/16
|
||||||
|
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true // 172.16/12
|
||||||
|
if (p[0] === 192 && p[1] === 168) return true // 192.168/16
|
||||||
|
if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true // CGNAT 100.64/10
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拒绝非规范数字型 IP 写法(整数/十六进制/八进制/短式),防止绕过内网黑名单:
|
||||||
|
// net.isIPv4 只认标准点分十进制,但 socket 层会把 2130706433 / 0x7f000001 / 127.1 / 0177.0.0.1
|
||||||
|
// 等写法同样解析为回环/内网地址,必须在校验层拦截
|
||||||
|
function isNonCanonicalNumericHost(host: string): boolean {
|
||||||
|
if (/(^|\.)0x/i.test(host)) return true // 十六进制:0x7f000001 / 0x7f.0.0.1
|
||||||
|
const segs = host.split('.')
|
||||||
|
if (segs.every((s) => /^\d+$/.test(s)) && !net.isIPv4(host)) return true // 整数型/短式/八进制数字 IP(含前导零)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主机名格式校验(纵深防御):只允许合法主机名/IP 字符(IPv6 字面量含 ':' 天然被拒),
|
||||||
|
// 拒绝 URL/路径/空格、非规范数字型 IP 及内网 IPv4,收窄 SSRF 面
|
||||||
|
const isValidHost = (v: unknown): boolean => {
|
||||||
|
if (typeof v !== 'string' || v.length === 0 || v.length > 253) return false
|
||||||
|
if (!/^[a-zA-Z0-9.-]+$/.test(v)) return false
|
||||||
|
if (isNonCanonicalNumericHost(v)) return false
|
||||||
|
if (isPrivateIPv4(v)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const VALIDATORS: Record<string, (v: unknown) => boolean> = {
|
const VALIDATORS: Record<string, (v: unknown) => boolean> = {
|
||||||
'monitor.enabled': (v) => typeof v === 'boolean',
|
'monitor.enabled': (v) => typeof v === 'boolean',
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,19 @@ export async function POST(request: NextRequest) {
|
||||||
webhookUrl = body?.webhook_url || null
|
webhookUrl = body?.webhook_url || null
|
||||||
} catch { /* 无 body */ }
|
} catch { /* 无 body */ }
|
||||||
|
|
||||||
|
// 如果 URL 被 mask(含 ••••••••),从 DB 查真实 URL
|
||||||
|
if (webhookUrl && webhookUrl.includes('••••••••')) {
|
||||||
|
const config = getMonitorConfig()
|
||||||
|
const found = config.wechat.webhooks.find(wh => {
|
||||||
|
if (!wh.url) return false
|
||||||
|
// 对比非 key 部分是否匹配(前缀 + 后缀),找到对应的真实 webhook
|
||||||
|
const maskedPattern = wh.url.replace(/key=([0-9a-f-]+)/, 'key=••••••••')
|
||||||
|
return maskedPattern === webhookUrl
|
||||||
|
})
|
||||||
|
if (found) webhookUrl = found.url
|
||||||
|
else webhookUrl = null // 找不到匹配的,走 fallback
|
||||||
|
}
|
||||||
|
|
||||||
// 如果指定了 URL,使用指定的;否则使用第一个启用的
|
// 如果指定了 URL,使用指定的;否则使用第一个启用的
|
||||||
if (!webhookUrl) {
|
if (!webhookUrl) {
|
||||||
const config = getMonitorConfig()
|
const config = getMonitorConfig()
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
|
||||||
import { initDatabase } from '@/lib/db-schema'
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
|
||||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
|
@ -89,6 +90,16 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
|
||||||
// 删除数据库记录
|
// 删除数据库记录
|
||||||
db.prepare('DELETE FROM reports WHERE id = ?').run(id)
|
db.prepare('DELETE FROM reports WHERE id = ?').run(id)
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: user.id,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'delete',
|
||||||
|
entityType: 'report',
|
||||||
|
entityId: Number(id),
|
||||||
|
details: { deleted: { id: Number(id), title: report.title, type: report.type, period_start: report.period_start, period_end: report.period_end, file_path: report.file_path } },
|
||||||
|
ipAddress: getClientIP(_request),
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '删除失败'
|
const msg = e instanceof Error ? e.message : '删除失败'
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
|
||||||
import { initDatabase } from '@/lib/db-schema'
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
import JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
|
||||||
|
|
@ -43,6 +44,16 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
|
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: user.id,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'export',
|
||||||
|
entityType: 'report',
|
||||||
|
entityId: null,
|
||||||
|
details: { exported_count: ids.length, ids },
|
||||||
|
ipAddress: getClientIP(request),
|
||||||
|
})
|
||||||
|
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
const today = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
const today = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||||
const downloadName = `reports_${today}.zip`
|
const downloadName = `reports_${today}.zip`
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getAssetByIp } from '@/lib/assets-client'
|
import { getAssetByIp } from '@/lib/assets-client'
|
||||||
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
|
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
|
||||||
|
import { notifyTicketResolved } from '@/lib/monitor/ticket-notifier'
|
||||||
|
import { applyResolutionSideEffects } from '@/lib/monitor/resolution-side-effects'
|
||||||
|
|
||||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -122,6 +124,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 结单推送:状态从非终态 → resolved/closed 时触发
|
||||||
|
const wasTerminal = ['resolved', 'closed'].includes(existing.current_status as string)
|
||||||
|
const nowTerminal = body.current_status && ['resolved', 'closed'].includes(body.current_status)
|
||||||
|
if (!wasTerminal && nowTerminal) {
|
||||||
|
const ticketId = Number(id)
|
||||||
|
applyResolutionSideEffects(ticketId) // 同步:回写 fault_records + 清理 reminder
|
||||||
|
void notifyTicketResolved(ticketId).catch(e =>
|
||||||
|
console.error(`[API] 结单推送失败 (ticket ${ticketId}):`, e)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ticket })
|
return NextResponse.json({ ticket })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '更新失败'
|
const msg = e instanceof Error ? e.message : '更新失败'
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { notifyTicketResolved } from '@/lib/monitor/ticket-notifier'
|
||||||
|
import { applyResolutionSideEffects } from '@/lib/monitor/resolution-side-effects'
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -25,6 +27,11 @@ export async function PUT(request: NextRequest) {
|
||||||
|
|
||||||
for (const item of updates) {
|
for (const item of updates) {
|
||||||
if (!item.id) continue
|
if (!item.id) continue
|
||||||
|
|
||||||
|
// 读取当前状态(用于状态跳变检测)
|
||||||
|
const existing = db.prepare('SELECT current_status FROM tickets WHERE id = ?').get(item.id) as { current_status: string } | undefined
|
||||||
|
if (!existing) continue
|
||||||
|
|
||||||
const fields: string[] = []
|
const fields: string[] = []
|
||||||
const values: unknown[] = []
|
const values: unknown[] = []
|
||||||
|
|
||||||
|
|
@ -42,6 +49,17 @@ export async function PUT(request: NextRequest) {
|
||||||
values.push(item.id)
|
values.push(item.id)
|
||||||
|
|
||||||
const result = db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
const result = db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||||
|
|
||||||
|
// 结单推送:状态从非终态 → resolved/closed 时触发
|
||||||
|
const wasTerminal = ['resolved', 'closed'].includes(existing.current_status)
|
||||||
|
const nowTerminal = item.current_status && ['resolved', 'closed'].includes(item.current_status)
|
||||||
|
if (result.changes > 0 && !wasTerminal && nowTerminal) {
|
||||||
|
applyResolutionSideEffects(item.id)
|
||||||
|
void notifyTicketResolved(item.id).catch(e =>
|
||||||
|
console.error(`[API:batch] 结单推送失败 (ticket ${item.id}):`, e)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
updated += result.changes
|
updated += result.changes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@config "../../tailwind.config.js";
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
@source "../../shared";
|
||||||
|
@source "../../src";
|
||||||
|
|
||||||
|
@import './tlyq-design-system.css' layer(base);
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
background-color: #f8fafc;
|
font-family: var(--font-body);
|
||||||
color: #0f172a;
|
color: var(--fg);
|
||||||
}
|
background-color: var(--bg);
|
||||||
html.dark body {
|
|
||||||
background-color: #020617;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,362 @@
|
||||||
|
/*
|
||||||
|
* TLYQ 统一设计系统 — OKLCh 色彩空间(2026-07-15 升级)
|
||||||
|
* 从 Tailwind Slate HEX 色板迁移至 OKLCh 色彩空间
|
||||||
|
* 感知均匀、色域更广,保持无障碍对比度(WCAG AA ≥ 4.5:1)
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* - CSS 变量:@import './tlyq-design-system.css'
|
||||||
|
* - Tailwind:颜色类见下方映射说明
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 设计令牌(CSS 变量) ==================== */
|
||||||
|
:root {
|
||||||
|
/* 背景色 */
|
||||||
|
--bg: oklch(0.984 0.003 247.858); /* ≈ #f8fafc (slate-50) */
|
||||||
|
--bg-subtle: oklch(0.968 0.007 247.896); /* ≈ #f1f5f9 (slate-100) */
|
||||||
|
--surface: oklch(1 0 0); /* #ffffff (white) */
|
||||||
|
--surface-raised: oklch(1 0 0); /* #ffffff (white) */
|
||||||
|
--surface-overlay: rgba(255, 255, 255, 0.95);
|
||||||
|
|
||||||
|
/* 文本色 */
|
||||||
|
--fg: oklch(0.129 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||||
|
--fg-subtle: oklch(0.372 0.044 257.287); /* ≈ #334155 (slate-700) */
|
||||||
|
--muted: oklch(0.446 0.043 257.281); /* ≈ #475569 (slate-600) */
|
||||||
|
--muted-subtle: oklch(0.662 0.051 257.281); /* ≈ #94a3b8 (slate-400) */
|
||||||
|
|
||||||
|
/* 边框 */
|
||||||
|
--border: oklch(0.928 0.006 264.531); /* ≈ #e2e8f0 (slate-200) */
|
||||||
|
--border-subtle: oklch(0.968 0.007 247.896);/* ≈ #f1f5f9 (slate-100) */
|
||||||
|
|
||||||
|
/* 主色调 — 蓝色系 */
|
||||||
|
--accent: oklch(0.546 0.245 262.881); /* ≈ #2563eb (blue-600) */
|
||||||
|
--accent-hover: oklch(0.479 0.247 262.881); /* ≈ #1d4ed8 (blue-700) */
|
||||||
|
--accent-active: oklch(0.426 0.228 262.881);/* ≈ #1e40af (blue-800) */
|
||||||
|
--accent-subtle: oklch(0.967 0.03 260.592); /* ≈ #eff6ff (blue-50) */
|
||||||
|
--accent-text: oklch(1 0 0); /* #ffffff */
|
||||||
|
|
||||||
|
/* 状态色 */
|
||||||
|
--success: oklch(0.627 0.194 149.214); /* ≈ #059669 (emerald-600) */
|
||||||
|
--success-subtle: oklch(0.982 0.041 152.117);/* ≈ #ecfdf5 (emerald-50) */
|
||||||
|
--warning: oklch(0.681 0.162 75.834); /* ≈ #d97706 (amber-600) */
|
||||||
|
--warning-subtle: oklch(0.986 0.03 92.708); /* ≈ #fffbeb (amber-50) */
|
||||||
|
--danger: oklch(0.577 0.245 27.325); /* ≈ #dc2626 (red-600) */
|
||||||
|
--danger-subtle: oklch(0.96 0.037 26.197); /* ≈ #fef2f2 (red-50) */
|
||||||
|
--info: oklch(0.546 0.245 262.881); /* ≈ #2563eb (blue-600) */
|
||||||
|
--info-subtle: oklch(0.967 0.03 260.592); /* ≈ #eff6ff (blue-50) */
|
||||||
|
|
||||||
|
/* 字体 */
|
||||||
|
--font-display: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-body: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-sans: var(--font-body);
|
||||||
|
--font-mono: ui-monospace, "JetBrains Mono", "IBM Plex Mono", SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
/* 字体大小 */
|
||||||
|
--text-xs: 0.75rem; /* 12px */
|
||||||
|
--text-sm: 0.875rem; /* 14px */
|
||||||
|
--text-base: 1rem; /* 16px */
|
||||||
|
--text-lg: 1.125rem; /* 18px */
|
||||||
|
--text-xl: 1.25rem; /* 20px */
|
||||||
|
--text-2xl: 1.5rem; /* 24px */
|
||||||
|
--text-3xl: 1.875rem; /* 30px */
|
||||||
|
|
||||||
|
/* 行高 */
|
||||||
|
--leading-tight: 1.25;
|
||||||
|
--leading-snug: 1.375;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--leading-relaxed: 1.625;
|
||||||
|
|
||||||
|
/* 字间距 */
|
||||||
|
--tracking-tighter: -0.05em;
|
||||||
|
--tracking-tight: -0.02em;
|
||||||
|
--tracking-normal: 0;
|
||||||
|
--tracking-wide: 0.02em;
|
||||||
|
--tracking-wider: 0.05em;
|
||||||
|
--tracking-widest: 0.1em;
|
||||||
|
|
||||||
|
/* 间距 */
|
||||||
|
--space-1: 0.25rem; /* 4px */
|
||||||
|
--space-2: 0.5rem; /* 8px */
|
||||||
|
--space-3: 0.75rem; /* 12px */
|
||||||
|
--space-4: 1rem; /* 16px */
|
||||||
|
--space-5: 1.25rem; /* 20px */
|
||||||
|
--space-6: 1.5rem; /* 24px */
|
||||||
|
--space-8: 2rem; /* 32px */
|
||||||
|
--space-10: 2.5rem; /* 40px */
|
||||||
|
--space-12: 3rem; /* 48px */
|
||||||
|
--space-16: 4rem; /* 64px */
|
||||||
|
|
||||||
|
/* 圆角 */
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
--radius-2xl: 24px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* 阴影 */
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
/* 过渡 */
|
||||||
|
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
||||||
|
/* 层级系统 */
|
||||||
|
--z-base: 0;
|
||||||
|
--z-raised: 10;
|
||||||
|
--z-dropdown: 100;
|
||||||
|
--z-sticky: 200;
|
||||||
|
--z-overlay: 300;
|
||||||
|
--z-modal: 400;
|
||||||
|
--z-toast: 700;
|
||||||
|
|
||||||
|
/* 布局 */
|
||||||
|
--sidebar-width: 240px;
|
||||||
|
--sidebar-width-collapsed: 64px;
|
||||||
|
--header-height: 56px;
|
||||||
|
--content-max-width: 1440px;
|
||||||
|
--content-padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 深色模式 ==================== */
|
||||||
|
:root.dark {
|
||||||
|
/* 背景色 */
|
||||||
|
--bg: oklch(0.129 0.042 264.695); /* ≈ #020617 (slate-950) */
|
||||||
|
--bg-subtle: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||||
|
--surface: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||||
|
--surface-raised: oklch(0.279 0.041 260.031);/* ≈ #1e293b (slate-800) */
|
||||||
|
--surface-overlay: rgba(15, 23, 42, 0.95);
|
||||||
|
|
||||||
|
/* 文本色 */
|
||||||
|
--fg: oklch(0.984 0.003 247.858); /* ≈ #f8fafc (slate-50) */
|
||||||
|
--fg-subtle: oklch(0.837 0.014 253.365); /* ≈ #cbd5e1 (slate-300) */
|
||||||
|
--muted: oklch(0.662 0.051 257.281); /* ≈ #94a3b8 (slate-400) */
|
||||||
|
--muted-subtle: oklch(0.527 0.045 257.281); /* ≈ #64748b (slate-500) */
|
||||||
|
|
||||||
|
/* 边框 */
|
||||||
|
--border: oklch(0.279 0.041 260.031); /* ≈ #1e293b (slate-800) */
|
||||||
|
--border-subtle: oklch(0.208 0.042 264.695);/* ≈ #0f172a (slate-900) */
|
||||||
|
|
||||||
|
/* 主色调 */
|
||||||
|
--accent: oklch(0.623 0.214 259.815); /* ≈ #3b82f6 (blue-500) */
|
||||||
|
--accent-hover: oklch(0.723 0.157 260.543); /* ≈ #60a5fa (blue-400) */
|
||||||
|
--accent-active: oklch(0.829 0.096 260.543);/* ≈ #93c5fd (blue-300) */
|
||||||
|
--accent-subtle: rgba(59, 130, 246, 0.1);
|
||||||
|
--accent-text: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||||
|
|
||||||
|
/* 状态色 */
|
||||||
|
--success: oklch(0.696 0.17 162.48); /* ≈ #34d399 (emerald-400) */
|
||||||
|
--success-subtle: rgba(52, 211, 153, 0.1);
|
||||||
|
--warning: oklch(0.828 0.12 76.523); /* ≈ #fbbf24 (amber-400) */
|
||||||
|
--warning-subtle: rgba(251, 191, 36, 0.1);
|
||||||
|
--danger: oklch(0.715 0.143 25.059); /* ≈ #f87171 (red-400) */
|
||||||
|
--danger-subtle: rgba(248, 113, 113, 0.1);
|
||||||
|
--info: oklch(0.723 0.157 260.543); /* ≈ #60a5fa (blue-400) */
|
||||||
|
--info-subtle: rgba(96, 165, 250, 0.1);
|
||||||
|
|
||||||
|
/* 阴影(深色下更突出) */
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Tailwind 暗色模式映射 ==================== */
|
||||||
|
/*
|
||||||
|
* 以下 Tailwind 类名可直接使用:
|
||||||
|
* 页面背景: bg-slate-50 dark:bg-slate-950
|
||||||
|
* 卡片背景: bg-white dark:bg-slate-900
|
||||||
|
* 边框: border-slate-200 dark:border-slate-800
|
||||||
|
* 正文: text-slate-900 dark:text-slate-50
|
||||||
|
* 次要文本: text-slate-700 dark:text-slate-300
|
||||||
|
* 弱文本: text-slate-500 dark:text-slate-400
|
||||||
|
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white
|
||||||
|
* 成功: text-emerald-600 dark:text-emerald-400
|
||||||
|
* 警告: text-amber-600 dark:text-amber-400
|
||||||
|
* 危险: text-red-600 dark:text-red-400
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 全局重置 ==================== */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--fg);
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 组件规范 ==================== */
|
||||||
|
|
||||||
|
/* 按钮规范
|
||||||
|
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm
|
||||||
|
* 次按钮: bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 rounded-lg
|
||||||
|
* 幽灵按钮: hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg
|
||||||
|
* 尺寸: sm(px-3 py-1.5 text-xs) / md(px-4 py-2 text-sm) / lg(px-6 py-2.5 text-base)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 输入框规范
|
||||||
|
* 边框: border border-slate-300 dark:border-slate-600
|
||||||
|
* 聚焦: focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||||
|
* 圆角: rounded-lg
|
||||||
|
* 尺寸: h-10(px-3 text-sm)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 卡片规范
|
||||||
|
* 基础: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6 shadow-sm
|
||||||
|
* 悬停: hover:border-blue-200 dark:hover:border-blue-800
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 表格规范
|
||||||
|
* 头部: bg-slate-50 dark:bg-slate-800 text-slate-500 text-xs font-medium uppercase
|
||||||
|
* 行: border-b border-slate-200 dark:border-slate-700
|
||||||
|
* 悬停: hover:bg-slate-50 dark:hover:bg-slate-800/50
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 徽标规范
|
||||||
|
* 默认: bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300
|
||||||
|
* 成功: bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400
|
||||||
|
* 警告: bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400
|
||||||
|
* 危险: bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400
|
||||||
|
* 信息: bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
|
||||||
|
* 圆角: rounded-full
|
||||||
|
* 尺寸: px-2.5 py-0.5 text-xs font-medium
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 弹窗规范
|
||||||
|
* 背景: bg-white dark:bg-slate-900
|
||||||
|
* 边框: border border-slate-200 dark:border-slate-800
|
||||||
|
* 圆角: rounded-xl
|
||||||
|
* 阴影: shadow-xl
|
||||||
|
* 标题: text-lg font-semibold
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 侧边栏规范
|
||||||
|
* 宽度: w-60 (240px)
|
||||||
|
* 定位: fixed left-0 top-0 bottom-0
|
||||||
|
* 背景: bg-white dark:bg-slate-900
|
||||||
|
* 边框: border-r border-slate-200 dark:border-slate-800
|
||||||
|
* 品牌区: h-14 flex items-center
|
||||||
|
* 导航项: px-3 py-2.5 rounded-lg text-sm font-medium
|
||||||
|
* 激活态: bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 顶栏规范
|
||||||
|
* 高度: h-14 (56px)
|
||||||
|
* 定位: fixed top-0
|
||||||
|
* 背景: bg-white dark:bg-slate-900
|
||||||
|
* 边框: border-b border-slate-200 dark:border-slate-800
|
||||||
|
* z-index: z-30
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 主内容区规范
|
||||||
|
* 边距: ml-60 pt-14 p-6 (侧边栏+顶栏)
|
||||||
|
* 门户布局: max-w-5xl mx-auto px-6 py-8
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 统计卡片 ==================== */
|
||||||
|
/*
|
||||||
|
* 用于仪表盘/概览页的统计数据展示
|
||||||
|
* 容器: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6
|
||||||
|
* 悬停: hover:border-blue-200 dark:hover:border-blue-800 hover:shadow-md transition-all
|
||||||
|
* 图标区: w-10 h-10 rounded-lg bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center
|
||||||
|
* 数值: text-2xl font-semibold text-slate-900 dark:text-white font-display
|
||||||
|
* 标签: text-sm text-slate-500 dark:text-slate-400
|
||||||
|
* 变化趋势: text-xs font-medium (positive=text-emerald-600, negative=text-red-600)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 骨架屏加载 ==================== */
|
||||||
|
/*
|
||||||
|
* 用于数据加载时的占位效果
|
||||||
|
* <div className="animate-pulse bg-slate-200 dark:bg-slate-700 rounded-lg [height]" />
|
||||||
|
* 文本行: h-4 w-full / w-3/4
|
||||||
|
* 标题: h-6 w-1/3
|
||||||
|
* 卡片: h-32 w-full rounded-xl
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 无缝统计条 ==================== */
|
||||||
|
/*
|
||||||
|
* 仪表盘顶部关键指标展示,分段之间仅 1px 间隙
|
||||||
|
* 容器: flex gap-px bg-slate-200 dark:bg-slate-800 rounded-xl overflow-hidden
|
||||||
|
* 段: flex-1 bg-white dark:bg-slate-900 px-6 py-4 flex flex-col gap-1
|
||||||
|
* 标签: text-xs font-medium text-slate-500 uppercase tracking-widest
|
||||||
|
* 数值: text-2xl font-semibold font-[family-name:var(--font-display)]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 服务状态卡片 ==================== */
|
||||||
|
/*
|
||||||
|
* 仪表盘中展示被监控服务实时状态
|
||||||
|
* 正常: bg-white dark:bg-slate-900 border rounded-xl p-5
|
||||||
|
* 悬停: hover:shadow-sm hover:-translate-y-px transition-all cursor-pointer
|
||||||
|
* 异常: bg-red-50 dark:bg-red-500/10 border-red-600 dark:border-red-400
|
||||||
|
* 红底红框,突出显示
|
||||||
|
* 状态圆点: w-2.5 h-2.5 rounded-full
|
||||||
|
* ok: bg-emerald-600 dark:bg-emerald-400 shadow-[0_0_8px_var(--success)]
|
||||||
|
* err: bg-red-600 dark:bg-red-400 shadow-[0_0_12px_var(--danger)] animate-pulse
|
||||||
|
* 持续时长: text-lg font-semibold font-mono text-red-600 dark:text-red-400
|
||||||
|
* 展开区: border-t mt-3 pt-3
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 图表容器 ==================== */
|
||||||
|
/*
|
||||||
|
* 用于包裹图表组件
|
||||||
|
* <div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6">
|
||||||
|
* <div className="flex items-center justify-between mb-6">
|
||||||
|
* <h3 className="text-lg font-semibold text-slate-900 dark:text-white">标题</h3>
|
||||||
|
* <div className="flex items-center gap-4 text-sm text-slate-500">图例</div>
|
||||||
|
* </div>
|
||||||
|
* [图表内容]
|
||||||
|
* </div>
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 下拉菜单 ==================== */
|
||||||
|
/*
|
||||||
|
* 用于用户菜单、操作菜单
|
||||||
|
* 容器: absolute right-0 top-full mt-1
|
||||||
|
* bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700
|
||||||
|
* rounded-lg shadow-lg py-1 min-w-[200px] z-[--z-dropdown]
|
||||||
|
* 菜单项: px-4 py-2.5 text-sm flex items-center gap-3
|
||||||
|
* hover:bg-slate-50 dark:hover:bg-slate-700
|
||||||
|
* text-slate-700 dark:text-slate-300
|
||||||
|
* 危险项: text-red-600 dark:text-red-400
|
||||||
|
* 分隔线: border-t border-slate-200 dark:border-slate-700 my-1
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 站点标识色 ==================== */
|
||||||
|
/*
|
||||||
|
* OA: blue-600
|
||||||
|
* assets: blue-600
|
||||||
|
* issue: blue-600
|
||||||
|
* monitor: indigo-600
|
||||||
|
*
|
||||||
|
* 站点卡片标识色(门户首页):
|
||||||
|
* 资产管理: blue-600 tag: CMDB
|
||||||
|
* 工单跟踪: violet-600 tag: ITS
|
||||||
|
* 监控中心: indigo-600 tag: MONITOR
|
||||||
|
* 官网: emerald-600 tag: WWW
|
||||||
|
* 云平台: amber-600 tag: CLOUD
|
||||||
|
* Token: rose-600 tag: TOKEN
|
||||||
|
* 代码仓库: pink-600 tag: GIT
|
||||||
|
*
|
||||||
|
* 2026-07-15:从 HEX 迁移至 OKLCh 色彩空间
|
||||||
|
* 主色 blue-600 OKLCh: oklch(0.546 0.245 262.881)
|
||||||
|
* 主色 indigo-600 OKLCh: oklch(0.511 0.262 276.966)
|
||||||
|
* 对比度验证:白底蓝字 (oklch(0.546 0.245 262.881) on oklch(1 0 0)) → 约 6.5:1(≥ 4.5:1 通过)
|
||||||
|
*/
|
||||||
|
|
@ -12,7 +12,7 @@ export default function AppShell({ children }: AppShellProps) {
|
||||||
}, [])
|
}, [])
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||||
<Sidebar />
|
<Sidebar role={user?.role} />
|
||||||
<TopBar user={user} />
|
<TopBar user={user} />
|
||||||
<main className="ml-60 pt-14 min-h-screen"><div className="p-6">{children}</div></main>
|
<main className="ml-60 pt-14 min-h-screen"><div className="p-6">{children}</div></main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,81 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect } from 'react'
|
// src/components/layout/Sidebar.tsx — 权限驱动侧边栏(统一标准布局)
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List, Mail } from 'lucide-react'
|
import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List, Mail, Bell } from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
interface NavItem { label: string; href: string; icon: React.ComponentType<{ size?: number }>; perm: string | null }
|
||||||
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard, perm: null },
|
interface NavSection { title?: string; items: NavItem[] }
|
||||||
{ href: '/tickets/pending', label: '待办工单', icon: Clock, perm: 'tickets:read' },
|
|
||||||
{ href: '/tickets/completed', label: '已办工单', icon: CheckCircle, perm: 'tickets:read' },
|
|
||||||
{ href: '/tickets/create', label: '手动建单', icon: PlusSquare, perm: 'tickets:create' },
|
|
||||||
{ href: '/tickets/import', label: '导入工单', icon: Upload, perm: 'tickets:import' },
|
|
||||||
{ href: '/reports', label: '报告管理', icon: FileText, perm: 'reports:read' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const settingsItems = [
|
export default function Sidebar({ role }: { role?: string }) {
|
||||||
{ href: '/settings/users', label: '用户管理', icon: Users, perm: 'users:read' },
|
|
||||||
{ href: '/settings/roles', label: '角色权限', icon: Shield, perm: 'roles:read' },
|
|
||||||
{ href: '/settings/api-keys', label: 'API Key', icon: Key, perm: 'api-keys:read' },
|
|
||||||
{ href: '/settings/audit-logs', label: '审计日志', icon: FileText, perm: 'audit-logs:read' },
|
|
||||||
{ href: '/settings/monitor', label: '邮件监控', icon: Mail, perm: 'monitor:read' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function hasAnyAdminPerm(permissions: string[]): boolean {
|
|
||||||
return permissions.includes('*') || permissions.some(p =>
|
|
||||||
p.startsWith('users:') || p.startsWith('roles:') || p.startsWith('api-keys:') ||
|
|
||||||
p.startsWith('audit-logs:') || p.startsWith('monitor:')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [permissions, setPermissions] = useState<string[]>([])
|
const isActive = (href: string) => pathname === href || (href !== '/' && pathname.startsWith(href))
|
||||||
|
const isAdmin = role === 'admin' || role === 'localadmin'
|
||||||
|
|
||||||
useEffect(() => {
|
const sections: NavSection[] = [
|
||||||
fetch('/api/auth/me')
|
{ items: [{ label: '仪表盘', href: '/dashboard', icon: LayoutDashboard, perm: null }] },
|
||||||
.then(r => r.json())
|
{
|
||||||
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
|
title: '工单管理',
|
||||||
.catch(() => {})
|
items: [
|
||||||
}, [])
|
{ label: '待办工单', href: '/tickets/pending', icon: Clock, perm: null },
|
||||||
|
{ label: '已办工单', href: '/tickets/completed', icon: CheckCircle, perm: null },
|
||||||
const canSee = (perm: string | null) => {
|
{ label: '手动建单', href: '/tickets/create', icon: PlusSquare, perm: null },
|
||||||
if (perm === null) return true
|
{ label: '导入工单', href: '/tickets/import', icon: Upload, perm: null },
|
||||||
if (permissions.includes('*')) return true
|
{ label: '全部工单', href: '/tickets/all', icon: List, perm: isAdmin ? null : 'hidden' },
|
||||||
return permissions.includes(perm)
|
],
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
title: '报告',
|
||||||
|
items: [{ label: '报告管理', href: '/reports', icon: FileText, perm: null }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '系统设置',
|
||||||
|
items: [
|
||||||
|
{ label: '用户管理', href: '/settings/users', icon: Users, perm: isAdmin ? null : 'hidden' },
|
||||||
|
{ label: '角色权限', href: '/settings/roles', icon: Shield, perm: isAdmin ? null : 'hidden' },
|
||||||
|
{ label: 'API Key', href: '/settings/api-keys', icon: Key, perm: isAdmin ? null : 'hidden' },
|
||||||
|
{ label: '审计日志', href: '/settings/audit-logs', icon: FileText, perm: isAdmin ? null : 'hidden' },
|
||||||
|
{ label: '邮件监控', href: '/settings/monitor', icon: Mail, perm: isAdmin ? null : 'hidden' },
|
||||||
|
{ label: '微信推送', href: '/settings/wechat', icon: Bell, perm: isAdmin ? null : 'hidden' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col z-40">
|
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 z-[200] flex flex-col">
|
||||||
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
|
{/* 品牌区 */}
|
||||||
<span className="text-lg font-semibold text-blue-600 dark:text-blue-400">IT工单跟踪系统</span>
|
<div className="h-14 flex items-center px-6 border-b border-slate-200 dark:border-slate-800 shrink-0">
|
||||||
</div>
|
<Link href="/dashboard" className="flex items-center gap-3">
|
||||||
<nav className="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
|
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-sm">工</div>
|
||||||
{navItems.filter(item => canSee(item.perm)).map((item) => {
|
<span className="font-semibold text-slate-900 dark:text-white">IT工单跟踪系统</span>
|
||||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
|
|
||||||
const Icon = item.icon
|
|
||||||
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
|
|
||||||
})}
|
|
||||||
{permissions.includes('*') && (
|
|
||||||
<Link
|
|
||||||
href="/tickets/all"
|
|
||||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${pathname === '/tickets/all' ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}
|
|
||||||
>
|
|
||||||
<List size={18} />全部工单
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
|
||||||
{hasAnyAdminPerm(permissions) && (
|
|
||||||
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 mt-3">
|
|
||||||
<div className="flex items-center gap-3 px-3 py-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
|
|
||||||
<Settings size={14} />系统设置
|
|
||||||
</div>
|
</div>
|
||||||
{settingsItems.filter(item => canSee(item.perm)).map((item) => {
|
|
||||||
const isActive = pathname === item.href
|
{/* 导航区 */}
|
||||||
|
<nav className="flex-1 overflow-y-auto p-3 space-y-6">
|
||||||
|
{sections.map((section, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{section.title && (
|
||||||
|
<p className="px-3 mb-1 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
|
||||||
|
{section.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{section.items.filter(item => item.perm !== 'hidden').map(item => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
|
return (
|
||||||
|
<Link key={item.href} href={item.href}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors mb-0.5 ${
|
||||||
|
isActive(item.href)
|
||||||
|
? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400'
|
||||||
|
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||||
|
}`}>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ interface ProcessFormProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const FAULT_CATEGORIES = [
|
const FAULT_CATEGORIES = [
|
||||||
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他',
|
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障',
|
||||||
]
|
]
|
||||||
|
|
||||||
const TICKET_TYPES = ['OEM诊断', 'OEM维修']
|
const TICKET_TYPES = ['OEM诊断', 'OEM维修']
|
||||||
|
|
@ -35,7 +35,7 @@ const TICKET_TYPES = ['OEM诊断', 'OEM维修']
|
||||||
const FAULT_SUBCATEGORIES = [
|
const FAULT_SUBCATEGORIES = [
|
||||||
'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障',
|
'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障',
|
||||||
'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断',
|
'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断',
|
||||||
'存储掉盘', 'RAID故障', '无故障', '其他',
|
'存储掉盘', 'RAID故障', '其他',
|
||||||
]
|
]
|
||||||
|
|
||||||
const HANDLER_OPTIONS = ['腾讯', '图灵']
|
const HANDLER_OPTIONS = ['腾讯', '图灵']
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ interface TicketFormProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultFaultCategories = [
|
const defaultFaultCategories = [
|
||||||
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他',
|
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障',
|
||||||
]
|
]
|
||||||
|
|
||||||
const defaultTicketTypes = ['OEM诊断', 'OEM维修']
|
const defaultTicketTypes = ['OEM诊断', 'OEM维修']
|
||||||
|
|
@ -19,7 +19,7 @@ const defaultTicketTypes = ['OEM诊断', 'OEM维修']
|
||||||
const defaultFaultSubcategories = [
|
const defaultFaultSubcategories = [
|
||||||
'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障',
|
'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障',
|
||||||
'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断',
|
'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断',
|
||||||
'存储掉盘', 'RAID故障', '无故障', '其他',
|
'存储掉盘', 'RAID故障', '其他',
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function TicketForm({ initialData, ticketId }: TicketFormProps) {
|
export default function TicketForm({ initialData, ticketId }: TicketFormProps) {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ const statusMap: Record<string, { label: string; variant: 'default' | 'info' | '
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = ['open', 'in_progress', 'resolved', 'closed']
|
const STATUS_OPTIONS = ['open', 'in_progress', 'resolved', 'closed']
|
||||||
const FAULT_CATEGORY_OPTIONS = ['硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他']
|
const FAULT_CATEGORY_OPTIONS = ['硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障']
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
{ key: 'id', label: '工单号', sortable: true, filterable: true, filterType: 'text' as const },
|
{ key: 'id', label: '工单号', sortable: true, filterable: true, filterType: 'text' as const },
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { createToken, verifyToken, type UserPayload } from './jwt'
|
||||||
export { createToken, verifyToken, type UserPayload }
|
export { createToken, verifyToken, type UserPayload }
|
||||||
|
|
||||||
import { verifySharedJwt } from './jwt-shared'
|
import { verifySharedJwt } from './jwt-shared'
|
||||||
import { ldapUserExists, ldapGetUserInfo } from './ldap'
|
import { ldapUserExists, ldapGetUserInfo, ldapIsAdmin } from './ldap'
|
||||||
import { getUserPermissions } from './permissions'
|
import { getUserPermissions } from './permissions'
|
||||||
|
|
||||||
export async function getCurrentUser(): Promise<UserPayload | null> {
|
export async function getCurrentUser(): Promise<UserPayload | null> {
|
||||||
|
|
@ -37,13 +37,14 @@ export async function getCurrentUser(): Promise<UserPayload | null> {
|
||||||
row.permissions = getUserPermissions(row.role)
|
row.permissions = getUserPermissions(row.role)
|
||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
// SSO 免登录:LLDAP 验证通过但本地无记录 → 自动创建(viewer 角色)
|
// SSO 免登录:LLDAP 验证通过但本地无记录 → 自动创建(从 LLDAP 判断角色,oa-ai 同步更新)
|
||||||
const ldapInfo = await ldapGetUserInfo(sharedPayload.username)
|
const ldapInfo = await ldapGetUserInfo(sharedPayload.username)
|
||||||
const displayName = ldapInfo?.displayName || sharedPayload.displayName
|
const displayName = ldapInfo?.displayName || sharedPayload.displayName
|
||||||
const email = ldapInfo?.email ?? null
|
const email = ldapInfo?.email ?? null
|
||||||
|
const role = (await ldapIsAdmin(sharedPayload.username)) ? 'admin' : 'viewer'
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
|
"INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
|
||||||
).run(sharedPayload.username, displayName, email)
|
).run(sharedPayload.username, displayName, email, role)
|
||||||
const newRow = db.prepare(
|
const newRow = db.prepare(
|
||||||
'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1'
|
'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1'
|
||||||
).get(sharedPayload.username) as UserPayload | undefined
|
).get(sharedPayload.username) as UserPayload | undefined
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ export function initDatabase(): void {
|
||||||
|
|
||||||
// 迁移:添加 parts_name 列
|
// 迁移:添加 parts_name 列
|
||||||
try { db.exec('ALTER TABLE tickets ADD COLUMN parts_name TEXT') } catch { /* 列已存在 */ }
|
try { db.exec('ALTER TABLE tickets ADD COLUMN parts_name TEXT') } catch { /* 列已存在 */ }
|
||||||
|
// 迁移:添加 audit_logs.username 列(共享库 write-audit-log.ts 写入此列)
|
||||||
|
try { db.exec('ALTER TABLE audit_logs ADD COLUMN username TEXT') } catch { /* 列已存在 */ }
|
||||||
|
// 迁移:添加 audit_logs.api_key_id 列(共享库 audit-schema.ts 已定义)
|
||||||
|
try { db.exec('ALTER TABLE audit_logs ADD COLUMN api_key_id INTEGER REFERENCES api_keys(id)') } catch { /* 列已存在 */ }
|
||||||
|
|
||||||
// 迁移:添加 ticket_type 列
|
// 迁移:添加 ticket_type 列
|
||||||
try { db.exec('ALTER TABLE tickets ADD COLUMN ticket_type TEXT') } catch { /* 列已存在 */ }
|
try { db.exec('ALTER TABLE tickets ADD COLUMN ticket_type TEXT') } catch { /* 列已存在 */ }
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,22 @@ export async function ldapGetUserInfo(username: string): Promise<{ displayName:
|
||||||
finally { await client.unbind() }
|
finally { await client.unbind() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 LLDAP 用户是否为 lldap_admin 组成员
|
||||||
|
export async function ldapIsAdmin(username: string): Promise<boolean> {
|
||||||
|
if (username === 'admin') return true
|
||||||
|
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
|
||||||
|
const adminPass = getLdapAdminPassword()
|
||||||
|
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
||||||
|
try {
|
||||||
|
await client.bind(adminDn, adminPass)
|
||||||
|
const { searchEntries } = await client.search(`ou=groups,${LDAP_BASE_DN}`, {
|
||||||
|
scope: 'sub', filter: `(&(cn=lldap_admin)(member=uid=${username},ou=people,${LDAP_BASE_DN}))`, timeLimit: 3,
|
||||||
|
})
|
||||||
|
return searchEntries.length > 0
|
||||||
|
} catch { return false }
|
||||||
|
finally { try { await client.unbind() } catch { /* */ } }
|
||||||
|
}
|
||||||
|
|
||||||
// Q1: 检查 LLDAP 中用户是否存在(用 admin bind 搜索,不在/不可达均返回 true 保证容错)
|
// Q1: 检查 LLDAP 中用户是否存在(用 admin bind 搜索,不在/不可达均返回 true 保证容错)
|
||||||
export async function ldapUserExists(username: string): Promise<boolean> {
|
export async function ldapUserExists(username: string): Promise<boolean> {
|
||||||
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
|
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
// src/lib/monitor/resolution-side-effects.ts
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结单副作用:回写 fault_records + 清理 reminder_state。
|
||||||
|
* 同步执行(better-sqlite3),对手动/外部/导入工单(无 fault_records)是安全 no-op。
|
||||||
|
* 必须在 notifyTicketResolved 之前调用(写 DB 不依赖推送结果)。
|
||||||
|
*/
|
||||||
|
export function applyResolutionSideEffects(ticketId: number): void {
|
||||||
|
const db = getDb()
|
||||||
|
const ticket = db.prepare(`
|
||||||
|
SELECT ticket_no, close_time, assign_time, device_sn
|
||||||
|
FROM tickets WHERE id = ?
|
||||||
|
`).get(ticketId) as {
|
||||||
|
ticket_no: string; close_time: string | null; assign_time: string; device_sn: string
|
||||||
|
} | undefined
|
||||||
|
if (!ticket || !ticket.close_time) return
|
||||||
|
|
||||||
|
// 1. 回写 fault_records(若存在 ongoing 记录)
|
||||||
|
const fault = db.prepare(
|
||||||
|
"SELECT id FROM fault_records WHERE order_number = ? AND status = 'ongoing'"
|
||||||
|
).get(ticket.ticket_no) as { id: number } | undefined
|
||||||
|
if (fault) {
|
||||||
|
const assignMs = new Date(ticket.assign_time.replace(' ', 'T') + '+08:00').getTime()
|
||||||
|
const closeMs = new Date(ticket.close_time.replace(' ', 'T') + '+08:00').getTime()
|
||||||
|
const durationSec = Math.floor((closeMs - assignMs) / 1000)
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE fault_records SET status = 'resolved', recovery_time = ?, duration_seconds = ? WHERE id = ?"
|
||||||
|
).run(ticket.close_time, durationSec, fault.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 清理 reminder_state(无对应记录时是安全 no-op)
|
||||||
|
db.prepare("DELETE FROM reminder_state WHERE order_number = ?").run(ticket.ticket_no)
|
||||||
|
}
|
||||||
|
|
@ -153,11 +153,16 @@ export function updateMonitorConfig(updates: Record<string, unknown>): void {
|
||||||
export function getMaskedConfig(): MonitorConfig {
|
export function getMaskedConfig(): MonitorConfig {
|
||||||
const config = getMonitorConfig()
|
const config = getMonitorConfig()
|
||||||
if (config.mail.password) config.mail.password = '••••••••'
|
if (config.mail.password) config.mail.password = '••••••••'
|
||||||
for (const wh of config.wechat.webhooks) {
|
// 深拷贝 webhooks 数组,避免 mask 操作污染 getMonitorConfig() 返回的原对象
|
||||||
|
// (前端拿到 masked URL 后若不重新输入就测试,会用 •••••••• 调企业微信导致 93000 invalid url)
|
||||||
|
config.wechat.webhooks = config.wechat.webhooks.map(wh => {
|
||||||
if (wh.url) {
|
if (wh.url) {
|
||||||
const keyMatch = wh.url.match(/key=([0-9a-f-]+)/)
|
const keyMatch = wh.url.match(/key=([0-9a-f-]+)/)
|
||||||
if (keyMatch) wh.url = wh.url.replace(keyMatch[1], '••••••••')
|
if (keyMatch) {
|
||||||
|
return { ...wh, url: wh.url.replace(keyMatch[1], '••••••••') }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return wh
|
||||||
|
})
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { getMonitorConfig } from './settings-manager'
|
||||||
import { WeChatPusher } from './wechat-pusher'
|
import { WeChatPusher } from './wechat-pusher'
|
||||||
import { AvailabilityEngine } from './availability-engine'
|
import { AvailabilityEngine } from './availability-engine'
|
||||||
import { getRackPosition } from '@/lib/assets-client'
|
import { getRackPosition } from '@/lib/assets-client'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
import { formatBeijingTime } from './types'
|
import { formatBeijingTime } from './types'
|
||||||
|
|
||||||
export interface TicketNotifyInput {
|
export interface TicketNotifyInput {
|
||||||
|
|
@ -44,6 +45,83 @@ function formatBasicMessage(t: TicketNotifyInput): string {
|
||||||
return msg.trim()
|
return msg.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 格式化处理时长为 "X小时Y分钟"(从分钟数转换) */
|
||||||
|
function formatDuration(minutes: number): string {
|
||||||
|
const h = Math.floor(minutes / 60)
|
||||||
|
const m = minutes % 60
|
||||||
|
return `${h}小时${m}分钟`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建 OEM 诊断结单消息 */
|
||||||
|
function buildOemDiagResolvedMessage(ticket: ResolvedTicketInfo, rack: string | null): string {
|
||||||
|
let msg = `✅ ${ticket.ticket_no}工单已结单\n\n`
|
||||||
|
msg += `服务器IP:${ticket.device_ip || '未知'}\n`
|
||||||
|
msg += `服务器SN:${ticket.device_sn || '未知'}\n`
|
||||||
|
if (rack) msg += `机架位置:${rack}\n`
|
||||||
|
msg += `故障类型:OEM诊断\n`
|
||||||
|
if (ticket.assign_time) msg += `故障时间:${ticket.assign_time}\n`
|
||||||
|
if (ticket.close_time) msg += `结单时间:${ticket.close_time}\n`
|
||||||
|
msg += `本次处理时长:${formatDuration(ticket.duration_minutes || 0)}`
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建 OEM 维修结单消息(月度统计按 close_time 月 + tickets 表 + isRealFault 过滤) */
|
||||||
|
function buildOemRepairResolvedMessage(ticket: ResolvedTicketInfo, rack: string | null): string {
|
||||||
|
const db = getDb()
|
||||||
|
const closeMonth = ticket.close_time ? ticket.close_time.substring(0, 7) : null
|
||||||
|
|
||||||
|
let monthCount = 0
|
||||||
|
let monthTotalMin = 0
|
||||||
|
if (closeMonth && ticket.device_sn) {
|
||||||
|
const countRow = db.prepare(`
|
||||||
|
SELECT COUNT(*) as cnt FROM tickets
|
||||||
|
WHERE device_sn = ? AND current_status IN ('resolved','closed')
|
||||||
|
AND fault_category IS NOT NULL AND fault_category NOT IN ('无故障','误判')
|
||||||
|
AND strftime('%Y-%m', close_time) = ?
|
||||||
|
`).get(ticket.device_sn, closeMonth) as { cnt: number } | undefined
|
||||||
|
monthCount = countRow?.cnt ?? 0
|
||||||
|
|
||||||
|
const durationRow = db.prepare(`
|
||||||
|
SELECT COALESCE(SUM(duration_minutes), 0) as total_min FROM tickets
|
||||||
|
WHERE device_sn = ? AND current_status IN ('resolved','closed')
|
||||||
|
AND fault_category IS NOT NULL AND fault_category NOT IN ('无故障','误判')
|
||||||
|
AND strftime('%Y-%m', close_time) = ?
|
||||||
|
`).get(ticket.device_sn, closeMonth) as { total_min: number } | undefined
|
||||||
|
monthTotalMin = durationRow?.total_min ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const thisDurationMin = ticket.duration_minutes || 0
|
||||||
|
const totalMin = monthTotalMin + thisDurationMin
|
||||||
|
const totalHours = Math.floor(totalMin / 60)
|
||||||
|
const totalMinutes = Math.floor(totalMin % 60)
|
||||||
|
|
||||||
|
let msg = `✅ ${ticket.ticket_no}工单已结单\n\n`
|
||||||
|
msg += `服务器IP:${ticket.device_ip || '未知'}\n`
|
||||||
|
msg += `服务器SN:${ticket.device_sn || '未知'}\n`
|
||||||
|
if (rack) msg += `机架位置:${rack}\n`
|
||||||
|
msg += `故障类型:OEM维修\n`
|
||||||
|
if (ticket.assign_time) msg += `故障时间:${ticket.assign_time}\n`
|
||||||
|
if (ticket.close_time) msg += `结单时间:${ticket.close_time}\n`
|
||||||
|
msg += `本次处理时长:${formatDuration(thisDurationMin)}\n`
|
||||||
|
msg += `本月故障次数:${monthCount + 1}次\n`
|
||||||
|
msg += `本月总处理时长:${totalHours}小时${totalMinutes}分钟`
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建基础结单消息(非 OEM 工单) */
|
||||||
|
function buildBasicResolvedMessage(ticket: ResolvedTicketInfo, rack: string | null): string {
|
||||||
|
let msg = `✅ ${ticket.ticket_no}工单已结单\n\n`
|
||||||
|
msg += `服务器IP:${ticket.device_ip || '未知'}\n`
|
||||||
|
msg += `服务器SN:${ticket.device_sn || '未知'}\n`
|
||||||
|
if (ticket.device_name) msg += `设备名称:${trunc(ticket.device_name, 128)}\n`
|
||||||
|
if (rack) msg += `机架位置:${rack}\n`
|
||||||
|
if (ticket.fault_category) msg += `故障大类:${ticket.fault_category}\n`
|
||||||
|
if (ticket.assign_time) msg += `故障时间:${ticket.assign_time}\n`
|
||||||
|
if (ticket.close_time) msg += `结单时间:${ticket.close_time}\n`
|
||||||
|
msg += `本次处理时长:${formatDuration(ticket.duration_minutes || 0)}`
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
async function getRackPositionSafe(ip: string | null, sn: string | null): Promise<string | null> {
|
async function getRackPositionSafe(ip: string | null, sn: string | null): Promise<string | null> {
|
||||||
// 注:assets-client.getRackPosition 内部有 encodeURIComponent,但【无】超时。
|
// 注:assets-client.getRackPosition 内部有 encodeURIComponent,但【无】超时。
|
||||||
// 这里用 Promise.race 加 5s 超时兜底(assets 不可达时 fetch 会挂到 Node 默认 TCP 超时 30-120s)。
|
// 这里用 Promise.race 加 5s 超时兜底(assets 不可达时 fetch 会挂到 Node 默认 TCP 超时 30-120s)。
|
||||||
|
|
@ -139,3 +217,54 @@ export async function notifyBatchSummary(ticketNos: string[]): Promise<void> {
|
||||||
console.error(`[Notifier] notifyBatchSummary 失败: ${e instanceof Error ? e.message : e}`)
|
console.error(`[Notifier] notifyBatchSummary 失败: ${e instanceof Error ? e.message : e}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResolvedTicketInfo {
|
||||||
|
ticket_no: string
|
||||||
|
device_ip: string | null
|
||||||
|
device_sn: string | null
|
||||||
|
device_name: string | null
|
||||||
|
ticket_type: string | null
|
||||||
|
fault_category: string | null
|
||||||
|
assign_time: string | null
|
||||||
|
close_time: string | null
|
||||||
|
duration_minutes: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结单/恢复推送。当工单状态从非终态变为 resolved/closed 时调用。
|
||||||
|
* fire-and-forget,全程 try/catch。
|
||||||
|
*/
|
||||||
|
export async function notifyTicketResolved(ticketId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const ticket = db.prepare(`
|
||||||
|
SELECT ticket_no, device_ip, device_sn, device_name, ticket_type,
|
||||||
|
fault_category, assign_time, close_time, duration_minutes
|
||||||
|
FROM tickets WHERE id = ?
|
||||||
|
`).get(ticketId) as ResolvedTicketInfo | undefined
|
||||||
|
if (!ticket) return
|
||||||
|
|
||||||
|
// 只推送"真实故障"结单
|
||||||
|
if (!ticket.fault_category
|
||||||
|
|| ticket.fault_category === '无故障'
|
||||||
|
|| ticket.fault_category === '误判') return
|
||||||
|
|
||||||
|
const hooks = getEnabledHooks()
|
||||||
|
if (hooks.length === 0) return
|
||||||
|
|
||||||
|
const rack = await getRackPositionSafe(ticket.device_ip, ticket.device_sn)
|
||||||
|
|
||||||
|
let message: string
|
||||||
|
if (ticket.ticket_type === 'OEM诊断') {
|
||||||
|
message = buildOemDiagResolvedMessage(ticket, rack)
|
||||||
|
} else if (ticket.ticket_type === 'OEM维修') {
|
||||||
|
message = buildOemRepairResolvedMessage(ticket, rack)
|
||||||
|
} else {
|
||||||
|
message = buildBasicResolvedMessage(ticket, rack)
|
||||||
|
}
|
||||||
|
|
||||||
|
await pushHooks(hooks, message)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Notifier] notifyTicketResolved 失败 (id=${ticketId}): ${e instanceof Error ? e.message : e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,10 +87,7 @@ export class BackgroundWorker {
|
||||||
}
|
}
|
||||||
this.lastTickTime = Date.now()
|
this.lastTickTime = Date.now()
|
||||||
|
|
||||||
// Step 3: 检查恢复
|
// Step 3: 7 点 flush
|
||||||
await this.checkRecoveryUpdates(config)
|
|
||||||
|
|
||||||
// Step 4: 7 点 flush
|
|
||||||
if (this.reminderEngine.isTimeToFlush()) {
|
if (this.reminderEngine.isTimeToFlush()) {
|
||||||
await this.reminderEngine.flushPendingMessages(
|
await this.reminderEngine.flushPendingMessages(
|
||||||
(text) => this.pushToAllWebhooks(text, config),
|
(text) => this.pushToAllWebhooks(text, config),
|
||||||
|
|
@ -99,19 +96,19 @@ export class BackgroundWorker {
|
||||||
this.reminderEngine.markFlushed()
|
this.reminderEngine.markFlushed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5-6: 检查提醒
|
// Step 4-5: 检查提醒
|
||||||
const pushText = (text: string) => this.pushToAllWebhooks(text, config)
|
const pushText = (text: string) => this.pushToAllWebhooks(text, config)
|
||||||
const getRack = (ip: string | null, sn: string | null) => this.ticketProcessor.getRackPosition(ip, sn)
|
const getRack = (ip: string | null, sn: string | null) => this.ticketProcessor.getRackPosition(ip, sn)
|
||||||
await this.reminderEngine.checkOemDiagReminders(pushText, config.monitor.push_delay_ms, getRack, config)
|
await this.reminderEngine.checkOemDiagReminders(pushText, config.monitor.push_delay_ms, getRack, config)
|
||||||
await this.reminderEngine.checkOemRepairReminders(pushText, config.monitor.push_delay_ms, getRack, config)
|
await this.reminderEngine.checkOemRepairReminders(pushText, config.monitor.push_delay_ms, getRack, config)
|
||||||
|
|
||||||
// Step 7: IMAP 连接
|
// Step 6: IMAP 连接
|
||||||
if (!this.mailMonitor.isConnected()) {
|
if (!this.mailMonitor.isConnected()) {
|
||||||
try { await this.mailMonitor.connect(config.mail) }
|
try { await this.mailMonitor.connect(config.mail) }
|
||||||
catch (e) { await this.mailMonitor.reconnect(config.mail) }
|
catch (e) { await this.mailMonitor.reconnect(config.mail) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 8-9: 搜索并处理邮件
|
// Step 7-8: 搜索并处理邮件
|
||||||
const emails = await this.mailMonitor.fetchUnread(config)
|
const emails = await this.mailMonitor.fetchUnread(config)
|
||||||
let processed = 0, errors = 0
|
let processed = 0, errors = 0
|
||||||
|
|
||||||
|
|
@ -216,82 +213,4 @@ export class BackgroundWorker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkRecoveryUpdates(config: MonitorConfig): Promise<void> {
|
|
||||||
const ongoing = this.db.prepare("SELECT * FROM fault_records WHERE status = 'ongoing'").all() as {
|
|
||||||
id: number; order_number: string | null; server_sn: string; server_ip: string | null;
|
|
||||||
fault_time: string; fault_type: 'oem_diag' | 'oem_repair' | null
|
|
||||||
}[]
|
|
||||||
for (const fault of ongoing) {
|
|
||||||
if (!fault.order_number) continue
|
|
||||||
if (!fault.fault_time) continue
|
|
||||||
const ticket = this.db.prepare('SELECT current_status, close_time FROM tickets WHERE id = ?').get(parseInt(fault.order_number)) as { current_status: string; close_time: string | null } | undefined
|
|
||||||
if (ticket && ['resolved', 'closed'].includes(ticket.current_status)) {
|
|
||||||
const recoveryTime = ticket.close_time || formatBeijingTime(new Date())
|
|
||||||
const faultDate = new Date(fault.fault_time.replace(' ', 'T') + '+08:00')
|
|
||||||
const recoveryDate = new Date(recoveryTime.replace(' ', 'T') + '+08:00')
|
|
||||||
const duration = Math.floor((recoveryDate.getTime() - faultDate.getTime()) / 1000)
|
|
||||||
|
|
||||||
// 获取机架位置
|
|
||||||
const rackPosition = await this.ticketProcessor.getRackPosition(fault.server_ip, fault.server_sn)
|
|
||||||
|
|
||||||
// 格式化处理时长
|
|
||||||
const hours = Math.floor(duration / 3600)
|
|
||||||
const minutes = Math.floor((duration % 3600) / 60)
|
|
||||||
const durationStr = `${hours}小时${minutes}分钟`
|
|
||||||
|
|
||||||
// 按故障类型构建结单消息
|
|
||||||
let message: string
|
|
||||||
if (fault.fault_type === 'oem_diag') {
|
|
||||||
message = `✅ ${fault.order_number}工单已结单\n\n`
|
|
||||||
+ `服务器IP:${fault.server_ip || '未知'}\n`
|
|
||||||
+ `服务器SN:${fault.server_sn}\n`
|
|
||||||
+ (rackPosition ? `机架位置:${rackPosition}\n` : '')
|
|
||||||
+ `故障类型:OEM诊断\n`
|
|
||||||
+ `故障时间:${fault.fault_time}\n`
|
|
||||||
+ `结单时间:${recoveryTime}\n`
|
|
||||||
+ `本次处理时长:${durationStr}`
|
|
||||||
} else if (fault.fault_type === 'oem_repair') {
|
|
||||||
const monthKey = fault.fault_time.substring(0, 7)
|
|
||||||
const monthCount = (this.db.prepare(
|
|
||||||
"SELECT COUNT(*) as cnt FROM fault_records WHERE server_sn = ? AND status = 'resolved' AND strftime('%Y-%m', fault_time) = ?"
|
|
||||||
).get(fault.server_sn, monthKey) as { cnt: number } | undefined) || { cnt: 0 }
|
|
||||||
const monthTotal = (this.db.prepare(
|
|
||||||
"SELECT COALESCE(SUM(duration_seconds), 0) as total FROM fault_records WHERE server_sn = ? AND status = 'resolved' AND strftime('%Y-%m', fault_time) = ?"
|
|
||||||
).get(fault.server_sn, monthKey) as { total: number } | undefined) || { total: 0 }
|
|
||||||
// 先累加秒数再转时分(避免分钟进位问题)
|
|
||||||
const totalSec = monthTotal.total + duration
|
|
||||||
const totalHours = Math.floor(totalSec / 3600)
|
|
||||||
const totalMinutes = Math.floor((totalSec % 3600) / 60)
|
|
||||||
|
|
||||||
message = `✅ ${fault.order_number}工单已结单\n\n`
|
|
||||||
+ `服务器IP:${fault.server_ip || '未知'}\n`
|
|
||||||
+ `服务器SN:${fault.server_sn}\n`
|
|
||||||
+ (rackPosition ? `机架位置:${rackPosition}\n` : '')
|
|
||||||
+ `故障类型:OEM维修\n`
|
|
||||||
+ `故障时间:${fault.fault_time}\n`
|
|
||||||
+ `结单时间:${recoveryTime}\n`
|
|
||||||
+ `本次处理时长:${durationStr}\n`
|
|
||||||
+ `本月故障次数:${monthCount.cnt + 1}次\n`
|
|
||||||
+ `本月总处理时长:${totalHours}小时${totalMinutes}分钟`
|
|
||||||
} else {
|
|
||||||
message = `✅ ${fault.order_number}工单已结单\n\n`
|
|
||||||
+ `服务器IP:${fault.server_ip || '未知'}\n`
|
|
||||||
+ `服务器SN:${fault.server_sn}\n`
|
|
||||||
+ (rackPosition ? `机架位置:${rackPosition}\n` : '')
|
|
||||||
+ `结单时间:${recoveryTime}\n`
|
|
||||||
+ `本次处理时长:${durationStr}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先推送消息,成功后再更新数据库(防止推送失败导致通知丢失)
|
|
||||||
const pushed = await this.pushToAllWebhooks(message, config)
|
|
||||||
if (pushed) {
|
|
||||||
this.db.prepare("UPDATE fault_records SET status = 'resolved', recovery_time = ?, duration_seconds = ? WHERE id = ?").run(recoveryTime, duration, fault.id)
|
|
||||||
this.reminderEngine.cleanupForTicket(fault.order_number)
|
|
||||||
logger.info(`Recovery detected: ${fault.order_number}`)
|
|
||||||
} else {
|
|
||||||
logger.error(`Recovery push failed, will retry next tick: ${fault.order_number}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ export async function collectMonthlyReportData(
|
||||||
const dates = getDateRange(periodStart, periodEnd)
|
const dates = getDateRange(periodStart, periodEnd)
|
||||||
// 排除"无故障"分类和"其他"子分类(其他工单不计入节点在线数)
|
// 排除"无故障"分类和"其他"子分类(其他工单不计入节点在线数)
|
||||||
const monthFaults = tickets.filter(t =>
|
const monthFaults = tickets.filter(t =>
|
||||||
t.fault_category !== '无故障' && t.fault_subcategory !== '其他'
|
t.fault_category !== '无故障' && t.fault_category !== '误判' && t.fault_subcategory !== '其他'
|
||||||
)
|
)
|
||||||
|
|
||||||
const dailyStats: DailyOnlineStats[] = dates.map(date => {
|
const dailyStats: DailyOnlineStats[] = dates.map(date => {
|
||||||
|
|
@ -137,7 +137,7 @@ export async function collectMonthlyReportData(
|
||||||
})
|
})
|
||||||
|
|
||||||
// 5. 第二章:运营数据总览(仅 gpu/storage,排除"无故障"和"其他"工单)
|
// 5. 第二章:运营数据总览(仅 gpu/storage,排除"无故障"和"其他"工单)
|
||||||
const gpuStorageTickets = tickets.filter(t => t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_subcategory !== '其他')
|
const gpuStorageTickets = tickets.filter(t => t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_category !== '误判' && t.fault_subcategory !== '其他')
|
||||||
const chapter2Map = new Map<string, Chapter2FaultItem[]>()
|
const chapter2Map = new Map<string, Chapter2FaultItem[]>()
|
||||||
for (const t of gpuStorageTickets) {
|
for (const t of gpuStorageTickets) {
|
||||||
const assignDate = t.assign_time.slice(0, 10)
|
const assignDate = t.assign_time.slice(0, 10)
|
||||||
|
|
@ -205,22 +205,19 @@ export async function collectMonthlyReportData(
|
||||||
const storageFaults = storageFaultTickets.map(toFaultEntry)
|
const storageFaults = storageFaultTickets.map(toFaultEntry)
|
||||||
const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry)
|
const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry)
|
||||||
|
|
||||||
// 7. 第四章:服务可用性说明(已结单工单按实际时长,进行中工单按本月部分)
|
// 7. 第四章:服务可用性说明(进行中工单暂不计入,完整时长归入结单月后统计)
|
||||||
const ipDurationMap = new Map<string, number>()
|
const ipDurationMap = new Map<string, number>()
|
||||||
const ipHasOngoing = new Map<string, boolean>()
|
const ipHasOngoing = new Map<string, boolean>()
|
||||||
for (const t of tickets) {
|
for (const t of tickets) {
|
||||||
if (t.fault_category === '无故障') continue
|
if (t.fault_category === '无故障') continue
|
||||||
const dur = ipDurationMap.get(t.device_ip) || 0
|
const dur = ipDurationMap.get(t.device_ip) || 0
|
||||||
if (t.isOngoing) {
|
if (t.isOngoing) {
|
||||||
// 进行中工单:计算本月内部分(从 max(assign_date, periodStart) 到月末最后一天)
|
// 进行中工单:暂不计入可用性时长,完整时长归入结单月后统计
|
||||||
const assignDate = t.assign_time.slice(0, 10)
|
|
||||||
const effectiveStart = assignDate > periodStart ? assignDate : periodStart
|
|
||||||
const affectedDays = daysBetween(effectiveStart, periodEnd) + 1
|
|
||||||
ipDurationMap.set(t.device_ip, dur + affectedDays * 24 * 60)
|
|
||||||
ipHasOngoing.set(t.device_ip, true)
|
ipHasOngoing.set(t.device_ip, true)
|
||||||
} else {
|
continue
|
||||||
ipDurationMap.set(t.device_ip, dur + t.duration_minutes)
|
|
||||||
}
|
}
|
||||||
|
// 已结单工单:用完整 duration_minutes 归入结单月
|
||||||
|
ipDurationMap.set(t.device_ip, dur + t.duration_minutes)
|
||||||
}
|
}
|
||||||
const chapter4: Chapter4Entry[] = []
|
const chapter4: Chapter4Entry[] = []
|
||||||
for (const [ip, totalDuration] of ipDurationMap) {
|
for (const [ip, totalDuration] of ipDurationMap) {
|
||||||
|
|
@ -256,7 +253,7 @@ export async function collectMonthlyReportData(
|
||||||
/** 从采集数据构建 metadata JSON(供 API 路由和 generate 函数共用) */
|
/** 从采集数据构建 metadata JSON(供 API 路由和 generate 函数共用) */
|
||||||
export function buildMonthlyMetadata(data: MonthlyReportData): string {
|
export function buildMonthlyMetadata(data: MonthlyReportData): string {
|
||||||
const gpuStorageTickets = data.tickets.filter(t =>
|
const gpuStorageTickets = data.tickets.filter(t =>
|
||||||
t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_subcategory !== '其他'
|
t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_category !== '误判' && t.fault_subcategory !== '其他'
|
||||||
)
|
)
|
||||||
const resolvedTickets = gpuStorageTickets.filter(t => !t.isOngoing)
|
const resolvedTickets = gpuStorageTickets.filter(t => !t.isOngoing)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,20 +92,20 @@ export async function collectWeeklyReportData(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 4. 按规则分三类
|
// 4. 按规则分三类(是否有真实故障 + 设备类型)
|
||||||
// GPU服务器故障:OEM维修 + fault_category != '无故障' + device_type = 'gpu'
|
const isRealFault = (t: typeof entries[number]): boolean =>
|
||||||
|
t.faultCategory !== null && t.faultCategory !== '无故障' && t.faultCategory !== '误判'
|
||||||
|
|
||||||
|
// GPU服务器故障
|
||||||
const gpuFaultTickets = entries.filter(t =>
|
const gpuFaultTickets = entries.filter(t =>
|
||||||
t.ticketType === 'OEM维修' && t.faultCategory !== '无故障' && t.deviceType === 'gpu'
|
isRealFault(t) && t.deviceType === 'gpu'
|
||||||
)
|
)
|
||||||
// 存储服务器故障
|
// 存储服务器故障
|
||||||
const storageFaultTickets = entries.filter(t =>
|
const storageFaultTickets = entries.filter(t =>
|
||||||
t.ticketType === 'OEM维修' && t.faultCategory !== '无故障' && t.deviceType === 'storage'
|
isRealFault(t) && t.deviceType === 'storage'
|
||||||
)
|
|
||||||
// 其他工单:OEM诊断 + (OEM维修且fault_category='无故障')
|
|
||||||
const otherTickets = entries.filter(t =>
|
|
||||||
t.ticketType === 'OEM诊断' ||
|
|
||||||
(t.ticketType === 'OEM维修' && t.faultCategory === '无故障')
|
|
||||||
)
|
)
|
||||||
|
// 其他工单:无故障、误判、或 deviceType 未匹配
|
||||||
|
const otherTickets = entries.filter(t => !isRealFault(t))
|
||||||
|
|
||||||
// 5. 每日在线统计(GPU + 存储分别统计)
|
// 5. 每日在线统计(GPU + 存储分别统计)
|
||||||
const dates = getDateRange(periodStart, periodEnd)
|
const dates = getDateRange(periodStart, periodEnd)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
darkMode: 'class',
|
|
||||||
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
|
||||||
theme: { extend: {} },
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue