chore: 初始提交 - 移除所有硬编码凭据,统一从环境变量读取

This commit is contained in:
yangxiangyuan
2026-06-11 17:10:58 +08:00
commit c223c79b36
1699 changed files with 313940 additions and 0 deletions
@@ -0,0 +1,15 @@
{
"_comment": "私人剪贴板工具的前端鉴权配置",
"enable_auth_comment": "true:启用鉴权;false:关闭鉴权并放开访问",
"enable_auth": true,
"max_age_days_comment": "鉴权 Cookie 有效期(天),范围建议 1~365",
"max_age_days": 30,
"cookieName_comment": "鉴权 Cookie 名称,建议保持默认",
"cookieName": "private_clipboard_gate",
"requireGuid_comment": "true:入口必须带 guid 参数;false:允许仅凭 token 或会话访问",
"requireGuid": true,
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
"guidParam": "guid",
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换",
"guidValue": "SD9KK-LW32S-9KJJH-JJH43-LJ432J"
}
@@ -0,0 +1,685 @@
body {
background: #0f1115;
color: #e6e8ee;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
.pc-main {
max-width: 1100px;
margin: 0 auto;
padding: 32px 20px 60px;
}
.pc-header {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 24px;
}
.pc-title {
font-size: 28px;
font-weight: 700;
letter-spacing: 1px;
}
.pc-subtitle {
color: #9aa3b2;
font-size: 14px;
}
.pc-input {
margin-bottom: 28px;
}
.pc-paste-zone {
border: 1px dashed #3b4252;
border-radius: 14px;
padding: 24px;
background: rgba(28, 31, 40, 0.6);
transition: border-color 0.2s ease, background 0.2s ease;
outline: none;
}
.pc-paste-zone:focus {
border-color: #6ea8ff;
background: rgba(32, 38, 55, 0.75);
}
.pc-paste-zone.dragover {
border-color: #6ea8ff;
background: rgba(44, 54, 82, 0.8);
}
.pc-paste-text {
font-size: 16px;
font-weight: 600;
}
.pc-paste-hint {
margin-top: 6px;
font-size: 13px;
color: #8f99aa;
}
.pc-paste-actions {
margin-top: 12px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pc-paste-input {
position: absolute;
opacity: 0;
pointer-events: none;
width: 1px;
height: 1px;
left: -9999px;
top: 0;
}
.pc-desktop-only {
display: block;
}
.pc-mobile-only {
display: none;
}
@media (max-width: 900px), (pointer: coarse) {
.pc-desktop-only {
display: none;
}
.pc-mobile-only {
display: block;
}
}
.pc-mobile-inputs {
flex-direction: column;
gap: 14px;
}
.pc-mobile-only.pc-mobile-inputs {
display: none;
}
@media (max-width: 900px), (pointer: coarse) {
.pc-mobile-only.pc-mobile-inputs {
display: flex;
}
}
.pc-mobile-block {
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(63, 70, 89, 0.6);
background: rgba(20, 22, 30, 0.7);
display: flex;
flex-direction: column;
gap: 10px;
}
.pc-mobile-title {
font-size: 14px;
color: #cfe1ff;
font-weight: 600;
}
.pc-mobile-text {
width: 100%;
min-height: 110px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(63, 70, 89, 0.6);
background: rgba(18, 20, 28, 0.8);
color: #e6e8ee;
resize: vertical;
font-size: 14px;
line-height: 1.5;
}
.pc-mobile-text:focus {
outline: none;
border-color: #6ea8ff;
}
.pc-mobile-file {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(63, 70, 89, 0.6);
background: rgba(18, 20, 28, 0.8);
color: #e6e8ee;
}
.pc-mobile-actions {
display: flex;
justify-content: flex-end;
}
.pc-status {
min-height: 18px;
margin-top: 10px;
font-size: 13px;
color: #8f99aa;
}
.pc-status.ok {
color: #6ed6a6;
}
.pc-status.error {
color: #ff8484;
}
.pc-progress {
margin-top: 12px;
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(63, 70, 89, 0.5);
background: rgba(20, 22, 30, 0.7);
display: none;
}
.pc-progress.show {
display: block;
}
.pc-progress-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #9aa3b2;
margin-bottom: 8px;
}
.pc-progress-text {
font-weight: 500;
}
.pc-progress-bar {
width: 100%;
height: 8px;
border-radius: 999px;
background: rgba(63, 70, 89, 0.5);
overflow: hidden;
}
.pc-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #6ea8ff, #8fb8ff);
transition: width 0.2s ease;
}
.pc-list {
background: rgba(20, 22, 30, 0.75);
border-radius: 16px;
padding: 20px;
border: 1px solid rgba(63, 70, 89, 0.6);
}
.pc-list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.pc-list-title {
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.pc-count {
color: #9aa3b2;
font-size: 12px;
}
.pc-list-actions {
display: flex;
align-items: center;
gap: 8px;
}
.pc-view-tabs {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.pc-favorite-filter {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.pc-favorite-filter label {
font-size: 13px;
color: #9aa3b2;
}
.pc-favorite-filter select {
min-width: 220px;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid rgba(63, 70, 89, 0.6);
background: rgba(18, 20, 28, 0.8);
color: #e6e8ee;
}
.pc-filter {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.pc-filter-types {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pc-filter-btn.active {
border-color: #6ea8ff;
color: #cfe1ff;
}
.pc-filter-search {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
justify-content: flex-end;
}
.pc-filter-search input {
flex: 1;
min-width: 180px;
max-width: 320px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(63, 70, 89, 0.6);
background: rgba(18, 20, 28, 0.8);
color: #e6e8ee;
}
.pc-filter-search input:focus {
outline: none;
border-color: #6ea8ff;
}
.pc-items {
display: flex;
flex-direction: column;
gap: 16px;
}
.pc-empty {
text-align: center;
color: #7a8397;
padding: 20px 0;
font-size: 14px;
}
.pc-item {
border: 1px solid rgba(63, 70, 89, 0.6);
border-radius: 12px;
padding: 16px;
background: rgba(28, 31, 40, 0.7);
display: flex;
flex-direction: column;
gap: 12px;
}
.pc-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pc-item-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: #9aa3b2;
}
.pc-item-type {
padding: 2px 8px;
border-radius: 999px;
background: rgba(110, 168, 255, 0.16);
color: #8fb8ff;
font-weight: 600;
font-size: 12px;
}
.pc-item-actions {
display: flex;
gap: 8px;
}
.pc-item-actions button {
padding: 6px 10px;
font-size: 12px;
}
.pc-item-actions .disabled {
opacity: 0.55;
cursor: not-allowed;
}
.pc-favorite-tag {
padding: 2px 8px;
border-radius: 999px;
border: 1px solid rgba(255, 206, 64, 0.45);
background: rgba(255, 206, 64, 0.12);
color: #ffd66b;
font-size: 12px;
}
.pc-item-text {
white-space: pre-wrap;
word-break: break-word;
font-size: 14px;
line-height: 1.6;
color: #e6e8ee;
}
.pc-image-thumb {
width: 100%;
height: 180px;
border-radius: 10px;
border: 1px solid rgba(63, 70, 89, 0.4);
background: rgba(18, 20, 28, 0.7);
overflow: hidden;
cursor: zoom-in;
}
.pc-image-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pc-image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
}
.pc-text-snippet {
padding: 10px 12px;
border-radius: 10px;
background: rgba(18, 20, 28, 0.6);
border: 1px solid rgba(63, 70, 89, 0.4);
color: #e6e8ee;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.pc-viewer {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
}
.pc-viewer.show {
display: flex;
}
.pc-viewer-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
}
.pc-viewer-body {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.pc-viewer-img {
max-width: 90vw;
max-height: 85vh;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
}
.pc-viewer-toolbar {
position: absolute;
top: 20px;
right: 20px;
display: flex;
align-items: center;
gap: 12px;
color: #f4f6fb;
font-size: 13px;
}
.pc-viewer-close {
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(20, 20, 20, 0.6);
color: #ffffff;
cursor: pointer;
}
.pc-viewer-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(20, 20, 20, 0.6);
color: #ffffff;
font-size: 22px;
cursor: pointer;
}
.pc-viewer-nav.prev {
left: 24px;
}
.pc-viewer-nav.next {
right: 24px;
}
.pc-viewer-nav.disabled {
opacity: 0.35;
cursor: default;
}
.pc-file-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.pc-file-name {
font-size: 14px;
color: #e6e8ee;
word-break: break-all;
flex: 1;
}
.pc-viewer-actions {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 12px;
z-index: 2;
}
.pc-file-meta {
font-size: 12px;
color: #8f99aa;
}
.pc-modal {
position: fixed;
inset: 0;
display: none;
z-index: 10010;
}
.pc-modal.show {
display: block;
}
.pc-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.62);
}
.pc-modal-body {
position: relative;
width: min(760px, calc(100vw - 24px));
margin: 8vh auto 0;
border-radius: 12px;
border: 1px solid rgba(63, 70, 89, 0.7);
background: #171a22;
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.45);
z-index: 1;
}
.pc-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid rgba(63, 70, 89, 0.6);
}
.pc-modal-title {
font-size: 15px;
font-weight: 600;
}
.pc-modal-content {
padding: 14px 16px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.pc-modal-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pc-modal-row input,
.pc-modal-row select {
min-width: 160px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(63, 70, 89, 0.6);
background: rgba(18, 20, 28, 0.8);
color: #e6e8ee;
}
.pc-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.pc-folder-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.pc-folder-row {
display: grid;
grid-template-columns: minmax(160px, 1fr) 120px 90px auto auto;
gap: 8px;
align-items: center;
}
.pc-folder-count {
font-size: 12px;
color: #9aa3b2;
}
.pc-folder-empty {
color: #7a8397;
font-size: 13px;
}
@media (max-width: 768px) {
.pc-main {
padding: 24px 16px 40px;
}
.pc-title {
font-size: 22px;
}
.pc-list {
padding: 16px;
}
.pc-filter {
flex-direction: column;
align-items: stretch;
}
.pc-filter-search {
width: 100%;
}
.pc-filter-search input {
max-width: none;
}
.pc-folder-row {
grid-template-columns: 1fr;
}
.pc-favorite-filter {
flex-direction: column;
align-items: flex-start;
}
.pc-favorite-filter select {
width: 100%;
}
}
+898
View File
@@ -0,0 +1,898 @@
const apiBase = '/api/private_clipboard'
const $ = id => document.getElementById(id)
const state = {
items: [],
folders: [],
filter: { type: 'all', keyword: '', view: 'all', folderCode: 'all' },
favoriteDraft: { id: null },
viewer: { images: [], index: 0 }
}
const normalizeError = (error) => {
const text = String(error && error.message ? error.message : error || '').trim()
if (!text) return 'unknown_error'
return text
}
const folderDisplayName = (code) => {
const folder = state.folders.find(item => item.code === code)
return folder ? folder.name : '未命名目录'
}
const clearUrlQuery = () => {
try {
if (!window.location.search) return
const cleanUrl = `${window.location.pathname}${window.location.hash || ''}`
window.history.replaceState({}, '', cleanUrl)
} catch {}
}
const fmtDateTime = (ts) => {
const date = new Date(ts || Date.now())
try {
const s = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date)
return s.replace(/\//g, '-').replace(/\u200E/g, '')
} catch {
const pad = n => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
}
const fmtSize = (bytes) => {
const n = Number(bytes || 0)
if (!n) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let size = n
let idx = 0
while (size >= 1024 && idx < units.length - 1) {
size /= 1024
idx += 1
}
return `${size.toFixed(size >= 100 ? 0 : size >= 10 ? 1 : 2)} ${units[idx]}`
}
const setStatus = (text, type) => {
const el = $('pc-status')
if (!el) return
el.textContent = text || ''
el.className = 'pc-status' + (type ? ` ${type}` : '')
}
const setProgress = (show, done, total) => {
const box = $('pc-progress')
const text = $('pc-progress-text')
const percent = $('pc-progress-percent')
const fill = $('pc-progress-fill')
if (!box || !text || !percent || !fill) return
if (!show) {
box.className = 'pc-progress'
fill.style.width = '0%'
text.textContent = ''
percent.textContent = ''
return
}
const safeTotal = Math.max(1, Number(total || 0))
const safeDone = Math.min(safeTotal, Math.max(0, Number(done || 0)))
const pct = Math.round((safeDone / safeTotal) * 100)
box.className = 'pc-progress show'
text.textContent = `上传进度 ${safeDone}/${safeTotal}`
percent.textContent = `${pct}%`
fill.style.width = `${pct}%`
}
const copyTextToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
setStatus('已复制到剪贴板', 'ok')
setTimeout(() => setStatus('', ''), 2000)
} catch (e) {
setStatus('复制失败', 'error')
setTimeout(() => setStatus('', ''), 2000)
}
}
const copyImageToClipboard = async (url) => {
try {
const response = await fetch(url)
const blob = await response.blob()
const item = new ClipboardItem({ [blob.type]: blob })
await navigator.clipboard.write([item])
setStatus('已复制图片到剪贴板', 'ok')
setTimeout(() => setStatus('', ''), 2000)
} catch (e) {
setStatus('复制图片失败', 'error')
setTimeout(() => setStatus('', ''), 2000)
}
}
const detectMobile = () => {
if (window.matchMedia) {
return window.matchMedia('(max-width: 900px), (pointer: coarse)').matches
}
const ua = navigator.userAgent || ''
return /Android|iPhone|iPad|iPod|Mobile/i.test(ua)
}
const createGroupId = () => -(Date.now() * 1000 + Math.floor(Math.random() * 1000))
const updateViewer = () => {
const img = $('pc-viewer-img')
const count = $('pc-viewer-count')
const prev = $('pc-viewer-prev')
const next = $('pc-viewer-next')
const total = state.viewer.images.length
const idx = Math.max(0, Math.min(state.viewer.index, total - 1))
state.viewer.index = idx
const current = state.viewer.images[idx]
if (img) {
img.src = current ? current.url : ''
img.alt = current ? current.name : ''
}
if (count) count.textContent = total ? `${idx + 1} / ${total}` : ''
if (prev) prev.classList.toggle('disabled', idx <= 0)
if (next) next.classList.toggle('disabled', idx >= total - 1)
}
const openViewerAt = (images, index) => {
const viewer = $('pc-viewer')
if (!viewer || !Array.isArray(images) || !images.length) return
state.viewer.images = images
state.viewer.index = Math.max(0, Math.min(index, images.length - 1))
viewer.classList.add('show')
document.body.style.overflow = 'hidden'
updateViewer()
}
const closeViewer = () => {
const viewer = $('pc-viewer')
if (viewer) viewer.classList.remove('show')
document.body.style.overflow = ''
}
const stepViewer = (delta) => {
const total = state.viewer.images.length
if (!total) return
const next = state.viewer.index + delta
if (next < 0 || next >= total) return
state.viewer.index = next
updateViewer()
}
const apiJson = async (url, options) => {
const res = await fetch(url, options || {})
const contentType = res.headers.get('content-type') || ''
if (!contentType.includes('application/json')) {
if (res.status === 401) throw new Error('unauthorized')
throw new Error(`unexpected_response_${res.status}`)
}
const data = await res.json()
if (!data || !data.ok) throw new Error((data && data.error) || 'request_failed')
return data
}
const fetchFolders = async () => {
const data = await apiJson(`${apiBase}/folders`, { cache: 'no-store' })
state.folders = Array.isArray(data.folders) ? data.folders : []
renderFolderFilters()
renderFolderList()
}
const fetchList = async () => {
const data = await apiJson(`${apiBase}/list`, { cache: 'no-store' })
state.items = Array.isArray(data.items) ? data.items : []
renderList()
}
const renderFolderFilters = () => {
const wrap = $('pc-favorite-filter')
const select = $('pc-folder-select')
const favoriteSelect = $('pc-favorite-folder-select')
const viewTitle = $('pc-view-title')
if (viewTitle) viewTitle.textContent = state.filter.view === 'favorites' ? '我的收藏' : '全部内容'
if (wrap) wrap.style.display = state.filter.view === 'favorites' ? '' : 'none'
const fillSelect = (target, withAll) => {
if (!target) return
const old = String(target.value || '')
target.innerHTML = ''
if (withAll) {
const opt = document.createElement('option')
opt.value = 'all'
opt.textContent = '全部目录'
target.appendChild(opt)
}
state.folders.forEach(folder => {
const opt = document.createElement('option')
opt.value = folder.code
opt.textContent = `${folder.name} (${Number(folder.favorite_count || 0)})`
target.appendChild(opt)
})
if (withAll) {
target.value = state.folders.some(f => f.code === state.filter.folderCode) ? state.filter.folderCode : 'all'
state.filter.folderCode = String(target.value || 'all')
} else if (state.folders.length) {
target.value = state.folders.some(f => f.code === old) ? old : state.folders[0].code
}
}
fillSelect(select, true)
fillSelect(favoriteSelect, false)
}
const renderFolderList = () => {
const list = $('pc-folder-list')
if (!list) return
list.innerHTML = ''
if (!state.folders.length) {
const empty = document.createElement('div')
empty.className = 'pc-folder-empty'
empty.textContent = '暂无目录,请先新增目录'
list.appendChild(empty)
return
}
state.folders.forEach(folder => {
const row = document.createElement('div')
row.className = 'pc-folder-row'
const nameInput = document.createElement('input')
nameInput.value = folder.name || ''
nameInput.placeholder = '目录名称'
const sortInput = document.createElement('input')
sortInput.type = 'number'
sortInput.min = '1'
sortInput.step = '1'
sortInput.value = String(Number(folder.sort_order || 1))
const count = document.createElement('div')
count.className = 'pc-folder-count'
count.textContent = `收藏 ${Number(folder.favorite_count || 0)}`
const saveBtn = document.createElement('button')
saveBtn.className = 'btn'
saveBtn.type = 'button'
saveBtn.textContent = '保存'
saveBtn.onclick = async () => {
try {
const payload = {
name: String(nameInput.value || '').trim(),
sort_order: Number.parseInt(String(sortInput.value || ''), 10)
}
await apiJson(`${apiBase}/folders/${encodeURIComponent(folder.code)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
await Promise.all([fetchFolders(), fetchList()])
setStatus('目录已更新', 'ok')
} catch (e) {
setStatus(`目录更新失败: ${normalizeError(e)}`, 'error')
}
}
const delBtn = document.createElement('button')
delBtn.className = 'btn'
delBtn.type = 'button'
delBtn.textContent = '删除'
delBtn.onclick = async () => {
try {
await apiJson(`${apiBase}/folders/${encodeURIComponent(folder.code)}`, { method: 'DELETE' })
await Promise.all([fetchFolders(), fetchList()])
setStatus('目录已删除', 'ok')
} catch (e) {
const code = normalizeError(e)
if (code === 'folder_not_empty') setStatus('目录下还有收藏内容,不能删除。请先取消收藏或迁移。', 'error')
else setStatus(`删除目录失败: ${code}`, 'error')
}
}
row.appendChild(nameInput)
row.appendChild(sortInput)
row.appendChild(count)
row.appendChild(saveBtn)
row.appendChild(delBtn)
list.appendChild(row)
})
}
const openFavoriteModal = (id) => {
state.favoriteDraft.id = id
renderFolderFilters()
const modal = $('pc-favorite-modal')
if (modal) modal.classList.add('show')
}
const closeFavoriteModal = () => {
state.favoriteDraft.id = null
const modal = $('pc-favorite-modal')
if (modal) modal.classList.remove('show')
}
const openFolderModal = () => {
const modal = $('pc-folder-modal')
if (modal) modal.classList.add('show')
}
const closeFolderModal = () => {
const modal = $('pc-folder-modal')
if (modal) modal.classList.remove('show')
}
const favoriteItem = async (id, folderCode) => {
await apiJson(`${apiBase}/favorite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, folder_code: folderCode })
})
await Promise.all([fetchFolders(), fetchList()])
}
const unfavoriteItem = async (id) => {
await apiJson(`${apiBase}/unfavorite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
})
await Promise.all([fetchFolders(), fetchList()])
}
const renderList = () => {
const list = $('pc-items')
const empty = $('pc-empty')
const count = $('pc-count')
if (!list || !empty) return
list.innerHTML = ''
const filtered = state.items.filter(item => {
if (state.filter.view === 'favorites' && !item.is_favorite) return false
if (state.filter.view === 'favorites' && state.filter.folderCode !== 'all' && item.favorite_folder_code !== state.filter.folderCode) return false
const t = state.filter.type
const types = Array.isArray(item.group_types) && item.group_types.length ? item.group_types : [item.type].filter(Boolean)
if (t && t !== 'all' && !types.includes(t)) return false
const kw = state.filter.keyword.trim().toLowerCase()
if (!kw) return true
const parts = [item.text_content || '', item.favorite_folder_name || '']
if (Array.isArray(item.items)) {
item.items.forEach(i => {
if (i.file_name) parts.push(i.file_name)
})
}
const hay = parts.join(' ').toLowerCase()
return hay.includes(kw)
})
if (count) count.textContent = filtered.length ? `(${filtered.length})` : ''
if (!filtered.length) {
empty.style.display = ''
return
}
empty.style.display = 'none'
filtered.forEach(item => {
const card = document.createElement('div')
card.className = 'pc-item'
const header = document.createElement('div')
header.className = 'pc-item-header'
const meta = document.createElement('div')
meta.className = 'pc-item-meta'
const type = document.createElement('span')
type.className = 'pc-item-type'
type.textContent = item.type === 'image' ? '图片' : item.type === 'file' ? '文件' : item.type === 'mixed' ? '组合' : '文字'
const time = document.createElement('span')
time.textContent = fmtDateTime(item.created_at)
meta.appendChild(type)
meta.appendChild(time)
if (item.is_favorite) {
const tag = document.createElement('span')
tag.className = 'pc-favorite-tag'
tag.textContent = `收藏于: ${folderDisplayName(item.favorite_folder_code)}`
meta.appendChild(tag)
}
const actions = document.createElement('div')
actions.className = 'pc-item-actions'
if (item.text_content) {
const copyBtn = document.createElement('button')
copyBtn.className = 'btn'
copyBtn.type = 'button'
copyBtn.textContent = '复制文字'
copyBtn.addEventListener('click', () => copyTextToClipboard(item.text_content || ''))
actions.appendChild(copyBtn)
}
if (item.is_favorite) {
const unfavBtn = document.createElement('button')
unfavBtn.className = 'btn'
unfavBtn.type = 'button'
unfavBtn.textContent = '取消收藏'
unfavBtn.onclick = async () => {
try {
await unfavoriteItem(item.id)
setStatus('已取消收藏', 'ok')
} catch (e) {
setStatus(`取消收藏失败: ${normalizeError(e)}`, 'error')
}
}
actions.appendChild(unfavBtn)
} else {
const favBtn = document.createElement('button')
favBtn.className = 'btn'
favBtn.type = 'button'
favBtn.textContent = '收藏'
favBtn.onclick = () => {
if (!state.folders.length) {
openFolderModal()
setStatus('请先创建目录,再进行收藏', 'error')
return
}
openFavoriteModal(item.id)
}
actions.appendChild(favBtn)
}
const delBtn = document.createElement('button')
delBtn.className = 'btn'
delBtn.type = 'button'
delBtn.textContent = '删除'
if (item.is_favorite) {
delBtn.disabled = true
delBtn.title = '请先取消收藏才能删除'
delBtn.classList.add('disabled')
delBtn.addEventListener('click', () => setStatus('请先取消收藏,再删除内容', 'error'))
} else {
delBtn.addEventListener('click', async () => {
try {
await deleteItem(item.id)
await fetchFolders()
} catch (e) {
const code = normalizeError(e)
if (code === 'item_favorited') setStatus('请先取消收藏,再删除内容', 'error')
else setStatus(`删除失败: ${code}`, 'error')
}
})
}
actions.appendChild(delBtn)
header.appendChild(meta)
header.appendChild(actions)
card.appendChild(header)
if (item.text_content) {
const text = document.createElement('div')
text.className = 'pc-item-text'
text.textContent = item.text_content || ''
card.appendChild(text)
}
if (item.items && item.items.length) {
const items = Array.isArray(item.items) ? item.items : []
const images = items.filter(i => i.type === 'image')
const files = items.filter(i => i.type === 'file')
if (images.length) {
const grid = document.createElement('div')
grid.className = 'pc-image-grid'
const viewImages = images.map(i => ({ url: i.file_url, name: i.file_name || '图片' }))
images.forEach((imgItem, idx) => {
const wrap = document.createElement('div')
wrap.className = 'pc-image-thumb'
const img = document.createElement('img')
img.src = imgItem.file_url
img.alt = imgItem.file_name || 'image'
wrap.appendChild(img)
wrap.addEventListener('click', () => openViewerAt(viewImages, idx))
grid.appendChild(wrap)
})
card.appendChild(grid)
}
if (files.length) {
files.forEach(fileItem => {
const row = document.createElement('div')
row.className = 'pc-file-row'
const name = document.createElement('div')
name.className = 'pc-file-name'
name.textContent = fileItem.file_name || '文件'
const metaText = document.createElement('div')
metaText.className = 'pc-file-meta'
metaText.textContent = fmtSize(fileItem.file_size)
const copyUrlBtn = document.createElement('button')
copyUrlBtn.className = 'btn'
copyUrlBtn.textContent = '复制URL'
copyUrlBtn.onclick = () => {
const url = new URL(fileItem.file_url, window.location.origin).href
copyTextToClipboard(url)
}
const download = document.createElement('a')
download.className = 'btn'
download.href = fileItem.file_url
download.download = fileItem.file_name || ''
download.textContent = '下载'
row.appendChild(name)
row.appendChild(metaText)
row.appendChild(copyUrlBtn)
row.appendChild(download)
card.appendChild(row)
})
}
}
list.appendChild(card)
})
}
const saveText = async (text) => {
const res = await fetch(`${apiBase}/text`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
})
const data = await res.json()
if (!data || !data.ok) throw new Error((data && data.error) || 'save_failed')
}
const resolveFileName = (file) => {
const name = String(file && file.name || '').trim()
if (name) return name
const ext = file && file.type ? file.type.split('/')[1] : ''
const suffix = ext ? `.${ext}` : ''
return `clipboard-${Date.now()}${suffix}`
}
const uploadFile = async (file, groupId) => {
if (!file) return
const name = resolveFileName(file)
const buf = await file.arrayBuffer()
const groupPart = Number.isFinite(groupId) ? `&group_id=${encodeURIComponent(String(groupId))}` : ''
const url = `${apiBase}/upload?filename=${encodeURIComponent(name)}&mime=${encodeURIComponent(file.type || 'application/octet-stream')}${groupPart}`
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: new Uint8Array(buf)
})
const data = await res.json()
if (!data || !data.ok) throw new Error((data && data.error) || 'upload_failed')
}
const deleteItem = async (id) => {
if (!id) return
const res = await fetch(`${apiBase}/${id}`, { method: 'DELETE' })
const data = await res.json()
if (!data || !data.ok) throw new Error((data && data.error) || 'delete_failed')
await fetchList()
}
const handlePaste = async (event) => {
const data = event.clipboardData
if (!data) return
const items = Array.from(data.items || [])
if (!items.length) return
event.preventDefault()
setStatus('正在保存...', '')
setProgress(false)
try {
const files = items.filter(i => i.kind === 'file').map(i => i.getAsFile()).filter(Boolean)
const strings = items.filter(i => i.kind === 'string')
if (files.length) {
const groupId = files.length > 1 ? createGroupId() : null
setProgress(true, 0, files.length)
let done = 0
for (const f of files) {
await uploadFile(f, groupId)
done += 1
setProgress(true, done, files.length)
}
}
for (const s of strings) {
await new Promise((resolve) => {
s.getAsString(async (text) => {
const v = String(text || '').trim()
if (v) await saveText(v)
resolve()
})
})
}
await fetchList()
setProgress(false)
setStatus('已保存到服务器', 'ok')
} catch (e) {
setProgress(false)
setStatus('保存失败', 'error')
}
}
const handleDrop = async (event) => {
event.preventDefault()
const zone = $('paste-zone')
if (zone) zone.classList.remove('dragover')
const files = Array.from(event.dataTransfer && event.dataTransfer.files || [])
if (!files.length) return
setStatus('正在上传...', '')
const groupId = files.length > 1 ? createGroupId() : null
setProgress(true, 0, files.length)
try {
let done = 0
for (const f of files) {
await uploadFile(f, groupId)
done += 1
setProgress(true, done, files.length)
}
await fetchList()
setProgress(false)
setStatus('已保存到服务器', 'ok')
} catch (e) {
setProgress(false)
setStatus('上传失败', 'error')
}
}
const bindDragEvents = () => {
const zone = $('paste-zone')
if (!zone) return
zone.addEventListener('dragover', (event) => {
event.preventDefault()
zone.classList.add('dragover')
})
zone.addEventListener('dragleave', (event) => {
if (event.target === zone) zone.classList.remove('dragover')
})
zone.addEventListener('drop', handleDrop)
}
const bindPasteInput = () => {
const input = $('pc-paste-input')
const btn = $('pc-paste-btn')
if (!input) return
const focusInput = () => {
input.value = ''
input.focus({ preventScroll: true })
input.select()
}
if (btn) btn.addEventListener('click', focusInput)
input.addEventListener('paste', handlePaste)
const handleInput = async () => {
const value = String(input.value || '').trim()
if (!value) return
input.value = ''
setStatus('正在保存...', '')
try {
await saveText(value)
await fetchList()
setStatus('已保存到服务器', 'ok')
} catch (e) {
setStatus('保存失败', 'error')
}
}
input.addEventListener('input', handleInput)
input.addEventListener('change', handleInput)
}
const bindMobileInputs = () => {
const textInput = $('pc-mobile-text')
const textSave = $('pc-mobile-text-save')
if (textInput && textSave) {
textSave.addEventListener('click', async () => {
const value = String(textInput.value || '').trim()
if (!value) {
setStatus('请输入文字', 'error')
return
}
setStatus('正在保存...', '')
try {
await saveText(value)
textInput.value = ''
await fetchList()
setStatus('已保存到服务器', 'ok')
} catch (e) {
setStatus('保存失败', 'error')
}
})
}
const uploadSelected = (inputEl, emptyText) => {
if (!inputEl) return
return async () => {
const files = Array.from(inputEl.files || [])
if (!files.length) {
setStatus(emptyText, 'error')
return
}
setStatus('正在上传...', '')
setProgress(true, 0, files.length)
const groupId = files.length > 1 ? createGroupId() : null
try {
let done = 0
for (const f of files) {
await uploadFile(f, groupId)
done += 1
setProgress(true, done, files.length)
}
inputEl.value = ''
await fetchList()
setProgress(false)
setStatus('已保存到服务器', 'ok')
} catch (e) {
setProgress(false)
setStatus('上传失败', 'error')
}
}
}
const imageInput = $('pc-mobile-image')
const imageSave = $('pc-mobile-image-save')
if (imageSave) imageSave.addEventListener('click', uploadSelected(imageInput, '请选择图片'))
const fileInput = $('pc-mobile-file')
const fileSave = $('pc-mobile-file-save')
if (fileSave) fileSave.addEventListener('click', uploadSelected(fileInput, '请选择文件'))
}
const createFolder = async () => {
const nameInput = $('pc-folder-name')
const sortInput = $('pc-folder-sort')
const name = String(nameInput && nameInput.value || '').trim()
const sortOrder = Number.parseInt(String(sortInput && sortInput.value || ''), 10)
if (!name) {
setStatus('目录名称不能为空', 'error')
return
}
await apiJson(`${apiBase}/folders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
sort_order: Number.isFinite(sortOrder) && sortOrder > 0 ? sortOrder : undefined
})
})
if (nameInput) nameInput.value = ''
if (sortInput) sortInput.value = ''
await Promise.all([fetchFolders(), fetchList()])
setStatus('目录已创建', 'ok')
}
const init = async () => {
clearUrlQuery()
const isMobile = detectMobile()
const refreshBtn = $('pc-refresh')
if (refreshBtn) {
refreshBtn.addEventListener('click', async () => {
await Promise.all([fetchFolders(), fetchList()])
setStatus('已刷新', 'ok')
})
}
const viewAllBtn = $('pc-view-all')
const viewFavoritesBtn = $('pc-view-favorites')
const folderFilterSelect = $('pc-folder-select')
const setView = (view) => {
state.filter.view = view === 'favorites' ? 'favorites' : 'all'
if (viewAllBtn) viewAllBtn.classList.toggle('active', state.filter.view === 'all')
if (viewFavoritesBtn) viewFavoritesBtn.classList.toggle('active', state.filter.view === 'favorites')
renderFolderFilters()
renderList()
}
if (viewAllBtn) viewAllBtn.addEventListener('click', () => setView('all'))
if (viewFavoritesBtn) viewFavoritesBtn.addEventListener('click', () => setView('favorites'))
if (folderFilterSelect) {
folderFilterSelect.addEventListener('change', () => {
state.filter.folderCode = String(folderFilterSelect.value || 'all')
renderList()
})
}
const filterInput = $('pc-filter-input')
if (filterInput) {
filterInput.addEventListener('input', (event) => {
state.filter.keyword = String(event.target.value || '')
renderList()
})
}
const clearBtn = $('pc-filter-clear')
if (clearBtn) {
clearBtn.addEventListener('click', () => {
state.filter.keyword = ''
if (filterInput) filterInput.value = ''
renderList()
})
}
const typeButtons = Array.from(document.querySelectorAll('.pc-filter-btn'))
typeButtons.forEach(btn => {
btn.addEventListener('click', () => {
const type = String(btn.dataset.type || 'all')
state.filter.type = type || 'all'
typeButtons.forEach(b => b.classList.toggle('active', b === btn))
renderList()
})
})
if (typeButtons.length) typeButtons[0].classList.add('active')
const manageFoldersBtn = $('pc-manage-folders')
if (manageFoldersBtn) manageFoldersBtn.addEventListener('click', openFolderModal)
const folderCreateBtn = $('pc-folder-create')
if (folderCreateBtn) {
folderCreateBtn.addEventListener('click', async () => {
try {
await createFolder()
} catch (e) {
setStatus(`目录创建失败: ${normalizeError(e)}`, 'error')
}
})
}
const folderCloseBtn = $('pc-folder-close')
if (folderCloseBtn) folderCloseBtn.addEventListener('click', closeFolderModal)
const favoriteCloseBtn = $('pc-favorite-close')
if (favoriteCloseBtn) favoriteCloseBtn.addEventListener('click', closeFavoriteModal)
const favoriteGoManageBtn = $('pc-favorite-go-manage')
if (favoriteGoManageBtn) {
favoriteGoManageBtn.addEventListener('click', () => {
closeFavoriteModal()
openFolderModal()
})
}
const favoriteConfirmBtn = $('pc-favorite-confirm')
if (favoriteConfirmBtn) {
favoriteConfirmBtn.addEventListener('click', async () => {
const id = state.favoriteDraft.id
const select = $('pc-favorite-folder-select')
const folderCode = String(select && select.value || '').trim()
if (!id || !folderCode) {
setStatus('请选择收藏目录', 'error')
return
}
try {
await favoriteItem(id, folderCode)
closeFavoriteModal()
setStatus(`已收藏到 ${folderDisplayName(folderCode)}`, 'ok')
} catch (e) {
setStatus(`收藏失败: ${normalizeError(e)}`, 'error')
}
})
}
document.querySelectorAll('.pc-modal-backdrop').forEach(el => {
el.addEventListener('click', () => {
const closeType = String(el.getAttribute('data-close') || '')
if (closeType === 'favorite') closeFavoriteModal()
if (closeType === 'folder') closeFolderModal()
})
})
if (!isMobile) {
const zone = $('paste-zone')
if (zone) zone.addEventListener('click', () => zone.focus())
document.addEventListener('paste', handlePaste)
bindDragEvents()
bindPasteInput()
} else {
bindMobileInputs()
}
const viewerClose = $('pc-viewer-close')
if (viewerClose) viewerClose.addEventListener('click', closeViewer)
const viewerPrev = $('pc-viewer-prev')
if (viewerPrev) viewerPrev.addEventListener('click', () => stepViewer(-1))
const viewerNext = $('pc-viewer-next')
if (viewerNext) viewerNext.addEventListener('click', () => stepViewer(1))
const viewerBackdrop = $('pc-viewer-backdrop')
if (viewerBackdrop) viewerBackdrop.addEventListener('click', closeViewer)
const viewerCopyUrl = $('pc-viewer-copy-url')
if (viewerCopyUrl) {
viewerCopyUrl.addEventListener('click', () => {
const current = state.viewer.images[state.viewer.index]
if (current) {
const url = new URL(current.url, window.location.origin).href
copyTextToClipboard(url)
}
})
}
const viewerCopyImg = $('pc-viewer-copy-img')
if (viewerCopyImg) {
viewerCopyImg.addEventListener('click', () => {
const current = state.viewer.images[state.viewer.index]
if (current) {
const url = new URL(current.url, window.location.origin).href
copyImageToClipboard(url)
}
})
}
document.addEventListener('keydown', (event) => {
const viewer = $('pc-viewer')
const favoriteModal = $('pc-favorite-modal')
const folderModal = $('pc-folder-modal')
if (event.key === 'Escape') {
if (favoriteModal && favoriteModal.classList.contains('show')) closeFavoriteModal()
if (folderModal && folderModal.classList.contains('show')) closeFolderModal()
if (viewer && viewer.classList.contains('show')) closeViewer()
return
}
if (!viewer || !viewer.classList.contains('show')) return
if (event.key === 'ArrowLeft') stepViewer(-1)
if (event.key === 'ArrowRight') stepViewer(1)
})
await Promise.all([fetchFolders(), fetchList()])
setView('all')
}
init()
+145
View File
@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>私人全能剪贴板</title>
<link rel="icon" href="/icons/16X16PNG/attach.png" type="image/png" />
<link rel="stylesheet" href="/common/common.css" />
<link rel="stylesheet" href="/tools/private_clipboard/clipboard.css" />
</head>
<body>
<main class="pc-main">
<section class="pc-input">
<div id="paste-zone" class="pc-paste-zone pc-desktop-only" tabindex="0">
<div class="pc-paste-text">在这里粘贴文字 / 图片 / 文件</div>
<div class="pc-paste-hint">聚焦后可用 Ctrl+V / ⌘V,支持拖拽多文件</div>
<div class="pc-paste-actions">
</div>
</div>
<textarea id="pc-paste-input" class="pc-paste-input pc-desktop-only" autocomplete="off" autocapitalize="off" spellcheck="false"></textarea>
<div class="pc-mobile-inputs pc-mobile-only">
<div class="pc-mobile-block">
<div class="pc-mobile-title">文字</div>
<textarea id="pc-mobile-text" class="pc-mobile-text" placeholder="输入文字后点击保存" spellcheck="false"></textarea>
<div class="pc-mobile-actions">
<button id="pc-mobile-text-save" class="btn" type="button">保存文字</button>
</div>
</div>
<div class="pc-mobile-block">
<div class="pc-mobile-title">图片</div>
<input id="pc-mobile-image" class="pc-mobile-file" type="file" accept="image/*" multiple />
<div class="pc-mobile-actions">
<button id="pc-mobile-image-save" class="btn" type="button">保存图片</button>
</div>
</div>
<div class="pc-mobile-block">
<div class="pc-mobile-title">文件</div>
<input id="pc-mobile-file" class="pc-mobile-file" type="file" multiple />
<div class="pc-mobile-actions">
<button id="pc-mobile-file-save" class="btn" type="button">保存文件</button>
</div>
</div>
</div>
<div id="pc-status" class="pc-status"></div>
<div id="pc-progress" class="pc-progress">
<div class="pc-progress-row">
<div id="pc-progress-text" class="pc-progress-text"></div>
<div id="pc-progress-percent" class="pc-progress-percent"></div>
</div>
<div class="pc-progress-bar">
<div id="pc-progress-fill" class="pc-progress-fill"></div>
</div>
</div>
</section>
<section class="pc-list">
<div class="pc-list-header">
<div class="pc-list-title">
<span id="pc-view-title">全部内容</span>
<span id="pc-count" class="pc-count"></span>
</div>
<div class="pc-list-actions">
<button id="pc-manage-folders" class="btn" type="button">目录管理</button>
<button id="pc-refresh" class="btn" type="button">刷新</button>
</div>
</div>
<div class="pc-view-tabs">
<button id="pc-view-all" class="btn active" type="button">全部内容</button>
<button id="pc-view-favorites" class="btn" type="button">我的收藏</button>
</div>
<div id="pc-favorite-filter" class="pc-favorite-filter" style="display:none">
<label for="pc-folder-select">收藏目录</label>
<select id="pc-folder-select"></select>
</div>
<div class="pc-filter">
<div class="pc-filter-types">
<button class="btn pc-filter-btn" data-type="all" type="button">全部</button>
<button class="btn pc-filter-btn" data-type="text" type="button">文字</button>
<button class="btn pc-filter-btn" data-type="image" type="button">图片</button>
<button class="btn pc-filter-btn" data-type="file" type="button">文件</button>
</div>
<div class="pc-filter-search">
<input id="pc-filter-input" placeholder="筛选关键字(内容/文件名)" />
<button id="pc-filter-clear" class="btn" type="button">清空</button>
</div>
</div>
<div id="pc-items" class="pc-items"></div>
<div id="pc-empty" class="pc-empty">暂无内容</div>
</section>
</main>
<div id="pc-favorite-modal" class="pc-modal">
<div class="pc-modal-backdrop" data-close="favorite"></div>
<div class="pc-modal-body">
<div class="pc-modal-header">
<div class="pc-modal-title">收藏到目录</div>
<button id="pc-favorite-close" class="btn" type="button">关闭</button>
</div>
<div class="pc-modal-content">
<div class="pc-modal-row">
<label for="pc-favorite-folder-select">选择目录</label>
<select id="pc-favorite-folder-select"></select>
</div>
<div class="pc-modal-actions">
<button id="pc-favorite-go-manage" class="btn" type="button">管理目录</button>
<button id="pc-favorite-confirm" class="btn" type="button">确认收藏</button>
</div>
</div>
</div>
</div>
<div id="pc-folder-modal" class="pc-modal">
<div class="pc-modal-backdrop" data-close="folder"></div>
<div class="pc-modal-body">
<div class="pc-modal-header">
<div class="pc-modal-title">收藏目录管理</div>
<button id="pc-folder-close" class="btn" type="button">关闭</button>
</div>
<div class="pc-modal-content">
<div class="pc-modal-row">
<input id="pc-folder-name" placeholder="目录名称,例如:工作参考" />
<input id="pc-folder-sort" type="number" min="1" step="1" placeholder="排序(正整数)" />
<button id="pc-folder-create" class="btn" type="button">新增目录</button>
</div>
<div id="pc-folder-list" class="pc-folder-list"></div>
</div>
</div>
</div>
<div id="pc-viewer" class="pc-viewer">
<div id="pc-viewer-backdrop" class="pc-viewer-backdrop"></div>
<div class="pc-viewer-body">
<div class="pc-viewer-toolbar">
<div id="pc-viewer-count" class="pc-viewer-count"></div>
<button id="pc-viewer-close" class="pc-viewer-close" type="button">✕</button>
</div>
<button id="pc-viewer-prev" class="pc-viewer-nav prev" type="button">‹</button>
<img id="pc-viewer-img" class="pc-viewer-img" />
<button id="pc-viewer-next" class="pc-viewer-nav next" type="button">›</button>
<div class="pc-viewer-actions">
<button id="pc-viewer-copy-url" class="btn" type="button">复制URL</button>
<button id="pc-viewer-copy-img" class="btn" type="button">复制图片</button>
</div>
</div>
</div>
<script src="/tools/private_clipboard/clipboard.js"></script>
</body>
</html>