koishi-plugin-memesluna 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/lib/index.js +564 -237
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -1288,7 +1288,7 @@ function buildAdminHtml(basePath) {
1288
1288
  <head>
1289
1289
  <meta charset="utf-8" />
1290
1290
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1291
- <title>图床转发 - 管理</title>
1291
+ <title>图床转发 - 合集管理</title>
1292
1292
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet">
1293
1293
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.3/font/bootstrap-icons.css">
1294
1294
  <style>
@@ -1323,36 +1323,69 @@ function buildAdminHtml(basePath) {
1323
1323
  max-width: 1320px;
1324
1324
  }
1325
1325
 
1326
- .sidebar-panel,
1327
- .main-panel,
1328
- .sub-card {
1326
+ .panel {
1329
1327
  border-radius: 14px;
1330
1328
  border: 1px solid rgba(255, 255, 255, 0.5);
1331
1329
  background: rgba(255, 255, 255, 0.72);
1332
1330
  -webkit-backdrop-filter: blur(10px) saturate(130%);
1333
1331
  backdrop-filter: blur(10px) saturate(130%);
1334
1332
  box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
1333
+ padding: 1rem;
1335
1334
  }
1336
1335
 
1337
- .sidebar-panel {
1338
- padding: 1rem;
1339
- position: sticky;
1340
- top: 1rem;
1336
+ .sub-card {
1337
+ border-radius: 12px;
1338
+ border: 1px solid rgba(255, 255, 255, 0.5);
1339
+ background: rgba(255, 255, 255, 0.72);
1340
+ -webkit-backdrop-filter: blur(10px) saturate(130%);
1341
+ backdrop-filter: blur(10px) saturate(130%);
1342
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
1343
+ padding: 0.9rem;
1341
1344
  }
1342
1345
 
1343
- .main-panel {
1344
- padding: 1rem;
1346
+ .folder-grid {
1347
+ display: grid;
1348
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
1349
+ gap: 1rem;
1345
1350
  }
1346
1351
 
1347
- .collection-item {
1352
+ .folder-card {
1353
+ border-radius: 12px;
1354
+ overflow: hidden;
1355
+ border: 1px solid rgba(148, 163, 184, 0.3);
1356
+ background: rgba(255, 255, 255, 0.88);
1348
1357
  cursor: pointer;
1358
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
1349
1359
  }
1350
1360
 
1351
- .collection-item.active {
1352
- background: rgba(59, 130, 246, 0.14);
1353
- border-color: rgba(59, 130, 246, 0.35);
1354
- color: #1d4ed8;
1361
+ .folder-card:hover {
1362
+ transform: translateY(-3px);
1363
+ box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
1364
+ }
1365
+
1366
+ .folder-card .folder-icon {
1367
+ display: flex;
1368
+ align-items: center;
1369
+ justify-content: center;
1370
+ height: 100px;
1371
+ background: linear-gradient(135deg, #e0e7ff 0%, #f0f4ff 100%);
1372
+ font-size: 2.5rem;
1373
+ color: #6366f1;
1374
+ }
1375
+
1376
+ .folder-card .folder-info {
1377
+ padding: 0.6rem 0.8rem;
1378
+ }
1379
+
1380
+ .folder-card .folder-name {
1355
1381
  font-weight: 700;
1382
+ font-size: 0.95rem;
1383
+ color: #334155;
1384
+ }
1385
+
1386
+ .folder-card .folder-meta {
1387
+ font-size: 0.78rem;
1388
+ color: #64748b;
1356
1389
  }
1357
1390
 
1358
1391
  .image-grid {
@@ -1376,10 +1409,6 @@ function buildAdminHtml(basePath) {
1376
1409
  background: #f1f5f9;
1377
1410
  }
1378
1411
 
1379
- .sub-card {
1380
- padding: 0.9rem;
1381
- }
1382
-
1383
1412
  .code-url {
1384
1413
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
1385
1414
  font-size: 0.8rem;
@@ -1396,6 +1425,21 @@ function buildAdminHtml(basePath) {
1396
1425
  color: #64748b;
1397
1426
  font-size: 0.9rem;
1398
1427
  }
1428
+
1429
+ .drop-zone {
1430
+ border: 2px dashed #94a3b8;
1431
+ border-radius: 12px;
1432
+ padding: 2rem;
1433
+ text-align: center;
1434
+ color: #64748b;
1435
+ transition: border-color 0.2s, background 0.2s;
1436
+ cursor: pointer;
1437
+ }
1438
+
1439
+ .drop-zone.drag-over {
1440
+ border-color: #6366f1;
1441
+ background: rgba(99, 102, 241, 0.06);
1442
+ }
1399
1443
  </style>
1400
1444
  </head>
1401
1445
  <body>
@@ -1409,75 +1453,72 @@ function buildAdminHtml(basePath) {
1409
1453
  <ul class="navbar-nav me-auto">
1410
1454
  <li class="nav-item"><a class="nav-link" href="${basePath}/">首页</a></li>
1411
1455
  <li class="nav-item"><a class="nav-link active" href="${basePath}/admin">管理</a></li>
1456
+ <li class="nav-item"><a class="nav-link" href="${basePath}/admin/endpoint">端点</a></li>
1412
1457
  </ul>
1413
1458
  </div>
1414
1459
  </div>
1415
1460
  </nav>
1416
1461
 
1417
1462
  <div class="container admin-shell mt-3 pb-4">
1418
- <div class="row g-3">
1419
- <div class="col-lg-3">
1420
- <div class="sidebar-panel">
1421
- <h5 class="mb-3">合集管理</h5>
1422
- <div class="input-group mb-3">
1423
- <input id="new-collection-name" class="form-control" placeholder="新合集名称" />
1424
- <button id="create-collection" class="btn btn-primary">创建</button>
1425
- </div>
1426
- <div id="collection-list" class="list-group mb-3"></div>
1427
- <button id="delete-collection" class="btn btn-outline-danger w-100" disabled>删除当前合集</button>
1428
-
1429
- <hr>
1430
- <h6 class="mb-2">合集描述</h6>
1431
- <div class="input-group mb-3">
1432
- <input id="collection-description" class="form-control" placeholder="为当前合集添加描述" disabled />
1433
- <button id="save-description" class="btn btn-primary" disabled>保存</button>
1434
- </div>
1435
-
1436
- <hr>
1437
- <h6 class="mb-2">快捷信息</h6>
1438
- <div class="small text-muted">
1439
- <div>管理链接:<code>${basePath}/admin</code></div>
1440
- <div class="mt-1">随机访问:<code id="collection-random-url">-</code></div>
1463
+ <!-- View: folder list (main) -->
1464
+ <div id="view-folders">
1465
+ <div class="panel mb-3">
1466
+ <div class="d-flex justify-content-between align-items-center mb-3">
1467
+ <h5 class="mb-0">合集管理</h5>
1468
+ <div class="d-flex gap-2">
1469
+ <input id="new-collection-name" class="form-control form-control-sm" style="width:180px" placeholder="新合集名称" />
1470
+ <button id="create-collection" class="btn btn-sm btn-primary">创建</button>
1441
1471
  </div>
1442
1472
  </div>
1473
+ <div id="folder-grid" class="folder-grid"></div>
1474
+ <div id="folders-empty" class="empty-tip mt-2">暂无合集,请先创建</div>
1443
1475
  </div>
1476
+ </div>
1444
1477
 
1445
- <div class="col-lg-9">
1446
- <div class="main-panel mb-3">
1447
- <div class="d-flex justify-content-between align-items-center">
1448
- <h5 class="mb-0">合集内容</h5>
1449
- <span id="selected-collection-badge" class="badge bg-primary">未选择</span>
1478
+ <!-- View: collection detail -->
1479
+ <div id="view-detail" style="display:none">
1480
+ <div class="panel mb-3">
1481
+ <div class="d-flex justify-content-between align-items-center mb-3">
1482
+ <div class="d-flex align-items-center gap-2">
1483
+ <button id="back-to-folders" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> 返回</button>
1484
+ <h5 class="mb-0" id="detail-title"></h5>
1450
1485
  </div>
1486
+ <div class="d-flex gap-2">
1487
+ <button id="refresh-resources" class="btn btn-sm btn-outline-primary"><i class="bi bi-arrow-clockwise"></i> 刷新缓存</button>
1488
+ <button id="delete-collection" class="btn btn-sm btn-outline-danger">删除合集</button>
1489
+ </div>
1490
+ </div>
1451
1491
 
1452
- <div class="row g-3 mt-1">
1453
- <div class="col-md-6">
1454
- <div class="sub-card h-100">
1455
- <div class="section-title mb-2">上传本地图片</div>
1456
- <input id="upload-files" type="file" class="form-control mb-2" multiple accept="image/*" />
1457
- <button id="upload-images" class="btn btn-primary w-100" disabled>上传到当前合集</button>
1458
- <div class="form-text">支持多选。上传后自动刷新列表。</div>
1492
+ <div class="row g-3 mb-3">
1493
+ <div class="col-md-6">
1494
+ <div class="sub-card h-100">
1495
+ <div class="section-title mb-2">上传图片</div>
1496
+ <div id="drop-zone" class="drop-zone mb-2">
1497
+ <i class="bi bi-cloud-arrow-up" style="font-size:1.5rem"></i>
1498
+ <div>拖放图片到此处,或点击选择文件</div>
1499
+ <input id="upload-files" type="file" class="d-none" multiple accept="image/*" />
1459
1500
  </div>
1460
1501
  </div>
1461
- <div class="col-md-6">
1462
- <div class="sub-card h-100">
1463
- <div class="section-title mb-2">添加外链</div>
1464
- <textarea id="links-input" class="form-control mb-2" rows="4" placeholder="每行一个 http/https 链接"></textarea>
1465
- <button id="add-links" class="btn btn-primary w-100" disabled>添加外链到当前合集</button>
1466
- </div>
1502
+ </div>
1503
+ <div class="col-md-6">
1504
+ <div class="sub-card h-100">
1505
+ <div class="section-title mb-2">添加外链</div>
1506
+ <textarea id="links-input" class="form-control mb-2" rows="3" placeholder="每行一个 http/https 链接"></textarea>
1507
+ <button id="add-links" class="btn btn-primary btn-sm w-100">添加外链</button>
1467
1508
  </div>
1468
1509
  </div>
1510
+ </div>
1469
1511
 
1470
- <div class="sub-card mt-3">
1471
- <div class="section-title mb-2">本地图片</div>
1472
- <div id="images-grid" class="image-grid"></div>
1473
- <div id="images-empty" class="empty-tip mt-2">暂无本地图片</div>
1474
- </div>
1512
+ <div class="sub-card mb-3">
1513
+ <div class="section-title mb-2">本地图片</div>
1514
+ <div id="images-grid" class="image-grid"></div>
1515
+ <div id="images-empty" class="empty-tip mt-2">暂无本地图片</div>
1516
+ </div>
1475
1517
 
1476
- <div class="sub-card mt-3">
1477
- <div class="section-title mb-2">外链列表</div>
1478
- <div id="links-list" class="list-group"></div>
1479
- <div id="links-empty" class="empty-tip mt-2">暂无外链</div>
1480
- </div>
1518
+ <div class="sub-card">
1519
+ <div class="section-title mb-2">外链列表</div>
1520
+ <div id="links-list" class="list-group"></div>
1521
+ <div id="links-empty" class="empty-tip mt-2">暂无外链</div>
1481
1522
  </div>
1482
1523
  </div>
1483
1524
  </div>
@@ -1490,7 +1531,6 @@ function buildAdminHtml(basePath) {
1490
1531
  const state = {
1491
1532
  collectionNames: [],
1492
1533
  collections: [],
1493
- endpoints: [],
1494
1534
  selectedCollection: '',
1495
1535
  images: [],
1496
1536
  links: [],
@@ -1498,173 +1538,170 @@ function buildAdminHtml(basePath) {
1498
1538
 
1499
1539
  const byId = (id) => document.getElementById(id)
1500
1540
 
1501
- function showAlert(message, type = 'info') {
1502
- const el = byId('admin-alert')
1541
+ function showAlert(message, type) {
1542
+ type = type || 'info'
1543
+ var el = byId('admin-alert')
1503
1544
  el.className = 'alert alert-' + type + ' mt-3'
1504
1545
  el.textContent = message
1505
1546
  el.classList.remove('d-none')
1506
- setTimeout(() => el.classList.add('d-none'), 2200)
1547
+ setTimeout(function() { el.classList.add('d-none') }, 2200)
1507
1548
  }
1508
1549
 
1509
- async function request(url, options = {}) {
1510
- console.log('Requesting:', url)
1511
- const headers = Object.assign({}, options.headers || {})
1550
+ async function request(url, options) {
1551
+ options = options || {}
1552
+ var headers = Object.assign({}, options.headers || {})
1512
1553
  if (options.body && !headers['Content-Type']) {
1513
1554
  headers['Content-Type'] = 'application/json'
1514
1555
  }
1515
- const res = await fetch(url, Object.assign({}, options, { headers }))
1516
- console.log('Response status:', res.status)
1517
- let data = null
1518
- try {
1519
- data = await res.json()
1520
- console.log('Response data:', data)
1521
- } catch (e) {
1522
- console.log('JSON parse error:', e)
1523
- data = null
1524
- }
1556
+ var res = await fetch(url, Object.assign({}, options, { headers: headers }))
1557
+ var data = null
1558
+ try { data = await res.json() } catch(e) { data = null }
1525
1559
  if (!res.ok) {
1526
1560
  throw new Error(data && data.error ? data.error : String(res.status) + ' ' + String(res.statusText))
1527
1561
  }
1528
1562
  return data
1529
1563
  }
1530
1564
 
1565
+ function readFileAsDataURL(file) {
1566
+ return new Promise(function(resolve, reject) {
1567
+ var reader = new FileReader()
1568
+ reader.onload = function() { resolve(reader.result) }
1569
+ reader.onerror = function() { reject(new Error('读取文件失败')) }
1570
+ reader.readAsDataURL(file)
1571
+ })
1572
+ }
1573
+
1574
+ /* --- Folder list view --- */
1531
1575
  async function refreshState() {
1532
- const data = await request(BASE_PATH + '/api/admin/state')
1576
+ var data = await request(BASE_PATH + '/api/admin/state')
1533
1577
  state.collectionNames = Array.isArray(data.collectionNames) ? data.collectionNames : []
1534
1578
  state.collections = Array.isArray(data.collections) ? data.collections : []
1535
- state.endpoints = Array.isArray(data.endpoints) ? data.endpoints : []
1579
+ renderFolderGrid()
1580
+ }
1536
1581
 
1537
- if (!state.selectedCollection || !state.collectionNames.includes(state.selectedCollection)) {
1538
- state.selectedCollection = state.collectionNames[0] || ''
1539
- }
1582
+ function renderFolderGrid() {
1583
+ var grid = byId('folder-grid')
1584
+ var empty = byId('folders-empty')
1585
+ grid.textContent = ''
1586
+ empty.style.display = state.collections.length ? 'none' : 'block'
1587
+
1588
+ state.collections.forEach(function(col) {
1589
+ var card = document.createElement('div')
1590
+ card.className = 'folder-card'
1591
+ card.addEventListener('click', function() { enterCollection(col.name) })
1592
+
1593
+ var icon = document.createElement('div')
1594
+ icon.className = 'folder-icon'
1595
+ icon.innerHTML = '<i class="bi bi-folder-fill"></i>'
1596
+ card.appendChild(icon)
1597
+
1598
+ var info = document.createElement('div')
1599
+ info.className = 'folder-info'
1600
+ var nameEl = document.createElement('div')
1601
+ nameEl.className = 'folder-name'
1602
+ nameEl.textContent = col.name
1603
+ info.appendChild(nameEl)
1604
+ var meta = document.createElement('div')
1605
+ meta.className = 'folder-meta'
1606
+ meta.textContent = '本地 ' + (col.localCount || 0) + ' / 外链 ' + (col.linkCount || 0)
1607
+ info.appendChild(meta)
1608
+ card.appendChild(info)
1609
+ grid.appendChild(card)
1610
+ })
1611
+ }
1612
+
1613
+ function enterCollection(name) {
1614
+ state.selectedCollection = name
1615
+ byId('view-folders').style.display = 'none'
1616
+ byId('view-detail').style.display = 'block'
1617
+ byId('detail-title').textContent = name
1618
+ refreshCollectionResources()
1619
+ }
1540
1620
 
1541
- renderCollectionList()
1542
- await refreshCollectionResources()
1543
- syncSelectedCollectionUi()
1621
+ function backToFolders() {
1622
+ state.selectedCollection = ''
1623
+ byId('view-detail').style.display = 'none'
1624
+ byId('view-folders').style.display = 'block'
1625
+ refreshState()
1544
1626
  }
1545
1627
 
1628
+ /* --- Detail view --- */
1546
1629
  async function refreshCollectionResources() {
1547
- if (!state.selectedCollection) {
1548
- state.images = []
1549
- state.links = []
1550
- renderImages()
1551
- renderLinks()
1552
- return
1553
- }
1554
- const data = await request(BASE_PATH + '/api/collections/' + encodeURIComponent(state.selectedCollection) + '/resources')
1630
+ if (!state.selectedCollection) { state.images = []; state.links = []; renderImages(); renderLinks(); return }
1631
+ var data = await request(BASE_PATH + '/api/collections/' + encodeURIComponent(state.selectedCollection) + '/resources')
1555
1632
  state.images = Array.isArray(data.images) ? data.images : []
1556
1633
  state.links = Array.isArray(data.links) ? data.links : []
1557
1634
  renderImages()
1558
1635
  renderLinks()
1559
1636
  }
1560
1637
 
1561
- function syncSelectedCollectionUi() {
1562
- const selected = state.selectedCollection
1563
- byId('selected-collection-badge').textContent = selected || '未选择'
1564
- byId('delete-collection').disabled = !selected
1565
- byId('upload-images').disabled = !selected
1566
- byId('add-links').disabled = !selected
1567
- byId('collection-random-url').textContent = selected ? BASE_PATH + '/' + selected : '-'
1568
- byId('collection-description').disabled = !selected
1569
- byId('save-description').disabled = !selected
1570
-
1571
- const collectionInfo = state.collections.find((c) => c.name === selected)
1572
- byId('collection-description').value = collectionInfo ? (collectionInfo.description || '') : ''
1573
- }
1574
-
1575
- function renderCollectionList() {
1576
- const list = byId('collection-list')
1577
- list.textContent = ''
1578
- if (!state.collectionNames.length) {
1579
- const empty = document.createElement('div')
1580
- empty.className = 'text-muted small'
1581
- empty.textContent = '暂无合集'
1582
- list.appendChild(empty)
1583
- return
1584
- }
1585
- state.collectionNames.forEach((name) => {
1586
- const button = document.createElement('button')
1587
- button.type = 'button'
1588
- button.className = 'list-group-item list-group-item-action collection-item' + (name === state.selectedCollection ? ' active' : '')
1589
- button.textContent = name
1590
- button.addEventListener('click', async () => {
1591
- state.selectedCollection = name
1592
- renderCollectionList()
1593
- syncSelectedCollectionUi()
1594
- await refreshCollectionResources()
1595
- })
1596
- list.appendChild(button)
1597
- })
1598
- }
1599
-
1600
1638
  function imagePreviewUrl(filename) {
1601
1639
  return BASE_PATH + '/api/admin/collections/' + encodeURIComponent(state.selectedCollection) + '/images/' + encodeURIComponent(filename)
1602
1640
  }
1603
1641
 
1604
1642
  function renderImages() {
1605
- const grid = byId('images-grid')
1606
- const empty = byId('images-empty')
1643
+ var grid = byId('images-grid')
1644
+ var empty = byId('images-empty')
1607
1645
  grid.textContent = ''
1608
1646
  empty.style.display = state.images.length ? 'none' : 'block'
1609
1647
 
1610
- state.images.forEach((name) => {
1611
- const card = document.createElement('div')
1648
+ state.images.forEach(function(name) {
1649
+ var card = document.createElement('div')
1612
1650
  card.className = 'image-card'
1613
1651
 
1614
- const img = document.createElement('img')
1652
+ var img = document.createElement('img')
1615
1653
  img.src = imagePreviewUrl(name)
1616
1654
  img.alt = name
1617
1655
  card.appendChild(img)
1618
1656
 
1619
- const body = document.createElement('div')
1657
+ var body = document.createElement('div')
1620
1658
  body.className = 'p-2'
1621
- const title = document.createElement('div')
1659
+ var title = document.createElement('div')
1622
1660
  title.className = 'small text-truncate mb-2'
1623
1661
  title.textContent = name
1624
1662
  body.appendChild(title)
1625
1663
 
1626
- const row = document.createElement('div')
1664
+ var row = document.createElement('div')
1627
1665
  row.className = 'd-flex gap-1'
1628
1666
 
1629
- const moveSelect = document.createElement('select')
1667
+ var moveSelect = document.createElement('select')
1630
1668
  moveSelect.className = 'form-select form-select-sm'
1631
- const collections = state.collectionNames.filter((item) => item !== state.selectedCollection)
1632
- const placeholder = document.createElement('option')
1633
- placeholder.value = ''
1634
- placeholder.textContent = '移动到...'
1635
- moveSelect.appendChild(placeholder)
1636
- collections.forEach((target) => {
1637
- const opt = document.createElement('option')
1638
- opt.value = target
1639
- opt.textContent = target
1669
+ var others = state.collectionNames.filter(function(c) { return c !== state.selectedCollection })
1670
+ var ph = document.createElement('option')
1671
+ ph.value = ''
1672
+ ph.textContent = '移动到...'
1673
+ moveSelect.appendChild(ph)
1674
+ others.forEach(function(t) {
1675
+ var opt = document.createElement('option')
1676
+ opt.value = t; opt.textContent = t
1640
1677
  moveSelect.appendChild(opt)
1641
1678
  })
1642
1679
 
1643
- const moveBtn = document.createElement('button')
1680
+ var moveBtn = document.createElement('button')
1644
1681
  moveBtn.className = 'btn btn-sm btn-outline-primary'
1645
1682
  moveBtn.textContent = '移动'
1646
- moveBtn.disabled = collections.length === 0
1647
- moveBtn.addEventListener('click', async () => {
1648
- const targetCollection = moveSelect.value
1649
- if (!targetCollection) return
1683
+ moveBtn.disabled = others.length === 0
1684
+ moveBtn.addEventListener('click', async function() {
1685
+ var tc = moveSelect.value
1686
+ if (!tc) return
1650
1687
  await request(
1651
1688
  BASE_PATH + '/api/admin/collections/' + encodeURIComponent(state.selectedCollection) + '/images/' + encodeURIComponent(name) + '/move',
1652
- { method: 'POST', body: JSON.stringify({ targetCollection }) }
1689
+ { method: 'POST', body: JSON.stringify({ targetCollection: tc }) }
1653
1690
  )
1654
1691
  showAlert('图片已移动', 'success')
1655
- await refreshState()
1692
+ refreshCollectionResources()
1656
1693
  })
1657
1694
 
1658
- const delBtn = document.createElement('button')
1695
+ var delBtn = document.createElement('button')
1659
1696
  delBtn.className = 'btn btn-sm btn-outline-danger'
1660
1697
  delBtn.textContent = '删除'
1661
- delBtn.addEventListener('click', async () => {
1698
+ delBtn.addEventListener('click', async function() {
1662
1699
  await request(
1663
1700
  BASE_PATH + '/api/admin/collections/' + encodeURIComponent(state.selectedCollection) + '/images/' + encodeURIComponent(name),
1664
1701
  { method: 'DELETE' }
1665
1702
  )
1666
1703
  showAlert('图片已删除', 'success')
1667
- await refreshCollectionResources()
1704
+ refreshCollectionResources()
1668
1705
  })
1669
1706
 
1670
1707
  row.appendChild(moveSelect)
@@ -1677,40 +1714,40 @@ function buildAdminHtml(basePath) {
1677
1714
  }
1678
1715
 
1679
1716
  function renderLinks() {
1680
- const wrap = byId('links-list')
1681
- const empty = byId('links-empty')
1717
+ var wrap = byId('links-list')
1718
+ var empty = byId('links-empty')
1682
1719
  wrap.textContent = ''
1683
1720
  empty.style.display = state.links.length ? 'none' : 'block'
1684
- state.links.forEach((link) => {
1685
- const row = document.createElement('div')
1721
+ state.links.forEach(function(link) {
1722
+ var row = document.createElement('div')
1686
1723
  row.className = 'list-group-item d-flex justify-content-between align-items-center gap-2'
1687
1724
 
1688
- const text = document.createElement('div')
1725
+ var text = document.createElement('div')
1689
1726
  text.className = 'code-url flex-grow-1'
1690
1727
  text.textContent = link
1691
1728
 
1692
- const actions = document.createElement('div')
1729
+ var actions = document.createElement('div')
1693
1730
  actions.className = 'd-flex gap-1'
1694
1731
 
1695
- const open = document.createElement('a')
1696
- open.className = 'btn btn-sm btn-outline-primary'
1697
- open.textContent = '查看'
1698
- open.href = link
1699
- open.target = '_blank'
1732
+ var openEl = document.createElement('a')
1733
+ openEl.className = 'btn btn-sm btn-outline-primary'
1734
+ openEl.textContent = '查看'
1735
+ openEl.href = link
1736
+ openEl.target = '_blank'
1700
1737
 
1701
- const del = document.createElement('button')
1738
+ var del = document.createElement('button')
1702
1739
  del.className = 'btn btn-sm btn-outline-danger'
1703
1740
  del.textContent = '删除'
1704
- del.addEventListener('click', async () => {
1741
+ del.addEventListener('click', async function() {
1705
1742
  await request(
1706
1743
  BASE_PATH + '/api/admin/collections/' + encodeURIComponent(state.selectedCollection) + '/links',
1707
- { method: 'DELETE', body: JSON.stringify({ link }) }
1744
+ { method: 'DELETE', body: JSON.stringify({ link: link }) }
1708
1745
  )
1709
1746
  showAlert('外链已删除', 'success')
1710
- await refreshCollectionResources()
1747
+ refreshCollectionResources()
1711
1748
  })
1712
1749
 
1713
- actions.appendChild(open)
1750
+ actions.appendChild(openEl)
1714
1751
  actions.appendChild(del)
1715
1752
  row.appendChild(text)
1716
1753
  row.appendChild(actions)
@@ -1718,87 +1755,372 @@ function buildAdminHtml(basePath) {
1718
1755
  })
1719
1756
  }
1720
1757
 
1721
- function readFileAsDataURL(file) {
1722
- return new Promise((resolve, reject) => {
1723
- const reader = new FileReader()
1724
- reader.onload = () => resolve(reader.result)
1725
- reader.onerror = () => reject(new Error('读取文件失败'))
1726
- reader.readAsDataURL(file)
1758
+ /* --- Upload with drag/drop --- */
1759
+ async function uploadFiles(files) {
1760
+ if (!state.selectedCollection || !files || !files.length) return
1761
+ var images = []
1762
+ for (var i = 0; i < files.length; i++) {
1763
+ var b64 = await readFileAsDataURL(files[i])
1764
+ images.push({ base64: b64, originalName: files[i].name })
1765
+ }
1766
+ await request(BASE_PATH + '/api/admin/collections/' + encodeURIComponent(state.selectedCollection) + '/images', {
1767
+ method: 'POST',
1768
+ body: JSON.stringify({ images: images }),
1727
1769
  })
1770
+ showAlert('图片上传成功', 'success')
1771
+ refreshCollectionResources()
1728
1772
  }
1729
1773
 
1730
- byId('create-collection').addEventListener('click', async () => {
1731
- const name = byId('new-collection-name').value.trim()
1774
+ var dropZone = byId('drop-zone')
1775
+ var fileInput = byId('upload-files')
1776
+
1777
+ dropZone.addEventListener('click', function() { fileInput.click() })
1778
+ fileInput.addEventListener('change', function() { uploadFiles(fileInput.files); fileInput.value = '' })
1779
+
1780
+ dropZone.addEventListener('dragover', function(e) { e.preventDefault(); dropZone.classList.add('drag-over') })
1781
+ dropZone.addEventListener('dragleave', function(e) { e.preventDefault(); dropZone.classList.remove('drag-over') })
1782
+ dropZone.addEventListener('drop', function(e) {
1783
+ e.preventDefault()
1784
+ dropZone.classList.remove('drag-over')
1785
+ if (e.dataTransfer && e.dataTransfer.files) uploadFiles(e.dataTransfer.files)
1786
+ })
1787
+
1788
+ /* --- Buttons --- */
1789
+ byId('create-collection').addEventListener('click', async function() {
1790
+ var name = byId('new-collection-name').value.trim()
1732
1791
  if (!name) return
1733
1792
  await request(BASE_PATH + '/api/admin/collections', {
1734
1793
  method: 'POST',
1735
- body: JSON.stringify({ name }),
1794
+ body: JSON.stringify({ name: name }),
1736
1795
  })
1737
1796
  byId('new-collection-name').value = ''
1738
1797
  showAlert('合集创建成功', 'success')
1739
- await refreshState()
1798
+ refreshState()
1740
1799
  })
1741
1800
 
1742
- byId('delete-collection').addEventListener('click', async () => {
1801
+ byId('delete-collection').addEventListener('click', async function() {
1743
1802
  if (!state.selectedCollection) return
1803
+ if (!confirm('确定删除合集 ' + state.selectedCollection + ' ?')) return
1744
1804
  await request(BASE_PATH + '/api/admin/collections/' + encodeURIComponent(state.selectedCollection), {
1745
1805
  method: 'DELETE',
1746
1806
  })
1747
1807
  showAlert('合集已删除', 'success')
1748
- await refreshState()
1808
+ backToFolders()
1749
1809
  })
1750
1810
 
1751
- byId('save-description').addEventListener('click', async () => {
1752
- if (!state.selectedCollection) return
1753
- const description = byId('collection-description').value.trim()
1754
- await request(BASE_PATH + '/api/admin/collections/' + encodeURIComponent(state.selectedCollection) + '/description', {
1755
- method: 'PATCH',
1756
- body: JSON.stringify({ description }),
1757
- })
1758
- showAlert('描述已保存', 'success')
1759
- await refreshState()
1760
- })
1811
+ byId('back-to-folders').addEventListener('click', backToFolders)
1761
1812
 
1762
- byId('upload-images').addEventListener('click', async () => {
1763
- if (!state.selectedCollection) return
1764
- const files = byId('upload-files').files
1765
- if (!files || !files.length) return
1766
- const images = []
1767
- for (const file of files) {
1768
- const base64 = await readFileAsDataURL(file)
1769
- images.push({ base64, originalName: file.name })
1770
- }
1771
- await request(BASE_PATH + '/api/admin/collections/' + encodeURIComponent(state.selectedCollection) + '/images', {
1772
- method: 'POST',
1773
- body: JSON.stringify({ images }),
1774
- })
1775
- byId('upload-files').value = ''
1776
- showAlert('图片上传成功', 'success')
1777
- await refreshCollectionResources()
1778
- await refreshState()
1813
+ byId('refresh-resources').addEventListener('click', function() {
1814
+ refreshCollectionResources()
1815
+ showAlert('已刷新', 'success')
1779
1816
  })
1780
1817
 
1781
- byId('add-links').addEventListener('click', async () => {
1818
+ byId('add-links').addEventListener('click', async function() {
1782
1819
  if (!state.selectedCollection) return
1783
- const text = byId('links-input').value
1784
- const links = text.split(/\r?
1785
- /g).map((line) => line.trim()).filter(Boolean)
1786
- if (!links.length) return
1820
+ var text = byId('links-input').value
1821
+ var lines = text.split(/\\r?\\n/g).map(function(l) { return l.trim() }).filter(Boolean)
1822
+ if (!lines.length) return
1787
1823
  await request(BASE_PATH + '/api/admin/collections/' + encodeURIComponent(state.selectedCollection) + '/links', {
1788
1824
  method: 'POST',
1789
- body: JSON.stringify({ links }),
1825
+ body: JSON.stringify({ links: lines }),
1790
1826
  })
1791
1827
  byId('links-input').value = ''
1792
1828
  showAlert('外链添加成功', 'success')
1793
- await refreshCollectionResources()
1794
- await refreshState()
1829
+ refreshCollectionResources()
1795
1830
  })
1796
1831
 
1797
- byId('upload-files').addEventListener('change', () => {
1798
- byId('upload-images').disabled = !state.selectedCollection
1832
+ refreshState().catch(function(error) {
1833
+ showAlert(error instanceof Error ? error.message : String(error), 'danger')
1799
1834
  })
1835
+ </script>
1836
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
1837
+ </body>
1838
+ </html>`;
1839
+ }
1840
+ __name(buildAdminHtml, "buildAdminHtml");
1841
+ function buildAdminEndpointHtml(basePath) {
1842
+ return `<!doctype html>
1843
+ <html lang="zh-CN">
1844
+ <head>
1845
+ <meta charset="utf-8" />
1846
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1847
+ <title>图床转发 - 302 端点管理</title>
1848
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet">
1849
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.3/font/bootstrap-icons.css">
1850
+ <style>
1851
+ body {
1852
+ min-height: 100vh;
1853
+ margin: 0;
1854
+ color: #1f2937;
1855
+ background-color: #f4f6fb;
1856
+ background-image: url('/project_bg/default_background.jpg');
1857
+ background-size: cover;
1858
+ background-position: center;
1859
+ background-repeat: no-repeat;
1860
+ background-attachment: fixed;
1861
+ }
1862
+
1863
+ body::before {
1864
+ content: '';
1865
+ position: fixed;
1866
+ inset: 0;
1867
+ background-color: rgba(255, 255, 255, 0.78);
1868
+ z-index: -1;
1869
+ }
1870
+
1871
+ .acrylic-navbar {
1872
+ background-color: rgba(248, 249, 250, 0.68);
1873
+ -webkit-backdrop-filter: blur(12px) saturate(150%);
1874
+ backdrop-filter: blur(12px) saturate(150%);
1875
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
1876
+ }
1877
+
1878
+ .layout-shell {
1879
+ max-width: 1320px;
1880
+ }
1881
+
1882
+ .panel {
1883
+ border-radius: 14px;
1884
+ border: 1px solid rgba(255, 255, 255, 0.5);
1885
+ background: rgba(255, 255, 255, 0.72);
1886
+ -webkit-backdrop-filter: blur(10px) saturate(130%);
1887
+ backdrop-filter: blur(10px) saturate(130%);
1888
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
1889
+ padding: 1rem;
1890
+ }
1891
+
1892
+ .sidebar-panel {
1893
+ position: sticky;
1894
+ top: 1rem;
1895
+ }
1896
+
1897
+ .code-text {
1898
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
1899
+ font-size: 0.8rem;
1900
+ word-break: break-all;
1901
+ }
1902
+ </style>
1903
+ </head>
1904
+ <body>
1905
+ <nav class="navbar navbar-expand-lg navbar-light acrylic-navbar">
1906
+ <div class="container layout-shell">
1907
+ <a class="navbar-brand" href="${basePath}/">MemesLuna</a>
1908
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavEndpoint">
1909
+ <span class="navbar-toggler-icon"></span>
1910
+ </button>
1911
+ <div class="collapse navbar-collapse" id="navbarNavEndpoint">
1912
+ <ul class="navbar-nav me-auto">
1913
+ <li class="nav-item"><a class="nav-link" href="${basePath}/">首页</a></li>
1914
+ <li class="nav-item"><a class="nav-link" href="${basePath}/admin">管理</a></li>
1915
+ <li class="nav-item"><a class="nav-link active" href="${basePath}/admin/endpoint">端点</a></li>
1916
+ </ul>
1917
+ </div>
1918
+ </div>
1919
+ </nav>
1800
1920
 
1801
- refreshState().catch((error) => {
1921
+ <div class="container layout-shell mt-3 pb-4">
1922
+ <div class="row g-3">
1923
+ <div class="col-lg-3">
1924
+ <div class="panel sidebar-panel">
1925
+ <h5 class="mb-3">添加 / 编辑端点</h5>
1926
+ <input id="endpoint-name" class="form-control mb-2" placeholder="端点名称" />
1927
+ <input id="endpoint-description" class="form-control mb-2" placeholder="描述" />
1928
+ <input id="endpoint-url" class="form-control mb-2" placeholder="目标 URL" />
1929
+
1930
+ <div class="d-grid gap-2 mt-3">
1931
+ <button id="save-endpoint" class="btn btn-primary">创建</button>
1932
+ <button id="reset-endpoint" class="btn btn-outline-secondary">清空</button>
1933
+ </div>
1934
+ </div>
1935
+ </div>
1936
+
1937
+ <div class="col-lg-9">
1938
+ <div class="panel">
1939
+ <h4 class="mb-3">302 跳转端点管理</h4>
1940
+ <p class="text-muted mb-3">通过 <code>${basePath}/端点名称</code> 访问,自动 302 跳转到目标 URL。</p>
1941
+
1942
+ <div class="table-responsive">
1943
+ <table class="table table-sm align-middle">
1944
+ <thead>
1945
+ <tr>
1946
+ <th>名称</th>
1947
+ <th>描述</th>
1948
+ <th>目标 URL</th>
1949
+ <th>访问</th>
1950
+ <th></th>
1951
+ </tr>
1952
+ </thead>
1953
+ <tbody id="endpoint-table"></tbody>
1954
+ </table>
1955
+ </div>
1956
+ </div>
1957
+ </div>
1958
+ </div>
1959
+
1960
+ <div id="endpoint-alert" class="alert mt-3 d-none"></div>
1961
+ </div>
1962
+
1963
+ <script>
1964
+ var BASE_PATH = '${basePath}'
1965
+ var endpointState = {
1966
+ endpoints: [],
1967
+ editingName: '',
1968
+ }
1969
+
1970
+ var byId = function(id) { return document.getElementById(id) }
1971
+
1972
+ function showAlert(message, type) {
1973
+ type = type || 'info'
1974
+ var el = byId('endpoint-alert')
1975
+ el.className = 'alert alert-' + type + ' mt-3'
1976
+ el.textContent = message
1977
+ el.classList.remove('d-none')
1978
+ setTimeout(function() { el.classList.add('d-none') }, 2400)
1979
+ }
1980
+
1981
+ async function request(url, options) {
1982
+ options = options || {}
1983
+ var headers = Object.assign({}, options.headers || {})
1984
+ if (options.body && !headers['Content-Type']) {
1985
+ headers['Content-Type'] = 'application/json'
1986
+ }
1987
+ var res = await fetch(url, Object.assign({}, options, { headers: headers }))
1988
+ var data = null
1989
+ try { data = await res.json() } catch(e) { data = null }
1990
+ if (!res.ok) {
1991
+ throw new Error(data && data.error ? data.error : String(res.status) + ' ' + String(res.statusText))
1992
+ }
1993
+ return data
1994
+ }
1995
+
1996
+ function resetForm() {
1997
+ endpointState.editingName = ''
1998
+ byId('endpoint-name').value = ''
1999
+ byId('endpoint-description').value = ''
2000
+ byId('endpoint-url').value = ''
2001
+ byId('save-endpoint').textContent = '创建'
2002
+ byId('endpoint-name').disabled = false
2003
+ }
2004
+
2005
+ function fillForm(item) {
2006
+ endpointState.editingName = item.name || ''
2007
+ byId('endpoint-name').value = item.name || ''
2008
+ byId('endpoint-description').value = item.description || ''
2009
+ byId('endpoint-url').value = item.url || ''
2010
+ byId('save-endpoint').textContent = '更新'
2011
+ byId('endpoint-name').disabled = true
2012
+ }
2013
+
2014
+ function renderTable() {
2015
+ var body = byId('endpoint-table')
2016
+ body.textContent = ''
2017
+
2018
+ if (!endpointState.endpoints.length) {
2019
+ var tr = document.createElement('tr')
2020
+ var td = document.createElement('td')
2021
+ td.colSpan = 5
2022
+ td.className = 'text-muted'
2023
+ td.textContent = '暂无端点'
2024
+ tr.appendChild(td)
2025
+ body.appendChild(tr)
2026
+ return
2027
+ }
2028
+
2029
+ endpointState.endpoints.forEach(function(item) {
2030
+ var tr = document.createElement('tr')
2031
+ var visitUrl = BASE_PATH + '/' + encodeURIComponent(item.name || '')
2032
+
2033
+ tr.innerHTML =
2034
+ '<td class="code-text"></td>' +
2035
+ '<td></td>' +
2036
+ '<td class="code-text"></td>' +
2037
+ '<td></td>' +
2038
+ '<td></td>'
2039
+
2040
+ tr.children[0].textContent = item.name || ''
2041
+ tr.children[1].textContent = item.description || '-'
2042
+ tr.children[2].textContent = item.url || ''
2043
+
2044
+ var visitLink = document.createElement('a')
2045
+ visitLink.href = visitUrl
2046
+ visitLink.target = '_blank'
2047
+ visitLink.className = 'btn btn-sm btn-outline-secondary'
2048
+ visitLink.innerHTML = '<i class="bi bi-box-arrow-up-right"></i>'
2049
+ tr.children[3].appendChild(visitLink)
2050
+
2051
+ var actionWrap = document.createElement('div')
2052
+ actionWrap.className = 'd-flex gap-1 justify-content-end'
2053
+
2054
+ var editBtn = document.createElement('button')
2055
+ editBtn.className = 'btn btn-sm btn-outline-primary'
2056
+ editBtn.textContent = '编辑'
2057
+ editBtn.addEventListener('click', function() { fillForm(item) })
2058
+
2059
+ var delBtn = document.createElement('button')
2060
+ delBtn.className = 'btn btn-sm btn-outline-danger'
2061
+ delBtn.textContent = '删除'
2062
+ delBtn.addEventListener('click', async function() {
2063
+ await request(BASE_PATH + '/api/admin/endpoints/' + encodeURIComponent(item.name || ''), {
2064
+ method: 'DELETE',
2065
+ })
2066
+ showAlert('端点已删除', 'success')
2067
+ await loadEndpoints()
2068
+ if (endpointState.editingName === item.name) resetForm()
2069
+ })
2070
+
2071
+ actionWrap.appendChild(editBtn)
2072
+ actionWrap.appendChild(delBtn)
2073
+ tr.children[4].appendChild(actionWrap)
2074
+ body.appendChild(tr)
2075
+ })
2076
+ }
2077
+
2078
+ async function loadEndpoints() {
2079
+ var data = await request(BASE_PATH + '/api/admin/endpoints')
2080
+ endpointState.endpoints = Array.isArray(data.endpoints) ? data.endpoints : []
2081
+ renderTable()
2082
+ }
2083
+
2084
+ byId('save-endpoint').addEventListener('click', async function() {
2085
+ var name = byId('endpoint-name').value.trim()
2086
+ var url = byId('endpoint-url').value.trim()
2087
+ if (!name || !url) {
2088
+ showAlert('名称与目标 URL 必填', 'warning')
2089
+ return
2090
+ }
2091
+
2092
+ var payload = {
2093
+ name: name,
2094
+ description: byId('endpoint-description').value.trim(),
2095
+ url: url,
2096
+ method: 'redirect',
2097
+ urlConstruction: 'normal',
2098
+ modelName: '',
2099
+ queryParams: [],
2100
+ proxySettings: { fallbackAction: 'returnJson' },
2101
+ }
2102
+
2103
+ if (endpointState.editingName) {
2104
+ await request(BASE_PATH + '/api/admin/endpoints/' + encodeURIComponent(endpointState.editingName), {
2105
+ method: 'PATCH',
2106
+ body: JSON.stringify(payload),
2107
+ })
2108
+ showAlert('端点更新成功', 'success')
2109
+ } else {
2110
+ await request(BASE_PATH + '/api/admin/endpoints', {
2111
+ method: 'POST',
2112
+ body: JSON.stringify(payload),
2113
+ })
2114
+ showAlert('端点创建成功', 'success')
2115
+ }
2116
+
2117
+ await loadEndpoints()
2118
+ resetForm()
2119
+ })
2120
+
2121
+ byId('reset-endpoint').addEventListener('click', resetForm)
2122
+
2123
+ loadEndpoints().catch(function(error) {
1802
2124
  showAlert(error instanceof Error ? error.message : String(error), 'danger')
1803
2125
  })
1804
2126
  </script>
@@ -1806,7 +2128,7 @@ function buildAdminHtml(basePath) {
1806
2128
  </body>
1807
2129
  </html>`;
1808
2130
  }
1809
- __name(buildAdminHtml, "buildAdminHtml");
2131
+ __name(buildAdminEndpointHtml, "buildAdminEndpointHtml");
1810
2132
  async function updateMemesVariable(ctx, config, service) {
1811
2133
  const baseUrl = toAbsoluteBaseUrl(ctx, config);
1812
2134
  const inventory = await service.buildRouteInventory(config.backendPath);
@@ -2153,6 +2475,11 @@ function applyServer(ctx, config, service) {
2153
2475
  koa.set("Content-Type", "text/html; charset=utf-8");
2154
2476
  koa.body = buildAdminHtml(basePath);
2155
2477
  });
2478
+ ctx.server.get(`${basePath}/admin/endpoint`, async (koa) => {
2479
+ koa.status = 200;
2480
+ koa.set("Content-Type", "text/html; charset=utf-8");
2481
+ koa.body = buildAdminEndpointHtml(basePath);
2482
+ });
2156
2483
  ctx.server.get(`${basePath}/api/collections/:name/resources`, async (koa) => {
2157
2484
  const collectionName = koa.params.name;
2158
2485
  const images = await service.getCollectionImages(collectionName);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-memesluna",
3
3
  "description": "Image Forward service for Koishi with ChatLuna integration",
4
- "version": "0.2.5",
4
+ "version": "0.2.7",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "types": "lib/index.d.ts",