进一步完善,增加导入导出功能

This commit is contained in:
darkli
2025-12-21 12:48:26 +08:00
parent 55e200a3bb
commit f5c8f0c185

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').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');
} }