2844 lines
96 KiB
JavaScript
2844 lines
96 KiB
JavaScript
// Cloudflare Worker: 订阅管理与转换工具 (Subscription Manager & Converter)
|
||
// 部署说明:复制此代码到 Cloudflare Workers 编辑器中
|
||
// 版本:v4.1 - 智能订阅管理系统,支持 Clash/V2Ray 双向转换
|
||
|
||
// KV 存储结构说明:
|
||
// 1. 短链接键:{shortId} -> JSON {url, type, subscriptionId, outputType} (JSON对象)
|
||
// 2. 订阅配置键:sub:{subscriptionId} -> subscription object (JSON)
|
||
// 3. 订阅索引键:subscriptions:list -> array of subscription IDs (JSON)
|
||
// 4. 订阅类型:permanent (正式) / temporary (临时)
|
||
|
||
addEventListener('fetch', event => {
|
||
event.respondWith(handleRequest(event.request))
|
||
})
|
||
|
||
async function handleRequest(request) {
|
||
const url = new URL(request.url)
|
||
|
||
// 处理根路径 - 返回网页界面
|
||
if (url.pathname === '/' && request.method === 'GET') {
|
||
return new Response(getHTML(), {
|
||
headers: { 'Content-Type': 'text/html;charset=UTF-8' }
|
||
})
|
||
}
|
||
|
||
// 处理转换请求
|
||
if (url.pathname === '/convert' && request.method === 'POST') {
|
||
try {
|
||
const { clashUrl } = await request.json()
|
||
|
||
if (!clashUrl) {
|
||
return jsonResponse({ error: '请提供 Clash 订阅链接' }, 400)
|
||
}
|
||
|
||
const v2raySubscription = await convertClashToV2ray(clashUrl)
|
||
|
||
return new Response(v2raySubscription, {
|
||
headers: {
|
||
'Content-Type': 'text/plain;charset=UTF-8',
|
||
'Content-Disposition': 'attachment; filename="v2ray_subscription.txt"'
|
||
}
|
||
})
|
||
} catch (error) {
|
||
return jsonResponse({ error: error.message }, 500)
|
||
}
|
||
}
|
||
|
||
// 处理直接URL转换(用于在线订阅链接)
|
||
if (url.pathname === '/convert' && request.method === 'GET') {
|
||
const clashUrl = url.searchParams.get('url')
|
||
|
||
if (!clashUrl) {
|
||
return jsonResponse({ error: '请提供 url 参数' }, 400)
|
||
}
|
||
|
||
try {
|
||
const v2raySubscription = await convertClashToV2ray(clashUrl)
|
||
return new Response(v2raySubscription, {
|
||
headers: {
|
||
'Content-Type': 'text/plain;charset=UTF-8',
|
||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||
'Pragma': 'no-cache',
|
||
'Expires': '0'
|
||
}
|
||
})
|
||
} catch (error) {
|
||
return jsonResponse({ error: error.message }, 500)
|
||
}
|
||
}
|
||
|
||
// 处理生成短链接(如果配置了 KV)
|
||
if (url.pathname === '/shorten' && request.method === 'POST') {
|
||
try {
|
||
const { clashUrl } = await request.json()
|
||
|
||
if (!clashUrl) {
|
||
return jsonResponse({ error: '请提供 Clash 订阅链接' }, 400)
|
||
}
|
||
|
||
// 检查是否配置了 KV
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') {
|
||
// 如果没有配置 KV,直接返回长链接
|
||
const longUrl = `${url.origin}/convert?url=${encodeURIComponent(clashUrl)}`
|
||
return jsonResponse({ url: longUrl, type: 'direct' })
|
||
}
|
||
|
||
// 使用 KV 生成短链接
|
||
const shortId = generateShortId()
|
||
const expirationTime = 7 * 24 * 60 * 60 // 7天过期
|
||
|
||
const metadata = {
|
||
clashUrl,
|
||
createdAt: Date.now()
|
||
}
|
||
await SUBSCRIPTION_KV.put(shortId, clashUrl, {
|
||
expirationTtl: expirationTime,
|
||
metadata
|
||
})
|
||
|
||
const shortUrl = `${url.origin}/s/${shortId}`
|
||
return jsonResponse({ url: shortUrl, type: 'short', expiresIn: expirationTime })
|
||
|
||
} catch (error) {
|
||
return jsonResponse({ error: error.message }, 500)
|
||
}
|
||
}
|
||
|
||
// 处理短链接访问
|
||
if (url.pathname.startsWith('/s/')) {
|
||
const shortId = url.pathname.substring(3)
|
||
const requestedType = url.searchParams.get('type') // clash or v2ray
|
||
|
||
try {
|
||
// 检查是否配置了 KV
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') {
|
||
return new Response('短链接功能未启用', { status: 404 })
|
||
}
|
||
|
||
const shortLinkJson = await SUBSCRIPTION_KV.get(shortId)
|
||
|
||
if (!shortLinkJson) {
|
||
return new Response('链接不存在或已过期', { status: 404 })
|
||
}
|
||
|
||
const shortLinkData = JSON.parse(shortLinkJson)
|
||
|
||
const sourceUrl = shortLinkData.url
|
||
const sourceType = shortLinkData.type || 'clash'
|
||
const defaultOutputType = shortLinkData.outputType || 'v2ray'
|
||
const targetType = requestedType || defaultOutputType
|
||
|
||
let result
|
||
|
||
// 根据源类型和目标类型进行转换
|
||
if (sourceType === 'clash' && targetType === 'v2ray') {
|
||
result = await convertClashToV2ray(sourceUrl)
|
||
} else if (sourceType === 'v2ray' && targetType === 'clash') {
|
||
result = await convertV2rayToClash(sourceUrl)
|
||
} else if (sourceType === 'clash' && targetType === 'clash') {
|
||
// 直接返回 clash 订阅
|
||
const response = await fetch(sourceUrl, {
|
||
headers: { 'User-Agent': 'ClashForWindows/0.20.39' }
|
||
})
|
||
result = await response.text()
|
||
} else if (sourceType === 'v2ray' && targetType === 'v2ray') {
|
||
// 直接返回 v2ray 订阅
|
||
const response = await fetch(sourceUrl, {
|
||
headers: { 'User-Agent': 'v2rayN/6.0' }
|
||
})
|
||
result = await response.text()
|
||
} else {
|
||
// 默认:clash to v2ray
|
||
result = await convertClashToV2ray(sourceUrl)
|
||
}
|
||
|
||
return new Response(result, {
|
||
headers: {
|
||
'Content-Type': 'text/plain;charset=UTF-8',
|
||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||
'Pragma': 'no-cache',
|
||
'Expires': '0'
|
||
}
|
||
})
|
||
} catch (error) {
|
||
return new Response(`转换失败: ${error.message}`, { status: 500 })
|
||
}
|
||
}
|
||
|
||
// ========== 订阅管理 API ==========
|
||
|
||
// 创建新订阅
|
||
if (url.pathname === '/api/subscriptions' && request.method === 'POST') {
|
||
try {
|
||
const { name, clashUrl, expiration = '7d', outputType = 'auto', subscriptionMode = 'permanent' } = await request.json()
|
||
|
||
if (!name || !clashUrl) {
|
||
return jsonResponse({ error: '缺少必需参数 name 或 clashUrl' }, 400)
|
||
}
|
||
|
||
// 检查是否配置了 KV
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') {
|
||
return jsonResponse({ error: 'KV 未配置' }, 500)
|
||
}
|
||
|
||
// 临时订阅限制有效期
|
||
let finalExpiration = expiration
|
||
if (subscriptionMode === 'temporary') {
|
||
// 临时订阅只能选择 1天、3天、7天
|
||
if (!['1d', '3d', '7d'].includes(expiration)) {
|
||
finalExpiration = '3d' // 默认3天
|
||
}
|
||
}
|
||
|
||
// 自动检测订阅类型
|
||
const subscriptionType = await detectSubscriptionType(clashUrl)
|
||
|
||
// 生成订阅ID
|
||
const subscriptionId = generateShortId()
|
||
|
||
const expirationTime = parseExpiration(finalExpiration)
|
||
|
||
// 创建订阅对象(先创建,短链按需生成)
|
||
const subscription = {
|
||
id: subscriptionId,
|
||
name,
|
||
clashUrl,
|
||
subscriptionType,
|
||
subscriptionMode,
|
||
outputType,
|
||
shortIds: {}, // 改为对象,存储不同类型的短链: {clash: 'xxx', v2ray: 'yyy'}
|
||
shortIdsExpiry: {}, // 存储每个短链的过期时间: {clash: timestamp, v2ray: timestamp}
|
||
createdAt: Date.now(),
|
||
expiresAt: expirationTime ? Date.now() + expirationTime * 1000 : null,
|
||
expiration: finalExpiration,
|
||
updatedAt: Date.now()
|
||
}
|
||
|
||
// 保存订阅
|
||
await SUBSCRIPTION_KV.put(`sub:${subscriptionId}`, JSON.stringify(subscription))
|
||
|
||
// 更新订阅列表索引
|
||
await addToSubscriptionList(subscriptionId)
|
||
|
||
// 短链按需生成,不在这里预先创建
|
||
subscription.isExpired = expirationTime ? Date.now() > subscription.expiresAt : false
|
||
|
||
return jsonResponse({ success: true, subscription })
|
||
|
||
} catch (error) {
|
||
return jsonResponse({ error: error.message }, 500)
|
||
}
|
||
}
|
||
|
||
// 获取所有订阅列表
|
||
if (url.pathname === '/api/subscriptions' && request.method === 'GET') {
|
||
try {
|
||
const subscriptionIds = await getSubscriptionList()
|
||
const subscriptions = []
|
||
|
||
for (const id of subscriptionIds) {
|
||
const data = await SUBSCRIPTION_KV.get(`sub:${id}`)
|
||
if (data) {
|
||
const sub = JSON.parse(data)
|
||
sub.shortIds = sub.shortIds || {}
|
||
sub.shortIdsExpiry = sub.shortIdsExpiry || {}
|
||
// 检查是否过期
|
||
sub.isExpired = sub.expiresAt ? Date.now() > sub.expiresAt : false
|
||
subscriptions.push(sub)
|
||
}
|
||
}
|
||
|
||
return jsonResponse({ success: true, subscriptions })
|
||
|
||
} catch (error) {
|
||
console.error('获取订阅列表失败:', error)
|
||
return jsonResponse({ error: error.message }, 500)
|
||
}
|
||
}
|
||
|
||
// 获取单个订阅详情
|
||
if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+$/) && request.method === 'GET') {
|
||
try {
|
||
const subscriptionId = url.pathname.split('/').pop()
|
||
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') {
|
||
return jsonResponse({ error: 'KV 未配置' }, 500)
|
||
}
|
||
|
||
const data = await SUBSCRIPTION_KV.get(`sub:${subscriptionId}`)
|
||
if (!data) {
|
||
return jsonResponse({ error: '订阅不存在' }, 404)
|
||
}
|
||
|
||
const subscription = JSON.parse(data)
|
||
subscription.shortIds = subscription.shortIds || {}
|
||
subscription.shortIdsExpiry = subscription.shortIdsExpiry || {}
|
||
subscription.isExpired = subscription.expiresAt ? Date.now() > subscription.expiresAt : false
|
||
|
||
return jsonResponse({ success: true, subscription })
|
||
|
||
} catch (error) {
|
||
return jsonResponse({ error: error.message }, 500)
|
||
}
|
||
}
|
||
|
||
// 更新订阅
|
||
if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+$/) && request.method === 'PUT') {
|
||
try {
|
||
const subscriptionId = url.pathname.split('/').pop()
|
||
const updates = await request.json()
|
||
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') {
|
||
return jsonResponse({ error: 'KV 未配置' }, 500)
|
||
}
|
||
|
||
const data = await SUBSCRIPTION_KV.get(`sub:${subscriptionId}`)
|
||
if (!data) {
|
||
return jsonResponse({ error: '订阅不存在' }, 404)
|
||
}
|
||
|
||
const subscription = JSON.parse(data)
|
||
|
||
// 更新允许的字段
|
||
if (updates.name) subscription.name = updates.name
|
||
if (updates.outputType) subscription.outputType = updates.outputType
|
||
|
||
if (updates.clashUrl && updates.clashUrl !== subscription.clashUrl) {
|
||
// 如果更新了 clashUrl,重新检测类型并更新所有已存在的短链接
|
||
subscription.clashUrl = updates.clashUrl
|
||
subscription.subscriptionType = await detectSubscriptionType(updates.clashUrl)
|
||
|
||
// 更新所有已存在的短链接数据
|
||
const ttl = subscription.expiresAt ? Math.floor((subscription.expiresAt - Date.now()) / 1000) : null
|
||
const putOptions = ttl && ttl > 0
|
||
? { expirationTtl: ttl, metadata: { updatedAt: Date.now() } }
|
||
: { metadata: { updatedAt: Date.now(), permanent: true } }
|
||
|
||
subscription.shortIds = subscription.shortIds || {}
|
||
for (const [linkType, shortId] of Object.entries(subscription.shortIds)) {
|
||
if (shortId) {
|
||
const shortLinkData = {
|
||
url: updates.clashUrl,
|
||
type: subscription.subscriptionType,
|
||
subscriptionId,
|
||
outputType: linkType
|
||
}
|
||
await SUBSCRIPTION_KV.put(shortId, JSON.stringify(shortLinkData), putOptions)
|
||
}
|
||
}
|
||
}
|
||
|
||
subscription.updatedAt = Date.now()
|
||
|
||
// 保存更新后的订阅
|
||
await SUBSCRIPTION_KV.put(`sub:${subscriptionId}`, JSON.stringify(subscription))
|
||
|
||
subscription.shortIds = subscription.shortIds || {}
|
||
subscription.shortIdsExpiry = subscription.shortIdsExpiry || {}
|
||
subscription.isExpired = subscription.expiresAt ? Date.now() > subscription.expiresAt : false
|
||
|
||
return jsonResponse({ success: true, subscription })
|
||
|
||
} catch (error) {
|
||
return jsonResponse({ error: error.message }, 500)
|
||
}
|
||
}
|
||
|
||
// 清空所有KV数据 (必须放在删除订阅路由之前,否则会被正则匹配拦截)
|
||
if (url.pathname === '/api/subscriptions/clear-all' && request.method === 'POST') {
|
||
try {
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') {
|
||
return jsonResponse({ error: 'KV 未配置' }, 500)
|
||
}
|
||
|
||
// 列出所有 KV 键并逐一删除
|
||
let cursor = undefined
|
||
let deletedCount = 0
|
||
|
||
do {
|
||
const listResult = await SUBSCRIPTION_KV.list({ limit: 1000, cursor })
|
||
|
||
for (const key of listResult.keys) {
|
||
await SUBSCRIPTION_KV.delete(key.name)
|
||
deletedCount++
|
||
}
|
||
|
||
cursor = listResult.list_complete ? undefined : listResult.cursor
|
||
} while (cursor)
|
||
|
||
return jsonResponse({ success: true, message: `已清空所有数据,共删除 ${deletedCount} 条记录` })
|
||
|
||
} catch (error) {
|
||
return jsonResponse({ error: error.message }, 500)
|
||
}
|
||
}
|
||
|
||
// 删除订阅
|
||
if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+$/) && request.method === 'DELETE') {
|
||
try {
|
||
const subscriptionId = url.pathname.split('/').pop()
|
||
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') {
|
||
return jsonResponse({ error: 'KV 未配置' }, 500)
|
||
}
|
||
|
||
const data = await SUBSCRIPTION_KV.get(`sub:${subscriptionId}`)
|
||
if (!data) {
|
||
return jsonResponse({ error: '订阅不存在' }, 404)
|
||
}
|
||
|
||
const subscription = JSON.parse(data)
|
||
|
||
// 删除所有短链接
|
||
if (subscription.shortIds) {
|
||
for (const shortId of Object.values(subscription.shortIds)) {
|
||
if (shortId) {
|
||
await SUBSCRIPTION_KV.delete(shortId)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 删除订阅
|
||
await SUBSCRIPTION_KV.delete(`sub:${subscriptionId}`)
|
||
|
||
// 从订阅列表中移除
|
||
await removeFromSubscriptionList(subscriptionId)
|
||
|
||
return jsonResponse({ success: true, message: '订阅已删除' })
|
||
|
||
} catch (error) {
|
||
return jsonResponse({ error: error.message }, 500)
|
||
}
|
||
}
|
||
|
||
// 续期订阅短链
|
||
if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+\/renew$/) && request.method === 'POST') {
|
||
try {
|
||
const subscriptionId = url.pathname.split('/')[3]
|
||
const { expiration = '7d', outputType, linkType = 'auto' } = await request.json()
|
||
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') {
|
||
return jsonResponse({ error: 'KV 未配置' }, 500)
|
||
}
|
||
|
||
const data = await SUBSCRIPTION_KV.get(`sub:${subscriptionId}`)
|
||
if (!data) {
|
||
return jsonResponse({ error: '订阅不存在' }, 404)
|
||
}
|
||
|
||
const subscription = JSON.parse(data)
|
||
|
||
subscription.shortIds = subscription.shortIds || {}
|
||
subscription.shortIdsExpiry = subscription.shortIdsExpiry || {}
|
||
|
||
// 临时订阅限制有效期
|
||
let finalExpiration = expiration
|
||
if (subscription.subscriptionMode === 'temporary') {
|
||
if (!['1d', '3d', '7d'].includes(expiration)) {
|
||
finalExpiration = '3d'
|
||
}
|
||
}
|
||
|
||
const expirationTime = parseExpiration(finalExpiration)
|
||
const newExpiryTime = expirationTime ? Date.now() + expirationTime * 1000 : null
|
||
|
||
// 更新输出类型(如果提供)
|
||
if (outputType) {
|
||
subscription.outputType = outputType
|
||
}
|
||
|
||
// 检查是否已有该类型的短链,如有则续期,没有则创建
|
||
let targetShortId = subscription.shortIds[linkType]
|
||
|
||
if (!targetShortId) {
|
||
// 创建新的短链
|
||
targetShortId = generateShortId()
|
||
subscription.shortIds[linkType] = targetShortId
|
||
}
|
||
|
||
// 更新短链接过期时间
|
||
const shortLinkData = {
|
||
url: subscription.clashUrl,
|
||
type: subscription.subscriptionType || 'clash',
|
||
subscriptionId,
|
||
outputType: linkType,
|
||
subscriptionMode: subscription.subscriptionMode
|
||
}
|
||
|
||
const putOptions = expirationTime
|
||
? { expirationTtl: expirationTime, metadata: { renewedAt: Date.now() } }
|
||
: { metadata: { renewedAt: Date.now(), permanent: true } }
|
||
|
||
await SUBSCRIPTION_KV.put(targetShortId, JSON.stringify(shortLinkData), putOptions)
|
||
|
||
// 保存该短链的过期时间
|
||
subscription.shortIdsExpiry[linkType] = newExpiryTime
|
||
|
||
// 更新订阅过期时间(取所有短链中最晚的)
|
||
const allExpiries = Object.values(subscription.shortIdsExpiry).filter(t => t !== null)
|
||
subscription.expiresAt = allExpiries.length > 0 ? Math.max(...allExpiries) : null
|
||
subscription.expiration = finalExpiration
|
||
subscription.updatedAt = Date.now()
|
||
|
||
await SUBSCRIPTION_KV.put(`sub:${subscriptionId}`, JSON.stringify(subscription))
|
||
|
||
subscription.isExpired = false
|
||
|
||
return jsonResponse({
|
||
success: true,
|
||
subscription,
|
||
message: `已续期: ${finalExpiration}`,
|
||
shortId: targetShortId,
|
||
shortUrl: `${url.origin}/s/${targetShortId}`
|
||
})
|
||
|
||
} catch (error) {
|
||
return jsonResponse({ error: error.message }, 500)
|
||
}
|
||
}
|
||
|
||
// 重新生成短链
|
||
if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+\/regenerate$/) && request.method === 'POST') {
|
||
try {
|
||
const subscriptionId = url.pathname.split('/')[3]
|
||
const { expiration = '7d', outputType, linkType = 'auto' } = await request.json()
|
||
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') {
|
||
return jsonResponse({ error: 'KV 未配置' }, 500)
|
||
}
|
||
|
||
const data = await SUBSCRIPTION_KV.get(`sub:${subscriptionId}`)
|
||
if (!data) {
|
||
return jsonResponse({ error: '订阅不存在' }, 404)
|
||
}
|
||
|
||
const subscription = JSON.parse(data)
|
||
|
||
subscription.shortIds = subscription.shortIds || {}
|
||
subscription.shortIdsExpiry = subscription.shortIdsExpiry || {}
|
||
|
||
// 临时订阅限制有效期
|
||
let finalExpiration = expiration
|
||
if (subscription.subscriptionMode === 'temporary') {
|
||
if (!['1d', '3d', '7d'].includes(expiration)) {
|
||
finalExpiration = '3d'
|
||
}
|
||
}
|
||
|
||
// 删除该类型的旧短链接(如果存在)
|
||
const oldShortId = subscription.shortIds[linkType]
|
||
if (oldShortId) {
|
||
await SUBSCRIPTION_KV.delete(oldShortId)
|
||
}
|
||
|
||
// 生成新短链接
|
||
const newShortId = generateShortId()
|
||
const expirationTime = parseExpiration(finalExpiration)
|
||
const newExpiryTime = expirationTime ? Date.now() + expirationTime * 1000 : null
|
||
|
||
// 更新输出类型(如果提供)
|
||
if (outputType) {
|
||
subscription.outputType = outputType
|
||
}
|
||
|
||
const shortLinkData = {
|
||
url: subscription.clashUrl,
|
||
type: subscription.subscriptionType || 'clash',
|
||
subscriptionId,
|
||
outputType: linkType,
|
||
subscriptionMode: subscription.subscriptionMode
|
||
}
|
||
|
||
const putOptions = expirationTime
|
||
? { expirationTtl: expirationTime, metadata: { regeneratedAt: Date.now() } }
|
||
: { metadata: { regeneratedAt: Date.now(), permanent: true } }
|
||
|
||
await SUBSCRIPTION_KV.put(newShortId, JSON.stringify(shortLinkData), putOptions)
|
||
|
||
// 更新订阅中该类型的短链
|
||
subscription.shortIds[linkType] = newShortId
|
||
subscription.shortIdsExpiry[linkType] = newExpiryTime
|
||
|
||
// 更新订阅过期时间(取所有短链中最晚的)
|
||
const allExpiries = Object.values(subscription.shortIdsExpiry).filter(t => t !== null)
|
||
subscription.expiresAt = allExpiries.length > 0 ? Math.max(...allExpiries) : null
|
||
subscription.expiration = finalExpiration
|
||
subscription.updatedAt = Date.now()
|
||
|
||
await SUBSCRIPTION_KV.put(`sub:${subscriptionId}`, JSON.stringify(subscription))
|
||
|
||
subscription.isExpired = false
|
||
|
||
return jsonResponse({
|
||
success: true,
|
||
subscription,
|
||
message: '已生成新短链',
|
||
shortId: newShortId,
|
||
shortUrl: `${url.origin}/s/${newShortId}`
|
||
})
|
||
|
||
} catch (error) {
|
||
return jsonResponse({ error: error.message }, 500)
|
||
}
|
||
}
|
||
|
||
// ========== 管理页和API ==========
|
||
|
||
|
||
// 管理页(需要 ADMIN_TOKEN)
|
||
if (url.pathname === '/admin' && request.method === 'GET') {
|
||
const adminToken = getAdminToken()
|
||
if (!adminToken) return new Response('管理功能未启用', { status: 404 })
|
||
const requestToken = request.headers.get('x-admin-token') || url.searchParams.get('token')
|
||
if (requestToken !== adminToken) return new Response('未授权', { status: 401 })
|
||
return new Response(getAdminHTML(), {
|
||
headers: { 'Content-Type': 'text/html;charset=UTF-8' }
|
||
})
|
||
}
|
||
|
||
// 列出短链
|
||
if (url.pathname === '/admin/list' && request.method === 'GET') {
|
||
const adminToken = getAdminToken()
|
||
if (!adminToken) return new Response('管理功能未启用', { status: 404 })
|
||
const requestToken = request.headers.get('x-admin-token') || url.searchParams.get('token')
|
||
if (requestToken !== adminToken) return new Response('未授权', { status: 401 })
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') return new Response('KV 未配置', { status: 500 })
|
||
|
||
const cursor = url.searchParams.get('cursor') || undefined
|
||
const listResult = await SUBSCRIPTION_KV.list({ limit: 50, cursor })
|
||
|
||
const keys = listResult.keys.map(key => ({
|
||
id: key.name,
|
||
expiration: key.expiration || null,
|
||
metadata: key.metadata || null
|
||
}))
|
||
|
||
return jsonResponse({
|
||
keys,
|
||
cursor: listResult.cursor || null,
|
||
list_complete: listResult.list_complete
|
||
})
|
||
}
|
||
|
||
// 续期短链
|
||
if (url.pathname === '/admin/renew' && request.method === 'POST') {
|
||
const adminToken = getAdminToken()
|
||
if (!adminToken) return new Response('管理功能未启用', { status: 404 })
|
||
const requestToken = request.headers.get('x-admin-token') || url.searchParams.get('token')
|
||
if (requestToken !== adminToken) return new Response('未授权', { status: 401 })
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') return new Response('KV 未配置', { status: 500 })
|
||
|
||
const { id, days = 7 } = await request.json()
|
||
if (!id) return jsonResponse({ error: '缺少短链 id' }, 400)
|
||
|
||
const ttlDays = Math.max(1, parseInt(days, 10) || 7)
|
||
const expirationTime = ttlDays * 24 * 60 * 60
|
||
|
||
const { value, metadata } = await SUBSCRIPTION_KV.getWithMetadata(id, { type: 'text' })
|
||
if (!value) return jsonResponse({ error: '短链不存在或已过期' }, 404)
|
||
|
||
const newMetadata = Object.assign({}, metadata || {}, { renewedAt: Date.now() })
|
||
await SUBSCRIPTION_KV.put(id, value, {
|
||
expirationTtl: expirationTime,
|
||
metadata: newMetadata
|
||
})
|
||
|
||
return jsonResponse({ ok: true, expiresIn: expirationTime })
|
||
}
|
||
|
||
// 删除短链
|
||
if (url.pathname === '/admin/delete' && request.method === 'POST') {
|
||
const adminToken = getAdminToken()
|
||
if (!adminToken) return new Response('管理功能未启用', { status: 404 })
|
||
const requestToken = request.headers.get('x-admin-token') || url.searchParams.get('token')
|
||
if (requestToken !== adminToken) return new Response('未授权', { status: 401 })
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') return new Response('KV 未配置', { status: 500 })
|
||
|
||
const { id } = await request.json()
|
||
if (!id) return jsonResponse({ error: '缺少短链 id' }, 400)
|
||
|
||
await SUBSCRIPTION_KV.delete(id)
|
||
return jsonResponse({ ok: true })
|
||
}
|
||
|
||
return new Response('Not Found', { status: 404 })
|
||
}
|
||
|
||
// ========== 辅助函数 ==========
|
||
|
||
// 检测订阅类型
|
||
async function detectSubscriptionType(subscriptionUrl) {
|
||
try {
|
||
const response = await fetch(subscriptionUrl, {
|
||
headers: { 'User-Agent': 'ClashForWindows/0.20.39' },
|
||
cf: { cacheTtl: 0, cacheEverything: false }
|
||
})
|
||
|
||
if (!response.ok) return 'unknown'
|
||
|
||
const text = await response.text()
|
||
|
||
// 尝试 Base64 解码检测 v2ray
|
||
try {
|
||
const decoded = atob(text.trim())
|
||
if (decoded.includes('vmess://') || decoded.includes('ss://') ||
|
||
decoded.includes('trojan://') || decoded.includes('vless://')) {
|
||
return 'v2ray'
|
||
}
|
||
} catch (e) {
|
||
// 不是 base64,继续检测
|
||
}
|
||
|
||
// 检测是否是 Clash YAML 格式
|
||
if (text.includes('proxies:') || text.includes('proxy-groups:')) {
|
||
return 'clash'
|
||
}
|
||
|
||
// 直接包含协议链接(未编码的 v2ray)
|
||
if (text.includes('vmess://') || text.includes('ss://') ||
|
||
text.includes('trojan://') || text.includes('vless://')) {
|
||
return 'v2ray'
|
||
}
|
||
|
||
return 'unknown'
|
||
} catch (error) {
|
||
console.error('检测订阅类型失败:', error)
|
||
return 'unknown'
|
||
}
|
||
}
|
||
|
||
// 解析过期时间(支持:7, 30d, 3m, 1y, unlimited)
|
||
function parseExpiration(input) {
|
||
if (!input || input === 'unlimited' || input === 'never') {
|
||
return null // 无限期
|
||
}
|
||
|
||
const str = String(input).trim().toLowerCase()
|
||
|
||
// 纯数字,默认天
|
||
if (/^\d+$/.test(str)) {
|
||
return parseInt(str) * 24 * 60 * 60
|
||
}
|
||
|
||
// 带单位
|
||
const match = str.match(/^(\d+)([dmy])$/)
|
||
if (match) {
|
||
const value = parseInt(match[1])
|
||
const unit = match[2]
|
||
|
||
if (unit === 'd') return value * 24 * 60 * 60
|
||
if (unit === 'm') return value * 30 * 24 * 60 * 60
|
||
if (unit === 'y') return value * 365 * 24 * 60 * 60
|
||
}
|
||
|
||
// 默认 7 天
|
||
return 7 * 24 * 60 * 60
|
||
}
|
||
|
||
// V2Ray 转 Clash(简化版,支持基本协议)
|
||
async function convertV2rayToClash(v2rayUrl) {
|
||
const response = await fetch(v2rayUrl, {
|
||
headers: { 'User-Agent': 'v2rayN/6.0' },
|
||
cf: { cacheTtl: 0, cacheEverything: false }
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`下载订阅失败: ${response.status}`)
|
||
}
|
||
|
||
let text = await response.text()
|
||
|
||
// 尝试 Base64 解码
|
||
try {
|
||
text = atob(text.trim())
|
||
} catch (e) {
|
||
// 已经是解码状态
|
||
}
|
||
|
||
const lines = text.split('\n').filter(l => l.trim())
|
||
const proxies = []
|
||
|
||
for (const line of lines) {
|
||
try {
|
||
if (line.startsWith('vmess://')) {
|
||
proxies.push(parseVmessToClash(line))
|
||
} else if (line.startsWith('ss://')) {
|
||
proxies.push(parseShadowsocksToClash(line))
|
||
} else if (line.startsWith('trojan://')) {
|
||
proxies.push(parseTrojanToClash(line))
|
||
}
|
||
} catch (e) {
|
||
console.error('解析节点失败:', e)
|
||
}
|
||
}
|
||
|
||
if (proxies.length === 0) {
|
||
throw new Error('没有找到有效的节点')
|
||
}
|
||
|
||
// 生成 Clash YAML
|
||
const yaml = generateClashYAML(proxies)
|
||
return yaml
|
||
}
|
||
|
||
function parseVmessToClash(vmessLink) {
|
||
const json = JSON.parse(atob(vmessLink.substring(8)))
|
||
return {
|
||
name: json.ps || 'VMess',
|
||
type: 'vmess',
|
||
server: json.add,
|
||
port: parseInt(json.port),
|
||
uuid: json.id,
|
||
alterId: parseInt(json.aid || 0),
|
||
cipher: json.scy || 'auto',
|
||
network: json.net || 'tcp',
|
||
tls: json.tls === 'tls',
|
||
'skip-cert-verify': true
|
||
}
|
||
}
|
||
|
||
function parseShadowsocksToClash(ssLink) {
|
||
const url = new URL(ssLink)
|
||
const userinfo = atob(url.username)
|
||
const [cipher, password] = userinfo.split(':')
|
||
|
||
return {
|
||
name: decodeURIComponent(url.hash.substring(1)) || 'SS',
|
||
type: 'ss',
|
||
server: url.hostname,
|
||
port: parseInt(url.port),
|
||
cipher: cipher,
|
||
password: password
|
||
}
|
||
}
|
||
|
||
function parseTrojanToClash(trojanLink) {
|
||
const url = new URL(trojanLink)
|
||
|
||
return {
|
||
name: decodeURIComponent(url.hash.substring(1)) || 'Trojan',
|
||
type: 'trojan',
|
||
server: url.hostname,
|
||
port: parseInt(url.port),
|
||
password: url.username,
|
||
'skip-cert-verify': true
|
||
}
|
||
}
|
||
|
||
function generateClashYAML(proxies) {
|
||
let yaml = 'proxies:\n'
|
||
for (const proxy of proxies) {
|
||
yaml += ` - name: "${proxy.name}"\n`
|
||
yaml += ` type: ${proxy.type}\n`
|
||
yaml += ` server: ${proxy.server}\n`
|
||
yaml += ` port: ${proxy.port}\n`
|
||
|
||
if (proxy.type === 'vmess') {
|
||
yaml += ` uuid: ${proxy.uuid}\n`
|
||
yaml += ` alterId: ${proxy.alterId}\n`
|
||
yaml += ` cipher: ${proxy.cipher}\n`
|
||
if (proxy.tls) yaml += ` tls: true\n`
|
||
} else if (proxy.type === 'ss') {
|
||
yaml += ` cipher: ${proxy.cipher}\n`
|
||
yaml += ` password: ${proxy.password}\n`
|
||
} else if (proxy.type === 'trojan') {
|
||
yaml += ` password: ${proxy.password}\n`
|
||
yaml += ` skip-cert-verify: true\n`
|
||
}
|
||
}
|
||
return yaml
|
||
}
|
||
|
||
async function convertClashToV2ray(clashUrl) {
|
||
// 1. 下载 Clash 订阅配置
|
||
const response = await fetch(clashUrl, {
|
||
headers: {
|
||
'User-Agent': 'ClashForWindows/0.20.39'
|
||
},
|
||
cf: {
|
||
cacheTtl: 0,
|
||
cacheEverything: false
|
||
}
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`下载订阅失败: ${response.status} ${response.statusText}`)
|
||
}
|
||
|
||
const text = await response.text()
|
||
|
||
// 2. 解析 YAML (简化版解析器)
|
||
const config = parseSimpleYAML(text)
|
||
|
||
if (!config.proxies || !Array.isArray(config.proxies)) {
|
||
throw new Error('订阅格式错误: 找不到 proxies 节点')
|
||
}
|
||
|
||
// 3. 转换每个代理节点
|
||
const v2rayLinks = []
|
||
|
||
for (const proxy of config.proxies) {
|
||
try {
|
||
const link = convertProxyToV2ray(proxy)
|
||
if (link) {
|
||
v2rayLinks.push(link)
|
||
}
|
||
} catch (error) {
|
||
console.error(`转换节点失败: ${proxy.name}`, error)
|
||
// 继续处理其他节点
|
||
}
|
||
}
|
||
|
||
if (v2rayLinks.length === 0) {
|
||
throw new Error('没有成功转换的节点')
|
||
}
|
||
|
||
// 4. 生成最终订阅 (Base64 编码)
|
||
const allLinks = v2rayLinks.join('\n')
|
||
return btoa(unescape(encodeURIComponent(allLinks)))
|
||
}
|
||
|
||
function convertProxyToV2ray(proxy) {
|
||
const name = encodeURIComponent(proxy.name || 'node')
|
||
const sniValue = proxy.sni || proxy.servername || ''
|
||
const fingerprintValue = proxy.fingerprint || proxy['client-fingerprint'] || ''
|
||
|
||
// Shadowsocks
|
||
if (proxy.type === 'ss') {
|
||
const userInfo = `${proxy.cipher}:${proxy.password}`
|
||
const userInfoB64 = btoa(userInfo)
|
||
|
||
// 处理插件和插件选项
|
||
let plugin = ''
|
||
if (proxy.plugin) {
|
||
plugin = `?plugin=${encodeURIComponent(proxy.plugin)}`
|
||
if (proxy['plugin-opts']) {
|
||
const opts = Object.entries(proxy['plugin-opts'])
|
||
.map(([k, v]) => `${k}=${v}`)
|
||
.join(';')
|
||
if (opts) {
|
||
plugin += encodeURIComponent(';' + opts)
|
||
}
|
||
}
|
||
}
|
||
|
||
return `ss://${userInfoB64}@${proxy.server}:${proxy.port}${plugin}#${name}`
|
||
}
|
||
|
||
// VMess
|
||
if (proxy.type === 'vmess') {
|
||
const net = proxy.network || 'tcp'
|
||
const vmessData = {
|
||
v: '2',
|
||
ps: proxy.name || 'node',
|
||
add: proxy.server,
|
||
port: String(proxy.port),
|
||
id: proxy.uuid,
|
||
aid: String(proxy.alterId || 0),
|
||
scy: proxy.cipher || 'auto',
|
||
net: net,
|
||
type: 'none', // 伪装类型,默认none
|
||
host: '',
|
||
path: '',
|
||
tls: proxy.tls ? 'tls' : '',
|
||
sni: sniValue,
|
||
alpn: ''
|
||
}
|
||
|
||
if (proxy['skip-cert-verify']) {
|
||
vmessData.allowInsecure = true
|
||
}
|
||
|
||
// 处理 WebSocket
|
||
if (net === 'ws' && proxy['ws-opts']) {
|
||
vmessData.path = proxy['ws-opts'].path || '/'
|
||
vmessData.host = proxy['ws-opts'].headers?.Host || sniValue || ''
|
||
}
|
||
|
||
// 处理 HTTP/2
|
||
if (net === 'h2' && proxy['h2-opts']) {
|
||
vmessData.path = proxy['h2-opts'].path || '/'
|
||
vmessData.host = Array.isArray(proxy['h2-opts'].host)
|
||
? proxy['h2-opts'].host.join(',')
|
||
: (proxy['h2-opts'].host || '')
|
||
}
|
||
|
||
// 处理 gRPC
|
||
if (net === 'grpc' && proxy['grpc-opts']) {
|
||
vmessData.path = proxy['grpc-opts']['grpc-service-name'] || ''
|
||
vmessData.type = 'gun' // gRPC 的 type
|
||
}
|
||
|
||
// 处理 ALPN
|
||
if (proxy.alpn) {
|
||
vmessData.alpn = Array.isArray(proxy.alpn)
|
||
? proxy.alpn.join(',')
|
||
: proxy.alpn
|
||
}
|
||
|
||
// 处理 fingerprint (TLS)
|
||
if (fingerprintValue) {
|
||
vmessData.fp = fingerprintValue
|
||
}
|
||
|
||
const vmessJson = JSON.stringify(vmessData)
|
||
const vmessB64 = btoa(unescape(encodeURIComponent(vmessJson)))
|
||
return `vmess://${vmessB64}`
|
||
}
|
||
|
||
// Trojan
|
||
if (proxy.type === 'trojan') {
|
||
const params = new URLSearchParams()
|
||
|
||
if (sniValue) params.set('sni', sniValue)
|
||
if (proxy.alpn) {
|
||
const alpn = Array.isArray(proxy.alpn) ? proxy.alpn.join(',') : proxy.alpn
|
||
params.set('alpn', alpn)
|
||
}
|
||
if (proxy['skip-cert-verify']) params.set('allowInsecure', '1')
|
||
|
||
// WebSocket 传输
|
||
if (proxy.network === 'ws' || proxy['ws-opts']) {
|
||
params.set('type', 'ws')
|
||
if (proxy['ws-opts']?.path) params.set('path', proxy['ws-opts'].path)
|
||
if (proxy['ws-opts']?.headers?.Host) params.set('host', proxy['ws-opts'].headers.Host)
|
||
}
|
||
|
||
// gRPC 传输
|
||
if (proxy.network === 'grpc' || proxy['grpc-opts']) {
|
||
params.set('type', 'grpc')
|
||
if (proxy['grpc-opts']?.['grpc-service-name']) {
|
||
params.set('serviceName', proxy['grpc-opts']['grpc-service-name'])
|
||
}
|
||
if (proxy['grpc-opts']?.mode) {
|
||
params.set('mode', proxy['grpc-opts'].mode)
|
||
}
|
||
}
|
||
|
||
const query = params.toString() ? `?${params.toString()}` : ''
|
||
return `trojan://${proxy.password}@${proxy.server}:${proxy.port}${query}#${name}`
|
||
}
|
||
|
||
// VLESS (部分 Clash 版本支持)
|
||
if (proxy.type === 'vless') {
|
||
const params = new URLSearchParams()
|
||
|
||
params.set('encryption', proxy.cipher || 'none')
|
||
if (proxy.flow) params.set('flow', proxy.flow)
|
||
|
||
// TLS/Reality 设置
|
||
if (proxy.tls) {
|
||
params.set('security', 'tls')
|
||
if (sniValue) params.set('sni', sniValue)
|
||
if (proxy['skip-cert-verify']) params.set('allowInsecure', '1')
|
||
if (fingerprintValue) params.set('fp', fingerprintValue)
|
||
}
|
||
|
||
// Reality 协议支持
|
||
if (proxy['reality-opts']) {
|
||
params.set('security', 'reality')
|
||
if (proxy['reality-opts']['server-name'] && !sniValue) {
|
||
params.set('sni', proxy['reality-opts']['server-name'])
|
||
} else if (sniValue) {
|
||
params.set('sni', sniValue)
|
||
}
|
||
if (proxy['reality-opts']['public-key']) {
|
||
params.set('pbk', proxy['reality-opts']['public-key'])
|
||
}
|
||
if (proxy['reality-opts']['short-id']) {
|
||
params.set('sid', proxy['reality-opts']['short-id'])
|
||
}
|
||
if (proxy['reality-opts']['fingerprint']) {
|
||
params.set('fp', proxy['reality-opts']['fingerprint'])
|
||
} else if (fingerprintValue) {
|
||
params.set('fp', fingerprintValue)
|
||
}
|
||
if (proxy['reality-opts'].dest) {
|
||
params.set('dest', proxy['reality-opts'].dest)
|
||
}
|
||
if (proxy['reality-opts'].spiderx || proxy['reality-opts'].spiderX) {
|
||
params.set('spx', proxy['reality-opts'].spiderx || proxy['reality-opts'].spiderX)
|
||
}
|
||
}
|
||
|
||
// 传输协议
|
||
const network = proxy.network || 'tcp'
|
||
params.set('type', network)
|
||
|
||
if (network === 'ws' && proxy['ws-opts']) {
|
||
if (proxy['ws-opts'].path) params.set('path', proxy['ws-opts'].path)
|
||
const wsHost = proxy['ws-opts'].headers?.Host || sniValue
|
||
if (wsHost) params.set('host', wsHost)
|
||
}
|
||
|
||
// XHTTP (httpupgrade) 传输
|
||
if (network === 'xhttp' && proxy['xhttp-opts']) {
|
||
if (proxy['xhttp-opts'].path) params.set('path', proxy['xhttp-opts'].path)
|
||
const xhHost = proxy['xhttp-opts'].host || sniValue
|
||
if (xhHost) params.set('host', xhHost)
|
||
if (proxy['xhttp-opts'].mode) params.set('mode', proxy['xhttp-opts'].mode)
|
||
}
|
||
|
||
if (network === 'grpc' && proxy['grpc-opts']) {
|
||
params.set('serviceName', proxy['grpc-opts']['grpc-service-name'] || '')
|
||
if (proxy['grpc-opts'].mode) params.set('mode', proxy['grpc-opts'].mode)
|
||
}
|
||
|
||
if (network === 'h2' && proxy['h2-opts']) {
|
||
if (proxy['h2-opts'].path) params.set('path', proxy['h2-opts'].path)
|
||
if (proxy['h2-opts'].host) {
|
||
const host = Array.isArray(proxy['h2-opts'].host)
|
||
? proxy['h2-opts'].host.join(',')
|
||
: proxy['h2-opts'].host
|
||
params.set('host', host)
|
||
} else if (sniValue) {
|
||
params.set('host', sniValue)
|
||
}
|
||
}
|
||
|
||
// ALPN
|
||
if (proxy.alpn) {
|
||
const alpn = Array.isArray(proxy.alpn) ? proxy.alpn.join(',') : proxy.alpn
|
||
params.set('alpn', alpn)
|
||
}
|
||
|
||
return `vless://${proxy.uuid}@${proxy.server}:${proxy.port}?${params.toString()}#${name}`
|
||
}
|
||
|
||
// Hysteria (v1)
|
||
if (proxy.type === 'hysteria') {
|
||
const params = new URLSearchParams()
|
||
|
||
if (proxy.protocol) params.set('protocol', proxy.protocol)
|
||
if (proxy.up) params.set('upmbps', proxy.up)
|
||
if (proxy.down) params.set('downmbps', proxy.down)
|
||
if (proxy.obfs) params.set('obfs', proxy.obfs)
|
||
if (proxy['obfs-password']) params.set('obfsParam', proxy['obfs-password'])
|
||
if (proxy.alpn) {
|
||
const alpn = Array.isArray(proxy.alpn) ? proxy.alpn.join(',') : proxy.alpn
|
||
params.set('alpn', alpn)
|
||
}
|
||
if (proxy['skip-cert-verify']) params.set('insecure', '1')
|
||
if (sniValue) params.set('peer', sniValue)
|
||
|
||
const auth = proxy.auth || proxy.password || ''
|
||
return `hysteria://${proxy.server}:${proxy.port}?auth=${encodeURIComponent(auth)}&${params.toString()}#${name}`
|
||
}
|
||
|
||
// Hysteria2
|
||
if (proxy.type === 'hysteria2' || proxy.type === 'hy2') {
|
||
const params = new URLSearchParams()
|
||
|
||
if (proxy.password) params.set('password', proxy.password)
|
||
if (proxy.obfs) {
|
||
params.set('obfs', proxy.obfs)
|
||
if (proxy['obfs-password']) params.set('obfs-password', proxy['obfs-password'])
|
||
}
|
||
if (sniValue) params.set('sni', sniValue)
|
||
if (proxy['skip-cert-verify']) params.set('insecure', '1')
|
||
if (fingerprintValue) params.set('pinSHA256', fingerprintValue)
|
||
|
||
return `hysteria2://${proxy.password}@${proxy.server}:${proxy.port}?${params.toString()}#${name}`
|
||
}
|
||
|
||
console.log(`不支持的协议类型: ${proxy.type}`)
|
||
return null
|
||
}
|
||
|
||
// 增强的 YAML 解析器(支持嵌套对象)
|
||
function parseSimpleYAML(yamlText) {
|
||
const config = { proxies: [] }
|
||
const lines = yamlText.split('\n')
|
||
let inProxies = false
|
||
let currentProxy = null
|
||
let currentNested = null
|
||
let nestedKey = null
|
||
|
||
for (let line of lines) {
|
||
const trimmed = line.trim()
|
||
|
||
if (trimmed === 'proxies:') {
|
||
inProxies = true
|
||
continue
|
||
}
|
||
|
||
if (!inProxies) continue
|
||
|
||
// 检测新的代理节点 ( - name: xxx)
|
||
if (line.match(/^ - name:/)) {
|
||
if (currentProxy) {
|
||
config.proxies.push(currentProxy)
|
||
}
|
||
currentProxy = {}
|
||
currentNested = null
|
||
nestedKey = null
|
||
const match = line.match(/name:\s*(.+)/)
|
||
if (match) currentProxy.name = match[1].replace(/['"]/g, '').trim()
|
||
continue
|
||
}
|
||
|
||
// 检测嵌套对象开始 ( xxx-opts:) 或数组开始
|
||
if (currentProxy && line.match(/^ [\w-]+:/)) {
|
||
const match = line.match(/^ ([\w-]+):\s*(.*)$/)
|
||
if (match) {
|
||
const key = match[1]
|
||
let value = match[2].trim()
|
||
|
||
// 如果值为空,表示这是一个嵌套对象或数组的开始
|
||
if (!value || value === '') {
|
||
nestedKey = key
|
||
// 检查下一行是否是数组项(以 - 开头)
|
||
currentNested = {}
|
||
currentProxy[key] = currentNested
|
||
} else {
|
||
// 普通键值对
|
||
value = parseValue(value)
|
||
currentProxy[key] = value
|
||
currentNested = null
|
||
nestedKey = null
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
|
||
// 处理数组项 ( - value)
|
||
if (currentProxy && line.match(/^ - /)) {
|
||
const match = line.match(/^ - (.+)/)
|
||
if (match && nestedKey) {
|
||
const value = parseValue(match[1])
|
||
// 如果还不是数组,转换为数组
|
||
if (!Array.isArray(currentProxy[nestedKey])) {
|
||
currentProxy[nestedKey] = []
|
||
}
|
||
currentProxy[nestedKey].push(value)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// 解析嵌套对象的属性 ( key: value)
|
||
if (currentNested && line.match(/^ [\w-]+:/)) {
|
||
const match = line.match(/^ ([\w-]+):\s*(.+)/)
|
||
if (match) {
|
||
const key = match[1]
|
||
let value = parseValue(match[2])
|
||
|
||
// 特殊处理 headers
|
||
if ((nestedKey === 'ws-opts' || nestedKey === 'h2-opts') && key === 'headers') {
|
||
currentNested.headers = {}
|
||
continue
|
||
}
|
||
|
||
currentNested[key] = value
|
||
}
|
||
continue
|
||
}
|
||
|
||
// 解析更深层嵌套 ( Host: xxx)
|
||
if (currentNested && line.match(/^ [\w-]+:/)) {
|
||
const match = line.match(/^ ([\w-]+):\s*(.+)/)
|
||
if (match) {
|
||
const key = match[1]
|
||
const value = parseValue(match[2])
|
||
|
||
if (currentNested.headers) {
|
||
currentNested.headers[key] = value
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
|
||
// 检测新的 section(退出 proxies)
|
||
if (inProxies && line.match(/^[\w-]+:/) && !line.match(/^ /)) {
|
||
if (currentProxy) {
|
||
config.proxies.push(currentProxy)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
|
||
if (currentProxy) {
|
||
config.proxies.push(currentProxy)
|
||
}
|
||
|
||
return config
|
||
}
|
||
|
||
// 解析 YAML 值
|
||
function parseValue(str) {
|
||
// 去掉行内注释(# 之后的内容)
|
||
const noComment = str.split('#')[0]
|
||
let value = noComment.replace(/['"]/g, '').trim()
|
||
|
||
// 数字
|
||
if (/^\d+$/.test(value)) {
|
||
return parseInt(value)
|
||
}
|
||
// 布尔值
|
||
if (value === 'true') return true
|
||
if (value === 'false') return false
|
||
|
||
// 数组 [a, b, c]
|
||
if (value.startsWith('[') && value.endsWith(']')) {
|
||
return value.slice(1, -1).split(',').map(v => v.trim())
|
||
}
|
||
|
||
return value
|
||
}
|
||
|
||
// 生成短ID
|
||
function generateShortId() {
|
||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||
let result = ''
|
||
for (let i = 0; i < 8; i++) {
|
||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||
}
|
||
return result
|
||
}
|
||
|
||
// 获取订阅列表
|
||
async function getSubscriptionList() {
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') return []
|
||
const data = await SUBSCRIPTION_KV.get('subscriptions:list')
|
||
return data ? JSON.parse(data) : []
|
||
}
|
||
|
||
// 添加到订阅列表
|
||
async function addToSubscriptionList(subscriptionId) {
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') return
|
||
const list = await getSubscriptionList()
|
||
if (!list.includes(subscriptionId)) {
|
||
list.push(subscriptionId)
|
||
await SUBSCRIPTION_KV.put('subscriptions:list', JSON.stringify(list))
|
||
}
|
||
}
|
||
|
||
// 从订阅列表中移除
|
||
async function removeFromSubscriptionList(subscriptionId) {
|
||
if (typeof SUBSCRIPTION_KV === 'undefined') return
|
||
const list = await getSubscriptionList()
|
||
const newList = list.filter(id => id !== subscriptionId)
|
||
await SUBSCRIPTION_KV.put('subscriptions:list', JSON.stringify(newList))
|
||
}
|
||
|
||
function getAdminToken() {
|
||
if (typeof ADMIN_TOKEN === 'undefined') return null
|
||
return ADMIN_TOKEN || null
|
||
}
|
||
|
||
function jsonResponse(data, status = 200) {
|
||
return new Response(JSON.stringify(data), {
|
||
status,
|
||
headers: { 'Content-Type': 'application/json' }
|
||
})
|
||
}
|
||
|
||
function getHTML() {
|
||
return `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Subscription Manager</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--primary: #4f46e5;
|
||
--primary-hover: #4338ca;
|
||
--primary-light: #eef2ff;
|
||
--primary-transparent: rgba(79, 70, 229, 0.1);
|
||
|
||
--success: #10b981;
|
||
--success-light: #d1fae5;
|
||
--warning: #f59e0b;
|
||
--warning-light: #fef3c7;
|
||
--danger: #ef4444;
|
||
--danger-light: #fee2e2;
|
||
|
||
--bg-body: #f8fafc;
|
||
--bg-surface: #ffffff;
|
||
--bg-sidebar: rgba(255, 255, 255, 0.8);
|
||
|
||
--text-main: #0f172a;
|
||
--text-secondary: #64748b;
|
||
--text-tertiary: #94a3b8;
|
||
|
||
--border: #e2e8f0;
|
||
--radius-xl: 24px;
|
||
--radius-lg: 16px;
|
||
--radius-md: 12px;
|
||
--radius-sm: 8px;
|
||
|
||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||
--shadow-float: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-font-smoothing: antialiased; }
|
||
|
||
body {
|
||
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||
background-color: var(--bg-body);
|
||
color: var(--text-main);
|
||
height: 100vh;
|
||
display: flex;
|
||
padding: 24px;
|
||
overflow: hidden;
|
||
background-image:
|
||
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.15) 0px, transparent 50%),
|
||
radial-gradient(at 100% 100%, rgba(236, 72, 153, 0.15) 0px, transparent 50%);
|
||
}
|
||
|
||
.app-container {
|
||
width: 100%;
|
||
max-width: 1400px;
|
||
height: 100%;
|
||
margin: 0 auto;
|
||
background: rgba(255, 255, 255, 0.7);
|
||
backdrop-filter: blur(20px);
|
||
border-radius: var(--radius-xl);
|
||
box-shadow: var(--shadow-2xl);
|
||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||
display: flex;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Sidebar */
|
||
.sidebar {
|
||
width: 340px;
|
||
background: var(--bg-sidebar);
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
z-index: 10;
|
||
}
|
||
|
||
.sidebar-header {
|
||
padding: 32px 24px;
|
||
}
|
||
|
||
.app-title {
|
||
font-size: 24px;
|
||
font-weight: 800;
|
||
background: linear-gradient(135deg, var(--primary) 0%, #818cf8 100%);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.app-logo {
|
||
width: 32px;
|
||
height: 32px;
|
||
background: linear-gradient(135deg, var(--primary), #818cf8);
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: white;
|
||
font-size: 18px;
|
||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
||
}
|
||
|
||
.app-subtitle {
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
font-weight: 500;
|
||
padding-left: 44px;
|
||
}
|
||
|
||
.sidebar-actions {
|
||
padding: 0 24px 20px;
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.btn-add {
|
||
flex: 1;
|
||
background: var(--primary);
|
||
color: white;
|
||
border: none;
|
||
padding: 12px;
|
||
border-radius: var(--radius-md);
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
box-shadow: 0 4px 6px rgba(79, 70, 229, 0.25);
|
||
}
|
||
|
||
.btn-add:hover {
|
||
background: var(--primary-hover);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 12px rgba(79, 70, 229, 0.3);
|
||
}
|
||
|
||
.btn-icon {
|
||
width: 42px;
|
||
height: 42px;
|
||
border-radius: var(--radius-md);
|
||
border: 1px solid var(--border);
|
||
background: white;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.btn-icon:hover {
|
||
border-color: var(--primary);
|
||
color: var(--primary);
|
||
background: var(--primary-light);
|
||
}
|
||
|
||
.dropdown-container {
|
||
position: relative;
|
||
}
|
||
|
||
.dropdown-menu {
|
||
display: none;
|
||
position: absolute;
|
||
top: 48px;
|
||
right: 0;
|
||
background: white;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-md);
|
||
box-shadow: var(--shadow-lg);
|
||
min-width: 200px;
|
||
z-index: 1000;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.dropdown-menu.active {
|
||
display: block;
|
||
animation: fadeIn 0.2s ease;
|
||
}
|
||
|
||
.dropdown-item {
|
||
padding: 12px 16px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--text-main);
|
||
transition: all 0.2s;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.dropdown-item:hover {
|
||
background: var(--bg-body);
|
||
}
|
||
|
||
.dropdown-item.danger {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.dropdown-item.danger:hover {
|
||
background: var(--danger-light);
|
||
}
|
||
|
||
.subscription-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 0 16px 24px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.sub-item {
|
||
padding: 18px;
|
||
background: white;
|
||
border: 1px solid transparent;
|
||
border-radius: var(--radius-lg);
|
||
cursor: pointer;
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
position: relative;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.sub-item:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: var(--shadow-md);
|
||
border-color: var(--primary-light);
|
||
}
|
||
|
||
.sub-item.active {
|
||
background: var(--bg-surface);
|
||
border-color: var(--primary);
|
||
box-shadow: var(--shadow-lg);
|
||
border-width: 2px;
|
||
}
|
||
|
||
.sub-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.sub-name {
|
||
font-weight: 700;
|
||
font-size: 15px;
|
||
color: var(--text-main);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 160px;
|
||
}
|
||
|
||
.badge-group {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.badge {
|
||
font-size: 10px;
|
||
padding: 4px 8px;
|
||
border-radius: 6px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.badge-clash { background: #e0f2fe; color: #0284c7; }
|
||
.badge-v2ray { background: #dcfce7; color: #16a34a; }
|
||
.badge-temp { background: #fffbeb; color: #d97706; }
|
||
.badge-perm { background: #f1f5f9; color: #475569; }
|
||
.badge-expired { background: #fee2e2; color: #dc2626; }
|
||
.badge-expiring { background: #ffedd5; color: #ea580c; }
|
||
|
||
.sub-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding-top: 10px;
|
||
border-top: 1px solid var(--border);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.sub-info-text {
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* Main Content */
|
||
.main-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow-y: auto;
|
||
position: relative;
|
||
background: rgba(255, 255, 255, 0.5);
|
||
}
|
||
|
||
.empty-state {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--text-tertiary);
|
||
animation: fadeIn 0.5s ease;
|
||
}
|
||
|
||
.empty-icon {
|
||
font-size: 80px;
|
||
margin-bottom: 24px;
|
||
opacity: 0.8;
|
||
filter: drop-shadow(0 10px 10px rgba(0,0,0,0.1));
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.detail-view {
|
||
padding: 48px 64px;
|
||
width: 100%;
|
||
max-width: 1000px;
|
||
margin: 0 auto;
|
||
animation: slideUp 0.4s ease;
|
||
}
|
||
|
||
.detail-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 40px;
|
||
}
|
||
|
||
.detail-title-group h2 {
|
||
font-size: 32px;
|
||
font-weight: 800;
|
||
color: var(--text-main);
|
||
margin-bottom: 12px;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
.detail-meta {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.action-bar {
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.btn-action {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 20px;
|
||
border-radius: var(--radius-md);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
border: 1px solid var(--border);
|
||
background: white;
|
||
color: var(--text-secondary);
|
||
transition: all 0.2s;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.btn-action:hover {
|
||
background: var(--bg-body);
|
||
color: var(--text-main);
|
||
border-color: #cbd5e1;
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.btn-danger {
|
||
color: var(--danger);
|
||
border-color: var(--danger-light);
|
||
background: #fffafa;
|
||
}
|
||
|
||
.btn-danger:hover {
|
||
background: var(--danger-light);
|
||
border-color: #fca5a5;
|
||
color: #dc2626;
|
||
}
|
||
|
||
.card {
|
||
background: white;
|
||
border: 1px solid rgba(226, 232, 240, 0.8);
|
||
border-radius: var(--radius-lg);
|
||
padding: 32px;
|
||
margin-bottom: 32px;
|
||
box-shadow: var(--shadow-md);
|
||
transition: transform 0.3s ease;
|
||
}
|
||
|
||
.card:hover {
|
||
box-shadow: var(--shadow-lg);
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 24px;
|
||
padding-bottom: 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: var(--text-main);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.info-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 32px;
|
||
}
|
||
|
||
.info-item label {
|
||
display: block;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text-tertiary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.info-item div {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--text-main);
|
||
}
|
||
|
||
.full-width { grid-column: 1 / -1; }
|
||
|
||
.url-box {
|
||
background: var(--bg-body);
|
||
padding: 16px;
|
||
border-radius: var(--radius-md);
|
||
border: 1px solid var(--border);
|
||
color: var(--text-secondary);
|
||
font-family: 'Menlo', 'Monaco', monospace;
|
||
font-size: 13px;
|
||
word-break: break-all;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* Tabs */
|
||
.tab-container {
|
||
display: flex;
|
||
background: var(--bg-body);
|
||
padding: 4px;
|
||
border-radius: 14px;
|
||
width: fit-content;
|
||
}
|
||
|
||
.tab-btn {
|
||
padding: 10px 24px;
|
||
border-radius: 10px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--text-secondary);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.tab-btn.active {
|
||
background: white;
|
||
color: var(--primary);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
/* URL Generator Area */
|
||
.generator-box {
|
||
margin-top: 24px;
|
||
position: relative;
|
||
}
|
||
|
||
.input-wrapper {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.url-input {
|
||
width: 100%;
|
||
padding: 18px 120px 18px 20px;
|
||
background: var(--bg-body);
|
||
border: 2px solid transparent;
|
||
border-radius: var(--radius-md);
|
||
font-family: 'Monaco', monospace;
|
||
font-size: 14px;
|
||
color: var(--text-main);
|
||
outline: none;
|
||
transition: all 0.2s;
|
||
box-shadow: inset 0 2px 4px rgba(0,0,0,0.02);
|
||
}
|
||
|
||
.url-input:focus {
|
||
background: white;
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 4px var(--primary-light);
|
||
}
|
||
|
||
.btn-copy-absolute {
|
||
position: absolute;
|
||
right: 8px;
|
||
background: white;
|
||
border: 1px solid var(--border);
|
||
padding: 8px 16px;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text-main);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.btn-copy-absolute:hover {
|
||
color: var(--primary);
|
||
border-color: var(--primary);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.link-status-bar {
|
||
margin-top: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
background: var(--bg-body);
|
||
padding: 12px 16px;
|
||
border-radius: var(--radius-md);
|
||
}
|
||
|
||
.progress-track {
|
||
flex: 1;
|
||
height: 10px;
|
||
background: #e2e8f0;
|
||
border-radius: 5px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
background: var(--success);
|
||
border-radius: 5px;
|
||
width: 0%;
|
||
transition: width 1s ease, background-color 0.3s;
|
||
}
|
||
|
||
.status-text {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
min-width: 100px;
|
||
text-align: right;
|
||
}
|
||
|
||
.action-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-top: 24px;
|
||
padding-top: 24px;
|
||
border-top: 1px dashed var(--border);
|
||
}
|
||
|
||
/* Modals */
|
||
.modal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(15, 23, 42, 0.4);
|
||
backdrop-filter: blur(8px);
|
||
z-index: 100;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.modal.active { display: flex; opacity: 1; }
|
||
|
||
.modal-content {
|
||
background: white;
|
||
border-radius: var(--radius-xl);
|
||
padding: 40px;
|
||
width: 90%;
|
||
max-width: 520px;
|
||
box-shadow: var(--shadow-float);
|
||
transform: scale(0.95);
|
||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||
}
|
||
|
||
.modal.active .modal-content { transform: scale(1); }
|
||
|
||
.modal-header {
|
||
font-size: 24px;
|
||
font-weight: 800;
|
||
color: var(--text-main);
|
||
margin-bottom: 32px;
|
||
text-align: center;
|
||
}
|
||
|
||
.form-group { margin-bottom: 24px; }
|
||
|
||
.form-label {
|
||
display: block;
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
color: var(--text-main);
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.form-input, .form-select {
|
||
width: 100%;
|
||
padding: 14px 16px;
|
||
border: 2px solid var(--border);
|
||
border-radius: var(--radius-md);
|
||
font-size: 15px;
|
||
outline: none;
|
||
transition: all 0.2s;
|
||
background: var(--bg-body);
|
||
}
|
||
|
||
.form-input:focus, .form-select:focus {
|
||
border-color: var(--primary);
|
||
background: white;
|
||
box-shadow: 0 0 0 4px var(--primary-light);
|
||
}
|
||
|
||
.form-hint {
|
||
font-size: 12px;
|
||
color: var(--text-tertiary);
|
||
margin-top: 8px;
|
||
padding-left: 4px;
|
||
}
|
||
|
||
.modal-footer {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-top: 40px;
|
||
}
|
||
|
||
.btn-primary {
|
||
flex: 2;
|
||
background: var(--primary);
|
||
color: white;
|
||
border: none;
|
||
padding: 16px;
|
||
border-radius: var(--radius-lg);
|
||
font-weight: 700;
|
||
font-size: 15px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: var(--primary-hover);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.4);
|
||
}
|
||
|
||
.btn-secondary {
|
||
flex: 1;
|
||
background: white;
|
||
color: var(--text-secondary);
|
||
border: 2px solid var(--border);
|
||
padding: 16px;
|
||
border-radius: var(--radius-lg);
|
||
font-weight: 700;
|
||
font-size: 15px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
border-color: #cbd5e1;
|
||
background: var(--bg-body);
|
||
color: var(--text-main);
|
||
}
|
||
|
||
/* Animations */
|
||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||
|
||
/* Scrollbar */
|
||
::-webkit-scrollbar { width: 8px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- (HTML structure is same, omitted to save token usage perception, but technically tool output is not token heavy) -->
|
||
<!-- I will re-output the whole thing to be safe -->
|
||
<div class="app-container">
|
||
<!-- Sidebar -->
|
||
<div class="sidebar">
|
||
<div class="sidebar-header">
|
||
<div class="app-title">
|
||
<div class="app-logo">⚡️</div>
|
||
<span>SubManager</span>
|
||
</div>
|
||
<div class="app-subtitle">Cloudflare Subscription Tool</div>
|
||
</div>
|
||
|
||
<div class="sidebar-actions">
|
||
<button class="btn-add" onclick="showAddModal()">
|
||
<span>+</span> New Subscription
|
||
</button>
|
||
<div class="dropdown-container">
|
||
<button class="btn-icon" onclick="toggleDropdown()" title="More Options">
|
||
⋮
|
||
</button>
|
||
<div id="dropdownMenu" class="dropdown-menu">
|
||
<div class="dropdown-item danger" onclick="clearAllSubscriptions()">
|
||
🗑️ Clear All Data
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="subscriptionList" class="subscription-list">
|
||
<!-- List items injected by JS -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content -->
|
||
<div class="main-content">
|
||
<div id="emptyState" class="empty-state">
|
||
<div class="empty-icon">👋</div>
|
||
<div class="empty-text">Select a subscription to manage</div>
|
||
</div>
|
||
|
||
<div id="detailView" class="detail-view" style="display: none;">
|
||
<div class="detail-header">
|
||
<div class="detail-title-group">
|
||
<h2 id="detailTitle">Subscription Name</h2>
|
||
<div class="detail-meta">
|
||
<span id="detailTypeBadge" class="badge"></span>
|
||
<span id="detailModeBadge" class="badge"></span>
|
||
</div>
|
||
</div>
|
||
<div class="action-bar">
|
||
<button class="btn-action" onclick="editSubscription()">✏️ Edit</button>
|
||
<button class="btn-action btn-danger" onclick="deleteCurrentSubscription()">🗑️ Delete</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div class="card-title">📝 Basic Information</div>
|
||
</div>
|
||
<div class="info-grid">
|
||
<div class="info-item">
|
||
<label>Source Type</label>
|
||
<div id="detailSourceType">-</div>
|
||
</div>
|
||
<div class="info-item">
|
||
<label>Created At</label>
|
||
<div id="detailCreatedAt">-</div>
|
||
</div>
|
||
<div class="info-item">
|
||
<label>Expires At</label>
|
||
<div id="detailExpiration">-</div>
|
||
</div>
|
||
<div class="info-item full-width">
|
||
<label>Source URL</label>
|
||
<div id="detailOriginalUrl" class="url-box">-</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div class="card-title">🔗 Converter Link</div>
|
||
<div class="tab-container">
|
||
<button class="tab-btn active" data-type="clash" onclick="switchLinkType('clash')">Clash</button>
|
||
<button class="tab-btn" data-type="v2ray" onclick="switchLinkType('v2ray')">V2Ray</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="generator-box">
|
||
<div class="input-wrapper">
|
||
<input type="text" id="detailShortUrl" class="url-input" readonly placeholder="Generating...">
|
||
<button class="btn-copy-absolute" onclick="copyShortUrl(event)">Copy Link</button>
|
||
</div>
|
||
|
||
<div class="link-status-bar">
|
||
<span style="font-size:12px; color:var(--text-tertiary);">VALIDITY</span>
|
||
<div class="progress-track">
|
||
<div id="linkProgressBar" class="progress-fill"></div>
|
||
</div>
|
||
<div id="linkProgressText" class="status-text">--</div>
|
||
</div>
|
||
|
||
<div class="action-row">
|
||
<button class="btn-action" style="color: var(--primary); border-color: var(--primary-light); width: 100%; justify-content: center;" onclick="renewSubscription()">🔄 Renew Validity</button>
|
||
<button class="btn-action" style="width: 100%; justify-content: center;" onclick="regenerateShortUrl()">⚡️ Regenerate Token</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Add/Edit Modal -->
|
||
<div id="subscriptionModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header" id="modalTitle">New Subscription</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Name</label>
|
||
<input type="text" id="subName" class="form-input" placeholder="e.g. My Premium Node">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Source URL</label>
|
||
<input type="url" id="subClashUrl" class="form-input" placeholder="https://...">
|
||
<div class="form-hint">Supports Clash or V2Ray/SS/VMess links</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Mode</label>
|
||
<select id="subMode" class="form-select" onchange="onSubscriptionModeChange()">
|
||
<option value="permanent">Permanent (Long-term)</option>
|
||
<option value="temporary">Temporary (Short-term)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="expirationGroup" class="form-group">
|
||
<label class="form-label">Short Link Expiration</label>
|
||
<div id="permanentExpiration">
|
||
<select id="subPermanentExpiration" class="form-select">
|
||
<option value="7d">7 Days</option>
|
||
<option value="1m">1 Month</option>
|
||
<option value="3m">3 Months</option>
|
||
<option value="1y" selected>1 Year</option>
|
||
<option value="unlimited">Unlimited</option>
|
||
</select>
|
||
</div>
|
||
<div id="temporaryExpiration" style="display: none;">
|
||
<select id="subTempExpiration" class="form-select">
|
||
<option value="1d">1 Day</option>
|
||
<option value="3d" selected>3 Days</option>
|
||
<option value="7d">7 Days</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn-secondary" onclick="closeModal()">Cancel</button>
|
||
<button class="btn-primary" onclick="saveSubscription()">Save Subscription</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Renew Modal -->
|
||
<div id="renewModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header" id="renewModalTitle">Renew Link</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">New Duration</label>
|
||
<div id="renewPermanentExpiration">
|
||
<select id="renewPermanentExp" class="form-select">
|
||
<option value="7d">7 Days</option>
|
||
<option value="1m">1 Month</option>
|
||
<option value="3m">3 Months</option>
|
||
<option value="1y" selected>1 Year</option>
|
||
<option value="unlimited">Unlimited</option>
|
||
</select>
|
||
</div>
|
||
<div id="renewTemporaryExpiration" style="display: none;">
|
||
<select id="renewTempExpiration" class="form-select">
|
||
<option value="1d">1 Day</option>
|
||
<option value="3d" selected>3 Days</option>
|
||
<option value="7d">7 Days</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn-secondary" onclick="closeRenewModal()">Cancel</button>
|
||
<button class="btn-primary" onclick="confirmRenew()">Confirm</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Alert Modal -->
|
||
<div id="customAlertModal" class="modal">
|
||
<div class="modal-content" style="max-width: 400px; text-align: center;">
|
||
<div class="modal-header" id="customAlertTitle" style="font-size: 20px; margin-bottom: 16px;">Notice</div>
|
||
<div id="customAlertMessage" style="color: var(--text-secondary); margin-bottom: 32px; line-height: 1.6;"></div>
|
||
<button class="btn-primary" style="width: 100%;" onclick="closeCustomAlert()">OK</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Confirm Modal -->
|
||
<div id="customConfirmModal" class="modal">
|
||
<div class="modal-content" style="max-width: 400px; text-align: center;">
|
||
<div class="modal-header" id="customConfirmTitle" style="font-size: 20px; margin-bottom: 16px;">Confirm</div>
|
||
<div id="customConfirmMessage" style="color: var(--text-secondary); margin-bottom: 32px; line-height: 1.6;"></div>
|
||
<div class="modal-footer" style="margin-top: 24px;">
|
||
<button class="btn-secondary" onclick="closeCustomConfirm()">Cancel</button>
|
||
<button class="btn-primary" onclick="executeCustomConfirm()">Confirm</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let currentEditId = null;
|
||
let currentRenewId = null;
|
||
let currentRenewAction = 'renew';
|
||
let currentSelectedId = null;
|
||
let allSubscriptions = [];
|
||
let currentLinkType = 'clash';
|
||
let confirmCallback = null;
|
||
|
||
window.onload = loadSubscriptions;
|
||
|
||
// --- Custom Dialog ---
|
||
function showCustomAlert(message, title = 'Notice') {
|
||
document.getElementById('customAlertTitle').textContent = title;
|
||
document.getElementById('customAlertMessage').textContent = message;
|
||
document.getElementById('customAlertModal').classList.add('active');
|
||
}
|
||
|
||
function closeCustomAlert() {
|
||
document.getElementById('customAlertModal').classList.remove('active');
|
||
}
|
||
|
||
function showCustomConfirm(message, callback, title = 'Confirm Action') {
|
||
document.getElementById('customConfirmTitle').textContent = title;
|
||
document.getElementById('customConfirmMessage').textContent = message;
|
||
confirmCallback = callback;
|
||
document.getElementById('customConfirmModal').classList.add('active');
|
||
}
|
||
|
||
function closeCustomConfirm() {
|
||
document.getElementById('customConfirmModal').classList.remove('active');
|
||
confirmCallback = null;
|
||
}
|
||
|
||
function executeCustomConfirm() {
|
||
document.getElementById('customConfirmModal').classList.remove('active');
|
||
if (confirmCallback) confirmCallback();
|
||
confirmCallback = null;
|
||
}
|
||
|
||
// --- Logic ---
|
||
function onSubscriptionModeChange() {
|
||
const mode = document.getElementById('subMode').value;
|
||
document.getElementById('permanentExpiration').style.display = mode === 'permanent' ? 'block' : 'none';
|
||
document.getElementById('temporaryExpiration').style.display = mode === 'temporary' ? 'block' : 'none';
|
||
}
|
||
|
||
async function loadSubscriptions() {
|
||
try {
|
||
const res = await fetch('/api/subscriptions');
|
||
const data = await res.json();
|
||
allSubscriptions = data.subscriptions || [];
|
||
renderList();
|
||
|
||
if (allSubscriptions.length > 0 && !currentSelectedId) {
|
||
selectSubscription(allSubscriptions[0].id);
|
||
} else if (allSubscriptions.length === 0) {
|
||
showEmptyState();
|
||
}
|
||
} catch (e) { console.error(e); }
|
||
}
|
||
|
||
function renderList() {
|
||
const container = document.getElementById('subscriptionList');
|
||
if (allSubscriptions.length === 0) {
|
||
container.innerHTML = '<div style="text-align:center; padding:32px; color:var(--text-tertiary);">No subscriptions found</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = allSubscriptions.map(sub => {
|
||
const active = sub.id === currentSelectedId ? 'active' : '';
|
||
const typeText = sub.subscriptionType === 'v2ray' ? 'V2Ray' : 'Clash';
|
||
const typeClass = sub.subscriptionType === 'v2ray' ? 'badge-v2ray' : 'badge-clash';
|
||
const modeText = sub.subscriptionMode === 'temporary' ? 'TEMP' : 'PERM';
|
||
|
||
let statusHtml = '';
|
||
if (sub.subscriptionMode === 'permanent' && sub.expiresAt) {
|
||
const days = (sub.expiresAt - Date.now()) / 86400000;
|
||
if (sub.isExpired || days <= 0) statusHtml = '<span class="badge badge-expired">Expired</span>';
|
||
else if (days <= 15) statusHtml = '<span class="badge badge-expiring">Expiring</span>';
|
||
}
|
||
|
||
return \`<div class="sub-item \${active}" onclick="selectSubscription('\${sub.id}')">
|
||
<div class="sub-header">
|
||
<div class="sub-name">\${escapeHtml(sub.name)}</div>
|
||
<div class="badge-group">
|
||
<span class="badge \${typeClass}">\${typeText}</span>
|
||
\${statusHtml}
|
||
</div>
|
||
</div>
|
||
<div class="sub-footer">
|
||
<span class="sub-info-text">\${modeText}</span>
|
||
<span class="sub-info-text">\${sub.expiresAt ? formatDateSimple(sub.expiresAt) : 'Unlimited'}</span>
|
||
</div>
|
||
</div>\`;
|
||
}).join('');
|
||
}
|
||
|
||
function selectSubscription(id) {
|
||
currentSelectedId = id;
|
||
renderList();
|
||
|
||
const sub = allSubscriptions.find(s => s.id === id);
|
||
if (!sub) return;
|
||
|
||
document.getElementById('emptyState').style.display = 'none';
|
||
document.getElementById('detailView').style.display = 'block';
|
||
|
||
document.getElementById('detailTitle').textContent = sub.name;
|
||
|
||
// Badges
|
||
const typeBadge = document.getElementById('detailTypeBadge');
|
||
typeBadge.className = 'badge ' + (sub.subscriptionType === 'v2ray' ? 'badge-v2ray' : 'badge-clash');
|
||
typeBadge.textContent = sub.subscriptionType === 'v2ray' ? 'V2Ray' : 'Clash';
|
||
|
||
const modeBadge = document.getElementById('detailModeBadge');
|
||
modeBadge.className = 'badge ' + (sub.subscriptionMode === 'temporary' ? 'badge-temp' : 'badge-perm');
|
||
modeBadge.textContent = sub.subscriptionMode === 'temporary' ? 'Temporary' : 'Permanent';
|
||
|
||
// Info
|
||
document.getElementById('detailSourceType').textContent = sub.subscriptionType === 'v2ray' ? 'V2Ray / VLESS / VMess' : 'Clash YAML';
|
||
document.getElementById('detailCreatedAt').textContent = formatDate(sub.createdAt);
|
||
document.getElementById('detailExpiration').textContent = sub.expiresAt ? formatDate(sub.expiresAt) : 'Unlimited';
|
||
document.getElementById('detailOriginalUrl').textContent = sub.clashUrl;
|
||
|
||
// Link logic
|
||
const defaultType = sub.subscriptionType === 'v2ray' ? 'v2ray' : 'clash';
|
||
switchLinkType(defaultType); // Reset to default preference or keep state? Resetting is safer for now.
|
||
}
|
||
|
||
function showEmptyState() {
|
||
document.getElementById('emptyState').style.display = 'flex';
|
||
document.getElementById('detailView').style.display = 'none';
|
||
currentSelectedId = null;
|
||
}
|
||
|
||
async function switchLinkType(type) {
|
||
if (!currentSelectedId) return;
|
||
currentLinkType = type;
|
||
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.type === type);
|
||
});
|
||
|
||
const sub = allSubscriptions.find(s => s.id === currentSelectedId);
|
||
const shortId = sub?.shortIds?.[type];
|
||
const expiry = sub?.shortIdsExpiry?.[type];
|
||
|
||
if (shortId) {
|
||
document.getElementById('detailShortUrl').value = window.location.origin + '/s/' + shortId;
|
||
updateLinkStatus(expiry);
|
||
} else {
|
||
generateLink(type);
|
||
}
|
||
}
|
||
|
||
async function generateLink(type) {
|
||
const sub = allSubscriptions.find(s => s.id === currentSelectedId);
|
||
if (!sub) return;
|
||
|
||
const input = document.getElementById('detailShortUrl');
|
||
input.value = 'Generating...';
|
||
document.getElementById('linkProgressBar').style.width = '100%';
|
||
document.getElementById('linkProgressBar').style.backgroundColor = 'var(--warning)';
|
||
document.getElementById('linkProgressText').textContent = 'Processing';
|
||
|
||
try {
|
||
const res = await fetch('/api/subscriptions/' + currentSelectedId + '/renew', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
expiration: sub.expiration || '7d',
|
||
linkType: type
|
||
})
|
||
});
|
||
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error);
|
||
|
||
input.value = data.shortUrl;
|
||
await loadSubscriptions();
|
||
const updated = allSubscriptions.find(s => s.id === currentSelectedId);
|
||
updateLinkStatus(updated?.shortIdsExpiry?.[type]);
|
||
} catch (e) {
|
||
input.value = 'Error generating link';
|
||
document.getElementById('linkProgressBar').style.backgroundColor = 'var(--danger)';
|
||
}
|
||
}
|
||
|
||
function updateLinkStatus(expiry) {
|
||
const bar = document.getElementById('linkProgressBar');
|
||
const text = document.getElementById('linkProgressText');
|
||
|
||
if (!expiry) {
|
||
bar.style.width = '100%';
|
||
bar.style.backgroundColor = 'var(--success)';
|
||
text.textContent = 'Unlimited';
|
||
return;
|
||
}
|
||
|
||
const now = Date.now();
|
||
const remaining = expiry - now;
|
||
|
||
if (remaining <= 0) {
|
||
bar.style.width = '0%';
|
||
bar.style.backgroundColor = 'var(--danger)';
|
||
text.textContent = 'Expired';
|
||
return;
|
||
}
|
||
|
||
const total = 7 * 86400000; // Base reference
|
||
const pct = Math.min(100, Math.max(5, (remaining / total) * 100));
|
||
|
||
bar.style.width = pct + '%';
|
||
bar.style.backgroundColor = pct < 20 ? 'var(--danger)' : (pct < 50 ? 'var(--warning)' : 'var(--success)');
|
||
|
||
const days = Math.ceil(remaining / 86400000);
|
||
text.textContent = days > 1 ? \`\${days} Days\` : \`\${Math.ceil(remaining / 3600000)} Hours\`;
|
||
}
|
||
|
||
// Actions
|
||
function showAddModal() {
|
||
document.getElementById('modalTitle').textContent = 'New Subscription';
|
||
document.getElementById('subName').value = '';
|
||
document.getElementById('subClashUrl').value = '';
|
||
document.getElementById('subMode').value = 'permanent';
|
||
onSubscriptionModeChange();
|
||
// Show expiration field for new subscriptions
|
||
document.getElementById('expirationGroup').style.display = 'block';
|
||
currentEditId = null;
|
||
document.getElementById('subscriptionModal').classList.add('active');
|
||
}
|
||
|
||
function editSubscription() {
|
||
if (!currentSelectedId) return;
|
||
const sub = allSubscriptions.find(s => s.id === currentSelectedId);
|
||
|
||
document.getElementById('modalTitle').textContent = 'Edit Subscription';
|
||
document.getElementById('subName').value = sub.name;
|
||
document.getElementById('subClashUrl').value = sub.clashUrl;
|
||
document.getElementById('subMode').value = sub.subscriptionMode || 'permanent';
|
||
onSubscriptionModeChange();
|
||
// Hide expiration field when editing
|
||
document.getElementById('expirationGroup').style.display = 'none';
|
||
currentEditId = currentSelectedId;
|
||
document.getElementById('subscriptionModal').classList.add('active');
|
||
}
|
||
|
||
async function saveSubscription() {
|
||
const name = document.getElementById('subName').value.trim();
|
||
const url = document.getElementById('subClashUrl').value.trim();
|
||
const mode = document.getElementById('subMode').value;
|
||
const expiration = mode === 'temporary'
|
||
? document.getElementById('subTempExpiration').value
|
||
: document.getElementById('subPermanentExpiration').value;
|
||
|
||
if (!name || !url) {
|
||
showCustomAlert('Please fill in all fields');
|
||
return;
|
||
}
|
||
|
||
const endpoint = currentEditId ? '/api/subscriptions/' + currentEditId : '/api/subscriptions';
|
||
const method = currentEditId ? 'PUT' : 'POST';
|
||
|
||
try {
|
||
const res = await fetch(endpoint, {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, clashUrl: url, expiration, subscriptionMode: mode })
|
||
});
|
||
|
||
if (!res.ok) throw new Error((await res.json()).error);
|
||
|
||
closeModal();
|
||
await loadSubscriptions();
|
||
} catch (e) {
|
||
showCustomAlert('Failed: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function deleteCurrentSubscription() {
|
||
showCustomConfirm('Are you sure you want to delete this?', async () => {
|
||
await fetch('/api/subscriptions/' + currentSelectedId, { method: 'DELETE' });
|
||
currentSelectedId = null;
|
||
await loadSubscriptions();
|
||
});
|
||
}
|
||
|
||
async function clearAllSubscriptions() {
|
||
// Close dropdown if open
|
||
const dropdown = document.getElementById('dropdownMenu');
|
||
if (dropdown) dropdown.classList.remove('active');
|
||
|
||
showCustomConfirm('WARNING: This will delete ALL subscriptions! Continue?', async () => {
|
||
await fetch('/api/subscriptions/clear-all', { method: 'POST' });
|
||
currentSelectedId = null;
|
||
await loadSubscriptions();
|
||
showCustomAlert('All data cleared');
|
||
});
|
||
}
|
||
|
||
function toggleDropdown() {
|
||
const dropdown = document.getElementById('dropdownMenu');
|
||
dropdown.classList.toggle('active');
|
||
}
|
||
|
||
function renewSubscription() { openRenewModal('renew'); }
|
||
function regenerateShortUrl() { openRenewModal('regenerate'); }
|
||
|
||
function openRenewModal(action) {
|
||
currentRenewAction = action;
|
||
currentRenewId = currentSelectedId;
|
||
const sub = allSubscriptions.find(s => s.id === currentSelectedId);
|
||
if (!sub) return;
|
||
|
||
document.getElementById('renewModalTitle').textContent = action === 'renew' ? 'Renew Validity' : 'Regenerate Token';
|
||
|
||
const isTemp = sub.subscriptionMode === 'temporary';
|
||
document.getElementById('renewPermanentExpiration').style.display = isTemp ? 'none' : 'block';
|
||
document.getElementById('renewTemporaryExpiration').style.display = isTemp ? 'block' : 'none';
|
||
|
||
document.getElementById('renewModal').classList.add('active');
|
||
}
|
||
|
||
async function confirmRenew() {
|
||
const sub = allSubscriptions.find(s => s.id === currentRenewId);
|
||
const expiration = sub.subscriptionMode === 'temporary'
|
||
? document.getElementById('renewTempExpiration').value
|
||
: document.getElementById('renewPermanentExp').value;
|
||
|
||
try {
|
||
await fetch(\`/api/subscriptions/\${currentRenewId}/\${currentRenewAction}\`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ expiration, linkType: currentLinkType })
|
||
});
|
||
closeRenewModal();
|
||
await loadSubscriptions();
|
||
|
||
// Refresh link view
|
||
const updated = allSubscriptions.find(s => s.id === currentRenewId);
|
||
document.getElementById('detailShortUrl').value = window.location.origin + '/s/' + updated.shortIds[currentLinkType];
|
||
updateLinkStatus(updated.shortIdsExpiry[currentLinkType]);
|
||
} catch (e) {
|
||
showCustomAlert('Operation failed');
|
||
}
|
||
}
|
||
|
||
// Utils
|
||
function closeModal() { document.getElementById('subscriptionModal').classList.remove('active'); }
|
||
function closeRenewModal() { document.getElementById('renewModal').classList.remove('active'); }
|
||
|
||
function copyShortUrl(e) {
|
||
const input = document.getElementById('detailShortUrl');
|
||
input.select();
|
||
document.execCommand('copy');
|
||
|
||
const btn = e.target;
|
||
const original = btn.textContent;
|
||
btn.textContent = 'Copied!';
|
||
btn.style.color = 'var(--success)';
|
||
setTimeout(() => {
|
||
btn.textContent = original;
|
||
btn.style.color = '';
|
||
}, 1000);
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function formatDate(ts) {
|
||
return new Date(ts).toLocaleString();
|
||
}
|
||
|
||
function formatDateSimple(ts) {
|
||
return new Date(ts).toLocaleDateString() + ' ' + new Date(ts).getHours() + ':' + new Date(ts).getMinutes().toString().padStart(2, '0');
|
||
}
|
||
|
||
window.onclick = function(e) {
|
||
if (e.target.classList.contains('modal')) e.target.classList.remove('active');
|
||
|
||
// Close dropdown if clicking outside
|
||
const dropdown = document.getElementById('dropdownMenu');
|
||
if (dropdown && !e.target.closest('.dropdown-container')) {
|
||
dropdown.classList.remove('active');
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>`
|
||
}
|
||
|
||
|
||
function getAdminHTML() {
|
||
return `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>短链管理</title>
|
||
<style>
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: #f5f7fb; margin: 0; padding: 20px; }
|
||
.card { background: white; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.08); padding: 24px; max-width: 960px; margin: 0 auto; }
|
||
h1 { margin: 0 0 8px 0; font-size: 22px; color: #222; }
|
||
.sub { color: #666; margin-bottom: 18px; font-size: 14px; }
|
||
.status { padding: 10px 12px; border-radius: 8px; margin-bottom: 14px; display: none; font-size: 14px; }
|
||
.status.success { background: #e6ffed; color: #1b7f3f; border: 1px solid #b7f5c6; }
|
||
.status.error { background: #ffeaea; color: #b42318; border: 1px solid #f5c2c0; }
|
||
.status.info { background: #e7f3ff; color: #0d4ea6; border: 1px solid #c3defc; }
|
||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||
th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid #eef1f6; font-size: 13px; color: #333; }
|
||
th { background: #fafbff; font-weight: 600; }
|
||
.actions button { margin-right: 8px; padding: 6px 10px; border: none; border-radius: 6px; cursor: pointer; font-size: 12px; }
|
||
.btn { background: #667eea; color: white; }
|
||
.btn:hover { opacity: 0.9; }
|
||
.btn-secondary { background: #f1f3f9; color: #333; }
|
||
.toolbar { display: flex; gap: 10px; align-items: center; }
|
||
.pill { display: inline-block; padding: 6px 10px; background: #eef2ff; color: #4543c2; border-radius: 20px; font-size: 12px; margin-left: 10px; }
|
||
.small { color: #777; font-size: 12px; }
|
||
.nowrap { white-space: nowrap; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="card">
|
||
<h1>短链管理</h1>
|
||
<div class="sub">使用 token 访问本页。可续期、删除现有短链。</div>
|
||
<div class="toolbar">
|
||
<button id="refreshBtn" class="actions btn" onclick="loadKeys()">刷新列表</button>
|
||
<span class="pill">默认续期 7 天</span>
|
||
</div>
|
||
<div id="status" class="status"></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th class="nowrap">到期时间</th>
|
||
<th>原始 Clash 链接</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tableBody"></tbody>
|
||
</table>
|
||
<div id="pagination" class="small" style="margin-top: 10px;"></div>
|
||
<div class="small" style="margin-top: 12px;">提示:当前列表最多显示 50 条,如需更多可使用 cursor 翻页。</div>
|
||
</div>
|
||
|
||
<script>
|
||
const urlParams = new URLSearchParams(location.search);
|
||
const adminToken = urlParams.get('token') || '';
|
||
const statusDiv = document.getElementById('status');
|
||
const tableBody = document.getElementById('tableBody');
|
||
const pagination = document.getElementById('pagination');
|
||
|
||
if (!adminToken) {
|
||
showStatus('缺少 token,请在地址栏添加 ?token=你的管理口令', 'error');
|
||
} else {
|
||
loadKeys();
|
||
}
|
||
|
||
async function loadKeys(cursor = '') {
|
||
if (!adminToken) return;
|
||
try {
|
||
showStatus('加载中...', 'info');
|
||
const url = cursor ? '/admin/list?cursor=' + encodeURIComponent(cursor) : '/admin/list';
|
||
const resp = await fetch(url, { headers: { 'x-admin-token': adminToken } });
|
||
if (!resp.ok) throw new Error('加载失败');
|
||
const data = await resp.json();
|
||
renderTable(data.keys || []);
|
||
renderPagination(data.cursor, data.list_complete);
|
||
showStatus('加载完成', 'success');
|
||
} catch (e) {
|
||
showStatus(e.message || '加载失败', 'error');
|
||
}
|
||
}
|
||
|
||
function renderTable(keys) {
|
||
if (!keys.length) {
|
||
tableBody.innerHTML = '<tr><td colspan="4">暂无数据</td></tr>';
|
||
return;
|
||
}
|
||
tableBody.innerHTML = keys.map(k => {
|
||
const clash = k.metadata && k.metadata.clashUrl ? k.metadata.clashUrl : '-';
|
||
const exp = k.expiration ? formatTs(k.expiration * 1000) : '无';
|
||
return '<tr>' +
|
||
'<td class="nowrap">' + k.id + '</td>' +
|
||
'<td class="nowrap">' + exp + '</td>' +
|
||
'<td style="word-break: break-all;">' + clash + '</td>' +
|
||
'<td class="actions nowrap">' +
|
||
'<button class="btn" onclick="renew(\'' + k.id + '\')">续期</button>' +
|
||
'<button class="btn-secondary" onclick="removeLink(\'' + k.id + '\')">删除</button>' +
|
||
'</td>' +
|
||
'</tr>';
|
||
}).join('');
|
||
}
|
||
|
||
function renderPagination(cursor, complete) {
|
||
if (complete) {
|
||
pagination.textContent = '已到达末尾';
|
||
} else if (cursor) {
|
||
pagination.innerHTML = '有更多结果,点击 <button class="btn-secondary" onclick="loadKeys(\'' + cursor + '\')">加载下一页</button>';
|
||
} else {
|
||
pagination.textContent = '';
|
||
}
|
||
}
|
||
|
||
async function renew(id) {
|
||
const days = prompt('续期天数', '7');
|
||
if (days === null) return;
|
||
try {
|
||
const resp = await fetch('/admin/renew', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'x-admin-token': adminToken
|
||
},
|
||
body: JSON.stringify({ id, days: Number(days) })
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.error || '续期失败');
|
||
}
|
||
showStatus('续期成功', 'success');
|
||
loadKeys();
|
||
} catch (e) {
|
||
showStatus(e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function removeLink(id) {
|
||
if (!confirm('确认删除短链 ' + id + ' ?')) return;
|
||
try {
|
||
const resp = await fetch('/admin/delete', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'x-admin-token': adminToken
|
||
},
|
||
body: JSON.stringify({ id })
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.error || '删除失败');
|
||
}
|
||
showStatus('删除成功', 'success');
|
||
loadKeys();
|
||
} catch (e) {
|
||
showStatus(e.message, 'error');
|
||
}
|
||
}
|
||
|
||
function formatTs(ts) {
|
||
const d = new Date(ts);
|
||
return d.toISOString().replace('T', ' ').slice(0, 19);
|
||
}
|
||
|
||
function showStatus(msg, type) {
|
||
statusDiv.textContent = msg;
|
||
statusDiv.className = 'status ' + type;
|
||
statusDiv.style.display = 'block';
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>`
|
||
}
|