进一步完善,增加导入导出功能
This commit is contained in:
@@ -181,6 +181,15 @@ async function handleRequest(request) {
|
|||||||
return jsonResponse({ error: 'KV 未配置' }, 500)
|
return jsonResponse({ error: 'KV 未配置' }, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检测是否使用了本服务的短链接作为源
|
||||||
|
const selfShortLinkInfo = await checkSelfShortLink(clashUrl, url.origin)
|
||||||
|
if (selfShortLinkInfo.isSelfLink) {
|
||||||
|
return jsonResponse({
|
||||||
|
error: '不能使用本服务生成的短链接作为订阅源。请使用原始订阅链接,或直接复制现有订阅。',
|
||||||
|
hint: selfShortLinkInfo.originalUrl ? `原始订阅地址: ${selfShortLinkInfo.originalUrl}` : null
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
// 临时订阅限制有效期
|
// 临时订阅限制有效期
|
||||||
let finalExpiration = expiration
|
let finalExpiration = expiration
|
||||||
if (subscriptionMode === 'temporary') {
|
if (subscriptionMode === 'temporary') {
|
||||||
@@ -256,11 +265,15 @@ async function handleRequest(request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取单个订阅详情
|
// 获取单个订阅详情 (排除特殊路径: export, import, clear-all)
|
||||||
if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+$/) && request.method === 'GET') {
|
if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+$/) && request.method === 'GET') {
|
||||||
try {
|
|
||||||
const subscriptionId = url.pathname.split('/').pop()
|
const subscriptionId = url.pathname.split('/').pop()
|
||||||
|
|
||||||
|
// 跳过特殊路径,让后面的路由处理
|
||||||
|
if (['export', 'import', 'clear-all'].includes(subscriptionId)) {
|
||||||
|
// 继续到下一个路由
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
if (typeof SUBSCRIPTION_KV === 'undefined') {
|
if (typeof SUBSCRIPTION_KV === 'undefined') {
|
||||||
return jsonResponse({ error: 'KV 未配置' }, 500)
|
return jsonResponse({ error: 'KV 未配置' }, 500)
|
||||||
}
|
}
|
||||||
@@ -281,6 +294,7 @@ async function handleRequest(request) {
|
|||||||
return jsonResponse({ error: error.message }, 500)
|
return jsonResponse({ error: error.message }, 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 更新订阅
|
// 更新订阅
|
||||||
if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+$/) && request.method === 'PUT') {
|
if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+$/) && request.method === 'PUT') {
|
||||||
@@ -303,6 +317,28 @@ async function handleRequest(request) {
|
|||||||
if (updates.name) subscription.name = updates.name
|
if (updates.name) subscription.name = updates.name
|
||||||
if (updates.outputType) subscription.outputType = updates.outputType
|
if (updates.outputType) subscription.outputType = updates.outputType
|
||||||
|
|
||||||
|
// 更新订阅模式(长期/临时)
|
||||||
|
if (updates.subscriptionMode && ['permanent', 'temporary'].includes(updates.subscriptionMode)) {
|
||||||
|
subscription.subscriptionMode = updates.subscriptionMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新有效期
|
||||||
|
let expirationChanged = false
|
||||||
|
if (updates.expiration) {
|
||||||
|
// 临时订阅限制有效期
|
||||||
|
let finalExpiration = updates.expiration
|
||||||
|
if (subscription.subscriptionMode === 'temporary') {
|
||||||
|
if (!['1d', '3d', '7d'].includes(updates.expiration)) {
|
||||||
|
finalExpiration = '3d'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.expiration = finalExpiration
|
||||||
|
const expirationTime = parseExpiration(finalExpiration)
|
||||||
|
subscription.expiresAt = expirationTime ? Date.now() + expirationTime * 1000 : null
|
||||||
|
expirationChanged = true
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.clashUrl && updates.clashUrl !== subscription.clashUrl) {
|
if (updates.clashUrl && updates.clashUrl !== subscription.clashUrl) {
|
||||||
// 如果更新了 clashUrl,重新检测类型并更新所有已存在的短链接
|
// 如果更新了 clashUrl,重新检测类型并更新所有已存在的短链接
|
||||||
subscription.clashUrl = updates.clashUrl
|
subscription.clashUrl = updates.clashUrl
|
||||||
@@ -330,6 +366,34 @@ async function handleRequest(request) {
|
|||||||
|
|
||||||
subscription.updatedAt = Date.now()
|
subscription.updatedAt = Date.now()
|
||||||
|
|
||||||
|
// 如果有效期变更,更新所有短链的过期时间
|
||||||
|
if (expirationChanged && subscription.shortIds) {
|
||||||
|
const expirationTime = parseExpiration(subscription.expiration)
|
||||||
|
const newExpiryTime = expirationTime ? Date.now() + expirationTime * 1000 : null
|
||||||
|
|
||||||
|
for (const [linkType, shortId] of Object.entries(subscription.shortIds)) {
|
||||||
|
if (shortId) {
|
||||||
|
const shortLinkData = {
|
||||||
|
url: subscription.clashUrl,
|
||||||
|
type: subscription.subscriptionType || 'clash',
|
||||||
|
subscriptionId,
|
||||||
|
outputType: linkType,
|
||||||
|
subscriptionMode: subscription.subscriptionMode
|
||||||
|
}
|
||||||
|
|
||||||
|
const putOptions = expirationTime
|
||||||
|
? { expirationTtl: expirationTime, metadata: { updatedAt: Date.now() } }
|
||||||
|
: { metadata: { updatedAt: Date.now(), permanent: true } }
|
||||||
|
|
||||||
|
await SUBSCRIPTION_KV.put(shortId, JSON.stringify(shortLinkData), putOptions)
|
||||||
|
|
||||||
|
// 更新订阅中记录的短链过期时间
|
||||||
|
subscription.shortIdsExpiry = subscription.shortIdsExpiry || {}
|
||||||
|
subscription.shortIdsExpiry[linkType] = newExpiryTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 保存更新后的订阅
|
// 保存更新后的订阅
|
||||||
await SUBSCRIPTION_KV.put(`sub:${subscriptionId}`, JSON.stringify(subscription))
|
await SUBSCRIPTION_KV.put(`sub:${subscriptionId}`, JSON.stringify(subscription))
|
||||||
|
|
||||||
@@ -344,6 +408,288 @@ async function handleRequest(request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导出所有订阅(CSV格式)
|
||||||
|
if (url.pathname === '/api/subscriptions/export' && request.method === 'GET') {
|
||||||
|
try {
|
||||||
|
if (typeof SUBSCRIPTION_KV === 'undefined') {
|
||||||
|
return jsonResponse({ error: 'KV 未配置' }, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionIds = await getSubscriptionList()
|
||||||
|
const subscriptions = []
|
||||||
|
|
||||||
|
for (const id of subscriptionIds) {
|
||||||
|
const data = await SUBSCRIPTION_KV.get(`sub:${id}`)
|
||||||
|
if (data) {
|
||||||
|
subscriptions.push(JSON.parse(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 CSV 内容
|
||||||
|
const csvHeader = 'name,clashUrl,subscriptionType,subscriptionMode,expiration,createdAt,expiresAt'
|
||||||
|
const csvRows = subscriptions.map(sub => {
|
||||||
|
const name = `"${(sub.name || '').replace(/"/g, '""')}"`
|
||||||
|
const clashUrl = `"${(sub.clashUrl || '').replace(/"/g, '""')}"`
|
||||||
|
const subscriptionType = sub.subscriptionType || 'unknown'
|
||||||
|
const subscriptionMode = sub.subscriptionMode || 'permanent'
|
||||||
|
const expiration = sub.expiration || '7d'
|
||||||
|
const createdAt = sub.createdAt ? new Date(sub.createdAt).toISOString() : ''
|
||||||
|
const expiresAt = sub.expiresAt ? new Date(sub.expiresAt).toISOString() : ''
|
||||||
|
return `${name},${clashUrl},${subscriptionType},${subscriptionMode},${expiration},${createdAt},${expiresAt}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const csvContent = [csvHeader, ...csvRows].join('\n')
|
||||||
|
|
||||||
|
return new Response(csvContent, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv;charset=UTF-8',
|
||||||
|
'Content-Disposition': `attachment; filename="subscriptions_${new Date().toISOString().slice(0,10)}.csv"`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return jsonResponse({ error: error.message }, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入订阅
|
||||||
|
if (url.pathname === '/api/subscriptions/import' && request.method === 'POST') {
|
||||||
|
try {
|
||||||
|
if (typeof SUBSCRIPTION_KV === 'undefined') {
|
||||||
|
return jsonResponse({ error: 'KV 未配置' }, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { content, expiration = '7d', subscriptionMode = 'permanent' } = await request.json()
|
||||||
|
|
||||||
|
if (!content || !content.trim()) {
|
||||||
|
return jsonResponse({ error: '导入内容不能为空' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const importedSubscriptions = []
|
||||||
|
const errors = []
|
||||||
|
const lines = content.trim().split('\n')
|
||||||
|
|
||||||
|
// 检测格式:CSV 或自定义格式
|
||||||
|
const firstLine = lines[0].trim()
|
||||||
|
const isCSV = firstLine.toLowerCase().includes('name,') && firstLine.toLowerCase().includes('clashurl')
|
||||||
|
|
||||||
|
if (isCSV) {
|
||||||
|
// 跳过标题行,解析 CSV
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim()
|
||||||
|
if (!line) continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 简单 CSV 解析(支持引号包裹的字段)
|
||||||
|
const fields = parseCSVLine(line)
|
||||||
|
if (fields.length >= 2) {
|
||||||
|
const name = fields[0].trim()
|
||||||
|
const clashUrl = fields[1].trim()
|
||||||
|
|
||||||
|
if (name && clashUrl && (clashUrl.startsWith('http://') || clashUrl.startsWith('https://'))) {
|
||||||
|
importedSubscriptions.push({ name, clashUrl })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`行 ${i + 1}: 解析失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 自定义格式:[订阅名称]: [原始链接] 或 [订阅名称]: \n [原始链接]
|
||||||
|
// 也支持:[订阅名称] \n [原始链接](无冒号)
|
||||||
|
let currentName = null
|
||||||
|
let buffer = ''
|
||||||
|
let lineNumber = 0
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
lineNumber++
|
||||||
|
const trimmedLine = line.trim()
|
||||||
|
|
||||||
|
if (!trimmedLine) {
|
||||||
|
// 空行,检查是否有待处理的订阅
|
||||||
|
if (currentName && buffer) {
|
||||||
|
const clashUrl = buffer.trim()
|
||||||
|
if (clashUrl.startsWith('http://') || clashUrl.startsWith('https://')) {
|
||||||
|
importedSubscriptions.push({ name: currentName, clashUrl })
|
||||||
|
} else {
|
||||||
|
errors.push(`行 ${lineNumber}: URL 格式无效`)
|
||||||
|
}
|
||||||
|
currentName = null
|
||||||
|
buffer = ''
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是纯 URL 行
|
||||||
|
if (trimmedLine.startsWith('http://') || trimmedLine.startsWith('https://')) {
|
||||||
|
if (currentName) {
|
||||||
|
// 有等待中的名称,这个URL作为它的链接
|
||||||
|
importedSubscriptions.push({ name: currentName, clashUrl: trimmedLine })
|
||||||
|
currentName = null
|
||||||
|
buffer = ''
|
||||||
|
} else {
|
||||||
|
// 没有名称,使用 URL 的一部分作为名称
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(trimmedLine)
|
||||||
|
const autoName = urlObj.hostname.replace(/^www\./, '') + ' - ' + lineNumber
|
||||||
|
importedSubscriptions.push({ name: autoName, clashUrl: trimmedLine })
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`行 ${lineNumber}: URL 解析失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是 "名称: 链接" 或 "名称:" 格式
|
||||||
|
const colonIndex = trimmedLine.indexOf(':')
|
||||||
|
if (colonIndex > 0) {
|
||||||
|
// 先保存之前的订阅
|
||||||
|
if (currentName && buffer) {
|
||||||
|
const clashUrl = buffer.trim()
|
||||||
|
if (clashUrl.startsWith('http://') || clashUrl.startsWith('https://')) {
|
||||||
|
importedSubscriptions.push({ name: currentName, clashUrl })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const namePart = trimmedLine.substring(0, colonIndex).trim()
|
||||||
|
const urlPart = trimmedLine.substring(colonIndex + 1).trim()
|
||||||
|
|
||||||
|
if (urlPart && (urlPart.startsWith('http://') || urlPart.startsWith('https://'))) {
|
||||||
|
// 名称和链接在同一行
|
||||||
|
importedSubscriptions.push({ name: namePart, clashUrl: urlPart })
|
||||||
|
currentName = null
|
||||||
|
buffer = ''
|
||||||
|
} else {
|
||||||
|
// 只有名称或冒号后面不是 URL,链接可能在下一行
|
||||||
|
currentName = namePart
|
||||||
|
buffer = urlPart || ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没有冒号,可能是名称行
|
||||||
|
if (currentName && buffer) {
|
||||||
|
// 保存之前的
|
||||||
|
const clashUrl = buffer.trim()
|
||||||
|
if (clashUrl.startsWith('http://') || clashUrl.startsWith('https://')) {
|
||||||
|
importedSubscriptions.push({ name: currentName, clashUrl })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 当作新的名称
|
||||||
|
currentName = trimmedLine
|
||||||
|
buffer = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理最后一个订阅
|
||||||
|
if (currentName && buffer) {
|
||||||
|
const clashUrl = buffer.trim()
|
||||||
|
if (clashUrl.startsWith('http://') || clashUrl.startsWith('https://')) {
|
||||||
|
importedSubscriptions.push({ name: currentName, clashUrl })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importedSubscriptions.length === 0) {
|
||||||
|
return jsonResponse({
|
||||||
|
error: '没有找到有效的订阅数据。请确保格式正确:每行一个 "名称: URL" 或者使用 CSV 格式。',
|
||||||
|
errors,
|
||||||
|
hint: '支持的格式: 1) CSV格式(name,clashUrl,...) 2) 名称: URL 3) 名称换行URL',
|
||||||
|
debug: {
|
||||||
|
linesCount: lines.length,
|
||||||
|
firstLine: lines[0]?.substring(0, 50),
|
||||||
|
isCSV
|
||||||
|
}
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取现有订阅列表用于去重
|
||||||
|
const existingSubscriptionIds = await getSubscriptionList()
|
||||||
|
const existingUrls = new Set()
|
||||||
|
for (const id of existingSubscriptionIds) {
|
||||||
|
const data = await SUBSCRIPTION_KV.get(`sub:${id}`)
|
||||||
|
if (data) {
|
||||||
|
const sub = JSON.parse(data)
|
||||||
|
if (sub.clashUrl) {
|
||||||
|
existingUrls.add(sub.clashUrl.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建订阅(带去重)
|
||||||
|
const created = []
|
||||||
|
const duplicates = []
|
||||||
|
const selfLinks = []
|
||||||
|
for (const { name, clashUrl } of importedSubscriptions) {
|
||||||
|
// 检查是否重复
|
||||||
|
if (existingUrls.has(clashUrl.trim())) {
|
||||||
|
duplicates.push({ name, clashUrl })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是自引用短链接
|
||||||
|
const selfCheck = await checkSelfShortLink(clashUrl, url.origin)
|
||||||
|
if (selfCheck.isSelfLink) {
|
||||||
|
selfLinks.push({ name, clashUrl, originalUrl: selfCheck.originalUrl })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const subscriptionType = await detectSubscriptionType(clashUrl)
|
||||||
|
const subscriptionId = generateShortId()
|
||||||
|
|
||||||
|
let finalExpiration = expiration
|
||||||
|
if (subscriptionMode === 'temporary') {
|
||||||
|
if (!['1d', '3d', '7d'].includes(expiration)) {
|
||||||
|
finalExpiration = '3d'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationTime = parseExpiration(finalExpiration)
|
||||||
|
|
||||||
|
const subscription = {
|
||||||
|
id: subscriptionId,
|
||||||
|
name,
|
||||||
|
clashUrl,
|
||||||
|
subscriptionType,
|
||||||
|
subscriptionMode,
|
||||||
|
outputType: 'auto',
|
||||||
|
shortIds: {},
|
||||||
|
shortIdsExpiry: {},
|
||||||
|
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)
|
||||||
|
created.push({ id: subscriptionId, name })
|
||||||
|
// 添加到已存在集合,防止本次导入内的重复
|
||||||
|
existingUrls.add(clashUrl.trim())
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`"${name}": ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = `成功导入 ${created.length} 个订阅`
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
message += `,跳过 ${duplicates.length} 个重复`
|
||||||
|
}
|
||||||
|
if (selfLinks.length > 0) {
|
||||||
|
message += `,跳过 ${selfLinks.length} 个自引用短链接`
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
created,
|
||||||
|
duplicates: duplicates.length > 0 ? duplicates : undefined,
|
||||||
|
selfLinks: selfLinks.length > 0 ? selfLinks : undefined,
|
||||||
|
errors: errors.length > 0 ? errors : undefined
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return jsonResponse({ error: error.message }, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 清空所有KV数据 (必须放在删除订阅路由之前,否则会被正则匹配拦截)
|
// 清空所有KV数据 (必须放在删除订阅路由之前,否则会被正则匹配拦截)
|
||||||
if (url.pathname === '/api/subscriptions/clear-all' && request.method === 'POST') {
|
if (url.pathname === '/api/subscriptions/clear-all' && request.method === 'POST') {
|
||||||
try {
|
try {
|
||||||
@@ -666,6 +1012,50 @@ async function handleRequest(request) {
|
|||||||
|
|
||||||
// ========== 辅助函数 ==========
|
// ========== 辅助函数 ==========
|
||||||
|
|
||||||
|
// 检测是否使用了本服务的短链接作为源URL
|
||||||
|
async function checkSelfShortLink(inputUrl, currentOrigin) {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(inputUrl)
|
||||||
|
|
||||||
|
// 检查是否是同一个域名
|
||||||
|
if (parsedUrl.origin !== currentOrigin) {
|
||||||
|
return { isSelfLink: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是短链接路径
|
||||||
|
if (!parsedUrl.pathname.startsWith('/s/')) {
|
||||||
|
return { isSelfLink: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取短链接ID
|
||||||
|
const shortId = parsedUrl.pathname.substring(3)
|
||||||
|
|
||||||
|
if (!shortId || typeof SUBSCRIPTION_KV === 'undefined') {
|
||||||
|
return { isSelfLink: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试从KV获取短链接信息
|
||||||
|
const shortLinkJson = await SUBSCRIPTION_KV.get(shortId)
|
||||||
|
|
||||||
|
if (!shortLinkJson) {
|
||||||
|
// 短链接不存在,但URL格式匹配,仍然阻止
|
||||||
|
return { isSelfLink: true, originalUrl: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortLinkData = JSON.parse(shortLinkJson)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSelfLink: true,
|
||||||
|
originalUrl: shortLinkData.url,
|
||||||
|
subscriptionId: shortLinkData.subscriptionId,
|
||||||
|
sourceType: shortLinkData.type
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败,说明不是有效URL或不是本服务链接
|
||||||
|
return { isSelfLink: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检测订阅类型
|
// 检测订阅类型
|
||||||
async function detectSubscriptionType(subscriptionUrl) {
|
async function detectSubscriptionType(subscriptionUrl) {
|
||||||
try {
|
try {
|
||||||
@@ -1320,6 +1710,35 @@ async function removeFromSubscriptionList(subscriptionId) {
|
|||||||
await SUBSCRIPTION_KV.put('subscriptions:list', JSON.stringify(newList))
|
await SUBSCRIPTION_KV.put('subscriptions:list', JSON.stringify(newList))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析 CSV 行(支持引号包裹的字段)
|
||||||
|
function parseCSVLine(line) {
|
||||||
|
const result = []
|
||||||
|
let current = ''
|
||||||
|
let inQuotes = false
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i]
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && line[i + 1] === '"') {
|
||||||
|
// 转义的引号
|
||||||
|
current += '"'
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
result.push(current)
|
||||||
|
current = ''
|
||||||
|
} else {
|
||||||
|
current += char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(current)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
function getAdminToken() {
|
function getAdminToken() {
|
||||||
if (typeof ADMIN_TOKEN === 'undefined') return null
|
if (typeof ADMIN_TOKEN === 'undefined') return null
|
||||||
return ADMIN_TOKEN || null
|
return ADMIN_TOKEN || null
|
||||||
@@ -2045,6 +2464,65 @@ function getHTML() {
|
|||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loading bar */
|
||||||
|
.loading-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-body);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.loading-bar-inner {
|
||||||
|
height: 100%;
|
||||||
|
width: 30%;
|
||||||
|
background: linear-gradient(90deg, var(--primary), #818cf8);
|
||||||
|
border-radius: 3px;
|
||||||
|
animation: loadingSlide 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes loadingSlide {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
50% { transform: translateX(250%); }
|
||||||
|
100% { transform: translateX(-100%); }
|
||||||
|
}
|
||||||
|
.import-status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.btn-loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.overlay-loading {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.overlay-loading .spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 4px solid rgba(255,255,255,0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
@@ -2079,6 +2557,13 @@ function getHTML() {
|
|||||||
⋮
|
⋮
|
||||||
</button>
|
</button>
|
||||||
<div id="dropdownMenu" class="dropdown-menu">
|
<div id="dropdownMenu" class="dropdown-menu">
|
||||||
|
<div class="dropdown-item" onclick="showImportModal()">
|
||||||
|
📥 Import Subscriptions
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-item" onclick="exportSubscriptions()">
|
||||||
|
📤 Export Subscriptions
|
||||||
|
</div>
|
||||||
|
<div style="height: 1px; background: var(--border); margin: 8px 0;"></div>
|
||||||
<div class="dropdown-item danger" onclick="clearAllSubscriptions()">
|
<div class="dropdown-item danger" onclick="clearAllSubscriptions()">
|
||||||
🗑️ Clear All Data
|
🗑️ Clear All Data
|
||||||
</div>
|
</div>
|
||||||
@@ -2160,7 +2645,7 @@ function getHTML() {
|
|||||||
<div id="linkProgressText" class="status-text">--</div>
|
<div id="linkProgressText" class="status-text">--</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-row">
|
<div id="linkActionRow" 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="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>
|
<button class="btn-action" style="width: 100%; justify-content: center;" onclick="regenerateShortUrl()">⚡️ Regenerate Token</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2274,6 +2759,71 @@ function getHTML() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Import Modal -->
|
||||||
|
<div id="importModal" class="modal">
|
||||||
|
<div class="modal-content" style="max-width: 600px;">
|
||||||
|
<div class="modal-header">Import Subscriptions</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Import Content</label>
|
||||||
|
<textarea id="importContent" class="form-input" style="height: 200px; resize: vertical; font-family: monospace; font-size: 13px;" placeholder="Supported formats:
|
||||||
|
|
||||||
|
1. CSV format (with header):
|
||||||
|
name,clashUrl,...
|
||||||
|
My Sub,https://example.com/sub
|
||||||
|
|
||||||
|
2. Custom format:
|
||||||
|
Subscription Name: https://example.com/sub
|
||||||
|
|
||||||
|
or:
|
||||||
|
|
||||||
|
Subscription Name:
|
||||||
|
https://example.com/sub
|
||||||
|
|
||||||
|
(Blank lines separate different subscriptions)"></textarea>
|
||||||
|
<div class="form-hint">Supports CSV format or custom format (Name: URL)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Default Mode</label>
|
||||||
|
<select id="importMode" class="form-select" onchange="onImportModeChange()">
|
||||||
|
<option value="permanent">Permanent (Long-term)</option>
|
||||||
|
<option value="temporary">Temporary (Short-term)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Default Expiration</label>
|
||||||
|
<div id="importPermanentExpiration">
|
||||||
|
<select id="importPermanentExp" 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="importTemporaryExpiration" style="display: none;">
|
||||||
|
<select id="importTempExp" 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 id="importLoadingBar" class="loading-bar" style="display: none;">
|
||||||
|
<div class="loading-bar-inner"></div>
|
||||||
|
</div>
|
||||||
|
<div id="importStatus" class="import-status" style="display: none;"></div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="importCancelBtn" class="btn-secondary" onclick="closeImportModal()">Cancel</button>
|
||||||
|
<button id="importSubmitBtn" class="btn-primary" onclick="executeImport()">Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let currentEditId = null;
|
let currentEditId = null;
|
||||||
let currentRenewId = null;
|
let currentRenewId = null;
|
||||||
@@ -2288,7 +2838,9 @@ function getHTML() {
|
|||||||
// --- Custom Dialog ---
|
// --- Custom Dialog ---
|
||||||
function showCustomAlert(message, title = 'Notice') {
|
function showCustomAlert(message, title = 'Notice') {
|
||||||
document.getElementById('customAlertTitle').textContent = title;
|
document.getElementById('customAlertTitle').textContent = title;
|
||||||
document.getElementById('customAlertMessage').textContent = message;
|
// Support newlines in message by using innerHTML with escaped content
|
||||||
|
const escapedMessage = message.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\\n/g, '<br>');
|
||||||
|
document.getElementById('customAlertMessage').innerHTML = escapedMessage;
|
||||||
document.getElementById('customAlertModal').classList.add('active');
|
document.getElementById('customAlertModal').classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2308,6 +2860,25 @@ function getHTML() {
|
|||||||
confirmCallback = null;
|
confirmCallback = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Overlay Loading ---
|
||||||
|
function showOverlayLoading(message) {
|
||||||
|
let overlay = document.getElementById('overlayLoading');
|
||||||
|
if (!overlay) {
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.id = 'overlayLoading';
|
||||||
|
overlay.className = 'overlay-loading';
|
||||||
|
overlay.innerHTML = '<div class="spinner"></div><div class="overlay-text"></div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
overlay.querySelector('.overlay-text').textContent = message || 'Processing...';
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideOverlayLoading() {
|
||||||
|
const overlay = document.getElementById('overlayLoading');
|
||||||
|
if (overlay) overlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
function executeCustomConfirm() {
|
function executeCustomConfirm() {
|
||||||
document.getElementById('customConfirmModal').classList.remove('active');
|
document.getElementById('customConfirmModal').classList.remove('active');
|
||||||
if (confirmCallback) confirmCallback();
|
if (confirmCallback) confirmCallback();
|
||||||
@@ -2350,10 +2921,17 @@ function getHTML() {
|
|||||||
const modeText = sub.subscriptionMode === 'temporary' ? 'TEMP' : 'PERM';
|
const modeText = sub.subscriptionMode === 'temporary' ? 'TEMP' : 'PERM';
|
||||||
|
|
||||||
let statusHtml = '';
|
let statusHtml = '';
|
||||||
if (sub.subscriptionMode === 'permanent' && sub.expiresAt) {
|
if (sub.expiresAt) {
|
||||||
const days = (sub.expiresAt - Date.now()) / 86400000;
|
const days = (sub.expiresAt - Date.now()) / 86400000;
|
||||||
if (sub.isExpired || days <= 0) statusHtml = '<span class="badge badge-expired">Expired</span>';
|
if (sub.isExpired || days <= 0) {
|
||||||
else if (days <= 15) statusHtml = '<span class="badge badge-expiring">Expiring</span>';
|
statusHtml = '<span class="badge badge-expired">Expired</span>';
|
||||||
|
} else if (sub.subscriptionMode === 'temporary') {
|
||||||
|
// 临时订阅:<=3天时提示快过期
|
||||||
|
if (days <= 3) statusHtml = '<span class="badge badge-expiring">Expiring</span>';
|
||||||
|
} else {
|
||||||
|
// 长期订阅:<=15天时提示快过期
|
||||||
|
if (days <= 15) statusHtml = '<span class="badge badge-expiring">Expiring</span>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return \`<div class="sub-item \${active}" onclick="selectSubscription('\${sub.id}')">
|
return \`<div class="sub-item \${active}" onclick="selectSubscription('\${sub.id}')">
|
||||||
@@ -2399,6 +2977,12 @@ function getHTML() {
|
|||||||
document.getElementById('detailExpiration').textContent = sub.expiresAt ? formatDate(sub.expiresAt) : 'Unlimited';
|
document.getElementById('detailExpiration').textContent = sub.expiresAt ? formatDate(sub.expiresAt) : 'Unlimited';
|
||||||
document.getElementById('detailOriginalUrl').textContent = sub.clashUrl;
|
document.getElementById('detailOriginalUrl').textContent = sub.clashUrl;
|
||||||
|
|
||||||
|
// 临时订阅隐藏续期和换新链接按钮
|
||||||
|
const linkActionRow = document.getElementById('linkActionRow');
|
||||||
|
if (linkActionRow) {
|
||||||
|
linkActionRow.style.display = sub.subscriptionMode === 'temporary' ? 'none' : 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
// Link logic
|
// Link logic
|
||||||
const defaultType = sub.subscriptionType === 'v2ray' ? 'v2ray' : 'clash';
|
const defaultType = sub.subscriptionType === 'v2ray' ? 'v2ray' : 'clash';
|
||||||
switchLinkType(defaultType); // Reset to default preference or keep state? Resetting is safer for now.
|
switchLinkType(defaultType); // Reset to default preference or keep state? Resetting is safer for now.
|
||||||
@@ -2424,7 +3008,7 @@ function getHTML() {
|
|||||||
|
|
||||||
if (shortId) {
|
if (shortId) {
|
||||||
document.getElementById('detailShortUrl').value = window.location.origin + '/s/' + shortId;
|
document.getElementById('detailShortUrl').value = window.location.origin + '/s/' + shortId;
|
||||||
updateLinkStatus(expiry);
|
updateLinkStatus(expiry, sub);
|
||||||
} else {
|
} else {
|
||||||
generateLink(type);
|
generateLink(type);
|
||||||
}
|
}
|
||||||
@@ -2456,14 +3040,14 @@ function getHTML() {
|
|||||||
input.value = data.shortUrl;
|
input.value = data.shortUrl;
|
||||||
await loadSubscriptions();
|
await loadSubscriptions();
|
||||||
const updated = allSubscriptions.find(s => s.id === currentSelectedId);
|
const updated = allSubscriptions.find(s => s.id === currentSelectedId);
|
||||||
updateLinkStatus(updated?.shortIdsExpiry?.[type]);
|
updateLinkStatus(updated?.shortIdsExpiry?.[type], updated);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
input.value = 'Error generating link';
|
input.value = 'Error generating link';
|
||||||
document.getElementById('linkProgressBar').style.backgroundColor = 'var(--danger)';
|
document.getElementById('linkProgressBar').style.backgroundColor = 'var(--danger)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLinkStatus(expiry) {
|
function updateLinkStatus(expiry, sub) {
|
||||||
const bar = document.getElementById('linkProgressBar');
|
const bar = document.getElementById('linkProgressBar');
|
||||||
const text = document.getElementById('linkProgressText');
|
const text = document.getElementById('linkProgressText');
|
||||||
|
|
||||||
@@ -2484,14 +3068,39 @@ function getHTML() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = 7 * 86400000; // Base reference
|
// 根据订阅的实际有效期计算总时长
|
||||||
const pct = Math.min(100, Math.max(5, (remaining / total) * 100));
|
const expiration = sub?.expiration || '7d';
|
||||||
|
let totalMs = 7 * 86400000; // 默认7天
|
||||||
|
if (expiration === '1d') totalMs = 1 * 86400000;
|
||||||
|
else if (expiration === '3d') totalMs = 3 * 86400000;
|
||||||
|
else if (expiration === '7d') totalMs = 7 * 86400000;
|
||||||
|
else if (expiration === '1m') totalMs = 30 * 86400000;
|
||||||
|
else if (expiration === '3m') totalMs = 90 * 86400000;
|
||||||
|
else if (expiration === '1y') totalMs = 365 * 86400000;
|
||||||
|
else if (expiration === 'unlimited') totalMs = remaining; // 无限期时显示100%
|
||||||
|
|
||||||
|
const pct = Math.min(100, Math.max(5, (remaining / totalMs) * 100));
|
||||||
|
|
||||||
|
// 根据订阅模式决定警告阈值
|
||||||
|
const isTemporary = sub?.subscriptionMode === 'temporary';
|
||||||
|
const days = remaining / 86400000;
|
||||||
|
|
||||||
|
let barColor = 'var(--success)';
|
||||||
|
if (isTemporary) {
|
||||||
|
// 临时订阅:<=1天危险,<=3天警告
|
||||||
|
if (days <= 1) barColor = 'var(--danger)';
|
||||||
|
else if (days <= 3) barColor = 'var(--warning)';
|
||||||
|
} else {
|
||||||
|
// 长期订阅:<=7天危险,<=15天警告
|
||||||
|
if (days <= 7) barColor = 'var(--danger)';
|
||||||
|
else if (days <= 15) barColor = 'var(--warning)';
|
||||||
|
}
|
||||||
|
|
||||||
bar.style.width = pct + '%';
|
bar.style.width = pct + '%';
|
||||||
bar.style.backgroundColor = pct < 20 ? 'var(--danger)' : (pct < 50 ? 'var(--warning)' : 'var(--success)');
|
bar.style.backgroundColor = barColor;
|
||||||
|
|
||||||
const days = Math.ceil(remaining / 86400000);
|
const daysInt = Math.ceil(days);
|
||||||
text.textContent = days > 1 ? \`\${days} Days\` : \`\${Math.ceil(remaining / 3600000)} Hours\`;
|
text.textContent = daysInt > 1 ? (daysInt + ' Days') : (Math.ceil(remaining / 3600000) + ' Hours');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -2516,8 +3125,16 @@ function getHTML() {
|
|||||||
document.getElementById('subClashUrl').value = sub.clashUrl;
|
document.getElementById('subClashUrl').value = sub.clashUrl;
|
||||||
document.getElementById('subMode').value = sub.subscriptionMode || 'permanent';
|
document.getElementById('subMode').value = sub.subscriptionMode || 'permanent';
|
||||||
onSubscriptionModeChange();
|
onSubscriptionModeChange();
|
||||||
// Hide expiration field when editing
|
|
||||||
document.getElementById('expirationGroup').style.display = 'none';
|
// Show expiration field when editing and set current value
|
||||||
|
document.getElementById('expirationGroup').style.display = 'block';
|
||||||
|
const currentExpiration = sub.expiration || '7d';
|
||||||
|
if (sub.subscriptionMode === 'temporary') {
|
||||||
|
document.getElementById('subTempExpiration').value = ['1d', '3d', '7d'].includes(currentExpiration) ? currentExpiration : '3d';
|
||||||
|
} else {
|
||||||
|
document.getElementById('subPermanentExpiration').value = ['7d', '1m', '3m', '1y', 'unlimited'].includes(currentExpiration) ? currentExpiration : '1y';
|
||||||
|
}
|
||||||
|
|
||||||
currentEditId = currentSelectedId;
|
currentEditId = currentSelectedId;
|
||||||
document.getElementById('subscriptionModal').classList.add('active');
|
document.getElementById('subscriptionModal').classList.add('active');
|
||||||
}
|
}
|
||||||
@@ -2545,10 +3162,26 @@ function getHTML() {
|
|||||||
body: JSON.stringify({ name, clashUrl: url, expiration, subscriptionMode: mode })
|
body: JSON.stringify({ name, clashUrl: url, expiration, subscriptionMode: mode })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error((await res.json()).error);
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
let errorMsg = data.error || 'Unknown error';
|
||||||
|
if (data.hint) {
|
||||||
|
errorMsg += '\\n\\n' + data.hint;
|
||||||
|
}
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调试:检查返回的 subscriptionMode
|
||||||
|
console.log('Saved subscription mode:', mode, 'Server returned:', data.subscription?.subscriptionMode);
|
||||||
|
|
||||||
|
const editedId = currentEditId;
|
||||||
closeModal();
|
closeModal();
|
||||||
await loadSubscriptions();
|
await loadSubscriptions();
|
||||||
|
|
||||||
|
// 如果是编辑模式,重新选中该订阅以刷新详情页面
|
||||||
|
if (editedId) {
|
||||||
|
selectSubscription(editedId);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showCustomAlert('Failed: ' + e.message);
|
showCustomAlert('Failed: ' + e.message);
|
||||||
}
|
}
|
||||||
@@ -2568,13 +3201,156 @@ function getHTML() {
|
|||||||
if (dropdown) dropdown.classList.remove('active');
|
if (dropdown) dropdown.classList.remove('active');
|
||||||
|
|
||||||
showCustomConfirm('WARNING: This will delete ALL subscriptions! Continue?', async () => {
|
showCustomConfirm('WARNING: This will delete ALL subscriptions! Continue?', async () => {
|
||||||
|
showOverlayLoading('Clearing all data...');
|
||||||
|
try {
|
||||||
await fetch('/api/subscriptions/clear-all', { method: 'POST' });
|
await fetch('/api/subscriptions/clear-all', { method: 'POST' });
|
||||||
currentSelectedId = null;
|
currentSelectedId = null;
|
||||||
await loadSubscriptions();
|
await loadSubscriptions();
|
||||||
|
hideOverlayLoading();
|
||||||
showCustomAlert('All data cleared');
|
showCustomAlert('All data cleared');
|
||||||
|
} catch (e) {
|
||||||
|
hideOverlayLoading();
|
||||||
|
showCustomAlert('Clear failed: ' + e.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Import/Export Functions ---
|
||||||
|
function showImportModal() {
|
||||||
|
const dropdown = document.getElementById('dropdownMenu');
|
||||||
|
if (dropdown) dropdown.classList.remove('active');
|
||||||
|
|
||||||
|
document.getElementById('importContent').value = '';
|
||||||
|
document.getElementById('importMode').value = 'permanent';
|
||||||
|
onImportModeChange();
|
||||||
|
document.getElementById('importModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeImportModal() {
|
||||||
|
document.getElementById('importModal').classList.remove('active');
|
||||||
|
// 重置loading状态
|
||||||
|
isImporting = false;
|
||||||
|
const submitBtn = document.getElementById('importSubmitBtn');
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.classList.remove('btn-loading');
|
||||||
|
submitBtn.textContent = 'Import';
|
||||||
|
}
|
||||||
|
const loadingBar = document.getElementById('importLoadingBar');
|
||||||
|
if (loadingBar) loadingBar.style.display = 'none';
|
||||||
|
const statusDiv = document.getElementById('importStatus');
|
||||||
|
if (statusDiv) statusDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImportModeChange() {
|
||||||
|
const mode = document.getElementById('importMode').value;
|
||||||
|
document.getElementById('importPermanentExpiration').style.display = mode === 'permanent' ? 'block' : 'none';
|
||||||
|
document.getElementById('importTemporaryExpiration').style.display = mode === 'temporary' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
let isImporting = false;
|
||||||
|
|
||||||
|
async function executeImport() {
|
||||||
|
if (isImporting) return;
|
||||||
|
|
||||||
|
const content = document.getElementById('importContent').value.trim();
|
||||||
|
if (!content) {
|
||||||
|
showCustomAlert('Please enter import content');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = document.getElementById('importMode').value;
|
||||||
|
const expiration = mode === 'temporary'
|
||||||
|
? document.getElementById('importTempExp').value
|
||||||
|
: document.getElementById('importPermanentExp').value;
|
||||||
|
|
||||||
|
// 显示loading状态
|
||||||
|
isImporting = true;
|
||||||
|
const submitBtn = document.getElementById('importSubmitBtn');
|
||||||
|
const loadingBar = document.getElementById('importLoadingBar');
|
||||||
|
const statusDiv = document.getElementById('importStatus');
|
||||||
|
|
||||||
|
submitBtn.classList.add('btn-loading');
|
||||||
|
submitBtn.textContent = 'Importing...';
|
||||||
|
loadingBar.style.display = 'block';
|
||||||
|
statusDiv.style.display = 'block';
|
||||||
|
statusDiv.textContent = 'Processing subscriptions, please wait...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/subscriptions/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content, expiration, subscriptionMode: mode })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Import failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
closeImportModal();
|
||||||
|
await loadSubscriptions();
|
||||||
|
|
||||||
|
let message = data.message || 'Imported ' + (data.created ? data.created.length : 0) + ' subscriptions';
|
||||||
|
|
||||||
|
// 显示重复项信息
|
||||||
|
if (data.duplicates && data.duplicates.length > 0) {
|
||||||
|
message += '\\n\\nSkipped duplicates:\\n' + data.duplicates.slice(0, 5).map(d => d.name).join('\\n');
|
||||||
|
if (data.duplicates.length > 5) {
|
||||||
|
message += '\\n... and ' + (data.duplicates.length - 5) + ' more';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
message += '\\n\\nWarnings:\\n' + data.errors.slice(0, 5).join('\\n');
|
||||||
|
if (data.errors.length > 5) {
|
||||||
|
message += '\\n... and ' + (data.errors.length - 5) + ' more';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showCustomAlert(message, 'Import Complete');
|
||||||
|
} catch (e) {
|
||||||
|
showCustomAlert('Import failed: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
// 恢复按钮状态
|
||||||
|
isImporting = false;
|
||||||
|
submitBtn.classList.remove('btn-loading');
|
||||||
|
submitBtn.textContent = 'Import';
|
||||||
|
loadingBar.style.display = 'none';
|
||||||
|
statusDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportSubscriptions() {
|
||||||
|
const dropdown = document.getElementById('dropdownMenu');
|
||||||
|
if (dropdown) dropdown.classList.remove('active');
|
||||||
|
|
||||||
|
showOverlayLoading('Exporting subscriptions...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/subscriptions/export');
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || 'Export failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'subscriptions_' + new Date().toISOString().slice(0,10) + '.csv';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
hideOverlayLoading();
|
||||||
|
showCustomAlert('Export successful!', 'Export');
|
||||||
|
} catch (e) {
|
||||||
|
hideOverlayLoading();
|
||||||
|
showCustomAlert('Export failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleDropdown() {
|
function toggleDropdown() {
|
||||||
const dropdown = document.getElementById('dropdownMenu');
|
const dropdown = document.getElementById('dropdownMenu');
|
||||||
dropdown.classList.toggle('active');
|
dropdown.classList.toggle('active');
|
||||||
@@ -2616,7 +3392,7 @@ function getHTML() {
|
|||||||
// Refresh link view
|
// Refresh link view
|
||||||
const updated = allSubscriptions.find(s => s.id === currentRenewId);
|
const updated = allSubscriptions.find(s => s.id === currentRenewId);
|
||||||
document.getElementById('detailShortUrl').value = window.location.origin + '/s/' + updated.shortIds[currentLinkType];
|
document.getElementById('detailShortUrl').value = window.location.origin + '/s/' + updated.shortIds[currentLinkType];
|
||||||
updateLinkStatus(updated.shortIdsExpiry[currentLinkType]);
|
updateLinkStatus(updated.shortIdsExpiry[currentLinkType], updated);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showCustomAlert('Operation failed');
|
showCustomAlert('Operation failed');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user