Initial commit: TaskPool React panel

- React frontend with route-level code splitting
- Backend rebranded from Baihu to TaskPool
- DB brand migration script and local compatibility
This commit is contained in:
2026-07-26 08:43:52 +08:00
commit e6956aa001
397 changed files with 73621 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
import { checkAuth } from '@/api'
let authChecked = false
let isAuth = false
export async function getAuthStatus(force = false): Promise<boolean> {
if (!force && authChecked) return isAuth
isAuth = await checkAuth()
authChecked = true
return isAuth
}
export function resetAuthCache() {
authChecked = false
isAuth = false
}
export function setAuthCache(status: boolean) {
authChecked = true
isAuth = status
}
+120
View File
@@ -0,0 +1,120 @@
/**
* 系统事件总线驱动(SharedWorker + WebSocket 降级)
* 系统事件总线
*/
export interface WSMessage {
type: string
timestamp?: number
payload?: any
}
export type MessageHandler = (msg: WSMessage) => void
class EventBusDriver {
private wsUrl = ''
private workerPath = ''
private handlers = new Set<MessageHandler>()
private worker: SharedWorker | null = null
private socket: WebSocket | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private initialized = false
constructor() {
const baseUrl = (window as any).__BASE_URL__ || ''
const apiVersion = (window as any).__API_VERSION__ || '/api/v1'
let host = window.location.host
let protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
if (baseUrl.startsWith('http')) {
try {
const url = new URL(baseUrl)
host = url.host
protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
} catch {
// ignore
}
}
const path = baseUrl.startsWith('http') ? '' : baseUrl
this.wsUrl = `${protocol}//${host}${path}${apiVersion}/ws/events`
this.workerPath = `${path}/workers/event-worker.js`.replace(/\/{2,}/g, '/').replace(':/', '://')
if (!this.workerPath.startsWith('/') && !this.workerPath.startsWith('http')) {
this.workerPath = `/${this.workerPath}`
}
}
init() {
if (this.initialized) return
this.initialized = true
if (typeof window !== 'undefined' && 'SharedWorker' in window) {
this.initSharedWorker()
} else {
this.initStandardWebSocket()
}
}
private initSharedWorker() {
try {
this.worker = new SharedWorker(this.workerPath)
this.worker.port.postMessage({ type: 'init', data: { url: this.wsUrl } })
this.worker.port.onmessage = (e) => this.emit(e.data)
this.worker.port.start()
} catch {
this.initStandardWebSocket()
}
}
private initStandardWebSocket() {
if (this.socket) return
try {
this.socket = new WebSocket(this.wsUrl)
this.socket.onmessage = (e) => {
try {
const data = JSON.parse(e.data)
this.emit(data)
} catch {
// ignore
}
}
this.socket.onclose = () => {
this.socket = null
this.reconnect()
}
this.socket.onerror = () => {
this.socket = null
this.reconnect()
}
} catch {
this.reconnect()
}
}
private reconnect() {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
this.reconnectTimer = setTimeout(() => {
this.initialized = false
this.init()
}, 5000)
}
private emit(msg: WSMessage) {
if (!msg || !msg.type) return
this.handlers.forEach((handler) => {
try {
handler(msg)
} catch {
// ignore handler errors
}
})
}
subscribe(handler: MessageHandler) {
this.handlers.add(handler)
return () => {
this.handlers.delete(handler)
}
}
}
export const eventBus = new EventBusDriver()
+46
View File
@@ -0,0 +1,46 @@
export function formatDuration(ms?: number | null): string {
if (ms == null || Number.isNaN(ms)) return '-'
if (ms < 1000) return `${Math.round(ms)}毫秒`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}`
if (ms < 3600000) return `${(ms / 60000).toFixed(1)}分钟`
return `${(ms / 3600000).toFixed(1)}小时`
}
export function formatDateTime(value?: string | null): string {
if (!value || value === '-') return '-'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
return d.toLocaleString()
}
/** 简单 cron 说明(6 段秒级或 5 段) */
export function getCronDescription(expr?: string | null): string {
if (!expr?.trim()) return '未设置'
const parts = expr.trim().split(/\s+/)
if (parts.length < 5) return expr
// 常见快捷
if (expr === '0 * * * * *' || expr === '* * * * *') return '每分钟'
if (expr === '0 0 * * * *' || expr === '0 * * * *') return '每小时'
if (expr === '0 0 0 * * *' || expr === '0 0 * * *') return '每天 0 点'
if (expr.startsWith('*/')) return `周期: ${expr}`
return expr
}
export function copyToClipboard(text: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text).then(() => true).catch(() => false)
}
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
document.body.removeChild(ta)
return Promise.resolve(ok)
} catch {
return Promise.resolve(false)
}
}
+26
View File
@@ -0,0 +1,26 @@
import { lazy, Suspense, type ComponentType, type ReactNode } from 'react'
import { Loading } from '@/components/ui'
/** 路由级懒加载包装 */
export function lazyPage(factory: () => Promise<{ default: ComponentType<any> }>, fallback?: ReactNode) {
const Comp = lazy(factory)
function LazyRoute() {
return (
<Suspense
fallback={
fallback ?? (
<div className="flex min-h-[240px] items-center justify-center p-8">
<Loading />
</div>
)
}
>
<Comp />
</Suspense>
)
}
LazyRoute.displayName = 'LazyRoute'
return LazyRoute
}
+103
View File
@@ -0,0 +1,103 @@
/**
* 日志内容处理:base64 压缩解压 + ANSI 转 HTML
*/
/** 尝试将后端可能返回的压缩/base64 内容还原为明文 */
export function decodeLogContent(raw: string): string {
if (!raw) return ''
// 若包含 ANSI 或明显是明文,直接返回
if (raw.includes('\u001b[') || raw.includes('\n') || raw.length < 40) return raw
// 纯 base64 形态尝试解码
const compact = raw.replace(/\s+/g, '')
if (!/^[A-Za-z0-9+/=]+$/.test(compact) || compact.length % 4 !== 0) return raw
try {
const binary = atob(compact)
// 尝试 inflate(部分后端用 gzip/deflate 再 base64
if (typeof DecompressionStream !== 'undefined') {
// 同步路径:先按 UTF-8 解码;若失败再当明文 base64 文本
try {
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
// 非 gzip 魔数则直接当 UTF-8
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
// 异步不适合这里,回退文本
}
return new TextDecoder().decode(bytes)
} catch {
return binary
}
}
return decodeURIComponent(escape(binary))
} catch {
return raw
}
}
/** 转义 HTML */
function escapeHtml(s: string) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
const ANSI_COLORS: Record<string, string> = {
'30': '#000',
'31': '#ef4444',
'32': '#22c55e',
'33': '#eab308',
'34': '#3b82f6',
'35': '#a855f7',
'36': '#06b6d4',
'37': '#e5e7eb',
'90': '#6b7280',
'91': '#f87171',
'92': '#4ade80',
'93': '#facc15',
'94': '#60a5fa',
'95': '#c084fc',
'96': '#22d3ee',
'97': '#f9fafb',
}
/** ANSI → HTML(轻量实现,覆盖常用前景色/粗体/重置) */
export function ansiToHtml(input: string): string {
const text = decodeLogContent(input)
if (!text.includes('\u001b[') && !text.includes('\x1b[')) {
return escapeHtml(text)
}
let html = ''
let open = false
let style = ''
const re = /\x1b\[([0-9;]*)m/g
let last = 0
let m: RegExpExecArray | null
while ((m = re.exec(text))) {
const chunk = text.slice(last, m.index)
if (chunk) html += open ? `<span style="${style}">${escapeHtml(chunk)}</span>` : escapeHtml(chunk)
last = m.index + m[0].length
const codes = (m[1] || '0').split(';').filter(Boolean)
if (codes.length === 0 || codes.includes('0')) {
open = false
style = ''
continue
}
const parts: string[] = []
for (const c of codes) {
if (c === '1') parts.push('font-weight:700')
else if (c === '2') parts.push('opacity:.75')
else if (c === '4') parts.push('text-decoration:underline')
else if (ANSI_COLORS[c]) parts.push(`color:${ANSI_COLORS[c]}`)
}
if (parts.length) {
open = true
style = parts.join(';')
}
}
const rest = text.slice(last)
if (rest) html += open ? `<span style="${style}">${escapeHtml(rest)}</span>` : escapeHtml(rest)
return html
}
+146
View File
@@ -0,0 +1,146 @@
/** 渠道配置字段(对齐原版 channelConfigFields */
export type NotifyField = {
key: string
label: string
required: boolean
placeholder?: string
type?: 'text' | 'textarea' | 'note'
}
export const CHANNEL_CONFIG_FIELDS: Record<string, NotifyField[]> = {
Telegram: [
{ key: 'bot_token', label: 'Bot Token', required: true, placeholder: '从 @BotFather 获取' },
{ key: 'chat_id', label: 'Chat ID', required: true, placeholder: '聊天/群组 ID' },
{ key: 'api_host', label: 'API 地址', required: false, placeholder: '自定义 API 地址,留空使用官方' },
{ key: 'proxy_url', label: '代理地址', required: false, placeholder: 'http/https/socks5 代理' },
],
Bark: [
{ key: 'server', label: '服务地址', required: false, placeholder: '默认 https://api.day.app' },
{ key: 'push_key', label: 'Push Key', required: true, placeholder: 'Bark Push Key' },
{ key: 'proxy_url', label: '代理地址', required: false, placeholder: 'http/https/socks5 代理' },
{ key: 'sound', label: '推送声音', required: false, placeholder: '留空使用默认' },
{ key: 'badge', label: '角标数量', required: false, placeholder: '例如 1' },
{ key: 'group', label: '推送分组', required: false },
{ key: 'icon', label: '推送图标', required: false, placeholder: '图标 URL' },
{ key: 'level', label: '时效性', required: false, placeholder: 'active / timeSensitive / passive' },
{ key: 'url', label: '跳转URL', required: false },
{ key: 'copy', label: '复制内容', required: false },
{ key: 'auto_copy', label: '自动复制', required: false, placeholder: '1 表示开启' },
],
Dtalk: [
{ key: 'access_token', label: 'Access Token', required: true, placeholder: '钉钉机器人 access_token' },
{ key: 'secret', label: '加签秘钥', required: false, placeholder: '可选' },
],
QyWeiXin: [
{ key: 'access_token', label: 'Access Token', required: true, placeholder: '企业微信机器人 Key' },
],
Feishu: [
{ key: 'access_token', label: 'Access Token', required: true, placeholder: '飞书机器人 access_token' },
{ key: 'secret', label: '加签秘钥', required: false, placeholder: '可选' },
],
Custom: [
{ key: 'webhook', label: 'Webhook URL', required: true, placeholder: 'https://...' },
{ key: 'headers', label: '请求头', required: false, placeholder: 'JSON 格式', type: 'textarea' },
{ key: 'body', label: '请求体模板', required: false, placeholder: '使用 TEXT 作为消息内容占位符', type: 'textarea' },
],
Ntfy: [
{ key: 'topic', label: 'Topic', required: true },
{ key: 'url', label: 'API 地址', required: false, placeholder: '默认 https://ntfy.sh' },
{ key: 'priority', label: '优先级', required: false, placeholder: '1-5' },
{ key: 'icon', label: '图标 URL', required: false },
{ key: 'token', label: 'Token', required: false },
{ key: 'username', label: '用户名', required: false },
{ key: 'password', label: '密码', required: false },
],
Gotify: [
{ key: 'url', label: '服务地址', required: true, placeholder: 'https://gotify.example.com' },
{ key: 'token', label: 'Token', required: true },
{ key: 'priority', label: '优先级', required: false, placeholder: '0-10' },
],
PushMe: [
{ key: 'push_key', label: 'Push Key', required: true },
{ key: 'url', label: 'API 地址', required: false, placeholder: '默认 https://push.i-i.me' },
{ key: 'type', label: '类型', required: false },
],
Email: [
{ key: 'server', label: 'SMTP 服务器', required: true, placeholder: 'smtp.example.com' },
{ key: 'port', label: '端口', required: true, placeholder: '465' },
{ key: 'account', label: '邮箱账号', required: true },
{ key: 'passwd', label: '邮箱密码', required: true },
{ key: 'from_name', label: '发信人名称', required: false },
{ key: 'to_account', label: '收件邮箱', required: true },
],
AliyunSMS: [
{ key: 'access_key_id', label: 'AccessKeyId', required: true },
{ key: 'access_key_secret', label: 'AccessKeySecret', required: true },
{ key: 'sign_name', label: '短信签名', required: true },
{ key: 'region_id', label: '区域ID', required: false, placeholder: '默认 cn-hangzhou' },
{ key: 'phone_number', label: '手机号码', required: true },
{ key: 'template_code', label: '短信模板 CODE', required: true },
],
PushPlus: [
{ key: 'token', label: 'Token', required: true, placeholder: 'PushPlus Token' },
{ key: 'topic', label: '群组编码', required: false },
{ key: 'template', label: '推送模板', required: false, placeholder: 'html, txt, json, markdown' },
{ key: 'channel', label: '推送渠道', required: false, placeholder: 'wechat, dingding, feishu, mail等' },
{ key: 'webhook', label: 'Webhook', required: false },
{ key: 'callback_url', label: '回调地址', required: false },
{ key: 'to', label: '好友令牌', required: false },
],
VoceChat: [
{ key: 'server', label: '服务地址', required: true, placeholder: 'https://vocechat.yourdomain.com' },
{ key: 'api_key', label: 'API Key', required: true, placeholder: 'Bot API Key' },
{ key: 'target_id', label: '目标 ID', required: true, placeholder: 'uid 或 gid' },
{ key: 'target_type', label: '目标类型', required: false, placeholder: 'user (默认) / group' },
],
WxPusher: [
{ key: 'app_token', label: 'AppToken', required: true, placeholder: 'AT_...' },
{ key: 'uids', label: 'UIDs', required: false, placeholder: '用户 UID,多个用逗号分隔' },
{ key: 'topic_ids', label: 'TopicIDs', required: false, placeholder: '主题 ID,多个用逗号分隔' },
{ key: 'verify_pay_type', label: '付费验证', required: false, placeholder: '0:不验证, 1:仅付费, 2:仅未订阅/过期' },
],
// 兼容小写 type
telegram: [
{ key: 'bot_token', label: 'Bot Token', required: true },
{ key: 'chat_id', label: 'Chat ID', required: true },
{ key: 'api_host', label: 'API 地址', required: false },
{ key: 'proxy_url', label: '代理地址', required: false },
],
webhook: [
{ key: 'webhook', label: 'Webhook URL', required: true },
{ key: 'headers', label: '请求头', required: false, type: 'textarea' },
{ key: 'body', label: '请求体模板', required: false, type: 'textarea' },
],
}
const TYPE_ALIASES: Record<string, string> = {
PushMes: 'PushMe',
pushme: 'PushMe',
pushmes: 'PushMe',
webhook: 'Custom',
custom: 'Custom',
}
export function getChannelFields(type: string): NotifyField[] {
if (!type) return []
const normalized = TYPE_ALIASES[type] || type
return (
CHANNEL_CONFIG_FIELDS[normalized] ||
CHANNEL_CONFIG_FIELDS[type] ||
CHANNEL_CONFIG_FIELDS[type.charAt(0).toUpperCase() + type.slice(1)] ||
[]
)
}
export function emptyConfigForType(type: string, existing: Record<string, any> = {}) {
const fields = getChannelFields(type)
const cfg: Record<string, string> = {}
for (const f of fields) {
cfg[f.key] = existing[f.key] != null ? String(existing[f.key]) : ''
}
// 保留未知字段
Object.keys(existing || {}).forEach((k) => {
if (cfg[k] === undefined) cfg[k] = String(existing[k] ?? '')
})
return cfg
}
+59
View File
@@ -0,0 +1,59 @@
import type { Task } from '@/api'
import { TASK_TYPE } from '@/constants'
/** 从仓库任务生成可复制的同步指令 */
export function generateTaskPoolCommand(task: Partial<Task> | null | undefined): string {
if (!task || task.type !== TASK_TYPE.REPO) return ''
let repo: Record<string, any> = {}
try {
const cfg = JSON.parse(task.config || '{}')
repo = cfg.repo || cfg || {}
} catch {
repo = {}
}
const url = String(repo.source_url || '').trim()
if (!url) return ''
const branch = String(repo.branch || 'main').trim() || 'main'
const target = String(repo.target_path || '').trim()
const sparse = String(repo.sparse_path || '').trim()
const proxy = String(repo.proxy_url || repo.proxy || '').trim()
const token = String(repo.auth_token || '').trim()
const single = !!repo.single_file
const parts = ['taskpool repo-sync', `--url "${url}"`, `--branch "${branch}"`]
if (target) parts.push(`--target "${target}"`)
if (sparse) parts.push(`--sparse "${sparse}"`)
if (proxy) parts.push(`--proxy "${proxy}"`)
if (token) parts.push(`--token "***"`)
if (single) parts.push('--single-file')
if (task.schedule) parts.push(`--cron "${task.schedule}"`)
if (task.name) parts.push(`--name "${task.name}"`)
return parts.join(' ')
}
/** 构建脚本执行命令(对齐原版 buildExecutionCommand */
export function buildExecutionCommand(
filePath: string,
envs: { plugin: string; version: string }[],
scriptsDir = 'scripts',
) {
const rel = filePath.replace(/^\/+/, '')
const full = scriptsDir ? `${scriptsDir.replace(/\/$/, '')}/${rel}` : rel
const ext = rel.split('.').pop()?.toLowerCase() || ''
const misePrefix =
envs.length > 0
? `mise exec ${envs.map((e) => `${e.plugin}@${e.version}`).join(' ')} -- `
: ''
if (ext === 'py') return `${misePrefix}python "${full}"`
if (ext === 'js' || ext === 'mjs' || ext === 'cjs') return `${misePrefix}node "${full}"`
if (ext === 'ts') return `${misePrefix}npx tsx "${full}"`
if (ext === 'go') return `${misePrefix}go run "${full}"`
if (ext === 'rb') return `${misePrefix}ruby "${full}"`
if (ext === 'php') return `${misePrefix}php "${full}"`
if (ext === 'rs') return `${misePrefix}cargo run --manifest-path "${full}"`
if (ext === 'ps1') return `powershell -ExecutionPolicy Bypass -File "${full}"`
if (ext === 'bat' || ext === 'cmd') return `"${full}"`
return `${misePrefix}bash "${full}"`
}
+63
View File
@@ -0,0 +1,63 @@
import type { ITheme } from '@xterm/xterm'
import type { ThemeId } from '@/context/ThemeContext'
/**
* 终端配色:与面板主题同系,但保证终端可读性
* - 使用固定色板,不读 CSS 变量(避免切换瞬间读到旧值)
* - 暗色:高对比浅字;亮色:深字浅底
*/
export function getTerminalTheme(theme: ThemeId): ITheme {
if (theme === 'light') {
return {
background: '#f8f9fa',
foreground: '#212529',
cursor: '#212529',
cursorAccent: '#f8f9fa',
selectionBackground: 'rgba(33, 37, 41, 0.18)',
selectionForeground: '#212529',
selectionInactiveBackground: 'rgba(33, 37, 41, 0.1)',
black: '#212529',
red: '#dc2626',
green: '#059669',
yellow: '#d97706',
blue: '#2563eb',
magenta: '#7c3aed',
cyan: '#0e7490',
white: '#495057',
brightBlack: '#868e96',
brightRed: '#e03131',
brightGreen: '#2f9e44',
brightYellow: '#e67700',
brightBlue: '#1c7ed6',
brightMagenta: '#9c36b5',
brightCyan: '#0c8599',
brightWhite: '#212529',
}
}
return {
background: '#1a1a1a',
foreground: '#f0f0f0',
cursor: '#c0c0c0',
cursorAccent: '#1a1a1a',
selectionBackground: 'rgba(192, 192, 192, 0.35)',
selectionForeground: '#ffffff',
selectionInactiveBackground: 'rgba(192, 192, 192, 0.2)',
black: '#4a4a4a',
red: '#ff8a80',
green: '#69f0ae',
yellow: '#ffd54f',
blue: '#82b1ff',
magenta: '#e1bee7',
cyan: '#80deea',
white: '#e8e8e8',
brightBlack: '#b0b0b0',
brightRed: '#ff5252',
brightGreen: '#69f0ae',
brightYellow: '#ffee58',
brightBlue: '#448aff',
brightMagenta: '#e040fb',
brightCyan: '#18ffff',
brightWhite: '#ffffff',
}
}
+51
View File
@@ -0,0 +1,51 @@
export type ToastType = 'success' | 'error' | 'info' | 'warning'
export interface ToastItem {
id: number
type: ToastType
message: string
duration: number
}
type Listener = (items: ToastItem[]) => void
let seq = 1
let items: ToastItem[] = []
const listeners = new Set<Listener>()
function emit() {
listeners.forEach((fn) => fn([...items]))
}
function push(type: ToastType, message: string, duration = 3000) {
const id = seq++
const item: ToastItem = { id, type, message, duration }
items = [...items, item]
emit()
if (duration > 0) {
window.setTimeout(() => {
dismiss(id)
}, duration)
}
return id
}
export function dismiss(id: number) {
items = items.filter((t) => t.id !== id)
emit()
}
export function subscribeToast(listener: Listener) {
listeners.add(listener)
listener([...items])
return () => {
listeners.delete(listener)
}
}
export const toast = {
success: (message: string, duration?: number) => push('success', message, duration),
error: (message: string, duration?: number) => push('error', message, duration ?? 4000),
info: (message: string, duration?: number) => push('info', message, duration),
warning: (message: string, duration?: number) => push('warning', message, duration),
}
+3
View File
@@ -0,0 +1,3 @@
export function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}