Compare commits

..

6 Commits

Author SHA1 Message Date
admin 6ccb3ca2f6 Revert "test 2: verify fixed push mirror hook"
This reverts commit 4965d4c570.
2026-07-07 13:31:54 +08:00
admin af390e583b Revert "test 3: normal push to verify mirror"
This reverts commit dddd979843.
2026-07-07 13:31:54 +08:00
admin dddd979843 test 3: normal push to verify mirror 2026-07-07 13:31:29 +08:00
admin 4965d4c570 test 2: verify fixed push mirror hook 2026-07-07 13:30:45 +08:00
yangxiangyuan f7a5cbc9ee Merge branch 'main' of ssh://yang-gitea.umersoft.com:19773/admin/Toolbox 2026-06-23 09:08:39 +08:00
yangxiangyuan d3856cb52a chore: add and update gitignore rules for project files
add a comprehensive gitignore file to exclude unnecessary files like dependencies, runtime data, uploads, local configs and debug temporary files
2026-06-22 21:24:46 +08:00
58 changed files with 13061 additions and 524 deletions
+961
View File
@@ -0,0 +1,961 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="八音盒">
<meta name="theme-color" content="#0f0e17">
<title>八音盒</title>
<!-- PWA Manifest (inline via JS for single-file demo) -->
<style>
/* ── Design Tokens ─────────────────────────────────────── */
:root {
--bg: #0f0e17;
--surface: #1a1928;
--surface2: #232235;
--border: rgba(255,255,255,0.07);
--gold: #e8b86d;
--gold-dim: #a07840;
--text: #e8e6f0;
--text-dim: #7a7890;
--radius: 16px;
--bar-h: 88px;
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
/* ── Reset ─────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, 'PingFang SC', sans-serif;
-webkit-tap-highlight-color: transparent;
overscroll-behavior: none;
}
/* ── Layout ─────────────────────────────────────────────── */
.app {
display: flex;
flex-direction: column;
height: 100dvh;
max-width: 480px;
margin: 0 auto;
position: relative;
}
/* ── Header ─────────────────────────────────────────────── */
.header {
padding: 20px 20px 12px;
padding-top: calc(20px + env(safe-area-inset-top, 0px));
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.header-title {
font-size: 22px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text);
}
.header-title span { color: var(--gold); }
.header-count {
font-size: 13px;
color: var(--text-dim);
}
/* ── View Toggle ─────────────────────────────────────────── */
.view-toggle {
display: flex;
gap: 2px;
background: var(--surface);
border-radius: 10px;
padding: 3px;
margin: 0 20px 16px;
flex-shrink: 0;
}
.view-btn {
flex: 1;
border: none;
background: transparent;
color: var(--text-dim);
font-size: 13px;
padding: 7px 0;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
font-family: inherit;
}
.view-btn.active {
background: var(--surface2);
color: var(--text);
}
/* ── Now Playing Card (mini) ─────────────────────────────── */
.now-playing-hero {
margin: 0 20px 20px;
padding: 20px;
background: var(--surface);
border-radius: var(--radius);
border: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
flex-shrink: 0;
/* Signature: subtle inner glow on playing state */
transition: box-shadow 0.6s;
}
.now-playing-hero.playing {
box-shadow: 0 0 40px rgba(232, 184, 109, 0.08);
}
/* ── Vinyl Disc ──────────────────────────────────────────── */
/* SIGNATURE: rotating vinyl with subtle groove rings */
.disc-wrap {
position: relative;
width: 140px;
height: 140px;
flex-shrink: 0;
}
.disc {
width: 140px;
height: 140px;
border-radius: 50%;
background:
radial-gradient(circle at 50% 50%,
#fff 0%, #fff 7%,
transparent 7.5%, transparent 14%,
rgba(255,255,255,0.03) 14%, rgba(255,255,255,0.03) 14.5%,
transparent 15%, transparent 29%,
rgba(255,255,255,0.03) 29%, rgba(255,255,255,0.03) 29.5%,
transparent 30%, transparent 44%,
rgba(255,255,255,0.03) 44%, rgba(255,255,255,0.03) 44.5%,
transparent 45%),
conic-gradient(from 0deg,
#1a1928 0deg, #222130 60deg, #1a1928 120deg,
#222130 180deg, #1a1928 240deg, #222130 300deg, #1a1928 360deg);
display: flex;
align-items: center;
justify-content: center;
animation: spin 8s linear infinite;
animation-play-state: paused;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
}
.disc.spinning { animation-play-state: running; }
@keyframes spin { to { transform: rotate(360deg); } }
.disc-art {
width: 52px;
height: 52px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(255,255,255,0.1);
background: var(--surface2);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
font-size: 20px;
}
.disc-art-placeholder {
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--surface2);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
border: 2px solid rgba(255,255,255,0.08);
}
/* ── Track Info ──────────────────────────────────────────── */
.track-info {
text-align: center;
width: 100%;
}
.track-title {
font-size: 16px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.track-artist {
font-size: 13px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Progress ────────────────────────────────────────────── */
.progress-wrap {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
.progress-bar {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 3px;
background: var(--surface2);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.progress-bar::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--gold);
cursor: pointer;
box-shadow: 0 0 6px rgba(232,184,109,0.4);
}
.progress-bar::-webkit-slider-runnable-track {
height: 3px;
border-radius: 2px;
}
.time-row {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
/* ── Controls ────────────────────────────────────────────── */
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: 28px;
width: 100%;
}
.ctrl-btn {
border: none;
background: none;
color: var(--text-dim);
cursor: pointer;
padding: 8px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, transform 0.1s;
-webkit-tap-highlight-color: transparent;
}
.ctrl-btn:active { transform: scale(0.9); }
.ctrl-btn.active-mode { color: var(--gold); }
.ctrl-btn svg { display: block; }
.play-btn {
width: 56px;
height: 56px;
min-width: 56px;
min-height: 56px;
flex-shrink: 0;
border-radius: 50%;
background: var(--gold);
color: var(--bg);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.1s, background 0.2s;
box-shadow: 0 4px 16px rgba(232,184,109,0.3);
-webkit-tap-highlight-color: transparent;
}
.play-btn:active { transform: scale(0.94); }
/* ── Volume ──────────────────────────────────────────────── */
.volume-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.vol-icon { color: var(--text-dim); flex-shrink: 0; }
.volume-slider {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 3px;
background: var(--surface2);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--text);
cursor: pointer;
}
/* ── Playlist ────────────────────────────────────────────── */
.playlist-wrap {
flex: 1;
overflow-y: auto;
padding: 0 20px;
padding-bottom: calc(16px + var(--safe-bottom));
-webkit-overflow-scrolling: touch;
}
.playlist-wrap::-webkit-scrollbar { display: none; }
.playlist-item {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: opacity 0.15s;
}
.playlist-item:last-child { border-bottom: none; }
.playlist-item:active { opacity: 0.6; }
.playlist-item.active .item-title { color: var(--gold); }
.item-num {
font-size: 13px;
color: var(--text-dim);
width: 24px;
text-align: center;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.item-num.playing-icon { color: var(--gold); }
.item-info { flex: 1; min-width: 0; }
.item-title {
font-size: 15px;
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 3px;
}
.item-sub {
font-size: 12px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-duration {
font-size: 12px;
color: var(--text-dim);
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
/* ── Mini Bar (bottom, when in list view) ─────────────────── */
/* We use a single-view approach so no mini bar needed */
/* ── Loading / Empty ─────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 48px 20px;
color: var(--text-dim);
font-size: 14px;
line-height: 1.7;
}
.empty-state .icon { font-size: 36px; margin-bottom: 12px; }
/* ── Toast ───────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: calc(100px + var(--safe-bottom));
left: 50%;
transform: translateX(-50%) translateY(10px);
background: rgba(35,34,53,0.95);
color: var(--text);
font-size: 13px;
padding: 8px 16px;
border-radius: 20px;
border: 1px solid var(--border);
opacity: 0;
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
white-space: nowrap;
z-index: 100;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
</style>
</head>
<body>
<div class="app">
<!-- Header -->
<div class="header">
<div class="header-title">
<!-- 禅坐图标 -->
<svg width="28" height="28" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"
style="display:inline-block;vertical-align:middle;margin-right:8px;margin-bottom:2px;">
<!-- aura ring -->
<circle cx="50" cy="44" r="32" stroke="#e8b86d" stroke-width="1.2" opacity="0.25"/>
<!-- ground shadow -->
<ellipse cx="50" cy="84" rx="22" ry="4" fill="rgba(232,184,109,0.12)"/>
<!-- left leg -->
<path d="M33 66 C24 65 16 70 14 78 C12 84 16 88 24 87 C32 86 42 79 44 72 L44 67 Z" fill="#e8e6f0"/>
<!-- right leg -->
<path d="M67 66 C76 65 84 70 86 78 C88 84 84 88 76 87 C68 86 58 79 56 72 L56 67 Z" fill="#e8e6f0"/>
<!-- torso -->
<path d="M44 67 C44 67 40 58 40 50 C40 40 44 34 48 30 L50 29 L52 30 C56 34 60 40 60 50 C60 58 56 67 56 67 C53 74 47 74 44 67 Z" fill="#e8e6f0"/>
<!-- hands / mudra -->
<ellipse cx="50" cy="72" rx="10" ry="4" fill="#e8e6f0"/>
<!-- neck -->
<rect x="46" y="25" width="8" height="7" rx="2" fill="#e8e6f0"/>
<!-- head -->
<ellipse cx="50" cy="17" rx="11" ry="12" fill="#e8e6f0"/>
<!-- hair bun -->
<ellipse cx="50" cy="6" rx="5" ry="4" fill="#e8e6f0"/>
<!-- closed eyes -->
<path d="M44 16 Q47 13 50 16" stroke="#0f0e17" stroke-width="1.2" stroke-linecap="round"/>
<path d="M50 16 Q53 13 56 16" stroke="#0f0e17" stroke-width="1.2" stroke-linecap="round"/>
<!-- sound waves left -->
<path d="M34 36 Q28 33 28 29 Q28 25 34 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.6"/>
<path d="M31 40 Q22 36 22 29 Q22 22 31 18" stroke="#e8b86d" stroke-width="0.9" stroke-linecap="round" fill="none" opacity="0.35"/>
<!-- sound waves right -->
<path d="M66 36 Q72 33 72 29 Q72 25 66 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.6"/>
<path d="M69 40 Q78 36 78 29 Q78 22 69 18" stroke="#e8b86d" stroke-width="0.9" stroke-linecap="round" fill="none" opacity="0.35"/>
<!-- crown dot -->
<circle cx="50" cy="7" r="1.5" fill="#e8b86d" opacity="0.8"/>
</svg>
八<span>音</span>盒
</div>
<div class="header-count" id="trackCount">0 首</div>
</div>
<!-- View Toggle -->
<div class="view-toggle">
<button class="view-btn active" id="btnPlayer" onclick="switchView('player')">正在播放</button>
<button class="view-btn" id="btnList" onclick="switchView('list')">播放列表</button>
</div>
<!-- ── Player View ── -->
<div id="viewPlayer">
<div class="now-playing-hero" id="heroCard">
<!-- Disc -->
<div class="disc-wrap">
<div class="disc" id="disc">
<div class="disc-art-placeholder" id="discArt">
<svg width="36" height="36" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M33 66 C24 65 16 70 14 78 C12 84 16 88 24 87 C32 86 42 79 44 72 L44 67 Z" fill="rgba(232,184,109,0.85)"/>
<path d="M67 66 C76 65 84 70 86 78 C88 84 84 88 76 87 C68 86 58 79 56 72 L56 67 Z" fill="rgba(232,184,109,0.85)"/>
<path d="M44 67 C44 67 40 58 40 50 C40 40 44 34 48 30 L50 29 L52 30 C56 34 60 40 60 50 C60 58 56 67 56 67 C53 74 47 74 44 67 Z" fill="rgba(232,184,109,0.85)"/>
<ellipse cx="50" cy="72" rx="10" ry="4" fill="rgba(232,184,109,0.85)"/>
<rect x="46" y="25" width="8" height="7" rx="2" fill="rgba(232,184,109,0.85)"/>
<ellipse cx="50" cy="17" rx="11" ry="12" fill="rgba(232,184,109,0.85)"/>
<ellipse cx="50" cy="6" rx="5" ry="4" fill="rgba(232,184,109,0.85)"/>
<path d="M44 16 Q47 13 50 16" stroke="#1a1928" stroke-width="2" stroke-linecap="round"/>
<path d="M50 16 Q53 13 56 16" stroke="#1a1928" stroke-width="2" stroke-linecap="round"/>
<path d="M34 36 Q28 33 28 29 Q28 25 34 22" stroke="rgba(232,184,109,0.5)" stroke-width="2" stroke-linecap="round"/>
<path d="M66 36 Q72 33 72 29 Q72 25 66 22" stroke="rgba(232,184,109,0.5)" stroke-width="2" stroke-linecap="round"/>
<circle cx="50" cy="5" r="2" fill="rgba(232,184,109,0.9)"/>
</svg>
</div>
</div>
</div>
<!-- Track Info -->
<div class="track-info">
<div class="track-title" id="trackTitle">尚未选择曲目</div>
<div class="track-artist" id="trackArtist">—</div>
</div>
<!-- Progress -->
<div class="progress-wrap">
<input type="range" class="progress-bar" id="progressBar" min="0" max="100" value="0"
oninput="onProgressInput(this.value)" onchange="onProgressChange(this.value)">
<div class="time-row">
<span id="timeCurrent">0:00</span>
<span id="timeTotal">0:00</span>
</div>
</div>
<!-- Controls -->
<div class="controls">
<!-- Shuffle -->
<button class="ctrl-btn" id="btnShuffle" onclick="toggleShuffle()" title="随机">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 3 21 3 21 8"/>
<path d="M4 4 C8 4 10 8 12 12 C14 16 16 20 20 20"/>
<polyline points="16 21 21 21 21 16"/>
<path d="M4 20 C8 20 10 16 12 12 C14 8 16 4 20 4"/>
</svg>
</button>
<!-- Prev -->
<button class="ctrl-btn" onclick="prevTrack()" title="上一首">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6V6zm3.5 6 8.5 6V6l-8.5 6z"/>
</svg>
</button>
<!-- Play/Pause -->
<button class="play-btn" id="playBtn" onclick="togglePlay()" title="播放/暂停">
<svg id="iconPlay" width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
<svg id="iconPause" width="26" height="26" viewBox="0 0 24 24" fill="currentColor" style="display:none">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</button>
<!-- Next -->
<button class="ctrl-btn" onclick="nextTrack()" title="下一首">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 6h2v12h-2V6zm-10 0v12l8.5-6L6 6z"/>
</svg>
</button>
<!-- Repeat -->
<button class="ctrl-btn" id="btnRepeat" onclick="cycleRepeat()" title="循环">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
</svg>
</button>
</div>
<!-- Volume -->
<div class="volume-row">
<span class="vol-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
</span>
<input type="range" class="volume-slider" id="volSlider" min="0" max="1" step="0.01" value="1"
oninput="setVolume(this.value)">
<span class="vol-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM18.5 12c0-2.77-1.5-5.18-3.5-6.5v13c2-.82 3.5-3.43 3.5-6.5z"/>
</svg>
</span>
</div>
</div>
</div>
<!-- ── List View ── -->
<div id="viewList" style="display:none; flex:1; overflow:hidden; display:none; flex-direction:column;">
<div class="playlist-wrap" id="playlistWrap">
<div class="empty-state" id="emptyState">
<div class="icon">🎶</div>
<div>暂无曲目<br>请配置 OSS 音乐列表</div>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<!-- Hidden Audio -->
<audio id="audio" preload="metadata"></audio>
<script>
// ─── Config ─────────────────────────────────────────────────
// 修改这里来接入你的 OSS 文件列表
// 每条: { title, artist, src, cover(可选) }
const PLAYLIST = [
{
title: "花与你",
artist: "Affloret",
src: "https://android-o1-images.oss-cn-shanghai.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/file_cabinet/090_%E8%8A%B1%E4%B8%8E%E4%BD%A0_%E8%8A%B1%E4%B8%8E%E5%BF%83%E4%BA%8BAffloret.m4a",
cover: ""
},
{
title: "心跳花火",
artist: "Affloret",
src: "https://android-o1-images.oss-cn-shanghai.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/file_cabinet/091_%E5%BF%83%E8%B7%B3%E8%8A%B1%E7%81%AB_%E8%8A%B1%E4%B8%8E%E5%BF%83%E4%BA%8BAffloret.m4a",
cover: ""
},
{
title: "Dear",
artist: "lecca",
src: "https://android-o1-images.oss-cn-shanghai.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/file_cabinet/072_Dear_lecca.m4a",
cover: ""
},
{
title: "Happy (2021 ver.)",
artist: "ハニースパイス Re..",
src: "https://android-o1-images.oss-cn-shanghai.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/file_cabinet/075_Happy(2021ver.)_%E3%83%8F%E3%83%8B%E3%83%BC%E3%82%B9%E3%83%91%E3%82%A4%E3%82%B9Re..m4a",
cover: ""
},
];
// ─── State ───────────────────────────────────────────────────
let currentIdx = 0;
let isPlaying = false;
let isSeeking = false;
let repeatMode = 0; // 0=顺序 1=单曲 2=列表循环
let shuffleOn = false;
let shuffleOrder = [];
const audio = document.getElementById('audio');
// ─── Init ────────────────────────────────────────────────────
function init() {
document.getElementById('trackCount').textContent = PLAYLIST.length + ' 首';
buildShuffleOrder();
if (PLAYLIST.length > 0) loadTrack(0, false);
renderPlaylist();
setupMediaSession();
registerSW();
}
// ─── Track Loading ───────────────────────────────────────────
function loadTrack(idx, autoplay) {
currentIdx = idx;
const t = PLAYLIST[idx];
if (!t) return;
audio.src = t.src;
audio.load();
document.getElementById('trackTitle').textContent = t.title || '未知曲目';
document.getElementById('trackArtist').textContent = t.artist || '未知歌手';
document.getElementById('timeCurrent').textContent = '0:00';
document.getElementById('timeTotal').textContent = '0:00';
document.getElementById('progressBar').value = 0;
// Cover art
const artEl = document.getElementById('discArt');
if (t.cover) {
artEl.innerHTML = `<img src="${t.cover}" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">`;
} else {
artEl.innerHTML = '<svg width="36" height="36" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M33 66 C24 65 16 70 14 78 C12 84 16 88 24 87 C32 86 42 79 44 72 L44 67 Z" fill="rgba(232,184,109,0.85)"/><path d="M67 66 C76 65 84 70 86 78 C88 84 84 88 76 87 C68 86 58 79 56 72 L56 67 Z" fill="rgba(232,184,109,0.85)"/><path d="M44 67 C44 67 40 58 40 50 C40 40 44 34 48 30 L50 29 L52 30 C56 34 60 40 60 50 C60 58 56 67 56 67 C53 74 47 74 44 67 Z" fill="rgba(232,184,109,0.85)"/><ellipse cx="50" cy="72" rx="10" ry="4" fill="rgba(232,184,109,0.85)"/><rect x="46" y="25" width="8" height="7" rx="2" fill="rgba(232,184,109,0.85)"/><ellipse cx="50" cy="17" rx="11" ry="12" fill="rgba(232,184,109,0.85)"/><ellipse cx="50" cy="6" rx="5" ry="4" fill="rgba(232,184,109,0.85)"/><path d="M44 16 Q47 13 50 16" stroke="#1a1928" stroke-width="2" stroke-linecap="round"/><path d="M50 16 Q53 13 56 16" stroke="#1a1928" stroke-width="2" stroke-linecap="round"/><path d="M34 36 Q28 33 28 29 Q28 25 34 22" stroke="rgba(232,184,109,0.5)" stroke-width="2" stroke-linecap="round"/><path d="M66 36 Q72 33 72 29 Q72 25 66 22" stroke="rgba(232,184,109,0.5)" stroke-width="2" stroke-linecap="round"/><circle cx="50" cy="5" r="2" fill="rgba(232,184,109,0.9)"/></svg>';
}
updatePlaylistHighlight();
updateMediaSessionMetadata();
if (autoplay) playAudio();
}
// ─── Play/Pause ──────────────────────────────────────────────
function togglePlay() {
if (!PLAYLIST.length) { showToast('播放列表为空'); return; }
if (!audio.src || audio.src === location.href) { loadTrack(0, true); return; }
isPlaying ? pauseAudio() : playAudio();
}
function playAudio() {
audio.play().then(() => {
isPlaying = true;
updatePlayUI(true);
}).catch(e => {
console.warn('Play failed', e);
showToast('播放失败,请检查音频地址');
});
}
function pauseAudio() {
audio.pause();
isPlaying = false;
updatePlayUI(false);
}
function updatePlayUI(playing) {
document.getElementById('iconPlay').style.display = playing ? 'none' : 'block';
document.getElementById('iconPause').style.display = playing ? 'block' : 'none';
const disc = document.getElementById('disc');
disc.classList.toggle('spinning', playing);
document.getElementById('heroCard').classList.toggle('playing', playing);
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = playing ? 'playing' : 'paused';
}
}
// ─── Navigation ──────────────────────────────────────────────
function nextTrack() {
if (!PLAYLIST.length) return;
if (repeatMode === 1) { audio.currentTime = 0; if (isPlaying) playAudio(); return; }
let next;
if (shuffleOn) {
const pos = shuffleOrder.indexOf(currentIdx);
next = shuffleOrder[(pos + 1) % shuffleOrder.length];
} else {
next = (currentIdx + 1) % PLAYLIST.length;
}
loadTrack(next, isPlaying);
}
function prevTrack() {
if (!PLAYLIST.length) return;
if (audio.currentTime > 3) { audio.currentTime = 0; return; }
let prev;
if (shuffleOn) {
const pos = shuffleOrder.indexOf(currentIdx);
prev = shuffleOrder[(pos - 1 + shuffleOrder.length) % shuffleOrder.length];
} else {
prev = (currentIdx - 1 + PLAYLIST.length) % PLAYLIST.length;
}
loadTrack(prev, isPlaying);
}
// ─── Shuffle / Repeat ────────────────────────────────────────
function buildShuffleOrder() {
shuffleOrder = [...Array(PLAYLIST.length).keys()];
for (let i = shuffleOrder.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]];
}
}
function toggleShuffle() {
shuffleOn = !shuffleOn;
if (shuffleOn) buildShuffleOrder();
document.getElementById('btnShuffle').classList.toggle('active-mode', shuffleOn);
showToast(shuffleOn ? '随机播放 开' : '随机播放 关');
}
const repeatLabels = ['顺序播放', '单曲循环', '列表循环'];
function cycleRepeat() {
repeatMode = (repeatMode + 1) % 3;
const btn = document.getElementById('btnRepeat');
btn.classList.toggle('active-mode', repeatMode > 0);
// Show repeat-one indicator
btn.innerHTML = repeatMode === 1
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/><text x="11" y="14" font-size="6" fill="currentColor" stroke="none" text-anchor="middle">1</text></svg>`
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>`;
showToast(repeatLabels[repeatMode]);
}
// ─── Progress ────────────────────────────────────────────────
function onProgressInput(val) {
isSeeking = true;
updateSliderFill(document.getElementById('progressBar'), val);
}
function onProgressChange(val) {
if (audio.duration) audio.currentTime = (val / 100) * audio.duration;
isSeeking = false;
}
function setVolume(val) {
audio.volume = val;
updateSliderFill(document.getElementById('volSlider'), val * 100);
}
function updateSliderFill(el, pct) {
const fill = 'linear-gradient(to right, var(--gold) 0%, var(--gold) ' + pct + '%, var(--surface2) ' + pct + '%, var(--surface2) 100%)';
el.style.background = fill;
}
function fmtTime(s) {
if (isNaN(s) || s < 0) return '0:00';
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
return m + ':' + String(sec).padStart(2, '0');
}
// ─── Audio Events ────────────────────────────────────────────
audio.addEventListener('timeupdate', () => {
if (isSeeking) return;
const pct = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0;
document.getElementById('progressBar').value = pct;
updateSliderFill(document.getElementById('progressBar'), pct);
document.getElementById('timeCurrent').textContent = fmtTime(audio.currentTime);
// Update Media Session position
if ('mediaSession' in navigator && audio.duration) {
try {
navigator.mediaSession.setPositionState({
duration: audio.duration,
playbackRate: audio.playbackRate,
position: audio.currentTime
});
} catch(e) {}
}
});
audio.addEventListener('loadedmetadata', () => {
document.getElementById('timeTotal').textContent = fmtTime(audio.duration);
});
audio.addEventListener('ended', () => {
isPlaying = false;
updatePlayUI(false);
if (repeatMode === 1) {
audio.currentTime = 0; playAudio();
} else if (repeatMode === 2 || currentIdx < PLAYLIST.length - 1) {
nextTrack();
}
});
audio.addEventListener('play', () => { isPlaying = true; updatePlayUI(true); });
audio.addEventListener('pause', () => { isPlaying = false; updatePlayUI(false); });
// ─── Playlist UI ─────────────────────────────────────────────
function renderPlaylist() {
const wrap = document.getElementById('playlistWrap');
const empty = document.getElementById('emptyState');
if (!PLAYLIST.length) { empty.style.display = 'block'; return; }
empty.style.display = 'none';
// Remove old items
wrap.querySelectorAll('.playlist-item').forEach(el => el.remove());
PLAYLIST.forEach((t, i) => {
const div = document.createElement('div');
div.className = 'playlist-item' + (i === currentIdx ? ' active' : '');
div.dataset.idx = i;
div.innerHTML = `
<div class="item-num" id="itemNum${i}">${i + 1}</div>
<div class="item-info">
<div class="item-title">${escHtml(t.title || '未知曲目')}</div>
<div class="item-sub">${escHtml(t.artist || '未知歌手')}</div>
</div>
<div class="item-duration" id="itemDur${i}">—</div>
`;
div.addEventListener('click', () => {
if (i === currentIdx) { togglePlay(); }
else { loadTrack(i, true); switchView('player'); }
});
wrap.appendChild(div);
});
}
function updatePlaylistHighlight() {
document.querySelectorAll('.playlist-item').forEach((el, i) => {
el.classList.toggle('active', i === currentIdx);
const num = document.getElementById('itemNum' + i);
if (num) {
num.textContent = (i === currentIdx && isPlaying) ? '▶' : (i + 1);
num.className = 'item-num' + (i === currentIdx ? ' playing-icon' : '');
}
});
}
// ─── View Switch ─────────────────────────────────────────────
function switchView(v) {
const isPlayer = v === 'player';
document.getElementById('viewPlayer').style.display = isPlayer ? 'block' : 'none';
const listView = document.getElementById('viewList');
listView.style.display = isPlayer ? 'none' : 'flex';
document.getElementById('btnPlayer').classList.toggle('active', isPlayer);
document.getElementById('btnList').classList.toggle('active', !isPlayer);
}
// ─── Media Session API (锁屏控制) ────────────────────────────
function setupMediaSession() {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.setActionHandler('play', () => playAudio());
navigator.mediaSession.setActionHandler('pause', () => pauseAudio());
navigator.mediaSession.setActionHandler('nexttrack', () => nextTrack());
navigator.mediaSession.setActionHandler('previoustrack',() => prevTrack());
navigator.mediaSession.setActionHandler('seekto', (e) => {
if (e.seekTime !== undefined && audio.duration) {
audio.currentTime = e.seekTime;
}
});
}
function updateMediaSessionMetadata() {
if (!('mediaSession' in navigator)) return;
const t = PLAYLIST[currentIdx];
if (!t) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: t.title || '未知曲目',
artist: t.artist || '未知歌手',
album: '八音盒',
artwork: t.cover ? [
{ src: t.cover, sizes: '512x512', type: 'image/jpeg' }
] : []
});
}
// ─── Service Worker (PWA 后台播放) ──────────────────────────
// iOS Safari 要求: 页面首次用户交互后才能播放,后台播放依赖 Media Session
// SW 主要用于缓存页面资源使其可离线安装为 PWA
function registerSW() {
if ('serviceWorker' in navigator) {
// 实际部署时把 SW 代码写成 /sw.js 文件
// 这里生成一个内联 blob SW 用于演示
const swCode = `
const CACHE = 'music-box-v1';
self.addEventListener('install', e => {
self.skipWaiting();
e.waitUntil(caches.open(CACHE).then(c => c.addAll(['/'])));
});
self.addEventListener('activate', e => {
e.waitUntil(clients.claim());
});
self.addEventListener('fetch', e => {
// 音频文件不缓存(走网络),页面资源走缓存优先
if (e.request.url.includes('.mp3') || e.request.url.includes('oss')) {
e.respondWith(fetch(e.request));
return;
}
e.respondWith(
caches.match(e.request).then(r => r || fetch(e.request))
);
});
`;
const blob = new Blob([swCode], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
navigator.serviceWorker.register(url).catch(() => {});
}
}
// ─── PWA Manifest ────────────────────────────────────────────
(function injectManifest() {
const manifest = {
name: '八音盒',
short_name: '八音盒',
start_url: '/',
display: 'standalone',
background_color: '#0f0e17',
theme_color: '#0f0e17',
icons: [
{ src: 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#0f0e17"/><circle cx="50" cy="44" r="32" stroke="#e8b86d" stroke-width="1.2" opacity="0.25" fill="none"/><path d="M33 66 C24 65 16 70 14 78 C12 84 16 88 24 87 C32 86 42 79 44 72 L44 67 Z" fill="#e8e6f0"/><path d="M67 66 C76 65 84 70 86 78 C88 84 84 88 76 87 C68 86 58 79 56 72 L56 67 Z" fill="#e8e6f0"/><path d="M44 67 C44 67 40 58 40 50 C40 40 44 34 48 30 L50 29 L52 30 C56 34 60 40 60 50 C60 58 56 67 56 67 C53 74 47 74 44 67 Z" fill="#e8e6f0"/><ellipse cx="50" cy="72" rx="10" ry="4" fill="#e8e6f0"/><rect x="46" y="25" width="8" height="7" rx="2" fill="#e8e6f0"/><ellipse cx="50" cy="17" rx="11" ry="12" fill="#e8e6f0"/><ellipse cx="50" cy="6" rx="5" ry="4" fill="#e8e6f0"/><path d="M34 36 Q28 33 28 29 Q28 25 34 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.7"/><path d="M66 36 Q72 33 72 29 Q72 25 66 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.7"/><circle cx="50" cy="7" r="1.5" fill="#e8b86d"/></svg>'), sizes: '192x192', type: 'image/svg+xml' }
]
};
const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('link');
link.rel = 'manifest'; link.href = url;
document.head.appendChild(link);
})();
// ─── Utils ───────────────────────────────────────────────────
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
let toastTimer;
function showToast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 1800);
}
// ─── Start ───────────────────────────────────────────────────
init();
// init slider fills
setTimeout(() => {
updateSliderFill(document.getElementById('progressBar'), 0);
updateSliderFill(document.getElementById('volSlider'), 100);
}, 50);
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+126
View File
@@ -1221,6 +1221,46 @@ const lessons=[
exp:"每一次重新进入股市,风险意识都比上一次更强——从热点个股到宽基ETF,从频繁操作到条件单等待,从无仓位上限到六层金字塔。未来随着临近退休,防御仓位会进一步提高,进攻仓位继续收缩。这个方向是正确的,但节奏需要和实际需求挂钩,不能盲目加速。", exp:"每一次重新进入股市,风险意识都比上一次更强——从热点个股到宽基ETF,从频繁操作到条件单等待,从无仓位上限到六层金字塔。未来随着临近退休,防御仓位会进一步提高,进攻仓位继续收缩。这个方向是正确的,但节奏需要和实际需求挂钩,不能盲目加速。",
theory:"Inflation Risk(通胀风险)——不像市场下跌那样显眼,但对退休资产的长期侵蚀是真实的。中国历史通胀均值约2%~3%,实际需要名义收益率5%~6%才能保住购买力。如果风险收缩太快,全部转向防御资产,收益率可能跌到4%以下,看起来在赚钱,实际上购买力在下降。建议设动态收缩标准:每五年评估一次,根据当时的通胀率、退休时间表、现金流需求,决定防御仓位提高多少。不是线性收缩,是阶梯式的、有触发条件的收缩。Life-Cycle Investing核心原则:防御化的节奏要匹配你的负债端(退休后需要多少现金流),而不是主观上'觉得应该更稳了'。", theory:"Inflation Risk(通胀风险)——不像市场下跌那样显眼,但对退休资产的长期侵蚀是真实的。中国历史通胀均值约2%~3%,实际需要名义收益率5%~6%才能保住购买力。如果风险收缩太快,全部转向防御资产,收益率可能跌到4%以下,看起来在赚钱,实际上购买力在下降。建议设动态收缩标准:每五年评估一次,根据当时的通胀率、退休时间表、现金流需求,决定防御仓位提高多少。不是线性收缩,是阶梯式的、有触发条件的收缩。Life-Cycle Investing核心原则:防御化的节奏要匹配你的负债端(退休后需要多少现金流),而不是主观上'觉得应该更稳了'。",
tags:["Inflation Risk","通胀侵蚀","Life-Cycle Investing","阶梯式收缩"] tags:["Inflation Risk","通胀侵蚀","Life-Cycle Investing","阶梯式收缩"]
},
{
theme:"第二十一课 · 六层仓位金字塔——风险分层是组合的骨架",
en:"仓位大小和风险特征完全反向对应。越稳的给越多钱,越进攻的给越少钱。",
zh:"仓位像盖楼打地基,越稳的层给的钱越多。大盘宽基是地基,10万打底稳稳当当;卫星博弈是楼顶的花园,1万够用,塌了也不影响整栋楼。每一层的亏损即使全亏完,也伤不到下面几层——这就是分层最大的价值,不是赚多少,是亏得起。",
exp:"2026年6月采摘节重新界定仓位分层,从大盘宽基到卫星博弈,金额从10万递减到1万,形成六级风险金字塔:大盘宽基¥10万 / 主题宽基¥8万 / 进取成长¥5万 / 优质进攻¥3.5万 / 弹性进攻¥2.5万 / 卫星博弈¥1万。通讯ETF定位在优质进攻层(¥3.5万),不是随手试错,是有真实信念支撑的标的。这个分层不是静态的,会随生命周期动态调整——临近退休,防御层比重上升,进攻层比重下降。",
theory:"Risk Pyramid(风险金字塔)——标准机构组合管理框架,底层是低风险高流动性资产,越往上风险越高、仓位越小。这个设计的价值不只是控制风险,而是让每一层的亏损即使最大化,也不会动摇下面几层的稳定性。六层架构比三层更精细,说明对持仓特征已经有了清晰的分类能力,不再是感性的配置,而是结构性的设计。Life-Cycle Investing补充:六层结构本身是动态的,防御层的比重应随退休时间临近而阶梯式上升,不是一劳永逸的固定比例。",
tags:["Risk Pyramid","仓位分层","风险金字塔","六级架构","Life-Cycle"]
},
{
theme:"第二十二课 · 锚定偏差——卖飞了是危险的买入动机",
en:"如果从来没有持有过它,今天第一次看到,你会买吗?",
zh:"追回一个之前卖飞了的标的,动机里混杂着信念和遗憾。过滤方法很简单:假设今天是第一次看到它,你会买吗?如果答案是会,才是真信念;如果犹豫,说明冲动里有锚定的成分。补充风险:估值基准可能重置,原来的伏击价格可能永远触发不了。",
exp:"通讯ETF设了条件单但一直涨,半年没有触发买入。采摘节考虑是否重新伏击时,发现追回的动机里混杂着两种成分:一是对这个板块真实的信念,二是对'卖便宜了'的遗憾。这两种动机看起来相似,但驱动力完全不同。在做任何分析之前,必须先过一道过滤:如果从来没有持有过它,今天第一次看到,你会买吗?答案是会,才继续分析;答案是不确定,说明追回来的冲动里有锚定的成分。补充风险:光模块板块在AI算力爆发背景下可能进入新估值区间,原来的伏击价格可能永远触发不了——这时需要判断是坚持等回调,还是接受新定价基准上移伏击价格。",
theory:"Anchoring Bias(锚定偏差)——Kahneman研究:人类决策天然会被第一个接触到的数字锚定。卖出价格成为锚点后,'追回来'的冲动看起来像逻辑,实际上是情绪。市场估值基准重置风险:某些板块在结构性利好驱动下会进入全新的估值区间,原来基于历史价格设定的伏击点可能永久失效。正确的应对不是硬等,而是重新评估:这个价格对应的是旧逻辑还是新逻辑?两个答案对应两种截然不同的操作。",
tags:["Anchoring Bias","锚定偏差","卖飞追回陷阱","估值基准重置"]
},
{
theme:"第二十三课 · 系统的终点是直觉专家——规则内化为本能",
en:"系统最高境界是成熟在胸,手到擒来。到了哪一步就走哪一步操作。",
zh:"投资系统就像开车,刚开始每一步都要想,十年后全部变肌肉记忆。达到这个境界靠的不是时间本身,而是系统化训练的积累——日志、条件单、复盘,把随机经验转化为真正的成长。没有日志,经验无法转化为进步,只是记忆在时间里的漂移。",
exp:"现阶段的系统需要刻意思考才能执行:查条件单、看仓位层级、对照多因子框架、写日志、复盘。每一个步骤都是有意识的动作。目标是10年后这些动作变成本能,不需要每次从头推演,规则已经编码进直觉——失败了怎么走,成功了怎么走,大盘跌了怎么走,大盘涨了怎么走,每种情景都有预设的路径,执行不需要临时判断。达到这个境界的条件,不是时间的积累,而是系统化训练的积累。日志、条件单、采摘节复盘,是这个训练的基础设施。",
theory:"Intuitive Expertise(直觉专家)——心理学家Klein研究:真正的专家直觉不是凭感觉,而是从系统化训练中提炼出来的模式识别能力。象棋大师看棋盘不需要逐步计算,是因为见过几十万个棋局,模式已经内化。投资的直觉专家同理。重要前提:直觉专家的直觉来自系统化训练,不是随机经验的积累。10年的随机经验只是一年经验的10次重复;10年的系统化记录和复盘,才是10年的真实成长。没有日志,经验无法转化为进步,只是记忆在时间里的漂移。",
tags:["Intuitive Expertise","直觉专家","系统化训练","模式内化"]
},
{
theme:"第二十四课 · 现金是期权,不是负担",
en:"现金放着就是未来盈利的期权。他没有之前想象中的白白躺在账户里。有大用处。",
zh:"判断对了但子弹不够,这是最难接受的一种输——输在准备上,不是输在判断上。现金不是等待中死去的钱,是等待中增值的权利。目标是把空闲现金仓提升至总仓位的20%,宁可不触发,也不提前消耗子弹。",
exp:"纳指科技ETF那笔操作,条件单触发、方向正确、执行完整,结果盈利。但回补时现金仓不足,只能降低金额。判断对了,子弹却不够用,这是最难承受的一种输——输在准备上,不是输在判断上。此后开始重新理解现金仓的本质:不是等待中死去的钱,而是等待中增值的权利。现金仓管理被列为采摘节的核心任务,目标是把空闲现金仓提升至总仓位的20%。条件单设置开始偏向保守,宁可不触发,也不提前消耗子弹。现金仓不足的三种模式:①子弹打在了优先级更低的目标上;②仓位上限设太满没给回补留空间;③月度子弹节奏和市场机会节奏不匹配。",
theory:"Option Value of Cash(现金的期权价值)——现金持有者拥有一张在任意时刻以任意价格买入任意资产的权利,这张权利没有行权成本,没有到期日。它的价值在危机时刻达到最高,恰恰是别人最缺现金的那一天。Capacity Constraint(容量约束):判断正确但资源不足,是一种不出现在账面上的机会成本损失。需要补充的批判:偏保守不是无限制的保守,第一档哨兵单需要设在合理但可触发的位置,否则等待本身会变成另一种形式的踏空——不是追高的踏空,是等待过度的踏空。",
tags:["Option Value of Cash","Dry Powder","期权价值","Capacity Constraint","现金仓三种不足"]
},
{
theme:"第二十五课 · 热点与非热点——聚合替代追逐",
en:"大家都知道的热点,早就不是最好的进入时机了。万变不离其宗,就是纳指科技、科创50、中证500这些聚合股票。",
zh:"追热点的本质是在消息已经定价之后入场。正确做法:热点用聚合型宽基ETF间接持有,指数自动做热点权重调整;非热点只选分红型蓝筹,焊着收分红,不依赖市场情绪。不认识的猎物不开枪,第一次遇到的猎物把打击距离拉远。",
exp:"两次追热点的教训:①航空航天ETF——举国体制非商业化,政策节奏无法预测,入场时消息已经定价,小仓位亏损。②中概互联网ETF——期待市场把腾讯阿里当AI公司定价,但市场定的是互联网平台估值,两套逻辑不匹配,仓位不小,一路下跌。形成的新原则:热点用聚合型宽基ETF(纳指科技、科创50、中证500)间接持有,指数自动做热点权重调整;非热点只选分红型蓝筹(中国石化、中国神华、中国银行),焊着收分红,不依赖市场情绪。稀土和电力设备下半年不碰。",
theory:"Catalyst Mismatch(催化剂错配):买入理由是期待A逻辑定价,市场实际采用B逻辑定价,两套逻辑不匹配,持有期间不会产生预期收益。Narrative Fallacy(叙事谬误):好故事不等于好投资。一个逻辑成立,不等于市场会在你预期的时间内为它定价。市场是前瞻性定价机器——当热点成为所有人都在讨论的话题,预期已经被定价了大半,你看到的是别人走过的路的终点,不是起点。Smart Beta via Index(指数替代选热点):通过持有聚合指数,间接持有所有热点的受益,同时不承担单一热点判断失误的风险。",
tags:["Catalyst Mismatch","Narrative Fallacy","叙事谬误","聚合替代追逐","Smart Beta"]
} }
]; ];
@@ -1821,6 +1861,10 @@ renderLesson();
<div class="p4-toc-item" data-ch="13"><span class="p4-toc-marker">⑭</span><span class="p4-toc-title">软件工程师的投资系统</span></div> <div class="p4-toc-item" data-ch="13"><span class="p4-toc-marker">⑭</span><span class="p4-toc-title">软件工程师的投资系统</span></div>
<div class="p4-toc-item" data-ch="14"><span class="p4-toc-marker">⑮</span><span class="p4-toc-title">系统的骨架</span></div> <div class="p4-toc-item" data-ch="14"><span class="p4-toc-marker">⑮</span><span class="p4-toc-title">系统的骨架</span></div>
<div class="p4-toc-item" data-ch="15"><span class="p4-toc-marker">⑯</span><span class="p4-toc-title">赢得不干净的那一次</span></div> <div class="p4-toc-item" data-ch="15"><span class="p4-toc-marker">⑯</span><span class="p4-toc-title">赢得不干净的那一次</span></div>
<div class="p4-toc-item" data-ch="16"><span class="p4-toc-marker">⑰</span><span class="p4-toc-title">散弹枪与狙击枪</span></div>
<div class="p4-toc-item" data-ch="17"><span class="p4-toc-marker">⑱</span><span class="p4-toc-title">三个隐蔽点</span></div>
<div class="p4-toc-item" data-ch="18"><span class="p4-toc-marker">⑲</span><span class="p4-toc-title">采摘节</span></div>
<div class="p4-toc-item" data-ch="19"><span class="p4-toc-marker">⑳</span><span class="p4-toc-title">热点与非热点</span></div>
</div> </div>
</div> </div>
@@ -2156,6 +2200,88 @@ const p4chapters = [
"盈利是结果,干净是过程。结果好,不代表过程没有问题。把过程里的问题找出来,是下一次赢得更干净的唯一方式。", "盈利是结果,干净是过程。结果好,不代表过程没有问题。把过程里的问题找出来,是下一次赢得更干净的唯一方式。",
"告诫后来者: 在每一次盈利之后,也要认真复盘。问自己:这次赢得干净吗?如果不干净,原因是什么?好结果会掩盖坏过程,而坏过程,终有一天会在没有好结果的时候,完整地呈现出来。" "告诫后来者: 在每一次盈利之后,也要认真复盘。问自己:这次赢得干净吗?如果不干净,原因是什么?好结果会掩盖坏过程,而坏过程,终有一天会在没有好结果的时候,完整地呈现出来。"
] ]
},
{
title: "散弹枪与狙击枪",
marker: "⑰",
paras: [
"如果要用最简单的语言,解释我的投资方法经历了什么样的转变,我会说:我从一个散弹枪手,变成了一个狙击手。",
"这不是隐喻的优劣之争,而是两种根本不同的作战哲学。",
"从前,我每天把枪拿出来,走进森林,听说哪里有猎物,就朝那个方向走过去。感觉是猎物了,开一枪。感觉还有猎物,再开一枪。回家的时候,子弹用了不少,背篓里却没什么东西。算下来,子弹的成本,远远超过了猎获的价值。",
"这就是散弹枪打法的本质问题:不是枪法不准,而是开枪太多。",
"每一颗子弹都有成本。显性的成本是手续费和价差,隐性的成本是注意力、判断力,以及每一次错误决策之后需要重建的信心。当你把精力分散在几十个方向上,表面上看起来勤奋,实际上是在用最高的代价,换取最低的期望回报。行为金融学的研究早已证实:交易最频繁的散户,年均收益率比交易最少的散户低6.5个百分点。勤奋,在这里是一个陷阱,不是优势。",
"后来,我换了一套作战方式。",
"我在森林里找了三个隐蔽点,分别叫中国、海外和防御。每一个隐蔽点,都是猎物必经之路——不是随机选择的,而是经过仔细勘察之后确定的战略阵地。然后,我趴下来。",
"趴着,是这套方法最反人性的部分。什么都不做,在心理上等同于浪费时间。大多数人无法忍受这种沉默,会忍不住起身,走动,找点什么事情做。但对狙击手来说,在猎物没有进入射程之前起身,才是真正的危险——不只是暴露位置,而是打乱了整个作战节奏。",
"我用条件单来维持这种趴伏状态。在平静的夜晚,根据每种猎物的特性,计算好打击距离,设定好每一枪的触发价格和仓位大小,然后把枪放在那里,让它自动开火。",
"不同的猎物,有不同的计算方式。带翅膀的飞禽,要用溢价率来判断它是否在合理的高度;善于奔跑的走兽,要用PE分位数看它是否已经跑到了估值的洼地;懂得潜水的水生动物,要用股息率判断它是否已经沉到了值得捕捞的深度。统一用箱体价格来瞄准所有猎物,就像用同一把标尺测量飞禽的速度和鱼的深度——工具用错了地方。",
"四枪的节奏,是这套打法的核心设计。第一枪在-10%开火,轻仓试探,这是哨兵枪;第二枪在-20%,加大仓位,这是主力枪;第三枪在-30%,是对极端情景的备用;第四枪尽量不开,留着看它是否真的需要。子弹是宝贵的,猎物挣扎五分钟自己倒下了,你还留着两颗子弹,这是最好的结局。",
"但这里有一处必须坦诚的局限:四枪之间的间距,应该根据猎物的特性来设定,不是所有猎物都适合10%的步长。低波动的猎物可以步长小一些;高速奔跑的猎物,步长太小会让你在它下跌的途中把子弹全部打光,等不到真正的底部。",
"还有两条铁律,是我用真实的代价换来的。不认识的猎物,不开枪。也许它看起来很诱人,但如果你不了解它的习性,你的第一枪很可能惊动了它,让它跑得更远。更糟糕的是,也许那根本不是猎物。第一次遇到的猎物,把打击距离拉远。不是-10%,而是-20%。你对它的了解打了折扣,安全边际就要相应地加宽。宁可少赚,不可不慎。",
"从散弹枪到狙击枪,不只是方法的改变,是心态的根本转变。以前,我害怕错过;现在,我更害怕打错。以前,我把每天开枪当成努力;现在,我把趴着等待当成工作。以前,我认为子弹用完就叫做积极;现在,我知道子弹留在手里,才叫做准备。",
"告诫后来者: 开枪之前,先问自己三个问题。这只猎物,我认识吗?它现在进入射程了吗?我剩下的子弹,够不够应对更好的机会?三个问题都有满意的答案,再扣动扳机。",
"---"
]
},
{
title: "三个隐蔽点",
marker: "⑱",
paras: [
"狙击手在出发之前,要做的第一件事不是磨枪,而是选阵地。",
"我选了三个隐蔽点。",
"一个叫中国,一个叫海外,一个叫防御。每一个都是猎物必经之路,每一个都有它独特的气候和季节规律。重要的是,我没有试图判断哪一个隐蔽点今天猎物最多——那是一个即使是最老练的猎人也常常判断错误的问题。我的选择,是在三个地方同时布点,让时间和市场来决定下一只猎物从哪里经过。",
"这是真正的分散逻辑。不是因为无知而分散,而是因为清醒而分散。",
"Buffett说,分散是对无知的对冲。这句话是对的,但有一个前提——他的分散标准,是基于深度研究之后仍然无法确定的不确定性,不是基于懒惰或者贪多。我的三个隐蔽点,每一个都有明确的逻辑支撑,不是随机撒网,是有意识的战略布局。这两种分散,有本质的区别。",
"隐蔽点没有好坏之分,只有时机的好坏之分。秋天大雁南飞,东边的隐蔽点是最佳位置;冬天狗熊出没,北边的林子更有机会。某一年龙卷风接连来袭,哪个隐蔽点都不安全。市场也是如此——中国有消费降级的周期,美国有选举年的震荡,防御资产有它自己的利率敏感性。阵地都没有问题,问题在于那一年的天气。",
"我不打算调整这三个位置。至少在现阶段,它们给我机会均等的布局——当其中一个阵地沉寂的时候,另外两个往往有猎物经过。这种低相关性,是组合里真正的稳定器。",
"有一句话值得认真对待:在不确定性最高的时候,最优仓位趋近于零。当你搞不清天气和季节,最好的做法是趴着,什么都不做。什么都不做,是一个主动的选择,不是被动的等待。等待本身,是这套系统最重要的工作内容之一。",
"偶尔,会有一只迷路的猎物,反常规地经过你的隐蔽点。2026年3月那次全球大跌,对很多人是灾难,对我是一只突然闯入的大猎物。它不在任何预测模型里,但我在那里,我有子弹,我趴着——这就是全部的条件。",
"退休之后,我可能会缩减到一个更安静的隐蔽点——少一些进攻性的布局,多一些风调雨顺的日子。防御型的阵地,猎物少一些,但天气稳定,偶尔还有日光浴。对那个年纪的我来说,这或许是最合适的节奏。",
"但有一点需要认真对待:防御不等于无风险。每一个隐蔽点都有它自己的风险,只是风险的形态不同。退休后的阵地,需要的不是没有风险,而是风险的形态符合那个阶段对现金流稳定性的需求。",
"告诫后来者: 选阵地,不是选哪里猎物最多,而是选哪里你最有把握在猎物经过时准确击发。了解自己的能力边界,比了解市场的机会,更重要。阵地选好了,趴着等,这是这套方法最难也最关键的部分。",
"---"
]
},
{
title: "采摘节",
marker: "⑲",
paras: [
"在我的投资系统里,有一个节点叫做采摘节。",
"不是每天,不是每周,而是每半年一次。时间通常定在六月底和十二月底。这个设计本身,就是一种表态:市场的节奏不是我的节奏,我有自己的节奏。",
"采摘节的第一个问题,永远是:我想要多少现金仓?",
"不是先看哪只股票涨了,不是先算账面盈利,而是先确定目标。这个顺序至关重要。大多数人是反过来的——看到涨了的就卖,卖完了发现手里多了些钱,然后找下一个买入目标。这是结果在驱动决策。我选择让目标驱动决策:先定20%的现金仓比例,再推导哪些仓位需要减,减多少。",
"从前,我把现金仓看成是没有被使用的资金,是等待中的浪费。这个认知,在某一次机会来临却子弹不足之后,彻底改变了。那次,条件单触发,方向正确,判断准确,但回补的资金不够,只能缩减仓位。账面上依然是盈利,但那个盈利,比它本可以达到的数字,少了一截。这种损失不出现在任何报表里,但它是真实的。它有一个名字,叫做机会成本。",
"从那之后,我把现金仓理解成一张期权。它赋予我在任何时刻以任何价格买入任何资产的权利。这张期权没有行权成本,没有到期日。它的价值,在市场最恐慌的时候达到顶点,恰恰是别人最缺现金的那一天。",
"采摘节的第二个任务,是做减法。三十个持仓,是这套系统走到今天积累下来的数字。每一个进入的时候都有理由,但不是每一个今天还有同样充分的理由留着。采摘节的核心问题只有一个:如果今天重新建仓,你还会买这个吗?答案是否定的,就是退出候选。不是因为它亏了,而是因为它占据了仓位、占据了注意力,却没有占据你的真实信念。",
"第三个任务,是重构伏击价格。从单一的箱体价格,升级到多因子判断——PE分位数、溢价率、股息率、均线,根据不同的猎物类型,选择不同的瞄准方式。箱体告诉你价格在哪里,估值告诉你价值在哪里。用价格瞄准,你打的是位置;用价值瞄准,你打的是意义。",
"还有一项容易被忽视的工作:逐一审查所有未触发的条件单。一张等待了六个月却没有触发的伏击单,有两种可能:一是原来的逻辑依然成立,市场只是还没有给你那个价格;二是底层逻辑已经发生了变化,那个价格可能永远不会再来。这两种情况需要不同的应对,而区分它们的唯一方式,是在采摘节认真地重新审视。",
"最后一个任务,是拿出盈利的10%,奖励自己。",
"这条是议程里最重要的,没有之一。不是因为钱的多少,而是因为这个动作本身的意义。它告诉你的大脑:这套系统是真实运转的,是有收获的,是值得继续维护的。采摘节不只是工作,它是你和自己的系统之间的一次仪式。仪式感,是长期坚持的燃料。",
"采摘节结束的那个晚上,我会去吃一顿好的。不是庆祝赚了多少,而是庆祝又认真地走完了一个完整的周期。",
"告诫后来者: 给你的投资系统设置一个有节奏的呼吸节点。不是为了频繁操作,而是为了让每一个持仓,在每一个时间点,都还有它存在的理由。采摘节的价值,不在于那一天做了什么,而在于你有这样一天。",
"---"
]
},
{
title: "热点与非热点",
marker: "⑳",
paras: [
"森林里每隔一段时间,就会出现一种奇特的现象。",
"某一块区域突然变得热闹起来。猎人们蜂拥而至,议论纷纷,说那里有稀有的猎物,有人已经打到了,有人正在赶去。整片森林都在谈论同一个名字。",
"我有过两次追进去的经历。",
"第一次是航空航天ETF。那段时间,相关新闻铺天盖地,政策利好接连出台,市场情绪高涨。我以为这是中国版的SpaceX时刻,追了进去。后来才明白,中国的航空航天是举国体制,不是商业化市场。它的估值逻辑不是由市场供需决定的,而是由政策节奏决定的。政策什么时候给信号,市场什么时候反应,这个节奏,局外人无从预测。进去的时机已经是消息兑现之后,最好的定价时机早已过去。幸好仓位小,代价有限。",
"第二次是中概互联网ETF。这一次,我以为自己想得很清楚——腾讯是中国互联网的基础设施,阿里是中国商业的底层平台,它们都有AI业务,是未来的受益者。逻辑严密,研究充分,仓位也不算小。但我犯了一个更隐蔽的错误。",
"我买入的理由,是期待市场把它们当作AI公司来定价。但市场在这段时间给它们的定价,是互联网平台公司——一个增长放缓、竞争加剧的行业框架。我预期的定价逻辑,和市场实际采用的定价逻辑,不是同一个。公司本身没有问题,但我等待的那个市场认可,迟迟没有到来。",
"这两次经历,让我想清楚了一件关于热点的根本性问题:大家都知道的热点,早就不是最好的进入时机了。",
"市场是一台高效的定价机器。当一个主题成为所有人都在讨论的话题,当财经媒体开始用\"风口\"这个词,当你在饭桌上听到有人推荐某只股票,这个信息的定价,往往已经发生了大半。你看到的,是别人已经走过的路的终点,不是起点。",
"真正的机会,往往安静得让人不安。",
"明白了这一点,我的应对方式变了。热点,我不再追单个板块。我用聚合型的宽基ETF来间接持有热点的受益——纳指科技、科创50、中证500。这些指数自动做了一件我做不到的事:当某个热点板块涨了,它在指数里的权重上升,我自然地获益;当热点退潮,权重下降,我的损失也被其他成分稀释。我通过持有指数,持有了所有热点,同时不需要为\"今年是半导体还是AI、是光模块还是机器人\"这个问题赌注。",
"非热点,我只选分红型蓝筹。中国石化、中国神华、中国银行——这些公司不需要热点来证明自己的价值。它们的价值来自稳定的现金流和持续的股息分配。如果焊在手上,最坏的结果是每年收分红。这是一种特殊的安全感:不依赖市场情绪,只依赖公司盈利。",
"进可攻,退可守。",
"这不是一个聪明的策略,而是一个诚实的策略。它承认了一个事实:我没有能力比市场更早知道下一个热点是什么,也没有时间逐一研究每一个新兴板块。既然如此,就用系统替代判断——指数替代选热点,分红替代判断估值。",
"有一点值得坦诚地说:这套方法,会让你错过一些真正的大机会。当某个板块从0涨到100,你持有的指数只涨了30。但它同样会保护你,当那个板块从100跌回30的时候,你的指数可能只跌了10。对于一个把投资定义为终身事业的人来说,少赚一些,是可以接受的代价。赔进去,才是真正的代价。",
"告诫后来者: 当你听说某个热点,问自己三个问题。第一,这个消息,有多少人已经知道了?第二,你比知道这件事的其他人,有什么额外的优势?第三,如果你的判断是错的,你能承受多大的损失?三个问题都有满意的答案,再考虑进场。如果答案是\"大家都知道了\"\"没有额外优势\"\"损失不确定\",那就让这只热点猎物,从你的隐蔽点旁边走过去。"
]
} }
]; ];
@@ -0,0 +1,15 @@
{
"_comment": "OSS 文件柜前端鉴权配置",
"enable_auth_comment": "true: 启用三层鉴权(cookie/GUID/token); false: 完全放开访问",
"enable_auth": true,
"max_age_days_comment": "鉴权 cookie 最大有效天数(1~365),过期后需重新获取入口授权",
"max_age_days": 30,
"cookieName_comment": "鉴权 Cookie 名称(gate 外层 cookie,不等于业务登录态)",
"cookieName": "oss_file_cabinet_gate",
"requireGuid_comment": "true:入口必须带 guid 参数,否则直接拒绝;false:允许仅凭 token 或已有会话访问",
"requireGuid": true,
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
"guidParam": "guid",
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换,不在前端硬编码",
"guidValue": "db8a_2f1c-90e7-45a-a(d2-c3e4f50890"
}
+152
View File
@@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OSS 文件柜</title>
<link rel="stylesheet" href="oss_file_cabinet.css">
</head>
<body>
<div id="loginScreen" class="login-screen">
<div class="login-card">
<div class="login-icon">🗂️</div>
<h1>OSS 文件柜</h1>
<p class="login-subtitle">请输入账号和密码登录</p>
<div class="login-form">
<div class="form-group">
<label>用户名</label>
<input type="text" id="loginUsername" autocomplete="username" autofocus>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="loginPassword" autocomplete="current-password">
</div>
<button id="loginBtn" class="btn-primary">登录</button>
<div id="loginError" class="login-error"></div>
</div>
</div>
</div>
<div id="app" class="finder-app" style="display:none;">
<div class="finder-window">
<div class="finder-titlebar">
<div class="window-buttons">
<span class="btn-close"></span>
<span class="btn-mini"></span>
<span class="btn-max"></span>
</div>
<div class="finder-title">OSS 文件柜</div>
<div class="finder-actions">
<button id="btnThemeToggle" class="icon-btn" title="切换浅色/深色模式">🌓</button>
<button id="btnLogout" class="icon-btn" title="退出登录">⎋</button>
</div>
</div>
<div class="finder-toolbar">
<div class="nav-buttons">
<button id="btnBack" class="nav-btn" title="返回">◀</button>
<button id="btnForward" class="nav-btn" title="前进">▶</button>
</div>
<div class="path-bar" id="pathBar"></div>
<div class="view-switcher">
<button class="view-btn active" data-view="icon" title="图标视图">▦</button>
<button class="view-btn" data-view="list" title="列表视图">☰</button>
<button class="view-btn" data-view="column" title="分栏视图">▥</button>
</div>
<div class="search-box">
<input type="text" id="searchInput" placeholder="搜索">
</div>
</div>
<div class="finder-body">
<div class="finder-sidebar">
<div class="sidebar-section-title">个人收藏</div>
<div class="sidebar-item active" data-target="home">
<span class="sidebar-icon">🏠</span><span>全部文件</span>
</div>
<div class="sidebar-item" data-target="trash">
<span class="sidebar-icon">🗑️</span><span>废纸篓</span>
</div>
<div class="sidebar-item" data-target="images">
<span class="sidebar-icon">🖼️</span><span>图片</span>
</div>
</div>
<div class="finder-content">
<div id="fileArea" class="file-area icon-view">
<div class="empty-state" style="display:none;">
<div class="empty-icon">📂</div>
<div class="empty-title">空文件夹</div>
<div class="empty-hint">拖拽文件到此处或点击「上传」</div>
</div>
</div>
</div>
</div>
<div class="finder-statusbar">
<span id="statusInfo">0 个项目</span>
<span id="statusSelect"></span>
</div>
</div>
<div id="dropOverlay" class="drop-overlay">
<div class="drop-box">
<div class="drop-icon">⬆️</div>
<div class="drop-text">松开以将文件上传到当前目录</div>
</div>
</div>
</div>
<div id="filePreview" class="modal" style="display:none;">
<div class="modal-backdrop"></div>
<div class="modal-content image-preview">
<button class="modal-close" id="closePreview">✕</button>
<div id="previewContainer"></div>
</div>
</div>
<div id="contextMenu" class="context-menu" style="display:none;">
<div class="cm-item" data-action="open">打开</div>
<div class="cm-item" data-action="preview">预览</div>
<div class="cm-item" data-action="rename">重命名</div>
<div class="cm-item" data-action="info">显示简介</div>
<div class="cm-sep"></div>
<div class="cm-item" data-action="copy">复制</div>
<div class="cm-item" data-action="move">移动到…</div>
<div class="cm-item" data-action="download">下载</div>
<div class="cm-item" data-action="compress">压缩</div>
<div class="cm-item" data-action="share">分享临时链接…</div>
<div class="cm-item" data-action="raw_url">原始OSS链接(不建议)</div>
<div class="cm-sep"></div>
<div class="cm-item danger" data-action="delete">移到废纸篓</div>
</div>
<div id="folderContextMenu" class="context-menu" style="display:none;">
<div class="cm-item" data-action="new_folder">新建文件夹</div>
<div class="cm-item" data-action="upload">上传文件…</div>
<div class="cm-sep"></div>
<div class="cm-item" data-action="paste">粘贴</div>
<div class="cm-item" data-action="refresh">刷新</div>
</div>
<div id="trashContextMenu" class="context-menu" style="display:none;">
<div class="cm-item" data-action="restore">放回</div>
<div class="cm-item danger" data-action="empty_perm">彻底删除</div>
</div>
<div id="modalDialog" class="modal" style="display:none;">
<div class="modal-backdrop"></div>
<div class="modal-content">
<div class="modal-title" id="modalTitle"></div>
<div class="modal-body" id="modalBody"></div>
<div class="modal-footer">
<button class="btn-ghost" id="modalCancel">取消</button>
<button class="btn-primary" id="modalOk">确定</button>
</div>
</div>
</div>
<input type="file" id="fileInput" multiple style="display:none;">
<script src="oss_file_cabinet.js"></script>
</body>
</html>
@@ -0,0 +1,688 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f5f7;
--window-bg: #ffffff;
--sidebar-bg: #ececec;
--header-bg: #f0f0f2;
--text: #1d1d1f;
--text-secondary: #6e6e73;
--border: rgba(0,0,0,0.08);
--selection: #0a84ff;
--selection-bg: rgba(10,132,255,0.14);
--selection-border: rgba(10,132,255,0.35);
--hover-bg: rgba(0,0,0,0.04);
--danger: #ff3b30;
--shadow: 0 10px 40px rgba(0,0,0,0.12), 0 2px 10px rgba(0,0,0,0.08);
--radius: 10px;
--radius-sm: 6px;
}
:root.theme-dark {
--bg: #1e1e1e;
--window-bg: #2c2c2e;
--sidebar-bg: #232325;
--header-bg: #2a2a2c;
--text: #f5f5f7;
--text-secondary: #a1a1a6;
--border: rgba(255,255,255,0.08);
--selection: #0a84ff;
--selection-bg: rgba(10,132,255,0.25);
--selection-border: rgba(10,132,255,0.5);
--hover-bg: rgba(255,255,255,0.05);
--shadow: 0 10px 40px rgba(0,0,0,0.5), 0 2px 10px rgba(0,0,0,0.3);
}
html, body {
height: 100%;
width: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 13px;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
button { font-family: inherit; cursor: pointer; border: none; outline: none; background: none; color: inherit; }
input, select, textarea { font-family: inherit; outline: none; }
/* ===== 登录页 ===== */
.login-screen {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100vh;
background: linear-gradient(180deg, #6b7dff 0%, #b066ff 50%, #ff6ec7 100%);
}
:root.theme-dark .login-screen { background: linear-gradient(180deg, #1a1d2e 0%, #2a1d3a 50%, #3a1d2e 100%); }
.login-card {
width: 320px;
padding: 32px 28px;
background: var(--window-bg);
border-radius: 14px;
box-shadow: var(--shadow);
text-align: center;
backdrop-filter: blur(20px) saturate(180%);
}
.login-icon { font-size: 48px; margin-bottom: 8px; }
.login-card h1 { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
.login-subtitle { font-size: 12px; color: var(--text-secondary); margin-bottom: 20px; }
.login-form { text-align: left; }
.form-group { margin-bottom: 12px; }
.form-group label { display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
.form-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
font-size: 13px;
}
.form-group input:focus { border-color: var(--selection); }
.btn-primary {
width: 100%;
padding: 9px 16px;
background: var(--selection);
color: #fff;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
margin-top: 6px;
transition: opacity 0.15s;
}
.btn-primary:hover { opacity: 0.88; }
.btn-ghost {
padding: 8px 16px;
background: transparent;
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px;
}
.btn-ghost:hover { background: var(--hover-bg); }
.login-error { margin-top: 12px; color: var(--danger); font-size: 12px; text-align: center; min-height: 16px; }
/* ===== Finder 窗口 ===== */
.finder-app { width: 100%; height: 100vh; padding: 12px; }
.finder-window {
width: 100%;
height: 100%;
background: var(--window-bg);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
overflow: hidden;
}
.finder-titlebar {
height: 38px;
display: flex;
align-items: center;
padding: 0 14px;
background: var(--header-bg);
border-bottom: 1px solid var(--border);
position: relative;
}
.window-buttons { display: flex; gap: 8px; }
.window-buttons span {
width: 12px; height: 12px;
border-radius: 50%;
display: block;
}
.btn-close { background: #ff5f57; }
.btn-mini { background: #febc2e; }
.btn-max { background: #28c840; }
.finder-title {
flex: 1;
text-align: center;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.finder-actions { display: flex; gap: 6px; }
.icon-btn {
padding: 4px 8px;
font-size: 14px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
}
.icon-btn:hover { background: var(--hover-bg); color: var(--text); }
/* ===== Toolbar ===== */
.finder-toolbar {
height: 44px;
display: flex;
align-items: center;
padding: 0 14px;
gap: 12px;
background: var(--header-bg);
border-bottom: 1px solid var(--border);
}
.nav-buttons { display: flex; gap: 4px; }
.nav-btn {
width: 28px; height: 28px;
border-radius: var(--radius-sm);
font-size: 14px;
color: var(--text-secondary);
display: flex; align-items: center; justify-content: center;
}
.nav-btn:hover { background: var(--hover-bg); color: var(--text); }
.path-bar {
flex: 1;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
background: var(--bg);
border-radius: var(--radius-sm);
font-size: 13px;
min-height: 28px;
overflow-x: auto;
white-space: nowrap;
}
.crumb {
padding: 2px 8px;
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
}
.crumb.active { color: var(--text); font-weight: 500; }
.crumb:hover:not(.active) { background: var(--hover-bg); color: var(--text); }
.crumb-sep { color: var(--text-secondary); opacity: 0.5; }
.view-switcher {
display: flex;
background: var(--bg);
border-radius: var(--radius-sm);
padding: 2px;
}
.view-btn {
width: 28px; height: 24px;
border-radius: 4px;
font-size: 12px;
color: var(--text-secondary);
display: flex; align-items: center; justify-content: center;
}
.view-btn:hover { color: var(--text); }
.view-btn.active { background: var(--window-bg); color: var(--text); box-shadow: 0 1px 2px rgba(0,0,0,0.06); }
.search-box input {
width: 180px;
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 12px;
}
.search-box input:focus { border-color: var(--selection); }
/* ===== Body ===== */
.finder-body {
flex: 1;
display: flex;
overflow: hidden;
}
.finder-sidebar {
width: 180px;
background: var(--sidebar-bg);
padding: 10px 6px;
overflow-y: auto;
border-right: 1px solid var(--border);
}
.sidebar-section-title {
font-size: 11px;
color: var(--text-secondary);
padding: 8px 10px 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
margin: 1px 0;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
color: var(--text);
}
.sidebar-item:hover { background: var(--hover-bg); }
.sidebar-item.active { background: var(--selection); color: #fff; }
.sidebar-icon { font-size: 14px; }
.finder-content {
flex: 1;
overflow: hidden;
background: var(--window-bg);
}
/* ===== 文件区 ===== */
.file-area {
width: 100%;
height: 100%;
padding: 18px;
overflow-y: auto;
position: relative;
}
/* 图标视图 */
.icon-view {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
gap: 16px;
align-content: start;
}
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 6px;
border-radius: 8px;
cursor: pointer;
text-align: center;
transition: background 0.12s;
user-select: none;
}
.icon-item:hover { background: var(--hover-bg); }
.icon-item.selected { background: var(--selection-bg); }
.icon-wrap {
width: 64px; height: 64px;
display: flex; align-items: center; justify-content: center;
font-size: 34px;
margin-bottom: 4px;
border-radius: 12px;
background: var(--file-icon-bg, #f0f0f0);
overflow: hidden;
}
.icon-wrap.file-red { background: #ffebee; }
.icon-wrap.file-blue { background: #e3f2fd; }
.icon-wrap.file-green { background: #e8f5e9; }
.icon-wrap.file-orange { background: #fff3e0; }
.icon-wrap.file-purple { background: #f3e5f5; }
.icon-wrap.file-pink { background: #fce4ec; }
.icon-wrap.file-yellow { background: #fffde7; }
.icon-wrap.file-image { background: #e8eaf6; }
.icon-wrap.file-text { background: #f5f5f5; }
.icon-wrap.file-gray { background: #f0f0f0; }
.icon-thumb {
width: 100%; height: 100%;
object-fit: cover;
}
.icon-name {
font-size: 12px;
line-height: 1.3;
max-width: 80px;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-align: center;
}
/* 列表视图 */
.list-table {
width: 100%;
display: flex;
flex-direction: column;
}
.list-row {
display: grid;
grid-template-columns: minmax(280px, 1fr) 100px 180px 100px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
border-bottom: 1px solid var(--border);
align-items: center;
user-select: none;
}
.list-row:hover { background: var(--hover-bg); }
.list-row.selected { background: var(--selection-bg); }
.list-header {
background: var(--header-bg);
font-weight: 600;
color: var(--text-secondary);
position: sticky;
top: 0;
cursor: default;
border-bottom: 1px solid var(--border);
z-index: 1;
}
.list-header:hover { background: var(--header-bg); }
.col-name { display: flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-size, .col-modified, .col-kind { color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row-icon {
width: 22px; height: 22px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 13px;
border-radius: 5px;
flex-shrink: 0;
background: var(--file-icon-bg, #f0f0f0);
}
.row-icon.file-red { background: #ffebee; }
.row-icon.file-blue { background: #e3f2fd; }
.row-icon.file-green { background: #e8f5e9; }
.row-icon.file-orange { background: #fff3e0; }
.row-icon.file-purple { background: #f3e5f5; }
.row-icon.file-pink { background: #fce4ec; }
.row-icon.file-yellow { background: #fffde7; }
.row-icon.file-image { background: #e8eaf6; }
.row-icon.file-text { background: #f5f5f5; }
.row-icon.file-gray { background: #f0f0f0; }
/* 分栏视图 */
.file-area.column-view {
display: flex;
padding: 0;
}
.column {
flex: 1;
min-width: 200px;
border-right: 1px solid var(--border);
overflow-y: auto;
}
.column:last-child { border-right: none; }
.column-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
border-bottom: 1px solid var(--border);
user-select: none;
}
.column-row:hover { background: var(--hover-bg); }
.column-row.selected { background: var(--selection); color: #fff; }
.column-hint { flex: 2; display: flex; align-items: center; justify-content: center; }
.column-empty { color: var(--text-secondary); font-size: 12px; }
.col-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.column-info-panel { padding: 20px; text-align: center; }
.column-info-name { font-size: 14px; font-weight: 600; margin-bottom: 12px; word-break: break-all; }
.column-info-row { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
.file-area.column-view { overflow-x: auto; }
.column { min-width: 180px; max-width: 280px; }
/* 状态栏 */
.finder-statusbar {
height: 24px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
background: var(--header-bg);
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-secondary);
}
/* 空状态 */
.empty-state {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: var(--text-secondary);
}
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
.empty-title { font-size: 15px; margin-bottom: 4px; font-weight: 500; color: var(--text); opacity: 0.7; }
.empty-hint { font-size: 12px; }
/* 拖拽蒙层 */
.drop-overlay {
position: fixed;
inset: 0;
background: rgba(10,132,255,0.15);
backdrop-filter: blur(8px);
display: none;
align-items: center;
justify-content: center;
z-index: 999;
pointer-events: none;
border: 3px dashed var(--selection);
}
.drop-box {
background: var(--window-bg);
padding: 32px 48px;
border-radius: 12px;
text-align: center;
box-shadow: var(--shadow);
pointer-events: auto;
}
.drop-icon { font-size: 48px; margin-bottom: 8px; }
.drop-text { font-size: 14px; color: var(--text); }
/* 选框 */
.selection-rect {
position: fixed;
border: 1px solid var(--selection);
background: var(--selection-bg);
border-radius: 2px;
pointer-events: none;
z-index: 998;
}
/* ===== 右键菜单 ===== */
.context-menu {
position: fixed;
min-width: 200px;
background: var(--window-bg);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0,0,0,0.25), 0 2px 8px rgba(0,0,0,0.1);
padding: 4px;
z-index: 1000;
backdrop-filter: blur(30px) saturate(180%);
border: 1px solid var(--border);
}
.cm-item {
padding: 7px 12px;
font-size: 13px;
border-radius: 4px;
cursor: pointer;
color: var(--text);
}
.cm-item:hover { background: var(--selection); color: #fff; }
.cm-item.danger { color: var(--danger); }
.cm-item.danger:hover { background: var(--danger); color: #fff; }
.cm-sep {
height: 1px;
background: var(--border);
margin: 4px 8px;
}
/* ===== 弹窗 ===== */
.modal {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.3);
backdrop-filter: blur(8px);
}
.modal-content {
position: relative;
background: var(--window-bg);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 20px 24px;
min-width: 360px;
max-width: 520px;
max-height: 80vh;
overflow-y: auto;
}
.modal-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 14px;
color: var(--text);
}
.modal-body {
font-size: 13px;
color: var(--text);
line-height: 1.6;
margin-bottom: 18px;
}
.modal-body label {
display: block;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
margin-top: 8px;
}
.modal-body label:first-child { margin-top: 0; }
.modal-body input, .modal-body select {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
color: var(--text);
font-size: 13px;
}
.modal-body input:focus, .modal-body select:focus { border-color: var(--selection); }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.modal-footer .btn-primary, .modal-footer .btn-ghost {
width: auto;
min-width: 72px;
margin-top: 0;
}
/* 简介 */
.info-row {
display: flex;
padding: 4px 0;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.info-row:last-child { border-bottom: none; }
.info-label {
width: 80px;
color: var(--text-secondary);
flex-shrink: 0;
}
.info-value {
flex: 1;
color: var(--text);
overflow-wrap: break-word;
word-break: break-all;
}
/* 分享输出 */
.share-output { margin-top: 8px; }
.share-title { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
.share-url-box {
padding: 8px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
font-size: 11px;
font-family: 'SF Mono', Menlo, monospace;
color: var(--text);
word-break: break-all;
line-height: 1.5;
}
/* 图片预览 */
.modal-content.image-preview {
background: transparent;
box-shadow: none;
padding: 0;
min-width: auto;
max-width: 90vw;
max-height: 90vh;
overflow: visible;
}
.modal-content.image-preview img {
max-width: 90vw;
max-height: 85vh;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
transition: transform 0.08s ease-out;
cursor: zoom-in;
}
.modal-close {
position: absolute;
top: -10px;
right: -10px;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--window-bg);
color: var(--text);
font-size: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.modal-close:hover { background: var(--selection); color: #fff; }
/* ===== Toast ===== */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--window-bg);
color: var(--text);
padding: 10px 20px;
border-radius: 20px;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
font-size: 13px;
z-index: 9999;
display: none;
align-items: center;
border: 1px solid var(--border);
backdrop-filter: blur(20px);
}
.toast-success { border-left: 3px solid #28c840; }
.toast-error { border-left: 3px solid var(--danger); }
.toast-info { border-left: 3px solid var(--selection); }
/* 滚动条 */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.3); }
:root.theme-dark ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); }
:root.theme-dark ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
@media (max-width: 900px) {
.finder-sidebar { width: 140px; }
.list-row { grid-template-columns: minmax(180px, 1fr) 80px 140px 80px; font-size: 11px; }
.search-box input { width: 120px; }
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -206,6 +206,14 @@ textarea {
margin-bottom: 8px; margin-bottom: 8px;
} }
.weather-data-timestamp {
font-size: 11px;
color: rgba(63, 95, 45, 0.5);
margin-bottom: 4px;
font-family: "Consolas", "Monaco", monospace;
letter-spacing: 0.5px;
}
.expand-btn { .expand-btn {
align-self: flex-end; align-self: flex-end;
background: transparent; background: transparent;
+12
View File
@@ -567,6 +567,17 @@ const renderWeather = weather => {
const adviceText = escapeHtml(weather.advice_text || '暂无建议') const adviceText = escapeHtml(weather.advice_text || '暂无建议')
const weatherIcon = getWeatherIcon(weather.icon) const weatherIcon = getWeatherIcon(weather.icon)
// 转换 data_gateway 更新时间为北京时间
const formatBeijingTime = (utcStr) => {
if (!utcStr) return ''
const d = new Date(utcStr.replace(' ', 'T') + '+00:00')
if (isNaN(d.getTime())) return ''
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds())}`
}
const dataTimestamp = formatBeijingTime(weather._data_gateway_updated_at)
const dataSourceLabel = dataTimestamp ? `数据更新:${dataTimestamp}` : ''
// 检查温度是否已经包含°C,如果没有则添加 // 检查温度是否已经包含°C,如果没有则添加
if (temp && !temp.includes('°C')) { if (temp && !temp.includes('°C')) {
temp = temp + '°C' temp = temp + '°C'
@@ -602,6 +613,7 @@ const renderWeather = weather => {
<div class="weather-advice"> <div class="weather-advice">
<div class="advice-text" id="advice-text">${adviceText}</div> <div class="advice-text" id="advice-text">${adviceText}</div>
${dataSourceLabel ? `<div class="weather-data-timestamp">${escapeHtml(dataSourceLabel)}</div>` : ''}
<button class="expand-btn" id="expand-advice-btn">展开</button> <button class="expand-btn" id="expand-advice-btn">展开</button>
</div> </div>
` `
@@ -2,8 +2,8 @@
"_comment": "私人剪贴板工具的前端鉴权配置", "_comment": "私人剪贴板工具的前端鉴权配置",
"enable_auth_comment": "true:启用鉴权;false:关闭鉴权并放开访问", "enable_auth_comment": "true:启用鉴权;false:关闭鉴权并放开访问",
"enable_auth": true, "enable_auth": true,
"max_age_days_comment": "鉴权 Cookie 有效期(天),范围建议 1~365", "max_age_days_comment": "鉴权 Cookie 有效期(天),1天后过期需重新登录",
"max_age_days": 30, "max_age_days": 1,
"cookieName_comment": "鉴权 Cookie 名称,建议保持默认", "cookieName_comment": "鉴权 Cookie 名称,建议保持默认",
"cookieName": "private_clipboard_gate", "cookieName": "private_clipboard_gate",
"requireGuid_comment": "true:入口必须带 guid 参数;false:允许仅凭 token 或会话访问", "requireGuid_comment": "true:入口必须带 guid 参数;false:允许仅凭 token 或会话访问",
@@ -11,5 +11,5 @@
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid", "guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
"guidParam": "guid", "guidParam": "guid",
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换", "guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换",
"guidValue": "SD9KK-LW32S-9KJJH-JJH43-LJ432J" "guidValue": "db8a2f1c-90e7-4b5a-a1d2-c3e4f5067890"
} }
@@ -4,6 +4,68 @@ body {
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
} }
/* ===== 登录界面 ===== */
.pc-auth-card {
max-width: 360px;
margin: 120px auto 0;
padding: 32px 28px;
background: #1a1d24;
border: 1px solid #2a2d35;
border-radius: 12px;
text-align: center;
}
.pc-auth-card h2 {
margin: 0 0 24px;
font-size: 20px;
font-weight: 600;
}
.pc-auth-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.pc-auth-form input {
padding: 10px 12px;
background: #0f1115;
border: 1px solid #2a2d35;
border-radius: 6px;
color: #e6e8ee;
font-size: 14px;
outline: none;
}
.pc-auth-form input:focus {
border-color: #4a9eff;
}
.pc-auth-status {
margin-top: 12px;
font-size: 13px;
color: #ff6b6b;
}
.pc-auth-status.ok {
color: #4ade80;
}
/* ===== 顶部栏 ===== */
.pc-topbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.pc-topbar-title {
font-size: 20px;
font-weight: 600;
}
.pc-btn-logout {
padding: 6px 16px;
font-size: 13px;
background: #2a2d35;
border: 1px solid #3a3d45;
}
.pc-btn-logout:hover {
background: #3a3d45;
}
.pc-main { .pc-main {
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
@@ -736,6 +736,99 @@ const createFolder = async () => {
} }
const init = async () => { const init = async () => {
// ===== set_token 处理:必须在 clearUrlQuery 之前 =====
try {
const params = new URLSearchParams(window.location.search)
const setToken = params.get('set_token')
if (setToken) {
localStorage.setItem('toolbox_creds_token', setToken)
}
} catch {}
clearUrlQuery()
// ===== 登录逻辑 =====
const $auth = $('pc-auth')
const $app = $('pc-app')
const $status = $('pc-auth-status')
const $user = $('pc-login-user')
const $pass = $('pc-login-pass')
async function doLogin() {
try {
const username = $user.value.trim()
const password = $pass.value
const res = await fetch(apiBase + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'same-origin',
cache: 'no-store'
})
const data = await res.json()
if (!data.ok) throw new Error(data.error || '登录失败')
$auth.style.display = 'none'
$app.style.display = ''
initApp()
} catch (e) {
$status.textContent = String(e.message || e)
$status.style.display = ''
$status.className = 'pc-auth-status'
}
}
async function doLogout() {
try {
await fetch(apiBase + '/auth/logout', { method: 'POST', credentials: 'same-origin' })
} catch {}
$app.style.display = 'none'
$auth.style.display = ''
$pass.value = ''
$status.textContent = '已注销'
$status.className = 'pc-auth-status ok'
$status.style.display = ''
}
// 自动填充
async function tryAutoFill() {
try {
const token = localStorage.getItem('toolbox_creds_token') || ''
if (!token) return
const res = await fetch(apiBase + '/auth/local_creds?token=' + encodeURIComponent(token), {
credentials: 'same-origin',
cache: 'no-store'
})
const data = await res.json().catch(() => null)
if (data && data.ok && data.creds) {
if (data.creds.username) $user.value = data.creds.username
if (data.creds.password) $pass.value = data.creds.password
}
} catch {}
}
$('pc-btn-login').onclick = doLogin
$('pc-btn-logout').onclick = doLogout
$user.removeAttribute('readonly')
$pass.removeAttribute('readonly')
$pass.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin() })
// 先检查是否已登录
try {
const res = await fetch(apiBase + '/auth/me', { credentials: 'same-origin', cache: 'no-store' })
const data = await res.json()
if (data && data.ok) {
$auth.style.display = 'none'
$app.style.display = ''
initApp()
return
}
} catch {}
// 未登录,尝试自动填充
await tryAutoFill()
}
const initApp = async () => {
clearUrlQuery() clearUrlQuery()
const isMobile = detectMobile() const isMobile = detectMobile()
const refreshBtn = $('pc-refresh') const refreshBtn = $('pc-refresh')
+17 -1
View File
@@ -9,7 +9,23 @@
<link rel="stylesheet" href="/tools/private_clipboard/clipboard.css" /> <link rel="stylesheet" href="/tools/private_clipboard/clipboard.css" />
</head> </head>
<body> <body>
<main class="pc-main"> <!-- 登录界面 -->
<section id="pc-auth" class="pc-auth-card">
<h2>私人剪贴板 - 登录</h2>
<div class="pc-auth-form" autocomplete="off">
<input id="pc-login-user" placeholder="用户名" autocomplete="off" readonly />
<input id="pc-login-pass" placeholder="密码" type="password" autocomplete="off" readonly />
<button id="pc-btn-login" class="btn">登录</button>
</div>
<div id="pc-auth-status" class="pc-auth-status" style="display:none"></div>
</section>
<!-- 主应用界面 -->
<main id="pc-app" class="pc-main" style="display:none">
<div class="pc-topbar">
<span class="pc-topbar-title">私人剪贴板</span>
<button id="pc-btn-logout" class="btn pc-btn-logout" type="button">注销</button>
</div>
<section class="pc-input"> <section class="pc-input">
<div id="paste-zone" class="pc-paste-zone pc-desktop-only" tabindex="0"> <div id="paste-zone" class="pc-paste-zone pc-desktop-only" tabindex="0">
<div class="pc-paste-text">在这里粘贴文字 / 图片 / 文件</div> <div class="pc-paste-text">在这里粘贴文字 / 图片 / 文件</div>
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>打谷场 - 塔防合集</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#0d1117;--card-bg:#161b22;--border:#30363d;
--accent:#e94560;--accent2:#4caf50;--gold:#f0c040;
--text:#e6edf3;--text2:#8b949e;
}
body{
font-family:'Segoe UI','Microsoft YaHei',sans-serif;
background:var(--bg);color:var(--text);min-height:100vh;
}
header{
text-align:center;padding:40px 20px 20px;
background:linear-gradient(180deg, #1a1a2e 0%, var(--bg) 100%);
border-bottom:1px solid var(--border);
}
header h1{font-size:32px;color:var(--accent);margin-bottom:8px}
header p{font-size:14px;color:var(--text2)}
.container{
max-width:900px;margin:0 auto;padding:24px 16px;
display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
gap:16px;
}
.card{
background:var(--card-bg);border:1px solid var(--border);
border-radius:10px;padding:20px;cursor:pointer;
transition:all 0.2s;text-decoration:none;color:var(--text);
position:relative;overflow:hidden;
}
.card:hover{transform:translateY(-3px);border-color:var(--accent);box-shadow:0 4px 20px rgba(233,69,96,0.2)}
.card .icon{font-size:36px;margin-bottom:12px}
.card h3{font-size:16px;margin-bottom:6px}
.card .diff{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;margin-bottom:8px}
.card .diff.easy{background:#4caf50;color:#fff}
.card .diff.hell{background:#e94560;color:#fff}
.card .tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:10px}
.card .tag{font-size:10px;padding:2px 6px;border-radius:3px;background:rgba(255,255,255,0.06);color:var(--text2)}
.card .desc{font-size:12px;color:var(--text2);line-height:1.5}
.card.coming-soon{opacity:0.5;cursor:default}
.card.coming-soon:hover{transform:none;border-color:var(--border);box-shadow:none}
.card.coming-soon .badge{position:absolute;top:10px;right:10px;font-size:10px;background:var(--border);color:var(--text2);padding:2px 8px;border-radius:10px}
footer{
text-align:center;padding:20px;color:var(--text2);font-size:12px;
border-top:1px solid var(--border);margin-top:20px;
}
</style>
</head>
<body>
<header>
<h1>打谷场</h1>
<p>军事风自定义路线塔防游戏合集 - 多个难度版本,自由绘制敌军路径</p>
</header>
<div class="container">
<!-- 简单版 -->
<a class="card" href="simple.html">
<div class="icon">🎯</div>
<h3>简单版</h3>
<span class="diff easy">入门</span>
<p class="desc">标准难度,500金币起步,20生命值。BOSS每10波出现。适合新手熟悉游戏机制。</p>
<div class="tags">
<span class="tag">金币500</span><span class="tag">生命20</span><span class="tag">BOSS=10波</span>
</div>
</a>
<!-- 地狱版 -->
<a class="card" href="hell.html">
<div class="icon">💀</div>
<h3>地狱版</h3>
<span class="diff hell">地狱</span>
<p class="desc">极难难度!300金币紧巴巴,仅10生命值。BOSS每5波就来,敌人1.5倍数量+1.3倍速度,塔造价涨30%。</p>
<div class="tags">
<span class="tag">金币300</span><span class="tag">生命10</span><span class="tag">BOSS=5波</span><span class="tag">1.5x敌军</span><span class="tag">1.3x塔价</span>
</div>
</a>
<!-- 占位:更多版本 -->
<div class="card coming-soon">
<div class="icon">⚡</div>
<h3>闪电战</h3>
<span class="badge">即将推出</span>
<p class="desc">敌人速度翻倍,但塔攻击力也翻倍。快节奏极限操作。</p>
<div class="tags">
<span class="tag">2x速度</span><span class="tag">2x伤害</span>
</div>
</div>
<div class="card coming-soon">
<div class="icon">♾️</div>
<h3>无尽模式</h3>
<span class="badge">即将推出</span>
<p class="desc">无波次限制,敌人无限刷新,难度持续递增,挑战最高击杀数。</p>
<div class="tags">
<span class="tag">无限波</span><span class="tag">排行榜</span>
</div>
</div>
<div class="card coming-soon">
<div class="icon">🌙</div>
<h3>噩梦版</h3>
<span class="badge">即将推出</span>
<p class="desc">比地狱更恐怖。0金币起步,杀敌才得金币。每波随机出特殊兵种。</p>
<div class="tags">
<span class="tag">0金币</span><span class="tag">特殊兵种</span>
</div>
</div>
<div class="card coming-soon">
<div class="icon">🛡️</div>
<h3>坚守阵地</h3>
<span class="badge">即将推出</span>
<p class="desc">固定路线+预设塔布局,纯策略防御。能用有限的塔撑多久?</p>
<div class="tags">
<span class="tag">固定路线</span><span class="tag">预设塔</span>
</div>
</div>
</div>
<footer>
<p>所有版本离线可玩 · 存档永久保留在浏览器中 · 无贴图无音效极简风格</p>
</footer>
</body>
</html>
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
// ============================================================
// data_gateway/auth.js - 对外 API 鉴权模块
// 职责:校验 X-API-Id + X-API-Key,匹配 skill config.json 中的哈希
// ============================================================
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const SKILLS_DIR = path.join(__dirname, 'skills')
// 缓存已加载的 skill 配置
const skillConfigCache = new Map()
const loadSkillConfig = (skillId) => {
if (skillConfigCache.has(skillId)) return skillConfigCache.get(skillId)
const cfgPath = path.join(SKILLS_DIR, skillId, 'config.json')
if (!fs.existsSync(cfgPath)) return null
try {
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
skillConfigCache.set(skillId, cfg)
return cfg
} catch {
return null
}
}
// 刷新缓存(用于配置热更新)
const refreshCache = (skillId) => {
skillConfigCache.delete(skillId)
return loadSkillConfig(skillId)
}
// 枚举所有已启用的 skill
const listEnabledSkills = () => {
const result = []
if (!fs.existsSync(SKILLS_DIR)) return result
const dirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
for (const d of dirs) {
if (!d.isDirectory()) continue
const cfg = loadSkillConfig(d.name)
if (cfg && cfg.enabled !== false) {
result.push({ id: cfg.id, name: cfg.name })
}
}
return result
}
// 校验外部 API 请求
const verifyApiKey = (req) => {
const apiId = String(req.headers['x-api-id'] || '').trim()
const apiKey = String(req.headers['x-api-key'] || '').trim()
if (!apiId || !apiKey) return { ok: false, error: 'missing api id or key' }
const cfg = loadSkillConfig(apiId)
if (!cfg) return { ok: false, error: 'skill not found' }
if (cfg.enabled === false) return { ok: false, error: 'skill disabled' }
const expectedHash = String(cfg.api_key_hash || '')
if (!expectedHash) return { ok: false, error: 'skill not configured' }
const actualHash = 'sha256:' + crypto.createHash('sha256').update(apiKey).digest('hex')
if (actualHash !== expectedHash) return { ok: false, error: 'invalid api key' }
return { ok: true, skill: cfg }
}
// 校验内部 Tool 读取请求
const verifyInternalToken = (req) => {
const token = String(req.headers['x-internal-token'] || '').trim()
const skillId = String(req.headers['x-skill-id'] || req.params?.skillId || '').trim()
if (!token || !skillId) return { ok: false, error: 'missing token or skill id' }
const cfg = loadSkillConfig(skillId)
if (!cfg) return { ok: false, error: 'skill not found' }
const expectedHash = String(cfg.read_token_hash || '')
if (!expectedHash) return { ok: false, error: 'skill not configured for internal read' }
const actualHash = 'sha256:' + crypto.createHash('sha256').update(token).digest('hex')
if (actualHash !== expectedHash) return { ok: false, error: 'invalid internal token' }
return { ok: true, skill: cfg }
}
module.exports = { loadSkillConfig, refreshCache, listEnabledSkills, verifyApiKey, verifyInternalToken }
+125
View File
@@ -0,0 +1,125 @@
// ============================================================
// data_gateway/index.js - 对外 API 数据网关主入口
// 暴露两个路由:
// POST /api/v1/ingest/:skillId 外部数据写入(公网,需 API Key)
// GET /api/v1/data/:skillId/query 内部数据读取(仅 localhost,需 Internal Token)
// ============================================================
const { logJSON } = require('../logger')
const { verifyApiKey, verifyInternalToken, listEnabledSkills } = require('./auth')
const { insert, upsertLatest, queryLatest, queryList, queryByTimeRange, count } = require('./store')
const { loadSkillConfig } = require('./auth')
const { bindRoutes: bindExternalStorageRoutes } = require('./skills/external_storage')
const setNoCache = (res) => {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
res.set('Pragma', 'no-cache')
res.set('Expires', '0')
} catch {}
}
const bindRoutes = (app) => {
// ============================================================
// 外部存储 skill(第三方 OSS 读写)
// 路由挂载在 /api/v1/skills/external_storage/*
// ============================================================
try {
bindExternalStorageRoutes(app)
} catch (e) {
console.error(`[data_gateway] Failed to bind external_storage routes: ${e.message}`)
}
// ============================================================
// 外部写入接口(公网暴露)
// POST /api/v1/ingest/:skillId
// Headers: X-API-Id, X-API-Key
// Body: { "source": "...", "data": { ... } }
// ============================================================
app.post('/api/v1/ingest/:skillId', (req, res) => {
setNoCache(res)
const skillId = String(req.params.skillId || '').trim()
if (!skillId) return res.status(400).json({ ok: false, error: 'missing skill id' })
const auth = verifyApiKey(req)
if (!auth.ok) {
try { logJSON('data_gateway.auth.fail', { skillId, error: auth.error, ip: req.ip }, 'data_gateway') } catch {}
return res.status(401).json({ ok: false, error: auth.error })
}
const { source, data } = req.body || {}
if (data === undefined && req.body && typeof req.body === 'object') {
// 如果没传 data 字段,将整个 body 视为 data
const bodyData = { ...req.body }
delete bodyData.source
const record = insert(skillId, { source: String(source || ''), data: Object.keys(bodyData).length > 0 ? bodyData : req.body })
try { logJSON('data_gateway.ingest.ok', { skillId, source, recordId: record.id }, 'data_gateway') } catch {}
return res.json({ ok: true, id: record.id, created_at: record.created_at })
}
const record = insert(skillId, { source: String(source || ''), data: data || {} })
try { logJSON('data_gateway.ingest.ok', { skillId, source, recordId: record.id }, 'data_gateway') } catch {}
return res.json({ ok: true, id: record.id, created_at: record.created_at })
})
// ============================================================
// 内部读取接口(仅 localhost)
// GET /api/v1/data/:skillId/query?mode=latest|list&limit=100&offset=0
// Headers: X-Internal-Token, X-Skill-Id
// ============================================================
app.get('/api/v1/data/:skillId/query', (req, res) => {
setNoCache(res)
const skillId = String(req.params.skillId || '').trim()
if (!skillId) return res.status(400).json({ ok: false, error: 'missing skill id' })
const auth = verifyInternalToken(req)
if (!auth.ok) return res.status(401).json({ ok: false, error: auth.error })
const mode = String(req.query.mode || 'latest').trim()
const limit = parseInt(req.query.limit, 10) || 100
const offset = parseInt(req.query.offset, 10) || 0
const source = req.query.source || null
const start = req.query.start || null
const end = req.query.end || null
try {
if (mode === 'list') {
if (start || end) {
const result = queryByTimeRange(skillId, { start, end, limit })
return res.json({ ok: true, ...result })
}
const result = queryList(skillId, { limit, offset, source })
return res.json({ ok: true, ...result })
}
// mode === 'latest'(默认)
const row = queryLatest(skillId)
if (!row) return res.json({ ok: true, rows: [], total: 0 })
return res.json({ ok: true, rows: [row], total: 1 })
} catch (e) {
try { logJSON('data_gateway.query.error', { skillId, error: String(e.message || e) }, 'data_gateway') } catch {}
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// ============================================================
// 管理接口:列出所有已启用的 skill(仅 localhost)
// GET /api/v1/data/skills
// ============================================================
app.get('/api/v1/data/skills', (req, res) => {
setNoCache(res)
const skills = listEnabledSkills()
const result = skills.map(s => {
const cfg = loadSkillConfig(s.id)
return {
id: s.id,
name: s.name,
mode: String(cfg && cfg.mode || 'append'),
record_count: count(s.id),
db_path: require('./store').getDbPath(s.id)
}
})
res.json({ ok: true, skills: result })
})
}
module.exports = { bindRoutes, listEnabledSkills, loadSkillConfig }
@@ -0,0 +1,14 @@
{
"_comment": "外部存储 skill - 供第三方程序读写 OSS external_storage 区域",
"id": "external_storage",
"name": "外部存储",
"enabled": true,
"api_key_comment": "这个发给对方,API Key,用于上传文件到外部存储区域",
"api_key": "4a211084d059Pk0C6t8dlk3ew61d46",
"api_key_hash": "sha256:17eb4c895064b955d80d007b15af992d7bb7bd1525ce5726f2f63f06229857c9",
"oss_bucket": "android-o1-images",
"oss_endpoint_backup1": "oss-accelerate.aliyuncs.com 传输加速域名(全地域上传下载加速)",
"oss_endpoint_backup2": "oss-cn-shanghai.aliyuncs.com 上海区域",
"oss_endpoint": "oss-accelerate.aliyuncs.com",
"oss_prefix": "550e8400-e29b-41d4-a716-446655442082/external_storage"
}
@@ -0,0 +1,322 @@
// ============================================================
// external_storage/index.js - 外部存储 skill 路由入口
// 提供第三方程序读写 OSS external_storage 区域的 API
// ============================================================
const express = require('express')
const multer = require('multer')
const path = require('path')
const archiver = require('archiver')
const { createOssClient } = require('./oss_client')
const { sanitizePath, createSizeLimitMiddleware } = require('./middleware')
const { loadSkillConfig } = require('../../auth')
const { verifyApiKey } = require('../../auth')
const { logJSON } = require('../../../logger')
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 // 50MB
// 内存存储上传文件
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_UPLOAD_BYTES, files: 20 }
})
// MIME 类型推断(支持中文文件名 UTF-8)
const contentTypeByExt = (filename) => {
const ext = path.extname(filename).toLowerCase().slice(1)
const map = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', ico: 'image/x-icon', bmp: 'image/bmp', avif: 'image/avif',
txt: 'text/plain; charset=utf-8', md: 'text/plain; charset=utf-8', html: 'text/html; charset=utf-8',
js: 'application/javascript', ts: 'application/javascript', css: 'text/css', json: 'application/json',
xml: 'text/xml', log: 'text/plain; charset=utf-8', csv: 'text/csv',
pdf: 'application/pdf',
doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo', webm: 'video/webm', mkv: 'video/x-matroska',
mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', flac: 'audio/flac',
zip: 'application/zip', rar: 'application/x-rar-compressed', '7z': 'application/x-7z-compressed',
tar: 'application/x-tar', gz: 'application/gzip',
exe: 'application/x-msdownload', dmg: 'application/x-apple-diskimage',
font: 'font/woff', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf'
}
return map[ext] || 'application/octet-stream'
}
// 安全文件名清理(保留中文,移除危险字符)
const safeName = (name) => {
let s = String(name || 'untitled')
// 尝试修复编码(UTF-8 被误读为 latin1 的情况)
try {
const recovered = Buffer.from(s, 'latin1').toString('utf8')
if (recovered !== s && !/\ufffd/.test(recovered)) s = recovered
} catch {}
// 移除危险字符,但保留中文、英文、数字、常见符号
return s.replace(/[<>\":/\\|?*\x00-\x1F]/g, '_').trim() || 'untitled'
}
// 设置无缓存响应头
const setNoCache = (res) => {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
res.set('Pragma', 'no-cache')
res.set('Expires', '0')
} catch {}
}
/**
* 绑定路由到 Express 应用
* @param {express.Application} app
*/
const bindRoutes = (app) => {
const skillId = 'external_storage'
const skillConfig = loadSkillConfig(skillId)
if (!skillConfig) {
console.warn(`[external_storage] config.json not found, skill disabled`)
return
}
let ossClient
try {
ossClient = createOssClient(skillConfig)
} catch (e) {
console.error(`[external_storage] Failed to init OSS client: ${e.message}`)
return
}
const router = express.Router()
// API Key 鉴权中间件
router.use((req, res, next) => {
const auth = verifyApiKey(req)
if (!auth.ok) {
try { logJSON('external_storage.auth.fail', { error: auth.error, ip: req.ip }, 'external_storage') } catch {}
return res.status(401).json({ ok: false, error: auth.error })
}
next()
})
// 全局无缓存
router.use((req, res, next) => {
setNoCache(res)
next()
})
// ============================================================
// 上传文件
// POST /api/v1/skills/external_storage/upload?path=subdir/
// Headers: X-API-Id, X-API-Key
// Body: multipart/form-data, field: "files"
// ============================================================
router.post('/upload', createSizeLimitMiddleware(MAX_UPLOAD_BYTES), upload.array('files', 20), async (req, res) => {
try {
const basePath = sanitizePath(req.query.path || '')
if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
return res.status(400).json({ ok: false, error: '未收到文件' })
}
const results = []
for (const f of req.files) {
const originalName = safeName(f.originalname || 'file')
const keyPath = basePath ? `${basePath}/${originalName}` : originalName
const contentType = f.mimetype || contentTypeByExt(originalName)
const { key, raw_url } = await ossClient.putObject(keyPath, f.buffer, contentType)
results.push({
path: keyPath,
oss_key: key,
raw_url,
name: originalName,
size: f.size,
content_type: contentType
})
}
res.json({ ok: true, count: results.length, files: results })
} catch (e) {
if (String(e.message || '').includes('File too large')) {
return res.status(413).json({ ok: false, error: '文件超过 50MB 上限' })
}
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// ============================================================
// 下载文件(代理流)
// GET /api/v1/skills/external_storage/download?path=xxx
// Headers: X-API-Id, X-API-Key
// ============================================================
router.get('/download', async (req, res) => {
try {
const filePath = sanitizePath(req.query.path)
if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
const displayName = path.basename(filePath)
const { stream, headers } = await ossClient.getObjectStream(filePath)
// 使用 RFC 5987 格式支持 UTF-8 文件名
res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName)}`)
if (headers['content-type']) res.set('Content-Type', headers['content-type'])
if (headers['content-length']) res.set('Content-Length', headers['content-length'])
stream.pipe(res)
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// ============================================================
// 获取原始 URL
// GET /api/v1/skills/external_storage/raw_url?path=xxx
// Headers: X-API-Id, X-API-Key
// ============================================================
router.get('/raw_url', async (req, res) => {
try {
const filePath = sanitizePath(req.query.path)
if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
const raw_url = ossClient.getRawUrl(filePath)
res.json({ ok: true, path: filePath, raw_url })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// ============================================================
// 列出文件
// GET /api/v1/skills/external_storage/list?prefix=subdir/&recursive=true|false
// Headers: X-API-Id, X-API-Key
// ============================================================
router.get('/list', async (req, res) => {
try {
const prefix = sanitizePath(req.query.prefix || '')
const recursive = req.query.recursive === 'true'
let result
if (recursive) {
const files = await ossClient.listRecursive(prefix)
result = { files, folders: [] }
} else {
result = await ossClient.listObjects(prefix)
}
res.json({ ok: true, prefix, recursive, ...result })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// ============================================================
// 删除文件
// DELETE /api/v1/skills/external_storage/delete?path=xxx
// Headers: X-API-Id, X-API-Key
// ============================================================
router.delete('/delete', async (req, res) => {
try {
const filePath = sanitizePath(req.query.path)
if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
await ossClient.deleteObject(filePath)
res.json({ ok: true, path: filePath })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// ============================================================
// 文件夹打包下载
// GET /api/v1/skills/external_storage/folder/download?path=subdir/
// Headers: X-API-Id, X-API-Key
// ============================================================
router.get('/folder/download', async (req, res) => {
try {
const folderPath = sanitizePath(req.query.path)
if (!folderPath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
const folderKey = folderPath.endsWith('/') ? folderPath : folderPath + '/'
const folderName = path.basename(folderKey.replace(/\/+$/, '')) || 'folder'
const files = await ossClient.listRecursive(folderKey)
res.set('Content-Type', 'application/zip')
res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(folderName + '.zip')}`)
const archive = archiver('zip', { zlib: { level: 6 } })
archive.on('error', err => {
try { res.status(500).end() } catch {}
})
archive.pipe(res)
const prefixClean = folderKey.replace(/\/+$/, '') + '/'
for (const f of files) {
try {
// 提取相对于文件夹前缀的路径
const fullRel = f.key
const relPart = fullRel.slice(prefixClean.length)
const { stream } = await ossClient.getObjectStream(relPart)
archive.append(stream, { name: relPart })
} catch {}
}
archive.finalize()
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// ============================================================
// 获取文件信息
// GET /api/v1/skills/external_storage/info?path=xxx
// Headers: X-API-Id, X-API-Key
// ============================================================
router.get('/info', async (req, res) => {
try {
const filePath = sanitizePath(req.query.path)
if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
const isFolder = filePath.endsWith('/')
const fullKey = ossClient.fullKey(filePath)
const raw_url = ossClient.getRawUrl(filePath)
let size = 0
let lastModified = 0
if (!isFolder) {
try {
const parent = filePath.includes('/') ? filePath.split('/').slice(0, -1).join('/') : ''
const result = await ossClient.listObjects(parent)
const targetKey = fullKey
const hit = result.files.find(f => f.key === targetKey)
if (hit) {
size = hit.size
lastModified = hit.lastModified
}
} catch {}
} else {
try {
const files = await ossClient.listRecursive(filePath)
size = files.reduce((s, f) => s + (Number(f.size) || 0), 0)
} catch {}
}
res.json({
ok: true,
info: {
path: filePath,
oss_key: fullKey,
raw_url,
isFolder,
size,
lastModified: lastModified ? new Date(lastModified).toISOString() : null
}
})
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// 挂载路由
app.use('/api/v1/skills/external_storage', router)
console.log('[external_storage] Routes mounted at /api/v1/skills/external_storage')
}
module.exports = { bindRoutes }
@@ -0,0 +1,83 @@
// ============================================================
// external_storage/middleware.js - 路径安全校验中间件
// 职责:防止路径遍历攻击,确保所有操作限定在 external_storage 范围内
// ============================================================
/**
* 校验并清理路径
* - 拒绝包含 ../ 的路径遍历
* - 拒绝绝对路径
* - 拒绝控制字符
* - 统一使用 UTF-8 编码,正确处理中文
* @param {string} rawPath - 原始路径参数
* @returns {string} 清理后的安全路径
* @throws {Error} 如果路径不合法
*/
const sanitizePath = (rawPath) => {
if (rawPath === undefined || rawPath === null) return ''
// 确保使用 UTF-8 字符串
let p = String(rawPath)
// 拒绝控制字符(除了正常的换行和制表符)
if (/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(p)) {
throw new Error('路径包含非法控制字符')
}
// 拒绝路径遍历
if (p.includes('..')) {
throw new Error('路径不允许包含 ".."')
}
// 拒绝绝对路径
if (p.startsWith('/') || p.startsWith('\\')) {
throw new Error('路径不允许以 "/" 或 "\\" 开头')
}
// 统一斜杠
p = p.replace(/[\\]+/g, '/')
// 去除连续斜杠
p = p.replace(/\/+/g, '/')
// 去除首尾斜杠
p = p.replace(/^\/+|\/+$/g, '')
return p
}
/**
* 校验路径是否在允许的前缀范围内
* @param {string} ossKey - 完整 OSS key
* @param {string} allowedPrefix - 允许的前缀
* @returns {boolean}
*/
const isPathWithinPrefix = (ossKey, allowedPrefix) => {
const key = String(ossKey || '')
const prefix = String(allowedPrefix || '').replace(/\/+$/, '')
return key === prefix || key.startsWith(prefix + '/')
}
/**
* 文件大小校验中间件
* @param {number} maxBytes - 最大字节数
* @returns {Function} express 中间件
*/
const createSizeLimitMiddleware = (maxBytes = 50 * 1024 * 1024) => {
return (req, res, next) => {
const contentLength = parseInt(req.headers['content-length'] || '0', 10)
if (contentLength > maxBytes) {
return res.status(413).json({
ok: false,
error: `文件超过大小限制 (${Math.round(maxBytes / 1024 / 1024)}MB)`
})
}
next()
}
}
module.exports = {
sanitizePath,
isPathWithinPrefix,
createSizeLimitMiddleware
}
@@ -0,0 +1,321 @@
// ============================================================
// external_storage/oss_client.js - 阿里云 OSS 客户端封装
// 职责:提供上传、下载、删除、列表、获取URL等基础操作
// 所有操作限定在 config.json 配置的 oss_prefix 范围内
// ============================================================
const crypto = require('crypto')
const https = require('https')
const http = require('http')
const fs = require('fs')
const path = require('path')
const { URLSearchParams } = require('url')
/**
* 初始化 OSS 客户端配置
* @param {Object} skillConfig - skill 的 config.json 内容
* @returns {Object} 客户端实例
*/
const createOssClient = (skillConfig) => {
const bucket = skillConfig.oss_bucket || 'android-o1-images'
const endpoint = skillConfig.oss_endpoint || 'oss-cn-shanghai.aliyuncs.com'
const prefix = (skillConfig.oss_prefix || '550e8400-e29b-41d4-a716-446655442082/external_storage').replace(/\/+$/, '')
// 从 process.env 读取(index.js 启动时已从 Toolbox_local_creds.env.local 加载)
let accessKeyId = process.env.EXTERNAL_STORAGE_OSS_ACCESS_KEY_ID || ''
let accessKeySecret = process.env.EXTERNAL_STORAGE_OSS_ACCESS_KEY_SECRET || ''
if (!accessKeyId || !accessKeySecret) {
throw new Error('OSS credentials not found. Please set oss_access_key_id and oss_access_key_secret in config.json')
}
/**
* 规范化 OSS key:处理斜杠、去除危险字符
*/
const normalizeKey = (relativePath) => {
let k = String(relativePath || '').replace(/[\\]+/g, '/').replace(/\/+/g, '/')
if (k.startsWith('/')) k = k.slice(1)
return k
}
/**
* 拼接完整 OSS key(自动加前缀)
*/
const fullKey = (relativePath) => {
const rel = normalizeKey(relativePath)
return rel ? `${prefix}/${rel}` : prefix
}
/**
* URL 编码 OSS key(用于 HTTP 路径)
*/
const encodeKey = (key) => String(key || '').split('/').map(s => encodeURIComponent(s)).join('/')
/**
* 构建 OSS 资源标识
*/
const ossResource = (key) => `/${bucket}/${String(key || '').replace(/^\/+/, '')}`
/**
* OSS 请求签名(HMAC-SHA1)
*/
const signRequest = ({ method, contentType = '', date, key, extraHeaders = {} }) => {
const canonicalHeaders = Object.keys(extraHeaders)
.filter(k => k && k.toLowerCase().startsWith('x-oss-'))
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
.map(k => `${k.toLowerCase()}:${String(extraHeaders[k] || '').trim()}`)
.join('\n')
const resource = ossResource(key)
const stringToSign = `${method.toUpperCase()}\n\n${contentType}\n${date}\n${canonicalHeaders ? canonicalHeaders + '\n' : ''}${resource}`
return crypto.createHmac('sha1', accessKeySecret).update(stringToSign).digest('base64')
}
/**
* 发起 HTTP 请求
*/
const httpRequest = (options, body = null, timeoutMs = 60000) => new Promise((resolve, reject) => {
const lib = options.protocol === 'https:' ? https : http
const req = lib.request(options, (res) => {
const chunks = []
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
const buf = Buffer.concat(chunks)
resolve({ statusCode: res.statusCode, headers: res.headers, body: buf })
})
})
req.on('error', reject)
req.setTimeout(timeoutMs, () => req.destroy(new Error('request timeout')))
if (body !== null && body !== undefined) {
if (Buffer.isBuffer(body) || typeof body === 'string') req.write(body)
}
req.end()
})
/**
* 获取文件流
*/
const getObjectStream = (relativePath) => new Promise((resolve, reject) => {
const key = fullKey(relativePath)
const date = new Date().toUTCString()
const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'GET', date, key })}`
const options = {
hostname: `${bucket}.${endpoint}`,
path: `/${encodeKey(key)}`,
method: 'GET',
headers: {
Host: `${bucket}.${endpoint}`,
Date: date,
Authorization: authorization
}
}
const req = https.request(options, (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
const chunks = []
res.on('data', c => chunks.push(c))
res.on('end', () => reject(new Error(`OSS get failed: ${res.statusCode} ${Buffer.concat(chunks).toString('utf8').slice(0, 500)}`)))
return
}
resolve({ stream: res, headers: res.headers })
})
req.on('error', reject)
req.setTimeout(60000, () => req.destroy(new Error('request timeout')))
req.end()
})
/**
* 上传文件
* @param {string} relativePath - 相对路径(自动加前缀)
* @param {Buffer} body - 文件内容
* @param {string} contentType - MIME 类型
* @returns {Object} { key, raw_url }
*/
const putObject = async (relativePath, body, contentType = 'application/octet-stream') => {
const key = fullKey(relativePath)
const date = new Date().toUTCString()
const extraHeaders = { 'x-oss-object-acl': 'public-read' }
const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'PUT', contentType, date, key, extraHeaders })}`
const options = {
hostname: `${bucket}.${endpoint}`,
path: `/${encodeKey(key)}`,
method: 'PUT',
headers: {
Host: `${bucket}.${endpoint}`,
Date: date,
'Content-Type': contentType,
'Content-Length': Buffer.isBuffer(body) ? body.length : Buffer.byteLength(body || ''),
...extraHeaders,
Authorization: authorization
}
}
const resp = await httpRequest(options, body, 120000)
if (resp.statusCode < 200 || resp.statusCode >= 300) {
throw new Error(`OSS put failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`)
}
const raw_url = `https://${bucket}.${endpoint}/${encodeKey(key)}`
return { key, raw_url }
}
/**
* 删除文件
*/
const deleteObject = async (relativePath) => {
const key = fullKey(relativePath)
const date = new Date().toUTCString()
const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'DELETE', date, key })}`
const options = {
hostname: `${bucket}.${endpoint}`,
path: `/${encodeKey(key)}`,
method: 'DELETE',
headers: {
Host: `${bucket}.${endpoint}`,
Date: date,
Authorization: authorization
}
}
const resp = await httpRequest(options)
if (resp.statusCode < 200 || resp.statusCode >= 300) {
throw new Error(`OSS delete failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`)
}
return true
}
/**
* 解析 OSS ListObjects XML 响应
*/
const parseListXml = (xml) => {
const extractTag = (tag, src) => {
const open = `<${tag}>`
const close = `</${tag}>`
const result = []
let idx = 0
while (true) {
const s = src.indexOf(open, idx)
if (s < 0) break
const e = src.indexOf(close, s + open.length)
if (e < 0) break
result.push(src.slice(s + open.length, e))
idx = e + close.length
}
return result
}
const extractInSection = (tag, sectionOpen, sectionClose, src) => {
const result = []
let searchFrom = 0
while (true) {
const sOpen = src.indexOf(sectionOpen, searchFrom)
if (sOpen < 0) break
const sClose = src.indexOf(sectionClose, sOpen)
if (sClose < 0) break
const section = src.slice(sOpen, sClose)
result.push(...extractTag(tag, section))
searchFrom = sClose + sectionClose.length
}
return result
}
const folders = extractInSection('Prefix', '<CommonPrefixes>', '</CommonPrefixes>', xml).map(p => ({
key: p,
name: p.split('/').filter(Boolean).pop() + '/',
isFolder: true,
size: 0
}))
const fileKeys = extractTag('Key', xml)
const fileSizes = extractTag('Size', xml)
const fileLms = extractTag('LastModified', xml)
const files = fileKeys.map((k, i) => ({
key: k,
name: k.split('/').filter(Boolean).pop(),
isFolder: false,
size: parseInt(fileSizes[i] || '0', 10) || 0,
lastModified: fileLms[i] ? Date.parse(fileLms[i]) : 0
})).filter(f => f.key && !f.key.endsWith('/'))
return { folders, files }
}
/**
* 列出文件(递归)
* @param {string} relativePrefix - 相对前缀
* @returns {Array} 文件列表
*/
const listRecursive = async (relativePrefix = '') => {
const all = []
const stack = [relativePrefix]
while (stack.length > 0) {
const current = stack.pop()
const keyPrefix = fullKey(current)
const date = new Date().toUTCString()
const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'GET', date, key: '' })}`
const query = new URLSearchParams()
query.set('prefix', keyPrefix)
query.set('delimiter', '/')
query.set('max-keys', '1000')
const options = {
hostname: `${bucket}.${endpoint}`,
path: `/?${query.toString()}`,
method: 'GET',
headers: {
Host: `${bucket}.${endpoint}`,
Date: date,
Authorization: authorization
}
}
const resp = await httpRequest(options)
if (resp.statusCode < 200 || resp.statusCode >= 300) {
throw new Error(`OSS list failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`)
}
const parsed = parseListXml(resp.body.toString('utf8'))
all.push(...parsed.files)
for (const f of parsed.folders) {
stack.push(f.key.slice(keyPrefix.length > 0 ? (fullKey('').length) : 0))
}
}
return all
}
/**
* 列出对象(单层)
*/
const listObjects = async (relativePrefix = '') => {
const keyPrefix = fullKey(relativePrefix)
const date = new Date().toUTCString()
const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'GET', date, key: '' })}`
const query = new URLSearchParams()
query.set('prefix', keyPrefix)
query.set('delimiter', '/')
query.set('max-keys', '1000')
const options = {
hostname: `${bucket}.${endpoint}`,
path: `/?${query.toString()}`,
method: 'GET',
headers: {
Host: `${bucket}.${endpoint}`,
Date: date,
Authorization: authorization
}
}
const resp = await httpRequest(options)
if (resp.statusCode < 200 || resp.statusCode >= 300) {
throw new Error(`OSS list failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`)
}
return parseListXml(resp.body.toString('utf8'))
}
/**
* 获取文件原始 URL(公共读可直接访问)
*/
const getRawUrl = (relativePath) => {
const key = fullKey(relativePath)
return `https://${bucket}.${endpoint}/${encodeKey(key)}`
}
return {
fullKey,
normalizeKey,
putObject,
deleteObject,
getObjectStream,
listObjects,
listRecursive,
getRawUrl
}
}
module.exports = { createOssClient }
@@ -0,0 +1,10 @@
{
"_comment": "天气数据 skill - 由外部 Hermes Agent 每30分钟写入一次",
"id": "weather",
"name": "天气数据",
"enabled": true,
"mode": "append",
"mode_comment": "append: 增量追加保留所有历史;latest: 只保留最新一条",
"api_key_hash": "sha256:7b4035f766bf66e39cff1a58a5e8c73f2fcbf75f3f2a368bf0003e1110a56255",
"read_token_hash": "sha256:aa40833cd71a76b2fc558edb1384f4d993c9e8d966a730005f7c99d56ab19037"
}
+123
View File
@@ -0,0 +1,123 @@
// ============================================================
// data_gateway/store.js - 数据存储模块
// 职责:动态创建/管理每个 skill 的独立 SQLite 数据库
// 表结构:id | source | data(JSON) | created_at
// 支持 mode: "append"(增量追加)和 "latest"(覆盖最新)
// ============================================================
const Database = require('better-sqlite3')
const fs = require('fs')
const path = require('path')
const DATA_DIR = path.join(process.cwd(), 'data', 'ingest')
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
// 已打开的数据库连接缓存
const dbCache = new Map()
const getDbPath = (skillId) => path.join(DATA_DIR, `${skillId}.db`)
const getOrOpenDb = (skillId) => {
if (dbCache.has(skillId)) return dbCache.get(skillId)
const dbPath = getDbPath(skillId)
const db = new Database(dbPath)
// 启用 WAL 模式提高并发写入性能
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
db.exec(
`CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT '',
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`
)
db.exec(`CREATE INDEX IF NOT EXISTS idx_records_created ON records(created_at DESC)`)
dbCache.set(skillId, db)
return db
}
// 写入一条记录
const insert = (skillId, { source = '', data = {} }) => {
const db = getOrOpenDb(skillId)
const dataStr = JSON.stringify(data)
const info = db.prepare(`INSERT INTO records (source, data) VALUES (?, ?)`).run(
String(source || ''), dataStr
)
return { id: info.lastInsertRowid, source, data, created_at: new Date().toISOString() }
}
// 覆盖写入(mode: "latest")- 先删后插,保证只有一条
const upsertLatest = (skillId, { source = '', data = {} }) => {
const db = getOrOpenDb(skillId)
const dataStr = JSON.stringify(data)
const tx = db.transaction(() => {
db.prepare(`DELETE FROM records`).run()
return db.prepare(`INSERT INTO records (source, data) VALUES (?, ?)`).run(
String(source || ''), dataStr
)
})
const info = tx()
return { id: info.lastInsertRowid, source, data, created_at: new Date().toISOString() }
}
// 查询最新一条记录
const queryLatest = (skillId) => {
const db = getOrOpenDb(skillId)
const row = db.prepare(`SELECT * FROM records ORDER BY id DESC LIMIT 1`).get()
if (!row) return null
try { row.data = JSON.parse(row.data) } catch { /* keep as string */ }
return row
}
// 查询记录列表(分页)
const queryList = (skillId, { limit = 100, offset = 0, source = null } = {}) => {
const db = getOrOpenDb(skillId)
let sql = `SELECT * FROM records`
const params = []
if (source) {
sql += ` WHERE source = ?`
params.push(String(source))
}
sql += ` ORDER BY id DESC LIMIT ? OFFSET ?`
params.push(Math.min(Number(limit) || 100, 1000), Math.max(0, Number(offset) || 0))
const rows = db.prepare(sql).all(...params)
for (const row of rows) {
try { row.data = JSON.parse(row.data) } catch { /* keep as string */ }
}
const total = db.prepare(`SELECT COUNT(*) as cnt FROM records`).get().cnt
return { rows, total }
}
// 按时间范围查询
const queryByTimeRange = (skillId, { start, end, limit = 100 } = {}) => {
const db = getOrOpenDb(skillId)
let sql = `SELECT * FROM records WHERE 1=1`
const params = []
if (start) { sql += ` AND created_at >= ?`; params.push(String(start)) }
if (end) { sql += ` AND created_at <= ?`; params.push(String(end)) }
sql += ` ORDER BY id DESC LIMIT ?`
params.push(Math.min(Number(limit) || 100, 1000))
const rows = db.prepare(sql).all(...params)
for (const row of rows) {
try { row.data = JSON.parse(row.data) } catch { /* keep as string */ }
}
return { rows, total: rows.length }
}
// 获取记录总数
const count = (skillId) => {
const db = getOrOpenDb(skillId)
return db.prepare(`SELECT COUNT(*) as cnt FROM records`).get().cnt
}
// 关闭所有数据库连接
const closeAll = () => {
for (const [skillId, db] of dbCache) {
try { db.close() } catch {}
dbCache.delete(skillId)
}
}
module.exports = {
insert, upsertLatest, queryLatest, queryList, queryByTimeRange, count, closeAll, getDbPath, getOrOpenDb
}
+144
View File
@@ -78,6 +78,8 @@ const plantHome = require('./plant_home')
const globalNews = require('./global_news') const globalNews = require('./global_news')
const boxBackup = require('./box_backup') const boxBackup = require('./box_backup')
const yuanzhupai = require('./yuanzhupai') const yuanzhupai = require('./yuanzhupai')
const dataGateway = require('./data_gateway')
const ossFileCabinet = require('./oss_file_cabinet')
yuanzhupai.initDb() yuanzhupai.initDb()
const app = express() const app = express()
@@ -1337,6 +1339,7 @@ const mapSystemIdToUrl = (systemId) => {
'FitnessDiary': '/tools/fitness_diary', 'FitnessDiary': '/tools/fitness_diary',
'Tools-doc_cloud_keeper': '/tools/doc_cloud_keeper', 'Tools-doc_cloud_keeper': '/tools/doc_cloud_keeper',
'Tools-yuanzhupai': '/tools/yuanzhupai', 'Tools-yuanzhupai': '/tools/yuanzhupai',
'Tools-oss_file_cabinet': '/tools/oss_file_cabinet',
'Tools-plant_home': '/tools/plant_home', 'Tools-plant_home': '/tools/plant_home',
'Tools-apple_watch': '/tools/apple_watch', 'Tools-apple_watch': '/tools/apple_watch',
'Tools-private_clipboard': '/tools/private_clipboard', 'Tools-private_clipboard': '/tools/private_clipboard',
@@ -2080,6 +2083,145 @@ app.use('/api/private_clipboard', (req, res, next) => {
return res.status(503).json({ ok: false, error: 'service_not_ready' }) return res.status(503).json({ ok: false, error: 'service_not_ready' })
}) })
const OSS_FILE_CABINET_AUTH_COOKIE = 'oss_file_cabinet_gate'
const readOssFileCabinetAuthConfig = () => {
try {
const p = path.join(process.cwd(), 'public', 'tools', 'oss_file_cabinet', 'auth_config.json')
if (fs.existsSync(p)) {
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
if (cfg && typeof cfg === 'object') return cfg
}
} catch {}
return {}
}
const readOssFileCabinetJwk = () => {
try {
const p = path.join(process.cwd(), 'config', 'oss_file_cabinet.jwk.json')
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch {}
return { kty: 'RSA', n: '', e: '' }
}
const getOssFileCabinetCookieName = () => {
try {
const cfg = readOssFileCabinetAuthConfig()
const n = String(cfg.cookieName || '')
if (n) return n
} catch {}
return OSS_FILE_CABINET_AUTH_COOKIE
}
const getOssFileCabinetMaxAge = () => {
try {
const cfg = readOssFileCabinetAuthConfig()
const days = Number(cfg.max_age_days || 0)
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
} catch {}
return 30 * 24 * 3600 * 1000
}
const getOssFileCabinetGuidConfig = () => {
try {
const cfg = readOssFileCabinetAuthConfig()
const keyRaw = String(cfg.guidParam || cfg.guid_param || 'guid').trim()
const valueRaw = String(cfg.guidValue || cfg.guid_value || '').trim()
return { key: keyRaw || 'guid', value: valueRaw }
} catch { return { key: 'guid', value: '' } }
}
const hasValidOssFileCabinetGuid = (req) => {
try {
const cfg = readOssFileCabinetAuthConfig()
if (cfg.enable_auth === false) return false
const { key, value } = getOssFileCabinetGuidConfig()
if (!value) return false
const q = req && req.query ? req.query : {}
const raw = q[key]
if (raw === undefined || raw === null) return false
const incoming = Array.isArray(raw) ? String(raw[0] || '') : String(raw)
return incoming === value
} catch { return false }
}
const hasOssFileCabinetAuth = (req) => {
try {
const cfg = readOssFileCabinetAuthConfig()
if (cfg.enable_auth === false) return true
const cookies = parseCookie(req.headers.cookie || '')
const name = getOssFileCabinetCookieName()
return cookies[name] === '1'
} catch { return false }
}
const isOssFileCabinetForceGuid = () => {
try {
const cfg = readOssFileCabinetAuthConfig()
if (cfg.enable_auth === false) return false
if (Object.prototype.hasOwnProperty.call(cfg, 'requireGuid')) return !!cfg.requireGuid
if (Object.prototype.hasOwnProperty.call(cfg, 'require_guid')) return !!cfg.require_guid
return true
} catch { return true }
}
app.get('/tools/oss_file_cabinet', (req, res, next) => {
setNoCache(res)
try {
const forceGuid = isOssFileCabinetForceGuid()
if (hasOssFileCabinetAuth(req)) return next()
const guidKey = getOssFileCabinetGuidConfig().key
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
if (hasValidOssFileCabinetGuid(req)) {
const maxAgeOfc = getOssFileCabinetMaxAge()
const cookieName = getOssFileCabinetCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' })
return res.redirect('/tools/oss_file_cabinet/index.html')
}
if (hasGuidInput) return res.status(401).send('未授权')
const token = String(req.query.token || '')
if (!token) {
if (forceGuid) return res.status(401).send('未授权')
return next()
}
const payload = verifyJwtWithKeys(token, jwkKeys(readOssFileCabinetJwk()))
if (!payload) return res.status(401).send('未授权')
{
const iss = String(payload.iss || '')
const navIss = getNavIssFromFlags()
if (iss === navIss && !audMatch(payload, 'Tools-oss_file_cabinet')) return res.status(401).send('未授权')
}
const maxAgeOfc = getOssFileCabinetMaxAge()
const cookieName = getOssFileCabinetCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' })
return res.redirect('/tools/oss_file_cabinet/index.html')
} catch { return res.status(401).send('未授权') }
})
app.use('/tools/oss_file_cabinet', (req, res, next) => {
const forceGuid = isOssFileCabinetForceGuid()
if (hasOssFileCabinetAuth(req)) return next()
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
const guidKey = getOssFileCabinetGuidConfig().key
const hasGuidInput = isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
if (isHandshake && hasValidOssFileCabinetGuid(req)) {
const maxAgeOfc = getOssFileCabinetMaxAge()
const cookieName = getOssFileCabinetCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' })
return res.redirect('/tools/oss_file_cabinet/index.html')
}
if (hasGuidInput) return res.status(401).send('未授权')
if (isHandshake && String(req.query.token || '')) {
const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readOssFileCabinetJwk()))
if (payload) {
const iss = String(payload.iss || '')
const navIss = getNavIssFromFlags()
if (iss === navIss && !audMatch(payload, 'Tools-oss_file_cabinet')) return res.status(401).send('未授权')
const maxAgeOfc = getOssFileCabinetMaxAge()
const cookieName = getOssFileCabinetCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' })
return res.redirect('/tools/oss_file_cabinet/index.html')
}
}
if (isHandshake && forceGuid) return res.status(401).send('未授权')
return res.status(401).send('未授权')
})
app.use('/api/oss_file_cabinet', (req, res, next) => {
if (req.path.startsWith('/share/access/')) return next()
if (hasOssFileCabinetAuth(req)) return next()
return res.status(401).json({ ok: false, error: '未授权' })
})
const WEEKLY_AUTH_COOKIE = 'weekly_gate' const WEEKLY_AUTH_COOKIE = 'weekly_gate'
const readWeeklyJwk = () => { const readWeeklyJwk = () => {
try { try {
@@ -5844,6 +5986,8 @@ expense.bindRoutes(app)
investmentLedger.bindRoutes(app) investmentLedger.bindRoutes(app)
styleCheck.bindRoutes(app) styleCheck.bindRoutes(app)
globalNews.bindRoutes(app) globalNews.bindRoutes(app)
dataGateway.bindRoutes(app)
ossFileCabinet.bindRoutes(app)
const marketsCfgAtBoot = getMarketsCfg() const marketsCfgAtBoot = getMarketsCfg()
const currentPort = Number(process.env.PORT || '8976') || 8976 const currentPort = Number(process.env.PORT || '8976') || 8976
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "巴西IBOVESPA", "name": "巴西IBOVESPA",
"card_order": 18, "card_order": 18,
"card_type": "overseas", "card_type": "overseas",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(iShares MSCI Brazil ETF)", "symbol_comment": "监控的代",
"symbol": "usEWZ", "symbol": "usEWZ",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWZ", "url": "https://gu.qq.com/usEWZ",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "yahoo_probe", "source": "yahoo_probe",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
+18 -18
View File
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "北证50", "name": "北证50",
"card_order": 6, "card_order": 6,
"card_type": "domestic", "card_type": "domestic",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": false, "display": false,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "bj899050", "symbol": "bj899050",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/bj899050", "url": "https://gu.qq.com/bj899050",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "tencent", "source": "tencent",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -1,17 +1,17 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "å·´è�²ç‰¹æŒ‡æ•?, "name": "巴菲特指",
"card_order_comment": "å�¡ç‰‡æŽ’åº�åº�å�·ï¼Œè¶Šå¤§è¶Šé� å�Žï¼ˆå½“å‰�最大å€?87,设ä¸?88 排最å�Žï¼‰", "card_order_comment": "卡片排序序号,越大越靠后(当前最大",
"card_order": 88, "card_order": 88,
"card_type_comment": "卡片类型:domestic(国内)/ overseas(海外)/ enhanced(增强)/ defensive(防御)", "card_type_comment": "卡片类型:domestic(国内)/ overseas(海外)/ enhanced(增强)/ defensive(防御)",
"card_type": "overseas", "card_type": "overseas",
"card_span_comment": "å�¡ç‰‡æ¨ªå�‘å� ä½�列数ï¼?为默认,2为跨两列", "card_span_comment": "卡片横向占位列数",
"card_span": 2, "card_span": 2,
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?1h 表示1å°�æ—¶)", "schedule_comment": "刷新频率 (",
"schedule": "1h", "schedule": "1h",
"symbol_comment": "监控的代码(技能内部定义)", "symbol_comment": "监控的代码(技能内部定义)",
"symbol": "us_buffett_index", "symbol": "us_buffett_index",
@@ -21,9 +21,9 @@
"source": "fred", "source": "fred",
"alerts_comment": "预警阈值配置(基于S&P500/GDP比率,非Wilshire5000原值)", "alerts_comment": "预警阈值配置(基于S&P500/GDP比率,非Wilshire5000原值)",
"alerts": { "alerts": {
"ratio_high_warning_comment": "S&P500/GDP 比率高于此值触å�‘估值å��高预è­?, "ratio_high_warning_comment": "S&P500/GDP 比率高于此值触发估值偏高预",
"ratio_high_warning": 0.19, "ratio_high_warning": 0.19,
"ratio_critical_comment": "S&P500/GDP 比率高于此值触å�‘æž�端高估严é‡�告è­?, "ratio_critical_comment": "S&P500/GDP 比率高于此值触发极端高估严重告",
"ratio_critical": 0.21, "ratio_critical": 0.21,
"change_percent_down_comment": "单日跌幅告警阈值(比率变化百分比)", "change_percent_down_comment": "单日跌幅告警阈值(比率变化百分比)",
"change_percent_down": -5, "change_percent_down": -5,
@@ -31,20 +31,20 @@
"change_percent_up": 5, "change_percent_up": 5,
"urgent_abs_change_percent_comment": "严重告警阈值(绝对波动百分比,日变动超过此值触发紧急推送)", "urgent_abs_change_percent_comment": "严重告警阈值(绝对波动百分比,日变动超过此值触发紧急推送)",
"urgent_abs_change_percent": 8, "urgent_abs_change_percent": 8,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 240 "urgent_cooldown_minutes": 240
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": false, "enabled": false,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -55,19 +55,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": false, "email_reminder_enabled": false,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": false, "calendar_reminder_enabled": false,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": false, "enabled": false,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -76,7 +76,7 @@
"enabled": false "enabled": false
} }
], ],
"market_status_comment": "市场æ—åŒºä¸Žäº¤æ˜“æ—æ®µå®šä¹‰ï¼Œç”¨äºŽå‰�ç«¯æ˜¾ç¤ºå¼€ä¼‘å¸‚çŠæ€?, "market_status_comment": "市场时区与交易时段定义,用于前端显示开休市状",
"market_status": { "market_status": {
"timezone": "America/New_York", "timezone": "America/New_York",
"sessions": [ "sessions": [
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "中韩å�Šå¯¼ä½“指æ•?, "name": "中韩半导体指",
"card_order": 19, "card_order": 19,
"card_type": "overseas", "card_type": "overseas",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(科创50,原中韩å�Šå¯¼ä½“指数无ETF替代)", "symbol_comment": "监控的代",
"symbol": "sh000688", "symbol": "sh000688",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000688", "url": "https://gu.qq.com/sh000688",
"source_comment": "数据来源", "source_comment": "数据来源",
"source": "tencent", "source": "tencent",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -76,7 +76,7 @@
"end": "15:30" "end": "15:30"
} }
], ],
"display_label": "中韩å�Šå¯¼ä½? "display_label": "中韩半导"
}, },
"market_status_comment": "Defines the source market timezone and trading sessions for status display." "market_status_comment": "Defines the source market timezone and trading sessions for status display."
} }
+20 -20
View File
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "创业板指", "name": "创业板指",
"card_order": 5, "card_order": 5,
"card_type": "domestic", "card_type": "domestic",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "sz399006", "symbol": "sz399006",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sz399006", "url": "https://gu.qq.com/sz399006",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "tencent", "source": "tencent",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -80,7 +80,7 @@
"end": "15:00" "end": "15:00"
} }
], ],
"display_label": "创业æ�? "display_label": "创业"
}, },
"market_status_comment": "Defines the source market timezone and trading sessions for status display." "market_status_comment": "Defines the source market timezone and trading sessions for status display."
} }
+25 -25
View File
@@ -1,52 +1,52 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "COMEX黄金", "name": "COMEX黄金",
"card_order": 15, "card_order": 15,
"card_type": "defensive", "card_type": "defensive",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "hf_GC", "symbol": "hf_GC",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/globalfuture/GC00Y.html", "url": "https://quote.eastmoney.com/globalfuture/GC00Y.html",
"silver_symbol_comment": "白银代ç � (用于计算金银æ¯?", "silver_symbol_comment": "白银代码 (用于计算金银",
"silver_symbol": "hf_SI", "silver_symbol": "hf_SI",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "tencent", "source": "tencent",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"price_min_comment": "最低价格预è­?, "price_min_comment": "最低价格预",
"price_min": 4800, "price_min": 4800,
"price_max_comment": "最高价格预è­?, "price_max_comment": "最高价格预",
"price_max": 5500, "price_max": 5500,
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 3, "change_percent_up": 3,
"gold_silver_ratio_min_comment": "金铿¯”最低预è­?, "gold_silver_ratio_min_comment": "金银比最低预",
"gold_silver_ratio_min": 60, "gold_silver_ratio_min": 60,
"gold_silver_ratio_max_comment": "金铿¯”最高预è­?, "gold_silver_ratio_max_comment": "金银比最高预",
"gold_silver_ratio_max": 65 "gold_silver_ratio_max": 65
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -57,19 +57,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -86,7 +86,7 @@
"end": "17:00" "end": "17:00"
} }
], ],
"display_label": "纽约é‡? "display_label": "纽约"
}, },
"market_status_comment": "Defines the source market timezone and trading sessions for status display." "market_status_comment": "Defines the source market timezone and trading sessions for status display."
} }
@@ -1,46 +1,46 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "COMEX白银", "name": "COMEX白银",
"card_order": 15, "card_order": 15,
"card_type": "defensive", "card_type": "defensive",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "hf_SI", "symbol": "hf_SI",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/globalfuture/SI00Y.html", "url": "https://quote.eastmoney.com/globalfuture/SI00Y.html",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "tencent", "source": "tencent",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"price_min_comment": "最低价格预è­?, "price_min_comment": "最低价格预",
"price_min": 75, "price_min": 75,
"price_max_comment": "最高价格预è­?, "price_max_comment": "最高价格预",
"price_max": 95, "price_max": 95,
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 3 "change_percent_up": 3
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -51,19 +51,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -80,7 +80,7 @@
"end": "17:00" "end": "17:00"
} }
], ],
"display_label": "纽约é“? "display_label": "纽约"
}, },
"market_status_comment": "Defines the source market timezone and trading sessions for status display." "market_status_comment": "Defines the source market timezone and trading sessions for status display."
} }
+18 -18
View File
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "沪深300", "name": "沪深300",
"card_order": 3, "card_order": 3,
"card_type": "domestic", "card_type": "domestic",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "sh000300", "symbol": "sh000300",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000300", "url": "https://gu.qq.com/sh000300",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "tencent", "source": "tencent",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
+21 -21
View File
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "é�“ç�¼æ–¯å·¥ä¸šæŒ‡æ•?, "name": "道琼斯工业指",
"card_order": 13.5, "card_order": 13.5,
"card_type": "overseas", "card_type": "overseas",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "usDJI", "symbol": "usDJI",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usDJI", "url": "https://gu.qq.com/usDJI",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "yahoo_probe", "source": "yahoo_probe",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -76,7 +76,7 @@
"end": "16:00" "end": "16:00"
} }
], ],
"display_label": "é�“ç�¼æ–? "display_label": "道琼"
}, },
"market_status_comment": "Defines the source market timezone and trading sessions for status display." "market_status_comment": "Defines the source market timezone and trading sessions for status display."
} }
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "德国DAX30", "name": "德国DAX30",
"card_order": 10, "card_order": 10,
"card_type": "overseas", "card_type": "overseas",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(iShares MSCI Germany ETF)", "symbol_comment": "监控的代",
"symbol": "usEWG", "symbol": "usEWG",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWG", "url": "https://gu.qq.com/usEWG",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "yahoo_probe", "source": "yahoo_probe",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -1,9 +1,9 @@
{ {
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule": "5m", "schedule": "5m",
"name": "黄金白é“净值跟è¸?, "name": "黄金白银净值跟",
"card_order": 16, "card_order": 16,
"card_type": "defensive", "card_type": "defensive",
"gold_fund_code": "518880", "gold_fund_code": "518880",
@@ -33,7 +33,7 @@
{ {
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled": true, "enabled": true,
\"key\": \"\" "key": ""
}, },
{ {
"url": "http://localhost:{{PORT}}/api/calendar/events", "url": "http://localhost:{{PORT}}/api/calendar/events",
+18 -18
View File
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "恒生指数", "name": "恒生指数",
"card_order": 8, "card_order": 8,
"card_type": "overseas", "card_type": "overseas",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "hkHSI", "symbol": "hkHSI",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/hkHSI", "url": "https://gu.qq.com/hkHSI",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "tencent", "source": "tencent",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
+19 -19
View File
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "å�°åº¦NFTY(ç¾?ETF", "name": "印度NFTY(",
"card_order": 11, "card_order": 11,
"card_type": "overseas", "card_type": "overseas",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(India Nifty 50 ETF)", "symbol_comment": "监控的代",
"symbol": "usNFTY", "symbol": "usNFTY",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usNFTY", "url": "https://gu.qq.com/usNFTY",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "yahoo_probe", "source": "yahoo_probe",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "韩国综合指数", "name": "韩国综合指数",
"card_order": 86, "card_order": 86,
"card_type": "overseas", "card_type": "overseas",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(iShares MSCI South Korea ETF)", "symbol_comment": "监控的代",
"symbol": "usEWY", "symbol": "usEWY",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWY", "url": "https://gu.qq.com/usEWY",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "yahoo_probe", "source": "yahoo_probe",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -1,44 +1,44 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "流动性状态机", "name": "流动性状态机",
"card_order": 6, "card_order": 6,
"card_type": "overseas", "card_type": "overseas",
"card_span_comment": "å�¡ç‰‡æ¨ªå�‘å� ä½�列数ï¼?为默认,2为跨两列", "card_span_comment": "卡片横向占位列数",
"card_span": 2, "card_span": 2,
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "6h", "schedule": "6h",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "us_liquidity_state", "symbol": "us_liquidity_state",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://fred.stlouisfed.org/series/FEDFUNDS", "url": "https://fred.stlouisfed.org/series/FEDFUNDS",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "fred", "source": "fred",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -20, "change_percent_down": -20,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 40, "urgent_abs_change_percent": 40,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 240, "urgent_cooldown_minutes": 240,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 20 "change_percent_up": 20
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -49,19 +49,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
+18 -18
View File
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "纳斯达克", "name": "纳斯达克",
"card_order": 13, "card_order": 13,
"card_type": "overseas", "card_type": "overseas",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "usIXIC", "symbol": "usIXIC",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usIXIC", "url": "https://gu.qq.com/usIXIC",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "yahoo_probe", "source": "yahoo_probe",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
+18 -18
View File
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "日经225指数", "name": "日经225指数",
"card_order": 9, "card_order": 9,
"card_type": "overseas", "card_type": "overseas",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(iShares MSCI Japan ETF)", "symbol_comment": "监控的代",
"symbol": "usEWJ", "symbol": "usEWJ",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWJ", "url": "https://gu.qq.com/usEWJ",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "yahoo_probe", "source": "yahoo_probe",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
+18 -18
View File
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "沙特ETF(富时沙特)", "name": "沙特ETF(富时沙特)",
"card_order": 17, "card_order": 17,
"card_type": "overseas", "card_type": "overseas",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(Franklin FTSE Saudi Arabia ETF)", "symbol_comment": "监控的代",
"symbol": "usFLSA", "symbol": "usFLSA",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usFLSA", "url": "https://gu.qq.com/usFLSA",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "yahoo_probe", "source": "yahoo_probe",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
+18 -18
View File
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "上证指数", "name": "上证指数",
"card_order": 1, "card_order": 1,
"card_type": "domestic", "card_type": "domestic",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "sh000001", "symbol": "sh000001",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000001", "url": "https://gu.qq.com/sh000001",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "tencent", "source": "tencent",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -1,33 +1,33 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "中国神å�Ž-299æ³?, "name": "中国神华-299",
"card_order": 1, "card_order": 1,
"card_type": "299", "card_type": "299",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": false, "display": false,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "sh601088_299", "symbol": "sh601088_299",
"source_symbol_comment": "用于拉取K线的真实代码", "source_symbol_comment": "用于拉取K线的真实代码",
"source_symbol": "sh601088", "source_symbol": "sh601088",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/sh601088.html", "url": "https://quote.eastmoney.com/sh601088.html",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "tencent", "source": "tencent",
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -38,19 +38,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -71,7 +71,7 @@
"end": "15:00" "end": "15:00"
} }
], ],
"display_label": "中国Aè‚? "display_label": "中国A"
}, },
"market_status_comment": "Defines the source market timezone and trading sessions for status display." "market_status_comment": "Defines the source market timezone and trading sessions for status display."
} }
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "东南亚科技(海峡时报)", "name": "东南亚科技(海峡时报)",
"card_order": 20, "card_order": 20,
"card_type": "overseas", "card_type": "overseas",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(Global X 东å�—äº?ETF)", "symbol_comment": "监控的代",
"symbol": "usASEA", "symbol": "usASEA",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usASEA", "url": "https://gu.qq.com/usASEA",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "yahoo_probe", "source": "yahoo_probe",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
+18 -18
View File
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "标普500", "name": "标普500",
"card_order": 12, "card_order": 12,
"card_type": "overseas", "card_type": "overseas",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "usINX", "symbol": "usINX",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usINX", "url": "https://gu.qq.com/usINX",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "yahoo_probe", "source": "yahoo_probe",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
+18 -18
View File
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "科创50", "name": "科创50",
"card_order": 4, "card_order": 4,
"card_type": "domestic", "card_type": "domestic",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "sh000688", "symbol": "sh000688",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000688", "url": "https://gu.qq.com/sh000688",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "tencent", "source": "tencent",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
+18 -18
View File
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "深证成指", "name": "深证成指",
"card_order": 2, "card_order": 2,
"card_type": "domestic", "card_type": "domestic",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "sz399001", "symbol": "sz399001",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sz399001", "url": "https://gu.qq.com/sz399001",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "tencent", "source": "tencent",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -1,42 +1,42 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "宝岛加权指数", "name": "宝岛加权指数",
"card_order": 87, "card_order": 87,
"card_type": "overseas", "card_type": "overseas",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(iShares MSCI Taiwan ETF)", "symbol_comment": "监控的代",
"symbol": "usEWT", "symbol": "usEWT",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWT", "url": "https://gu.qq.com/usEWT",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "yahoo_probe", "source": "yahoo_probe",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2, "change_percent_down": -2,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120, "urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2 "change_percent_up": 2
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": true, "enabled": true,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -47,19 +47,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true, "email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true, "calendar_reminder_enabled": true,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": true, "enabled": true,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -1,15 +1,15 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "美国战争指数", "name": "美国战争指数",
"card_order": 14, "card_order": 14,
"card_type": "overseas", "card_type": "overseas",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(技能内部定ä¹?", "symbol_comment": "监控的代",
"symbol": "us_war_pizzint", "symbol": "us_war_pizzint",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://fred.stlouisfed.org/series/VIXCLS", "url": "https://fred.stlouisfed.org/series/VIXCLS",
@@ -21,34 +21,34 @@
"fail_alert_consecutive": 3, "fail_alert_consecutive": 3,
"source_comment": "数据来源", "source_comment": "数据来源",
"source": "fred_vix", "source": "fred_vix",
"alerts_comment": "预警阈值é…�ç½?, "alerts_comment": "预警阈值配",
"alerts": { "alerts": {
"doughcon_critical_max_comment": "DOUGHCON 小于等于该值触发高风险预警", "doughcon_critical_max_comment": "DOUGHCON 小于等于该值触发高风险预警",
"doughcon_critical_max": 2, "doughcon_critical_max": 2,
"doughcon_warning_max_comment": "DOUGHCON å°�于等于该值触å�‘风险抬å�‡é¢„è­?, "doughcon_warning_max_comment": "DOUGHCON 小于等于该值触发风险抬升预",
"doughcon_warning_max": 3, "doughcon_warning_max": 3,
"vix_warning_min_comment": "VIX 大于等于该值触å�‘风险抬å�‡é¢„è­?, "vix_warning_min_comment": "VIX 大于等于该值触发风险抬升预",
"vix_warning_min": 22, "vix_warning_min": 22,
"vix_critical_min_comment": "VIX 大于等于该值触发高风险预警", "vix_critical_min_comment": "VIX 大于等于该值触发高风险预警",
"vix_critical_min": 30, "vix_critical_min": 30,
"index_jump_threshold_comment": "战争指数å�•次上跳达到该值触å�‘快速å�‡æ¸©é¢„è­?, "index_jump_threshold_comment": "战争指数单次上跳达到该值触发快速升温预",
"index_jump_threshold": 20, "index_jump_threshold": 20,
"urgent_abs_change_percent_comment": "严é‡�告警阈å€?(ç»�对波动百分æ¯?", "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5, "urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严é‡�告警冷å�´åˆ†é’Ÿæ•?, "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120 "urgent_cooldown_minutes": 120
}, },
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": false, "enabled": false,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -59,19 +59,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": false, "email_reminder_enabled": false,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": false, "calendar_reminder_enabled": false,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": false, "enabled": false,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -1,45 +1,45 @@
{ {
"name_comment": "显示的å��ç§?, "name_comment": "显示的名",
"name": "中国é“行-299æ³?, "name": "中国银行-299",
"card_order": 2, "card_order": 2,
"card_type": "299", "card_type": "299",
"card_span_comment": "å�¡ç‰‡æ¨ªå�‘å� ä½�列数ï¼?为默认,2为跨两列", "card_span_comment": "卡片横向占位列数",
"card_span": 2, "card_span": 2,
"card_custom_class_comment": "中国银行卡片专属样式类名,仅用于前端独立展示", "card_custom_class_comment": "中国银行卡片专属样式类名,仅用于前端独立展示",
"card_custom_class": "card-zhonghang-299", "card_custom_class": "card-zhonghang-299",
"enabled_comment": "是å�¦å�¯ç”¨è¯¥ç›‘æŽ?(true/false)", "enabled_comment": "是否启用该监",
"enabled": true, "enabled": true,
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å­˜)", "display_comment": "是否显示该卡",
"display": true, "display": true,
"schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)", "symbol_comment": "监控的代",
"symbol": "sh601988_299", "symbol": "sh601988_299",
"source_symbol_comment": "用于拉取K线的真实代码", "source_symbol_comment": "用于拉取K线的真实代码",
"source_symbol": "sh601988", "source_symbol": "sh601988",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/sh601988.html", "url": "https://quote.eastmoney.com/sh601988.html",
"source_comment": "æ•°æ�®æ�¥æº� (å¦?tencent)", "source_comment": "数据来源 (",
"source": "tencent", "source": "tencent",
"box_window_comment": "收盘价箱体窗å�£ï¼Œä»…支æŒ?0/30/40", "box_window_comment": "收盘价箱体窗口,仅支",
"box_window": 30, "box_window": 30,
"box_confirm_days_comment": "箱体突破确认天数", "box_confirm_days_comment": "箱体突破确认天数",
"box_confirm_days": 2, "box_confirm_days": 2,
"box_volume_ma_window_comment": "突破日成交量均量窗口", "box_volume_ma_window_comment": "突破日成交量均量窗口",
"box_volume_ma_window": 5, "box_volume_ma_window": 5,
"box_volume_expand_ratio_comment": "çª�破日放é‡�å€�数阈å€?, "box_volume_expand_ratio_comment": "突破日放量倍数阈",
"box_volume_expand_ratio": 1.2, "box_volume_expand_ratio": 1.2,
"email_comment": "邮件通知配置", "email_comment": "邮件通知配置",
"email": { "email": {
"enabled_comment": "是否启用邮件通知", "enabled_comment": "是否启用邮件通知",
"enabled": false, "enabled": false,
"from_comment": "å�‘ä»äººé‚®ç®?, "from_comment": "发件人邮",
"from": "yangdafe@gmail.com", "from": "yangdafe@gmail.com",
"to_comment": "æ”ä»äººé‚®ç®±åˆ—è¡?, "to_comment": "收件人邮箱列",
"to": [ "to": [
"yangxiangyuan@umer.com.cn" "yangxiangyuan@umer.com.cn"
], ],
"smtp_comment": "SMTP æœ�务器é…�ç½?, "smtp_comment": "SMTP 服务器配",
"smtp": { "smtp": {
"host": "smtp.gmail.com", "host": "smtp.gmail.com",
"port": 465, "port": 465,
@@ -50,19 +50,19 @@
} }
} }
}, },
"email_reminder_enabled_comment": "是å�¦å�‘é€�邮仿��é†?, "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": false, "email_reminder_enabled": false,
"calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": false, "calendar_reminder_enabled": false,
"push_targets_comment": "推é€�目标列è¡?(支æŒ�多路推é€?", "push_targets_comment": "推送目标列",
"push_targets": [ "push_targets": [
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event", "url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用", "enabled_comment": "是否启用",
"enabled": false, "enabled": false,
"key_comment": "Weekly 工具çš?Key", "key_comment": "Weekly 工具",
\"key\": \"\" "key": ""
}, },
{ {
"url_comment": "推送接口地址", "url_comment": "推送接口地址",
@@ -83,7 +83,7 @@
"end": "15:00" "end": "15:00"
} }
], ],
"display_label": "中国Aè‚? "display_label": "中国A"
}, },
"market_status_comment": "Defines the source market timezone and trading sessions for status display." "market_status_comment": "Defines the source market timezone and trading sessions for status display."
} }
+1 -1
View File
@@ -27,7 +27,7 @@ if (!fs.existsSync(cfgPath)) {
enabled_comment: '是否启用 Yahoo 探针', enabled_comment: '是否启用 Yahoo 探针',
enabled: true, enabled: true,
base_url_comment: 'Yahoo 探针基础地址(不带结尾斜杠)', base_url_comment: 'Yahoo 探针基础地址(不带结尾斜杠)',
base_url: 'http://47.252.30.127:19071', base_url: 'https://yang-probe.umersoft.com',
token_comment: 'Yahoo 探针 token,仅后端使用,需在 config/markets.json 中配置', token_comment: 'Yahoo 探针 token,仅后端使用,需在 config/markets.json 中配置',
token: '', token: '',
endpoint_comment: 'Yahoo 探针统一路由', endpoint_comment: 'Yahoo 探针统一路由',
+2 -2
View File
@@ -175,10 +175,10 @@ const readMarketsConfig = () => {
const getYahooProbeConfig = () => { const getYahooProbeConfig = () => {
const cfg = readMarketsConfig() const cfg = readMarketsConfig()
const raw = cfg && typeof cfg.yahoo_probe === 'object' ? cfg.yahoo_probe : {} const raw = cfg && typeof cfg.yahoo_probe === 'object' ? cfg.yahoo_probe : {}
const baseUrl = String(raw.base_url || 'http://47.252.30.127:19071').trim().replace(/\/$/, '') const baseUrl = String(raw.base_url || 'https://yang-probe.umersoft.com').trim().replace(/\/$/, '')
return { return {
enabled: raw.enabled !== false, enabled: raw.enabled !== false,
base_url: baseUrl || 'http://47.252.30.127:19071', base_url: baseUrl || 'https://yang-probe.umersoft.com',
token: String(raw.token || '').trim(), token: String(raw.token || '').trim(),
quote_endpoint: String(raw.quote_endpoint || raw.endpoint || '/probe/yahoo').trim() || '/probe/yahoo', quote_endpoint: String(raw.quote_endpoint || raw.endpoint || '/probe/yahoo').trim() || '/probe/yahoo',
chart_endpoint: String(raw.chart_endpoint || raw.endpoint || '/probe/yahoo').trim() || '/probe/yahoo', chart_endpoint: String(raw.chart_endpoint || raw.endpoint || '/probe/yahoo').trim() || '/probe/yahoo',
+2 -2
View File
@@ -228,10 +228,10 @@ function getConfig() {
function getAlphaVantageConfig() { function getAlphaVantageConfig() {
const cfg = getConfig() const cfg = getConfig()
const raw = cfg && typeof cfg.alphavantage_probe === 'object' ? cfg.alphavantage_probe : {} const raw = cfg && typeof cfg.alphavantage_probe === 'object' ? cfg.alphavantage_probe : {}
const baseUrl = String(raw.base_url || 'http://47.252.30.127:19071').trim().replace(/\/$/, '') const baseUrl = String(raw.base_url || 'https://yang-probe.umersoft.com').trim().replace(/\/$/, '')
return { return {
enabled: raw.enabled !== false, enabled: raw.enabled !== false,
base_url: baseUrl || 'http://47.252.30.127:19071', base_url: baseUrl || 'https://yang-probe.umersoft.com',
token: String(raw.token || '').trim(), token: String(raw.token || '').trim(),
candle_endpoint: String(raw.candle_endpoint || '/probe/alphavantage/candle').trim() || '/probe/alphavantage/candle', candle_endpoint: String(raw.candle_endpoint || '/probe/alphavantage/candle').trim() || '/probe/alphavantage/candle',
request_timeout_ms: Math.max(5000, Number(raw.request_timeout_ms || 20000)), request_timeout_ms: Math.max(5000, Number(raw.request_timeout_ms || 20000)),
File diff suppressed because it is too large Load Diff
+45 -25
View File
@@ -890,7 +890,6 @@ const fetchWeather = async ({ force = false } = {}) => {
return weatherCache.payload return weatherCache.payload
} }
// 使用AI来获取天气信息,而不是依赖付费API
const locInfo = cfg.location || {} const locInfo = cfg.location || {}
const city = safeString(locInfo.city) || '上海' const city = safeString(locInfo.city) || '上海'
const district = safeString(locInfo.district) || '宝山区' const district = safeString(locInfo.district) || '宝山区'
@@ -916,14 +915,47 @@ const fetchWeather = async ({ force = false } = {}) => {
] ]
} }
// 使用AI来生成更准确的天气信息 // 优先从 data_gateway 读取外部 Agent 预写入的天气数据
const ai = getAiConfig() const ai = getAiConfig()
let adviceText = '' let adviceText = ''
let reviewedByAi = false let reviewedByAi = false
let dataGatewayUsed = false
if (ai.enabled && ai.apiKey) { try {
const dataGateway = require('./data_gateway/store')
const row = dataGateway.queryLatest('weather')
if (row && row.data && typeof row.data === 'object') {
const d = row.data
// 合并 data_gateway 数据到 payload(外部 Agent 写入的字段覆盖默认值)
if (d.text !== undefined) payload.text = safeString(d.text)
if (d.temp !== undefined) payload.temp = safeString(d.temp)
if (d.feelsLike !== undefined) payload.feelsLike = safeString(d.feelsLike)
if (d.humidity !== undefined) payload.humidity = safeString(d.humidity)
if (d.windDir !== undefined) payload.windDir = safeString(d.windDir)
if (d.windScale !== undefined) payload.windScale = safeString(d.windScale)
if (d.icon !== undefined) payload.icon = safeString(d.icon)
if (d.updateTime !== undefined) payload.updateTime = safeString(d.updateTime)
if (d.location !== undefined) payload.location = { ...payload.location, ...d.location }
if (Array.isArray(d.daily)) {
payload.daily = d.daily.slice(0, 3).map(day => ({
fxDate: safeString(day.fxDate || day.fxdate || ''),
textDay: safeString(day.textDay || day.textday || ''),
textNight: safeString(day.textNight || day.textnight || ''),
tempMin: safeString(day.tempMin || day.tempmin || ''),
tempMax: safeString(day.tempMax || day.tempmax || '')
}))
}
// 记录 data_gateway 更新时间(UTC),前端转为北京时间
if (row.created_at) payload._data_gateway_updated_at = safeString(row.created_at)
dataGatewayUsed = true
}
} catch (e) {
// data_gateway 不可用时静默回退,不影响 plant_home 正常运行
}
// 如果 data_gateway 没有数据,回退到 AI 获取天气(保留兼容)
if (!dataGatewayUsed && ai.enabled && ai.apiKey) {
try { try {
// 构建一个请求天气信息的消息
const weatherPrompt = `请基于以下位置信息提供当前天气情况和未来3天天气预报:位置:${city}${district}。请提供以下信息:当前天气状况、温度、体感温度、湿度、风向风力,以及未来3天的天气预报(包括每日最高最低温度和天气状况)。请以JSON格式返回,格式如下:{"current":{"text":"天气状况","temp":"温度","feelsLike":"体感温度","humidity":"湿度","windDir":"风向","windScale":"风力"},"daily":[{"fxDate":"日期","textDay":"白天天气","textNight":"夜间天气","tempMin":"最低温度","tempMax":"最高温度"}]}` const weatherPrompt = `请基于以下位置信息提供当前天气情况和未来3天天气预报:位置:${city}${district}。请提供以下信息:当前天气状况、温度、体感温度、湿度、风向风力,以及未来3天的天气预报(包括每日最高最低温度和天气状况)。请以JSON格式返回,格式如下:{"current":{"text":"天气状况","temp":"温度","feelsLike":"体感温度","humidity":"湿度","windDir":"风向","windScale":"风力"},"daily":[{"fxDate":"日期","textDay":"白天天气","textNight":"夜间天气","tempMin":"最低温度","tempMax":"最高温度"}]}`
const aiResponse = await callDashscope({ const aiResponse = await callDashscope({
@@ -935,12 +967,10 @@ const fetchWeather = async ({ force = false } = {}) => {
] ]
}) })
// 尝试解析AI返回的天气信息
const jsonMatch = aiResponse.match(/```json\s*([\s\S]*?)```|```([\s\S]*?)```/) const jsonMatch = aiResponse.match(/```json\s*([\s\S]*?)```|```([\s\S]*?)```/)
if (jsonMatch) { if (jsonMatch) {
try { try {
const jsonData = JSON.parse(jsonMatch[1] || jsonMatch[2]) const jsonData = JSON.parse(jsonMatch[1] || jsonMatch[2])
if (jsonData && jsonData.current) { if (jsonData && jsonData.current) {
payload.text = safeString(jsonData.current.text) payload.text = safeString(jsonData.current.text)
payload.temp = safeString(jsonData.current.temp) payload.temp = safeString(jsonData.current.temp)
@@ -949,7 +979,6 @@ const fetchWeather = async ({ force = false } = {}) => {
payload.windDir = safeString(jsonData.current.windDir) payload.windDir = safeString(jsonData.current.windDir)
payload.windScale = safeString(jsonData.current.windScale) payload.windScale = safeString(jsonData.current.windScale)
} }
if (jsonData && Array.isArray(jsonData.daily)) { if (jsonData && Array.isArray(jsonData.daily)) {
payload.daily = jsonData.daily.slice(0, 3).map(day => ({ payload.daily = jsonData.daily.slice(0, 3).map(day => ({
fxDate: safeString(day.fxDate), fxDate: safeString(day.fxDate),
@@ -963,8 +992,14 @@ const fetchWeather = async ({ force = false } = {}) => {
console.warn('AI天气JSON解析失败:', parseError.message) console.warn('AI天气JSON解析失败:', parseError.message)
} }
} }
} catch (error) {
// 生成植物养护建议 console.warn('AI天气获取失败,使用默认值:', error.message)
}
}
// 生成植物养护建议(plant_home 保留本地 AI 生成)
if (ai.enabled && ai.apiKey) {
try {
adviceText = await callDashscope({ adviceText = await callDashscope({
model: ai.model, model: ai.model,
temperature: 0.2, temperature: 0.2,
@@ -975,22 +1010,7 @@ const fetchWeather = async ({ force = false } = {}) => {
}) })
reviewedByAi = !!adviceText reviewedByAi = !!adviceText
} catch (error) { } catch (error) {
console.warn('AI天气获取失败,使用默认值:', error.message) console.warn('养护建议生成失败:', error.message)
// 如果AI调用失败,使用默认的天气信息
if (ai.autoWeatherReview) {
try {
// 使用AI基于默认天气生成建议
adviceText = await callDashscope({
model: ai.model,
temperature: 0.2,
messages: [
{ role: 'system', content: '输出简短、克制、实用的植物养护提醒。不要编造天气。' },
{ role: 'user', content: buildWeatherAdvicePrompt(payload) }
]
})
reviewedByAi = !!adviceText
} catch {}
}
} }
} }
+163
View File
@@ -111,6 +111,109 @@ const formatRow = (row) => {
} }
} }
// ===== 登录鉴权 =====
const AUTH_COOKIE = 'private_clipboard_auth'
const AUTH_MAX_AGE_MS = 24 * 3600 * 1000 // 1天
const AUTH_HMAC_SECRET = process.env.PRIVATE_CLIPBOARD_AUTH_SECRET || 'pc-auth-secret-change-me'
const signAuthPayload = (username) => {
const payload = { sub: username, iat: Date.now() }
const payloadStr = Buffer.from(JSON.stringify(payload)).toString('base64url')
const sig = crypto.createHmac('sha256', AUTH_HMAC_SECRET).update(payloadStr).digest('base64url')
return `${payloadStr}.${sig}`
}
const verifyAuthPayload = (token) => {
if (!token || typeof token !== 'string') return null
const [payloadStr, sig] = token.split('.')
if (!payloadStr || !sig) return null
const expected = crypto.createHmac('sha256', AUTH_HMAC_SECRET).update(payloadStr).digest('base64url')
if (expected.length !== sig.length) return null
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return null
try {
const payload = JSON.parse(Buffer.from(payloadStr, 'base64url').toString())
if (!payload || typeof payload !== 'object') return null
return payload
} catch { return null }
}
const parseCookies = (cookieHeader) => {
const cookies = {}
if (!cookieHeader) return cookies
cookieHeader.split(';').forEach(part => {
const idx = part.indexOf('=')
if (idx < 0) return
const key = part.slice(0, idx).trim()
const val = decodeURIComponent(part.slice(idx + 1).trim())
if (key) cookies[key] = val
})
return cookies
}
const hasAuth = (req) => {
const cookies = parseCookies(req.headers.cookie || '')
const token = cookies[AUTH_COOKIE]
if (!token) return false
const payload = verifyAuthPayload(token)
return !!payload
}
const mustAuth = (req, res, next) => {
if (hasAuth(req)) return next()
return res.status(401).json({ ok: false, error: 'unauthorized' })
}
const readLocalCreds = () => {
try {
const os = require('os')
const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir()
const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')]
const usersRoot = path.join(path.parse(userProfile).root, 'Users')
try {
if (fs.existsSync(usersRoot)) {
fs.readdirSync(usersRoot).forEach(name => {
const dir = path.join(usersRoot, name)
if (dir !== userProfile && fs.existsSync(dir)) {
candidates.push(path.join(dir, 'Toolbox_local_creds.env.local'))
}
})
}
} catch {}
let content = null
for (const p of candidates) {
if (fs.existsSync(p)) { content = fs.readFileSync(p, 'utf-8'); break }
}
if (!content) return {}
const creds = {}
content.split(/\r?\n/).forEach(line => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) return
const idx = trimmed.indexOf('=')
if (idx > 0) creds[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim()
})
return creds
} catch { return {} }
}
const getExpectedUsername = () => {
const fromEnv = String(process.env.PRIVATE_CLIPBOARD_USERNAME || '').trim()
if (fromEnv) return fromEnv
const creds = readLocalCreds()
return String(creds.PRIVATE_CLIPBOARD_USERNAME || '').trim()
}
const getExpectedPassword = () => {
const fromEnv = String(process.env.PRIVATE_CLIPBOARD_PASSWORD || '').trim()
if (fromEnv) return fromEnv
const creds = readLocalCreds()
return String(creds.PRIVATE_CLIPBOARD_PASSWORD || '').trim()
}
const getExpectedToken = () => {
const fromEnv = String(process.env.TOOLBOX_LOCAL_CREDS_TOKEN || '').trim()
if (fromEnv) return fromEnv
const creds = readLocalCreds()
return String(creds.TOOLBOX_LOCAL_CREDS_TOKEN || '').trim()
}
const createRouter = () => { const createRouter = () => {
const router = express.Router() const router = express.Router()
@@ -124,6 +227,66 @@ const createRouter = () => {
next() next()
}) })
// ===== Auth 路由(白名单,不需要登录)=====
router.post('/auth/login', (req, res) => {
try {
const username = String((req.body && req.body.username) || '').trim()
const password = String((req.body && req.body.password) || '')
const expectedUser = getExpectedUsername()
const expectedPass = getExpectedPassword()
if (!expectedUser || !expectedPass) {
return res.status(500).json({ ok: false, error: 'server_not_configured' })
}
if (username !== expectedUser || password !== expectedPass) {
return res.status(401).json({ ok: false, error: 'invalid_credentials' })
}
const token = signAuthPayload(username)
res.cookie(AUTH_COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
maxAge: AUTH_MAX_AGE_MS,
path: '/'
})
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
router.post('/auth/logout', (req, res) => {
res.clearCookie(AUTH_COOKIE, { path: '/' })
res.json({ ok: true })
})
router.get('/auth/me', (req, res) => {
if (hasAuth(req)) {
return res.json({ ok: true, authorized: true })
}
res.json({ ok: false, authorized: false })
})
router.get('/auth/local_creds', (req, res) => {
try {
const token = String((req.query && req.query.token) || '').trim()
const expectedToken = getExpectedToken()
if (!expectedToken || token !== expectedToken) {
return res.status(401).json({ ok: false, error: 'invalid_token' })
}
const creds = readLocalCreds()
const username = String(creds.PRIVATE_CLIPBOARD_USERNAME || '').trim()
const password = String(creds.PRIVATE_CLIPBOARD_PASSWORD || '').trim()
if (!username || !password) {
return res.status(500).json({ ok: false, error: 'no_local_creds' })
}
res.json({ ok: true, creds: { username, password } })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// ===== 以下业务路由需要登录 =====
router.use(mustAuth)
router.get('/list', (req, res) => { router.get('/list', (req, res) => {
try { try {
const rows = getDb().prepare(` const rows = getDb().prepare(`