Files
clash_subscriptions/tools/SubConverter/cf_worker_v2ray_converter.js
2025-12-20 23:31:34 +08:00

2844 lines
96 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>`
}