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:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}"`
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
Reference in New Issue
Block a user