Compare commits
6
Commits
58879024a2
...
dev-0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5622e4b3d4 | ||
|
|
afbb9bc603 | ||
|
|
82c4ae8a84 | ||
|
|
c5fc328f2b | ||
|
|
77e057c580 | ||
|
|
75cabe8339 |
@@ -2,25 +2,21 @@
|
||||
|
||||
跨平台个人看板 (Tauri + React + Express + MariaDB)。用于收集和整理经济数据,但是以策略为主体重新组织。
|
||||
|
||||
- 首页主要是放经济文章
|
||||
- 数据主要是放各种经济数据和图表
|
||||
- 策略是放按照经济理论组织的相关数据和图表的对比
|
||||
- 宏观:新闻 / 经济文章(本地),以及外接 ifinance 模块的分类(经济数据 / 策略研究 / 看板,运行时动态挂载为 tab)
|
||||
- 数据与策略由外接 ifinance 模块承担(静态包,见 `../ifinance/readme.md`)
|
||||
- 报警并入「监控」;油价模块已下线
|
||||
|
||||
## 首页
|
||||
## 宏观
|
||||
|
||||
首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。
|
||||
宏观页固定「新闻 / 经济文章」两个本地 tab,并动态挂载外接 ifinance `info.json` 的 `categories[]` 作为额外 tab:
|
||||
|
||||
### 新闻模块
|
||||
|
||||
增加一个新闻模块,数据来源是每日夜间 3 点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序 10 条。存入数据库。
|
||||
|
||||
### 报警模块
|
||||
|
||||
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
|
||||
- 新闻:每日夜间 3 点通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序 10 条,存入数据库;列表按时间倒序无限滚动。
|
||||
- 经济文章:由 `content/articles/*.md` 承载的长文。
|
||||
- 经济数据 / 策略研究 / …:交给外接 ifinance 模块,宿主只读取 `info.json` 并用 iframe 打开条目页(纯前端直连,见 `ifinance/readme.md` §9)。iBoard 本地不再保存指标 / 策略数据。
|
||||
|
||||
## 监控
|
||||
|
||||
监控分为服务器监控和服务监控两部分。服务器监控对接 komari,地址:<https://komari.honor3.com/>
|
||||
监控分为主机监控 / 服务监控 / 环境监控 / 报警四个 tab。主机监控对接 komari:<https://komari.honor3.com/>;报警 tab 展示跨源数据差异与定时任务异常,按时间倒序。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
| --- | --- |
|
||||
| 任务 `⋯` 菜单 | 新增「归档」(`Dialog.confirm`,含子任务时提示连带条数);成功后列表/看板乐观移除 |
|
||||
| 项目管理 | 「归档设置」分区:`Selector` 四档(3 天后/一周后/一月后/永不),保存即生效 |
|
||||
| 项目视图头部 | 项目栏新增「已归档」入口 → 全屏弹层:过滤条(关键词/类型/列/归档时段)+ 分页「加载更多」+ 行内「恢复 / 删除」 |
|
||||
| 项目视图头部 | 项目栏新增「已归档」入口 → 全屏弹层:过滤条(**关键词 / 看板列 / 归档时段**;时段含「本周 / 本月 / 本季度」快捷项与自定义区间;条件编辑后点「筛选」统一提交,另有「重置」;**不提供类型过滤**)+ 已应用条件标签(可单独撤销)与命中条数 + 分页「加载更多」+ 行内「恢复 / 删除」 |
|
||||
| 主视图 | 列表/看板无需感知归档(数据源已排除);恢复后经 `load()` 回到原位 |
|
||||
|
||||
### 15.5 边界
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>已归档任务管理 · 界面重设计(原型待评审)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f0f2f5; --page:#fff; --card:#fff;
|
||||
--text:#333; --muted:#666; --weak:#999; --line:#eee;
|
||||
--primary:#1677ff; --danger:#ff3141; --success:#00b578;
|
||||
--nav-h:45px; --tab-h:72px;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{
|
||||
background:var(--bg); color:#1f2328; font-size:14px; line-height:1.6;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',Roboto,sans-serif;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.wrap{max-width:1400px;margin:0 auto;padding:36px 24px 72px}
|
||||
.doc-head{border-bottom:1px solid #e3e6ea;padding-bottom:20px;margin-bottom:28px}
|
||||
.doc-head h1{font-size:26px;font-weight:700;letter-spacing:.5px}
|
||||
.doc-head p{color:var(--muted);font-size:13px;margin-top:6px}
|
||||
.meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
|
||||
.meta span{font-size:12px;background:#fff;border:1px solid #e3e6ea;border-radius:999px;padding:3px 12px;color:var(--muted)}
|
||||
h2{font-size:19px;font-weight:700;margin:40px 0 6px;display:flex;align-items:center;gap:10px}
|
||||
h2::before{content:"";width:4px;height:17px;background:var(--primary);border-radius:2px}
|
||||
h2 .sub{font-size:12px;font-weight:400;color:var(--weak)}
|
||||
p.desc{color:var(--muted);font-size:13px;margin-bottom:16px}
|
||||
|
||||
/* ---------- 改动要点 ---------- */
|
||||
.changes{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}
|
||||
.chg{background:#fff;border:1px solid #e6e8eb;border-radius:12px;padding:14px 16px}
|
||||
.chg .k{font-size:11px;color:var(--primary);font-weight:700;letter-spacing:.6px}
|
||||
.chg h4{font-size:14px;font-weight:700;margin:5px 0 6px}
|
||||
.chg p{font-size:12.5px;color:var(--muted);line-height:1.65}
|
||||
.chg .old{color:var(--danger);font-size:12px;text-decoration:line-through}
|
||||
|
||||
/* ---------- 手机框 ---------- */
|
||||
.stage{display:flex;gap:34px;flex-wrap:wrap;align-items:flex-start;margin-top:18px}
|
||||
.phone{
|
||||
width:400px;height:812px;background:var(--page);position:relative;overflow:hidden;
|
||||
border:1px solid #d8dde3;border-radius:22px;box-shadow:0 10px 24px rgba(15,23,42,.08);
|
||||
display:flex;flex-direction:column;
|
||||
}
|
||||
.phone::after{content:"";position:absolute;top:8px;left:50%;transform:translateX(-50%);width:112px;height:19px;background:#111827;border-radius:12px;opacity:.9;z-index:5}
|
||||
.phone.sm{width:340px;height:700px}
|
||||
.phone .nav{
|
||||
height:var(--nav-h);margin-top:26px;flex-shrink:0;display:flex;align-items:center;gap:10px;
|
||||
padding:0 14px;background:#fff;border-bottom:1px solid var(--line);
|
||||
}
|
||||
.phone .nav .back{font-size:20px;color:#666;line-height:1;cursor:pointer}
|
||||
.phone .nav .title{font-size:16px;font-weight:700}
|
||||
.phone .nav .proj{margin-left:auto;font-size:11px;color:var(--weak);background:#f5f6f8;border-radius:999px;padding:2px 9px;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.phone .body{flex:1;min-height:0;overflow-y:auto;background:#f5f5f5;padding-bottom:18px}
|
||||
|
||||
/* ---------- 筛选卡 ---------- */
|
||||
.fcard{background:#fff;margin:10px 12px 0;border-radius:12px;padding:4px 14px 12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||
.frow{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid #f4f5f7}
|
||||
.frow:last-of-type{border-bottom:0}
|
||||
.frow > .lbl{width:56px;flex-shrink:0;font-size:12.5px;font-weight:600;color:var(--text)}
|
||||
.frow > .ctl{flex:1;min-width:0}
|
||||
/* 标题输入框前的提示 */
|
||||
.kwbox{display:flex;align-items:center;gap:7px;background:#f7f8fa;border:1px solid #eceef1;border-radius:8px;padding:6px 9px}
|
||||
.kwbox:focus-within{border-color:var(--primary);background:#fff}
|
||||
.kwbox .ph{font-size:11.5px;color:var(--weak);flex-shrink:0}
|
||||
.kwbox input{flex:1;min-width:0;border:0;background:transparent;font-size:13px;outline:none;color:var(--text)}
|
||||
.kwbox input::placeholder{color:#c2c6cc}
|
||||
.kwbox .clr{font-size:12px;color:#c2c6cc;cursor:pointer;flex-shrink:0}
|
||||
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.chip{
|
||||
font-size:12px;padding:4px 11px;border-radius:999px;border:1px solid #e6e8eb;
|
||||
background:#fafbfc;color:var(--muted);cursor:pointer;white-space:nowrap;transition:.15s;
|
||||
}
|
||||
.chip:hover{border-color:#c9d3e0}
|
||||
.chip.on{background:#eaf3ff;border-color:var(--primary);color:var(--primary);font-weight:600}
|
||||
.chip .x{margin-left:5px;color:#9aa4b2}
|
||||
|
||||
.range{display:flex;align-items:center;gap:7px;margin-top:8px}
|
||||
.range .dp{font-size:12px;padding:4px 10px;border:1px dashed #d9dee5;border-radius:7px;color:var(--weak);cursor:pointer;background:#fff}
|
||||
.range .dp.on{color:var(--text);border-style:solid;border-color:#d9dee5}
|
||||
.range .sep{color:#c2c6cc;font-size:12px}
|
||||
|
||||
.factions{display:flex;gap:9px;padding-top:12px}
|
||||
.btn{flex:1;height:36px;border-radius:9px;border:0;font-size:13.5px;font-weight:600;cursor:pointer;transition:.15s}
|
||||
.btn.ghost{background:#f4f5f7;color:var(--muted);flex:0 0 84px}
|
||||
.btn.primary{background:var(--primary);color:#fff;box-shadow:0 2px 8px rgba(22,119,255,.26)}
|
||||
.btn.primary:active{transform:translateY(1px)}
|
||||
.btn .badge{display:inline-block;min-width:16px;height:16px;line-height:16px;border-radius:8px;background:rgba(255,255,255,.28);font-size:10.5px;margin-left:5px;padding:0 4px}
|
||||
|
||||
/* ---------- 已应用条件 ---------- */
|
||||
.applied{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:11px 14px 3px}
|
||||
.applied .tag{font-size:11.5px;background:#eaf3ff;color:var(--primary);border-radius:6px;padding:2px 7px;display:flex;align-items:center;gap:5px}
|
||||
.applied .tag b{font-weight:600}
|
||||
.applied .tag .x{cursor:pointer;opacity:.6}
|
||||
.applied .cnt{margin-left:auto;font-size:11.5px;color:var(--weak)}
|
||||
|
||||
/* ---------- 列表 ---------- */
|
||||
.list{padding:8px 12px 0;display:flex;flex-direction:column;gap:8px}
|
||||
.item{background:#fff;border-radius:10px;padding:11px 12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||
.item .top{display:flex;gap:8px;align-items:flex-start}
|
||||
.item .ttl{flex:1;min-width:0;font-size:13.5px;font-weight:600;line-height:1.42;word-break:break-word}
|
||||
.item .num{font-size:10px;color:#9aa4b2;background:#f5f6f8;border-radius:4px;padding:1px 5px;flex-shrink:0;margin-top:1px}
|
||||
.item .meta{display:flex;gap:7px;align-items:center;flex-wrap:wrap;margin-top:7px;font-size:10.5px;color:var(--weak)}
|
||||
.item .meta .col{color:var(--muted)}
|
||||
.item .meta .dot{width:3px;height:3px;border-radius:50%;background:#d5d9e0}
|
||||
.item .acts{display:flex;gap:7px;justify-content:flex-end;margin-top:9px;padding-top:9px;border-top:1px dashed #f0f1f3}
|
||||
.mini{font-size:11.5px;padding:3px 12px;border-radius:999px;border:1px solid var(--primary);color:var(--primary);background:#fff;cursor:pointer;white-space:nowrap}
|
||||
.mini.danger{border-color:#ffd4d7;color:var(--danger)}
|
||||
.kind{font-size:10px;line-height:1;font-weight:600;color:#fff;border-radius:4px;padding:3px 5px}
|
||||
|
||||
.empty{text-align:center;padding:56px 20px;color:#b8bcc4;font-size:13px}
|
||||
.empty .ico{font-size:34px;opacity:.35;margin-bottom:10px}
|
||||
.more{display:block;width:calc(100% - 24px);margin:12px auto 0;height:34px;border-radius:9px;border:1px solid #e6e8eb;background:#fff;color:var(--muted);font-size:12.5px;cursor:pointer}
|
||||
|
||||
.toast{
|
||||
position:absolute;left:50%;bottom:56px;transform:translateX(-50%) translateY(8px);
|
||||
background:rgba(0,0,0,.78);color:#fff;font-size:12.5px;padding:8px 16px;border-radius:8px;
|
||||
opacity:0;transition:.22s;z-index:9;pointer-events:none;white-space:nowrap;
|
||||
}
|
||||
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
|
||||
.caption{margin-top:12px;background:#fff;border:1px solid #e6e8eb;border-radius:10px;padding:11px 14px;width:400px}
|
||||
.caption b{font-size:13px;display:block;margin-bottom:4px}
|
||||
.caption span{font-size:12px;color:var(--muted);line-height:1.65}
|
||||
|
||||
.notes{flex:1;min-width:300px;display:flex;flex-direction:column;gap:12px}
|
||||
.note{background:#fff;border:1px solid #e6e8eb;border-radius:12px;padding:14px 16px}
|
||||
.note h4{font-size:13px;font-weight:700;margin-bottom:7px;display:flex;align-items:center;gap:7px}
|
||||
.note h4 i{width:18px;height:18px;border-radius:50%;background:#eaf3ff;color:var(--primary);font-size:11px;font-style:normal;display:flex;align-items:center;justify-content:center;font-weight:700}
|
||||
.note ul{list-style:none;font-size:12.5px;color:var(--muted);line-height:1.85}
|
||||
.note ul li::before{content:"·";color:#c2c6cc;margin-right:6px}
|
||||
.note code{background:#f5f6f8;border-radius:4px;padding:1px 5px;font-size:11.5px;color:#c7254e}
|
||||
|
||||
table{width:100%;border-collapse:collapse;background:#fff;border-radius:12px;overflow:hidden;border:1px solid #e6e8eb;font-size:12.5px}
|
||||
th,td{padding:10px 14px;text-align:left;border-bottom:1px solid #f0f1f3;vertical-align:top}
|
||||
th{background:#fafbfc;font-size:12px;color:var(--muted);font-weight:600}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td .no{color:var(--danger)}
|
||||
td .yes{color:var(--success);font-weight:600}
|
||||
.variants{display:flex;gap:26px;flex-wrap:wrap;margin-top:16px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<div class="doc-head">
|
||||
<h1>已归档任务管理 · 界面重设计</h1>
|
||||
<p>对应需求 T-ARC-04/05/06。本文件是<strong>评审用原型</strong>,不含业务代码改动;确认后再落地。</p>
|
||||
<div class="meta">
|
||||
<span>原型 · 待评审</span>
|
||||
<span>2026-10-28</span>
|
||||
<span>可交互:点预设 / 筛选 / 恢复</span>
|
||||
<span>范围:任务模块 › 项目 › 已归档</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>改动要点 <span class="sub">对照你提出的 4 点</span></h2>
|
||||
<div class="changes">
|
||||
<div class="chg">
|
||||
<div class="k">01</div>
|
||||
<h4>去掉「类型」过滤</h4>
|
||||
<p>现版本的「全部类型」下拉整体移除。任务类型是弱维度,归档场景几乎不按类型找东西,反而占掉一横行高。</p>
|
||||
<p class="old">全部类型 ▾</p>
|
||||
</div>
|
||||
<div class="chg">
|
||||
<div class="k">02</div>
|
||||
<h4>输入框前加提示</h4>
|
||||
<p>关键词输入框前加固定标签「标题」,输入框内用灰字占位「输入关键词」——标签常驻,占位随输入消失,不会出现"不知道搜什么"。</p>
|
||||
<p class="old">搜索任务标题(无标签)</p>
|
||||
</div>
|
||||
<div class="chg">
|
||||
<div class="k">03</div>
|
||||
<h4>归档时间快捷选择</h4>
|
||||
<p>新增 <b>全部 / 本周 / 本月 / 本季度</b> 四个一键预设,选完自动算出起止日期并显示;需要精确区间时再选「自定义」。</p>
|
||||
<p class="old">开始日期 ~ 结束日期(纯手点)</p>
|
||||
</div>
|
||||
<div class="chg">
|
||||
<div class="k">04</div>
|
||||
<h4>补上「筛选」按钮</h4>
|
||||
<p>现版本改任何条件都立即查询、没有确认动作。改为:改条件 → 点<b>筛选</b>才生效,按钮上显示生效条件数;另给「重置」。</p>
|
||||
<p class="old">无按钮(自动即时查询)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>主界面 <span class="sub">可交互 · 点一下试试</span></h2>
|
||||
<div class="stage">
|
||||
|
||||
<div>
|
||||
<div class="phone" id="phone">
|
||||
<div class="nav">
|
||||
<span class="back">‹</span>
|
||||
<span class="title">已归档任务</span>
|
||||
<span class="proj">Admin的任务</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<!-- 筛选卡 -->
|
||||
<div class="fcard">
|
||||
<div class="frow">
|
||||
<span class="lbl">标题</span>
|
||||
<span class="ctl">
|
||||
<span class="kwbox">
|
||||
<input id="kw" placeholder="输入关键词" />
|
||||
<span class="clr" id="kwClear" style="display:none">✕</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="frow">
|
||||
<span class="lbl">看板列</span>
|
||||
<span class="ctl">
|
||||
<span class="chips" id="colChips">
|
||||
<span class="chip on" data-col="">全部</span>
|
||||
<span class="chip" data-col="Plan">Plan</span>
|
||||
<span class="chip" data-col="Doing">Doing</span>
|
||||
<span class="chip" data-col="Done">Done</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="frow">
|
||||
<span class="lbl">归档时间</span>
|
||||
<span class="ctl">
|
||||
<span class="chips" id="timeChips">
|
||||
<span class="chip on" data-t="all">全部</span>
|
||||
<span class="chip" data-t="week">本周</span>
|
||||
<span class="chip" data-t="month">本月</span>
|
||||
<span class="chip" data-t="quarter">本季度</span>
|
||||
<span class="chip" data-t="custom">自定义</span>
|
||||
</span>
|
||||
<span class="range" id="rangeRow" style="display:none">
|
||||
<span class="dp" id="dpFrom">开始日期</span>
|
||||
<span class="sep">~</span>
|
||||
<span class="dp" id="dpTo">结束日期</span>
|
||||
</span>
|
||||
<div id="rangeHint" style="font-size:11px;color:#9aa4b2;margin-top:6px"></div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="factions">
|
||||
<button class="btn ghost" id="resetBtn">重置</button>
|
||||
<button class="btn primary" id="filterBtn">筛选</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已应用条件 -->
|
||||
<div class="applied" id="appliedBar" style="display:none"></div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="list" id="list"></div>
|
||||
<button class="more" id="moreBtn">加载更多</button>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
</div>
|
||||
<div class="caption">
|
||||
<b>主界面</b>
|
||||
<span>筛选卡固定三行:标题 / 看板列 / 归档时间,外加操作行。改动不再即时生效,点「筛选」统一提交。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note">
|
||||
<h4><i>1</i>为什么改成"点按钮才筛选"</h4>
|
||||
<ul>
|
||||
<li>现在每改一个条件就重查一次,连点三个条件就发三次请求</li>
|
||||
<li>输入关键词时每敲一个字都可能触发(失焦也查),列表会闪</li>
|
||||
<li>改成显式提交后,用户能先把条件填完整,再一次性看结果</li>
|
||||
<li>按钮上带数字徽标,一眼知道当前生效了几个条件</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="note">
|
||||
<h4><i>2</i>快捷时间怎么算</h4>
|
||||
<ul>
|
||||
<li><b>本周</b>:周一 00:00 ~ 今天(不跨到未来)</li>
|
||||
<li><b>本月</b>:1 号 00:00 ~ 今天</li>
|
||||
<li><b>本季度</b>:当季首月 1 号 00:00 ~ 今天</li>
|
||||
<li>选完在下方灰字直接显示实际区间,避免"以为选的是整周"</li>
|
||||
<li>「自定义」才展开两个日期选择器,默认收起</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="note">
|
||||
<h4><i>3</i>已应用条件可单独撤销</h4>
|
||||
<ul>
|
||||
<li>筛选后在列表上方显示条件标签,每个带 <code>×</code> 可单独去掉</li>
|
||||
<li>右侧显示命中条数,避免"筛完不知道有没有筛到"</li>
|
||||
<li>无结果时给明确空状态,而不是一片留白</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="note">
|
||||
<h4><i>4</i>列表行重排</h4>
|
||||
<ul>
|
||||
<li>标题独占一行(原来和类型/列/时间挤在下面)</li>
|
||||
<li>元信息收成一行:<code>#编号</code> · 看板列 · 归档于 日期</li>
|
||||
<li>「恢复 / 删除」从右侧竖排改到底部右侧横排,减少每行高度</li>
|
||||
<li>类型标签保留展示(只是不再作为筛选项)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>状态变体</h2>
|
||||
<div class="variants">
|
||||
<div>
|
||||
<div class="phone sm">
|
||||
<div class="nav"><span class="back">‹</span><span class="title">已归档任务</span></div>
|
||||
<div class="body">
|
||||
<div class="fcard">
|
||||
<div class="frow"><span class="lbl">标题</span><span class="ctl"><span class="kwbox"><input placeholder="输入关键词" /></span></span></div>
|
||||
<div class="frow"><span class="lbl">看板列</span><span class="ctl"><span class="chips"><span class="chip on">全部</span><span class="chip">Plan</span><span class="chip">Done</span></span></span></div>
|
||||
<div class="frow"><span class="lbl">归档时间</span><span class="ctl">
|
||||
<span class="chips"><span class="chip">全部</span><span class="chip on">本周</span><span class="chip">本月</span><span class="chip">本季度</span><span class="chip">自定义</span></span>
|
||||
<div style="font-size:11px;color:#9aa4b2;margin-top:6px">2026-10-26 ~ 2026-10-28</div>
|
||||
</span></div>
|
||||
<div class="factions"><button class="btn ghost">重置</button><button class="btn primary">筛选<span class="badge">2</span></button></div>
|
||||
</div>
|
||||
<div class="applied">
|
||||
<span class="tag"><b>本周</b><span class="x">×</span></span>
|
||||
<span class="tag"><b>Done</b><span class="x">×</span></span>
|
||||
<span class="cnt">共 3 条</span>
|
||||
</div>
|
||||
<div class="list">
|
||||
<div class="item">
|
||||
<div class="top"><div class="ttl">任务详情页滚动优化</div><span class="num">#22</span></div>
|
||||
<div class="meta"><span class="kind" style="background:#1677ff">优化</span><span class="col">Done</span><span class="dot"></span><span>归档于 10-27</span></div>
|
||||
<div class="acts"><span class="mini">恢复</span><span class="mini danger">删除</span></div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="top"><div class="ttl">桌面端。</div><span class="num">#5</span></div>
|
||||
<div class="meta"><span class="kind" style="background:#fa8c16">需求</span><span class="col">Done</span><span class="dot"></span><span>归档于 10-26</span></div>
|
||||
<div class="acts"><span class="mini">恢复</span><span class="mini danger">删除</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="caption"><b>已应用条件 + 命中条数</b><span>条件可逐个撤销;时间预设自动显示实际区间。</span></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="phone sm">
|
||||
<div class="nav"><span class="back">‹</span><span class="title">已归档任务</span></div>
|
||||
<div class="body">
|
||||
<div class="fcard">
|
||||
<div class="frow"><span class="lbl">标题</span><span class="ctl"><span class="kwbox"><input value="日报" /><span class="clr">✕</span></span></span></div>
|
||||
<div class="frow"><span class="lbl">看板列</span><span class="ctl"><span class="chips"><span class="chip on">全部</span><span class="chip">Done</span></span></span></div>
|
||||
<div class="frow"><span class="lbl">归档时间</span><span class="ctl">
|
||||
<span class="chips"><span class="chip">全部</span><span class="chip">本周</span><span class="chip">本月</span><span class="chip">本季度</span><span class="chip on">自定义</span></span>
|
||||
<span class="range"><span class="dp on">2026-09-01</span><span class="sep">~</span><span class="dp on">2026-09-30</span></span>
|
||||
</span></div>
|
||||
<div class="factions"><button class="btn ghost">重置</button><button class="btn primary">筛选<span class="badge">3</span></button></div>
|
||||
</div>
|
||||
<div class="applied">
|
||||
<span class="tag"><b>标题: 日报</b><span class="x">×</span></span>
|
||||
<span class="tag"><b>09-01~09-30</b><span class="x">×</span></span>
|
||||
<span class="cnt">共 0 条</span>
|
||||
</div>
|
||||
<div class="empty"><div class="ico">🗂</div>没有符合条件的归档任务<br /><span style="font-size:11.5px;color:#c2c6cc">试试放宽时间范围或清空关键词</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="caption"><b>自定义区间 + 空结果</b><span>选「自定义」才展开日期;空结果给可操作的建议文案。</span></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="phone sm">
|
||||
<div class="nav"><span class="back">‹</span><span class="title">已归档任务</span></div>
|
||||
<div class="body">
|
||||
<div class="fcard">
|
||||
<div class="frow"><span class="lbl">标题</span><span class="ctl"><span class="kwbox"><input placeholder="输入关键词" /></span></span></div>
|
||||
<div class="frow"><span class="lbl">看板列</span><span class="ctl"><span class="chips"><span class="chip on">全部</span><span class="chip">Plan</span><span class="chip">Doing</span><span class="chip">Done</span></span></span></div>
|
||||
<div class="frow"><span class="lbl">归档时间</span><span class="ctl"><span class="chips"><span class="chip on">全部</span><span class="chip">本周</span><span class="chip">本月</span><span class="chip">本季度</span><span class="chip">自定义</span></span></span></div>
|
||||
<div class="factions"><button class="btn ghost">重置</button><button class="btn primary">筛选</button></div>
|
||||
</div>
|
||||
<div class="empty"><div class="ico">📭</div>这个项目还没有归档任务<br /><span style="font-size:11.5px;color:#c2c6cc">任务完成后可在 ⋯ 菜单里归档</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="caption"><b>初始空状态</b><span>一个都没归档时,告诉用户归档入口在哪。</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>与现版本对照</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th style="width:130px">项</th><th style="width:38%">现在</th><th>建议</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>类型过滤</td><td class="no">「全部类型」下拉 + 独占一行</td><td class="yes">移除</td></tr>
|
||||
<tr><td>关键词</td><td class="no">无标签,占位「搜索任务标题」</td><td class="yes">左侧常驻标签「标题」+ 占位「输入关键词」</td></tr>
|
||||
<tr><td>归档时间</td><td class="no">两个日期按钮,无快捷项</td><td class="yes">全部 / 本周 / 本月 / 本季度 / 自定义</td></tr>
|
||||
<tr><td>查询触发</td><td class="no">改条件即时查询,无按钮</td><td class="yes">「重置 / 筛选」,按钮带生效条件数</td></tr>
|
||||
<tr><td>条件可见性</td><td class="no">条件散在控件里,看不出生效了什么</td><td class="yes">列表上方条件标签 + 命中条数,可单独撤销</td></tr>
|
||||
<tr><td>列表行</td><td class="no">标题与元信息挤在一起,右侧竖排两按钮</td><td class="yes">标题独占行;元信息一行;按钮移到底部右侧</td></tr>
|
||||
<tr><td>空状态</td><td class="no">一行灰字「没有符合条件的归档任务」</td><td class="yes">区分「还没归档过」与「筛选无结果」,给出下一步建议</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>待你确认 <span class="sub">确认后我再改代码</span></h2>
|
||||
<div class="notes" style="max-width:820px">
|
||||
<div class="note">
|
||||
<ul>
|
||||
<li>「筛选」改为显式提交,是否符合你的使用习惯?(也可以保留"改预设即时生效、只有关键词需要点按钮")</li>
|
||||
<li>看板列是否也保留?还是只留 标题 + 归档时间 两个条件更清爽?</li>
|
||||
<li>列表行的「恢复 / 删除」放底部右侧可以吗?还是希望点行进入详情/预览?</li>
|
||||
<li>快捷时间是否需要再加「近 7 天 / 近 30 天」?</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
// ---- 原型状态 ----
|
||||
const state = { kw:'', col:'', time:'all', from:'', to:'', applied:{ kw:'', col:'', time:'all', from:'', to:'' } }
|
||||
|
||||
const MOCK = [
|
||||
{ id:1, title:'任务详情页滚动优化', num:'#22', kind:'优化', kindColor:'#1677ff', col:'Done', at:'2026-10-27' },
|
||||
{ id:2, title:'桌面端。', num:'#5', kind:'需求', kindColor:'#fa8c16', col:'Done', at:'2026-10-26' },
|
||||
{ id:3, title:'任务详情中切换看板列是提示不存在。', num:'#23', kind:'Bug', kindColor:'#ff3141', col:'Done', at:'2026-10-25' },
|
||||
{ id:4, title:'windows 电脑加入监控', num:'#24', kind:'需求', kindColor:'#fa8c16', col:'Plan', at:'2026-10-21' },
|
||||
{ id:5, title:'补日报', num:'#28', kind:'需求', kindColor:'#fa8c16', col:'Doing', at:'2026-10-12' },
|
||||
]
|
||||
let rows = MOCK.slice()
|
||||
|
||||
const $ = (id) => document.getElementById(id)
|
||||
const toastEl = $('toast')
|
||||
let toastTimer = null
|
||||
function toast(msg){
|
||||
toastEl.textContent = msg
|
||||
toastEl.classList.add('show')
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1600)
|
||||
}
|
||||
|
||||
// ---- 快捷时间区间 ----
|
||||
function pad(n){ return String(n).padStart(2,'0') }
|
||||
function fmt(d){ return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}` }
|
||||
function presetRange(t){
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
if (t === 'week') {
|
||||
const dow = (today.getDay() + 6) % 7 // 周一=0
|
||||
const mon = new Date(today); mon.setDate(today.getDate() - dow)
|
||||
return { from: fmt(mon), to: fmt(today) }
|
||||
}
|
||||
if (t === 'month') return { from: fmt(new Date(today.getFullYear(), today.getMonth(), 1)), to: fmt(today) }
|
||||
if (t === 'quarter') {
|
||||
const qm = Math.floor(today.getMonth() / 3) * 3
|
||||
return { from: fmt(new Date(today.getFullYear(), qm, 1)), to: fmt(today) }
|
||||
}
|
||||
return { from:'', to:'' }
|
||||
}
|
||||
function rangeLabel(t){
|
||||
if (t === 'custom') return ''
|
||||
const r = presetRange(t)
|
||||
return r.from ? `${r.from} ~ ${r.to}` : ''
|
||||
}
|
||||
|
||||
// ---- 渲染 ----
|
||||
function render(){
|
||||
// 时间行
|
||||
const custom = state.time === 'custom'
|
||||
$('rangeRow').style.display = custom ? 'flex' : 'none'
|
||||
const hint = rangeLabel(state.time)
|
||||
$('rangeHint').textContent = hint
|
||||
$('dpFrom').textContent = state.from || '开始日期'
|
||||
$('dpTo').textContent = state.to || '结束日期'
|
||||
$('dpFrom').classList.toggle('on', !!state.from)
|
||||
$('dpTo').classList.toggle('on', !!state.to)
|
||||
$('kwClear').style.display = state.kw ? 'block' : 'none'
|
||||
|
||||
// 生效条件数
|
||||
const n = countApplied()
|
||||
const btn = $('filterBtn')
|
||||
btn.innerHTML = n ? `筛选<span class="badge">${n}</span>` : '筛选'
|
||||
|
||||
// 已应用条
|
||||
const bar = $('appliedBar')
|
||||
const tags = []
|
||||
const a = state.applied
|
||||
if (a.kw) tags.push(`<span class="tag"><b>标题: ${a.kw}</b><span class="x" data-clear="kw">×</span></span>`)
|
||||
if (a.col) tags.push(`<span class="tag"><b>看板列: ${a.col}</b><span class="x" data-clear="col">×</span></span>`)
|
||||
if (a.time === 'custom') {
|
||||
if (a.from || a.to) tags.push(`<span class="tag"><b>${a.from || '…'} ~ ${a.to || '…'}</b><span class="x" data-clear="time">×</span></span>`)
|
||||
} else if (a.time !== 'all') {
|
||||
const name = { week:'本周', month:'本月', quarter:'本季度' }[a.time]
|
||||
tags.push(`<span class="tag"><b>${name}</b><span class="x" data-clear="time">×</span></span>`)
|
||||
}
|
||||
if (tags.length) {
|
||||
bar.style.display = 'flex'
|
||||
bar.innerHTML = tags.join('') + `<span class="cnt">共 ${rows.length} 条</span>`
|
||||
} else {
|
||||
bar.style.display = 'none'
|
||||
}
|
||||
|
||||
// 列表
|
||||
const list = $('list')
|
||||
if (!rows.length) {
|
||||
const never = !tags.length
|
||||
list.innerHTML = never
|
||||
? `<div class="empty"><div class="ico">📭</div>这个项目还没有归档任务<br /><span style="font-size:11.5px;color:#c2c6cc">任务完成后可在 ⋯ 菜单里归档</span></div>`
|
||||
: `<div class="empty"><div class="ico">🗂</div>没有符合条件的归档任务<br /><span style="font-size:11.5px;color:#c2c6cc">试试放宽时间范围或清空关键词</span></div>`
|
||||
$('moreBtn').style.display = 'none'
|
||||
} else {
|
||||
$('moreBtn').style.display = 'block'
|
||||
list.innerHTML = rows.map(r => `
|
||||
<div class="item" data-id="${r.id}">
|
||||
<div class="top"><div class="ttl">${r.title}</div><span class="num">${r.num}</span></div>
|
||||
<div class="meta">
|
||||
<span class="kind" style="background:${r.kindColor}">${r.kind}</span>
|
||||
<span class="col">${r.col}</span><span class="dot"></span><span>归档于 ${r.at}</span>
|
||||
</div>
|
||||
<div class="acts"><span class="mini" data-act="restore">恢复</span><span class="mini danger" data-act="remove">删除</span></div>
|
||||
</div>`).join('')
|
||||
}
|
||||
}
|
||||
|
||||
function countApplied(){
|
||||
const a = state.applied
|
||||
let n = 0
|
||||
if (a.kw) n++
|
||||
if (a.col) n++
|
||||
if (a.time === 'custom') { if (a.from || a.to) n++ } else if (a.time !== 'all') n++
|
||||
return n
|
||||
}
|
||||
|
||||
function applyFilter(){
|
||||
state.applied = { kw: state.kw, col: state.col, time: state.time, from: state.from, to: state.to }
|
||||
const a = state.applied
|
||||
rows = MOCK.filter(r => {
|
||||
if (a.kw && !r.title.includes(a.kw)) return false
|
||||
if (a.col && r.col !== a.col) return false
|
||||
if (a.time !== 'custom' && a.time !== 'all') {
|
||||
const rg = presetRange(a.time)
|
||||
if (rg.from && (r.at < rg.from || r.at > rg.to)) return false
|
||||
}
|
||||
if (a.time === 'custom') {
|
||||
if (a.from && r.at < a.from) return false
|
||||
if (a.to && r.at > a.to) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
render()
|
||||
toast(countApplied() ? `已应用 ${countApplied()} 个条件 · ${rows.length} 条` : `显示全部 ${rows.length} 条`)
|
||||
}
|
||||
|
||||
// ---- 事件 ----
|
||||
$('kw').addEventListener('input', (e) => { state.kw = e.target.value.trim(); $('kwClear').style.display = state.kw ? 'block' : 'none' })
|
||||
$('kw').addEventListener('keydown', (e) => { if (e.key === 'Enter') applyFilter() })
|
||||
$('kwClear').addEventListener('click', () => { state.kw = ''; $('kw').value = ''; render() })
|
||||
|
||||
document.querySelectorAll('#colChips .chip').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
document.querySelectorAll('#colChips .chip').forEach(x => x.classList.remove('on'))
|
||||
el.classList.add('on')
|
||||
state.col = el.dataset.col || ''
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelectorAll('#timeChips .chip').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
document.querySelectorAll('#timeChips .chip').forEach(x => x.classList.remove('on'))
|
||||
el.classList.add('on')
|
||||
state.time = el.dataset.t
|
||||
if (state.time !== 'custom') { state.from = ''; state.to = '' }
|
||||
render()
|
||||
})
|
||||
})
|
||||
|
||||
$('dpFrom').addEventListener('click', () => {
|
||||
const v = prompt('开始日期 (YYYY-MM-DD)', state.from || '2026-10-01')
|
||||
if (v !== null) { state.from = v.trim(); render() }
|
||||
})
|
||||
$('dpTo').addEventListener('click', () => {
|
||||
const v = prompt('结束日期 (YYYY-MM-DD)', state.to || '2026-10-28')
|
||||
if (v !== null) { state.to = v.trim(); render() }
|
||||
})
|
||||
|
||||
$('filterBtn').addEventListener('click', applyFilter)
|
||||
|
||||
$('resetBtn').addEventListener('click', () => {
|
||||
state.kw = ''; state.col = ''; state.time = 'all'; state.from = ''; state.to = ''
|
||||
state.applied = { kw:'', col:'', time:'all', from:'', to:'' }
|
||||
$('kw').value = ''
|
||||
document.querySelectorAll('#colChips .chip').forEach((x, i) => x.classList.toggle('on', i === 0))
|
||||
document.querySelectorAll('#timeChips .chip').forEach((x, i) => x.classList.toggle('on', i === 0))
|
||||
rows = MOCK.slice()
|
||||
render()
|
||||
toast('已重置')
|
||||
})
|
||||
|
||||
$('appliedBar').addEventListener('click', (e) => {
|
||||
const x = e.target.closest('.x'); if (!x) return
|
||||
const k = x.dataset.clear
|
||||
if (k === 'kw') state.applied.kw = ''
|
||||
if (k === 'col') state.applied.col = ''
|
||||
if (k === 'time') { state.applied.time = 'all'; state.applied.from = ''; state.applied.to = '' }
|
||||
const a = state.applied
|
||||
rows = MOCK.filter(r => {
|
||||
if (a.kw && !r.title.includes(a.kw)) return false
|
||||
if (a.col && r.col !== a.col) return false
|
||||
return true
|
||||
})
|
||||
render()
|
||||
toast('已移除该条件')
|
||||
})
|
||||
|
||||
$('list').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-act]'); if (!btn) return
|
||||
const item = btn.closest('.item')
|
||||
const id = Number(item.dataset.id)
|
||||
const row = rows.find(r => r.id === id)
|
||||
if (btn.dataset.act === 'restore') {
|
||||
toast(`已恢复「${row.title}」`)
|
||||
} else {
|
||||
toast(`已删除「${row.title}」(原型演示,不实际删除)`)
|
||||
}
|
||||
item.style.opacity = '.35'
|
||||
item.style.pointerEvents = 'none'
|
||||
})
|
||||
|
||||
$('moreBtn').addEventListener('click', () => toast('加载更多(原型演示)'))
|
||||
|
||||
render()
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -27,21 +27,6 @@ export type User = Prisma.UserModel
|
||||
*
|
||||
*/
|
||||
export type Article = Prisma.ArticleModel
|
||||
/**
|
||||
* Model Strategy
|
||||
*
|
||||
*/
|
||||
export type Strategy = Prisma.StrategyModel
|
||||
/**
|
||||
* Model Indicator
|
||||
*
|
||||
*/
|
||||
export type Indicator = Prisma.IndicatorModel
|
||||
/**
|
||||
* Model IndicatorPoint
|
||||
*
|
||||
*/
|
||||
export type IndicatorPoint = Prisma.IndicatorPointModel
|
||||
/**
|
||||
* Model News
|
||||
*
|
||||
@@ -77,11 +62,6 @@ export type Econ_SowInventory = Prisma.Econ_SowInventoryModel
|
||||
*
|
||||
*/
|
||||
export type ScraperCache = Prisma.ScraperCacheModel
|
||||
/**
|
||||
* Model OilPriceHistory
|
||||
*
|
||||
*/
|
||||
export type OilPriceHistory = Prisma.OilPriceHistoryModel
|
||||
/**
|
||||
* Model MihomeAccount
|
||||
*
|
||||
|
||||
@@ -51,21 +51,6 @@ export type User = Prisma.UserModel
|
||||
*
|
||||
*/
|
||||
export type Article = Prisma.ArticleModel
|
||||
/**
|
||||
* Model Strategy
|
||||
*
|
||||
*/
|
||||
export type Strategy = Prisma.StrategyModel
|
||||
/**
|
||||
* Model Indicator
|
||||
*
|
||||
*/
|
||||
export type Indicator = Prisma.IndicatorModel
|
||||
/**
|
||||
* Model IndicatorPoint
|
||||
*
|
||||
*/
|
||||
export type IndicatorPoint = Prisma.IndicatorPointModel
|
||||
/**
|
||||
* Model News
|
||||
*
|
||||
@@ -101,11 +86,6 @@ export type Econ_SowInventory = Prisma.Econ_SowInventoryModel
|
||||
*
|
||||
*/
|
||||
export type ScraperCache = Prisma.ScraperCacheModel
|
||||
/**
|
||||
* Model OilPriceHistory
|
||||
*
|
||||
*/
|
||||
export type OilPriceHistory = Prisma.OilPriceHistoryModel
|
||||
/**
|
||||
* Model MihomeAccount
|
||||
*
|
||||
|
||||
@@ -99,97 +99,6 @@ export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type JsonFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<JsonFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<JsonFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<JsonFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type JsonFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
export type JsonWithAggregatesFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<JsonWithAggregatesFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type BoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
|
||||
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type FloatFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type StringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
@@ -228,6 +137,33 @@ export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type FloatFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type BigIntFilter<$PrismaModel = never> = {
|
||||
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
|
||||
in?: bigint[] | number[] | Prisma.ListBigIntFieldRefInput<$PrismaModel>
|
||||
@@ -328,6 +264,11 @@ export type DateTimeNullableFilter<$PrismaModel = never> = {
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type BoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
|
||||
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
@@ -358,6 +299,14 @@ export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type FloatNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
@@ -385,6 +334,57 @@ export type FloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type JsonFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<JsonFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<JsonFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<JsonFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type JsonFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
export type JsonWithAggregatesFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<JsonWithAggregatesFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
@@ -479,59 +479,6 @@ export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedJsonFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<NestedJsonFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<NestedJsonFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type NestedJsonFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
export type NestedBoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
|
||||
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedStringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
@@ -574,6 +521,22 @@ export type NestedIntNullableFilter<$PrismaModel = never> = {
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedBigIntFilter<$PrismaModel = never> = {
|
||||
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
|
||||
in?: bigint[] | number[] | Prisma.ListBigIntFieldRefInput<$PrismaModel>
|
||||
@@ -636,6 +599,11 @@ export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type NestedBoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
|
||||
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
@@ -677,6 +645,14 @@ export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
@@ -693,4 +669,28 @@ export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedJsonFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<NestedJsonFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<NestedJsonFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type NestedJsonFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -386,9 +386,6 @@ type FieldRefInputType<Model, FieldType> = Model extends never ? never : FieldRe
|
||||
export const ModelName = {
|
||||
User: 'User',
|
||||
Article: 'Article',
|
||||
Strategy: 'Strategy',
|
||||
Indicator: 'Indicator',
|
||||
IndicatorPoint: 'IndicatorPoint',
|
||||
News: 'News',
|
||||
Alert: 'Alert',
|
||||
ServerNode: 'ServerNode',
|
||||
@@ -396,7 +393,6 @@ export const ModelName = {
|
||||
ServiceCheck: 'ServiceCheck',
|
||||
Econ_SowInventory: 'Econ_SowInventory',
|
||||
ScraperCache: 'ScraperCache',
|
||||
OilPriceHistory: 'OilPriceHistory',
|
||||
MihomeAccount: 'MihomeAccount',
|
||||
MihomeHome: 'MihomeHome',
|
||||
MihomeDevice: 'MihomeDevice',
|
||||
@@ -428,7 +424,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
meta: {
|
||||
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache" | "oilPriceHistory" | "mihomeAccount" | "mihomeHome" | "mihomeDevice" | "mihomeEnvSample" | "mihomeEnvHour" | "habit" | "checkIn" | "checkList" | "checkListItem" | "checkListTemplate" | "project" | "projectMember" | "boardColumn" | "task" | "taskMember" | "taskTag"
|
||||
modelProps: "user" | "article" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache" | "mihomeAccount" | "mihomeHome" | "mihomeDevice" | "mihomeEnvSample" | "mihomeEnvHour" | "habit" | "checkIn" | "checkList" | "checkListItem" | "checkListTemplate" | "project" | "projectMember" | "boardColumn" | "task" | "taskMember" | "taskTag"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
model: {
|
||||
@@ -580,228 +576,6 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
}
|
||||
}
|
||||
}
|
||||
Strategy: {
|
||||
payload: Prisma.$StrategyPayload<ExtArgs>
|
||||
fields: Prisma.StrategyFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.StrategyFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.StrategyFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.StrategyFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.StrategyFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.StrategyFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.StrategyCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.StrategyCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.StrategyCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.StrategyDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.StrategyUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.StrategyDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.StrategyUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.StrategyUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.StrategyUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.StrategyAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateStrategy>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.StrategyGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.StrategyGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.StrategyCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.StrategyCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
Indicator: {
|
||||
payload: Prisma.$IndicatorPayload<ExtArgs>
|
||||
fields: Prisma.IndicatorFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.IndicatorFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.IndicatorFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.IndicatorFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.IndicatorFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.IndicatorFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.IndicatorCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.IndicatorCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.IndicatorCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.IndicatorDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.IndicatorUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.IndicatorDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.IndicatorUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.IndicatorUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.IndicatorUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.IndicatorAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateIndicator>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.IndicatorGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.IndicatorGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.IndicatorCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.IndicatorCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
IndicatorPoint: {
|
||||
payload: Prisma.$IndicatorPointPayload<ExtArgs>
|
||||
fields: Prisma.IndicatorPointFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.IndicatorPointFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.IndicatorPointFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.IndicatorPointFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.IndicatorPointFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.IndicatorPointFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.IndicatorPointCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.IndicatorPointCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.IndicatorPointCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.IndicatorPointDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.IndicatorPointUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.IndicatorPointDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.IndicatorPointUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.IndicatorPointUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.IndicatorPointUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.IndicatorPointAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateIndicatorPoint>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.IndicatorPointGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.IndicatorPointGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.IndicatorPointCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.IndicatorPointCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
News: {
|
||||
payload: Prisma.$NewsPayload<ExtArgs>
|
||||
fields: Prisma.NewsFieldRefs
|
||||
@@ -1320,80 +1094,6 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
}
|
||||
}
|
||||
}
|
||||
OilPriceHistory: {
|
||||
payload: Prisma.$OilPriceHistoryPayload<ExtArgs>
|
||||
fields: Prisma.OilPriceHistoryFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.OilPriceHistoryFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.OilPriceHistoryFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.OilPriceHistoryFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.OilPriceHistoryFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.OilPriceHistoryFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.OilPriceHistoryCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.OilPriceHistoryCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.OilPriceHistoryCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.OilPriceHistoryDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.OilPriceHistoryUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.OilPriceHistoryDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.OilPriceHistoryUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.OilPriceHistoryUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.OilPriceHistoryUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.OilPriceHistoryAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateOilPriceHistory>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.OilPriceHistoryGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.OilPriceHistoryGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.OilPriceHistoryCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.OilPriceHistoryCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
MihomeAccount: {
|
||||
payload: Prisma.$MihomeAccountPayload<ExtArgs>
|
||||
fields: Prisma.MihomeAccountFieldRefs
|
||||
@@ -2642,46 +2342,6 @@ export const ArticleScalarFieldEnum = {
|
||||
export type ArticleScalarFieldEnum = (typeof ArticleScalarFieldEnum)[keyof typeof ArticleScalarFieldEnum]
|
||||
|
||||
|
||||
export const StrategyScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
data: 'data',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type StrategyScalarFieldEnum = (typeof StrategyScalarFieldEnum)[keyof typeof StrategyScalarFieldEnum]
|
||||
|
||||
|
||||
export const IndicatorScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
groupName: 'groupName',
|
||||
title: 'title',
|
||||
unit: 'unit',
|
||||
source: 'source',
|
||||
frequency: 'frequency',
|
||||
hidden: 'hidden',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type IndicatorScalarFieldEnum = (typeof IndicatorScalarFieldEnum)[keyof typeof IndicatorScalarFieldEnum]
|
||||
|
||||
|
||||
export const IndicatorPointScalarFieldEnum = {
|
||||
id: 'id',
|
||||
indicatorId: 'indicatorId',
|
||||
period: 'period',
|
||||
value: 'value',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type IndicatorPointScalarFieldEnum = (typeof IndicatorPointScalarFieldEnum)[keyof typeof IndicatorPointScalarFieldEnum]
|
||||
|
||||
|
||||
export const NewsScalarFieldEnum = {
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
@@ -2801,19 +2461,6 @@ export const ScraperCacheScalarFieldEnum = {
|
||||
export type ScraperCacheScalarFieldEnum = (typeof ScraperCacheScalarFieldEnum)[keyof typeof ScraperCacheScalarFieldEnum]
|
||||
|
||||
|
||||
export const OilPriceHistoryScalarFieldEnum = {
|
||||
id: 'id',
|
||||
date: 'date',
|
||||
province: 'province',
|
||||
fuel: 'fuel',
|
||||
price: 'price',
|
||||
source: 'source',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type OilPriceHistoryScalarFieldEnum = (typeof OilPriceHistoryScalarFieldEnum)[keyof typeof OilPriceHistoryScalarFieldEnum]
|
||||
|
||||
|
||||
export const MihomeAccountScalarFieldEnum = {
|
||||
id: 'id',
|
||||
key: 'key',
|
||||
@@ -3076,13 +2723,6 @@ export const SortOrder = {
|
||||
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
|
||||
|
||||
|
||||
export const JsonNullValueInput = {
|
||||
JsonNull: JsonNull
|
||||
} as const
|
||||
|
||||
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
|
||||
|
||||
|
||||
export const NullableJsonNullValueInput = {
|
||||
DbNull: DbNull,
|
||||
JsonNull: JsonNull
|
||||
@@ -3091,6 +2731,13 @@ export const NullableJsonNullValueInput = {
|
||||
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
|
||||
|
||||
|
||||
export const JsonNullValueInput = {
|
||||
JsonNull: JsonNull
|
||||
} as const
|
||||
|
||||
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
|
||||
|
||||
|
||||
export const QueryMode = {
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
@@ -3099,6 +2746,14 @@ export const QueryMode = {
|
||||
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
|
||||
|
||||
|
||||
export const NullsOrder = {
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
} as const
|
||||
|
||||
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
||||
|
||||
|
||||
export const JsonNullValueFilter = {
|
||||
DbNull: DbNull,
|
||||
JsonNull: JsonNull,
|
||||
@@ -3108,14 +2763,6 @@ export const JsonNullValueFilter = {
|
||||
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]
|
||||
|
||||
|
||||
export const NullsOrder = {
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
} as const
|
||||
|
||||
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Field references
|
||||
@@ -3164,27 +2811,6 @@ export type ListDateTimeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaM
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Json'
|
||||
*/
|
||||
export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Json'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'QueryMode'
|
||||
*/
|
||||
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Boolean'
|
||||
*/
|
||||
export type BooleanFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Boolean'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Float'
|
||||
*/
|
||||
@@ -3212,6 +2838,27 @@ export type BigIntFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel,
|
||||
export type ListBigIntFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'BigInt[]'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Json'
|
||||
*/
|
||||
export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Json'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'QueryMode'
|
||||
*/
|
||||
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Boolean'
|
||||
*/
|
||||
export type BooleanFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Boolean'>
|
||||
|
||||
|
||||
/**
|
||||
* Batch Payload for updateMany & deleteMany & createMany
|
||||
*/
|
||||
@@ -3309,9 +2956,6 @@ export type PrismaClientOptions = ({
|
||||
export type GlobalOmitConfig = {
|
||||
user?: Prisma.UserOmit
|
||||
article?: Prisma.ArticleOmit
|
||||
strategy?: Prisma.StrategyOmit
|
||||
indicator?: Prisma.IndicatorOmit
|
||||
indicatorPoint?: Prisma.IndicatorPointOmit
|
||||
news?: Prisma.NewsOmit
|
||||
alert?: Prisma.AlertOmit
|
||||
serverNode?: Prisma.ServerNodeOmit
|
||||
@@ -3319,7 +2963,6 @@ export type GlobalOmitConfig = {
|
||||
serviceCheck?: Prisma.ServiceCheckOmit
|
||||
econ_SowInventory?: Prisma.Econ_SowInventoryOmit
|
||||
scraperCache?: Prisma.ScraperCacheOmit
|
||||
oilPriceHistory?: Prisma.OilPriceHistoryOmit
|
||||
mihomeAccount?: Prisma.MihomeAccountOmit
|
||||
mihomeHome?: Prisma.MihomeHomeOmit
|
||||
mihomeDevice?: Prisma.MihomeDeviceOmit
|
||||
|
||||
@@ -53,9 +53,6 @@ export const AnyNull = runtime.AnyNull
|
||||
export const ModelName = {
|
||||
User: 'User',
|
||||
Article: 'Article',
|
||||
Strategy: 'Strategy',
|
||||
Indicator: 'Indicator',
|
||||
IndicatorPoint: 'IndicatorPoint',
|
||||
News: 'News',
|
||||
Alert: 'Alert',
|
||||
ServerNode: 'ServerNode',
|
||||
@@ -63,7 +60,6 @@ export const ModelName = {
|
||||
ServiceCheck: 'ServiceCheck',
|
||||
Econ_SowInventory: 'Econ_SowInventory',
|
||||
ScraperCache: 'ScraperCache',
|
||||
OilPriceHistory: 'OilPriceHistory',
|
||||
MihomeAccount: 'MihomeAccount',
|
||||
MihomeHome: 'MihomeHome',
|
||||
MihomeDevice: 'MihomeDevice',
|
||||
@@ -123,46 +119,6 @@ export const ArticleScalarFieldEnum = {
|
||||
export type ArticleScalarFieldEnum = (typeof ArticleScalarFieldEnum)[keyof typeof ArticleScalarFieldEnum]
|
||||
|
||||
|
||||
export const StrategyScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
data: 'data',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type StrategyScalarFieldEnum = (typeof StrategyScalarFieldEnum)[keyof typeof StrategyScalarFieldEnum]
|
||||
|
||||
|
||||
export const IndicatorScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
groupName: 'groupName',
|
||||
title: 'title',
|
||||
unit: 'unit',
|
||||
source: 'source',
|
||||
frequency: 'frequency',
|
||||
hidden: 'hidden',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type IndicatorScalarFieldEnum = (typeof IndicatorScalarFieldEnum)[keyof typeof IndicatorScalarFieldEnum]
|
||||
|
||||
|
||||
export const IndicatorPointScalarFieldEnum = {
|
||||
id: 'id',
|
||||
indicatorId: 'indicatorId',
|
||||
period: 'period',
|
||||
value: 'value',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type IndicatorPointScalarFieldEnum = (typeof IndicatorPointScalarFieldEnum)[keyof typeof IndicatorPointScalarFieldEnum]
|
||||
|
||||
|
||||
export const NewsScalarFieldEnum = {
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
@@ -282,19 +238,6 @@ export const ScraperCacheScalarFieldEnum = {
|
||||
export type ScraperCacheScalarFieldEnum = (typeof ScraperCacheScalarFieldEnum)[keyof typeof ScraperCacheScalarFieldEnum]
|
||||
|
||||
|
||||
export const OilPriceHistoryScalarFieldEnum = {
|
||||
id: 'id',
|
||||
date: 'date',
|
||||
province: 'province',
|
||||
fuel: 'fuel',
|
||||
price: 'price',
|
||||
source: 'source',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type OilPriceHistoryScalarFieldEnum = (typeof OilPriceHistoryScalarFieldEnum)[keyof typeof OilPriceHistoryScalarFieldEnum]
|
||||
|
||||
|
||||
export const MihomeAccountScalarFieldEnum = {
|
||||
id: 'id',
|
||||
key: 'key',
|
||||
@@ -557,13 +500,6 @@ export const SortOrder = {
|
||||
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
|
||||
|
||||
|
||||
export const JsonNullValueInput = {
|
||||
JsonNull: JsonNull
|
||||
} as const
|
||||
|
||||
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
|
||||
|
||||
|
||||
export const NullableJsonNullValueInput = {
|
||||
DbNull: DbNull,
|
||||
JsonNull: JsonNull
|
||||
@@ -572,6 +508,13 @@ export const NullableJsonNullValueInput = {
|
||||
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
|
||||
|
||||
|
||||
export const JsonNullValueInput = {
|
||||
JsonNull: JsonNull
|
||||
} as const
|
||||
|
||||
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
|
||||
|
||||
|
||||
export const QueryMode = {
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
@@ -580,6 +523,14 @@ export const QueryMode = {
|
||||
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
|
||||
|
||||
|
||||
export const NullsOrder = {
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
} as const
|
||||
|
||||
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
||||
|
||||
|
||||
export const JsonNullValueFilter = {
|
||||
DbNull: DbNull,
|
||||
JsonNull: JsonNull,
|
||||
@@ -588,11 +539,3 @@ export const JsonNullValueFilter = {
|
||||
|
||||
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]
|
||||
|
||||
|
||||
export const NullsOrder = {
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
} as const
|
||||
|
||||
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
||||
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
*/
|
||||
export type * from './models/User.ts'
|
||||
export type * from './models/Article.ts'
|
||||
export type * from './models/Strategy.ts'
|
||||
export type * from './models/Indicator.ts'
|
||||
export type * from './models/IndicatorPoint.ts'
|
||||
export type * from './models/News.ts'
|
||||
export type * from './models/Alert.ts'
|
||||
export type * from './models/ServerNode.ts'
|
||||
@@ -20,7 +17,6 @@ export type * from './models/ServerNodeHistory.ts'
|
||||
export type * from './models/ServiceCheck.ts'
|
||||
export type * from './models/Econ_SowInventory.ts'
|
||||
export type * from './models/ScraperCache.ts'
|
||||
export type * from './models/OilPriceHistory.ts'
|
||||
export type * from './models/MihomeAccount.ts'
|
||||
export type * from './models/MihomeHome.ts'
|
||||
export type * from './models/MihomeDevice.ts'
|
||||
|
||||
@@ -480,6 +480,14 @@ export type AlertSumOrderByAggregateInput = {
|
||||
diffRatio?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type FloatFieldUpdateOperationsInput = {
|
||||
set?: number
|
||||
increment?: number
|
||||
decrement?: number
|
||||
multiply?: number
|
||||
divide?: number
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type AlertSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -737,6 +737,10 @@ export type NullableDateTimeFieldUpdateOperationsInput = {
|
||||
set?: Date | string | null
|
||||
}
|
||||
|
||||
export type BoolFieldUpdateOperationsInput = {
|
||||
set?: boolean
|
||||
}
|
||||
|
||||
export type MihomeDeviceCreateNestedOneWithoutSamplesInput = {
|
||||
create?: Prisma.XOR<Prisma.MihomeDeviceCreateWithoutSamplesInput, Prisma.MihomeDeviceUncheckedCreateWithoutSamplesInput>
|
||||
connectOrCreate?: Prisma.MihomeDeviceCreateOrConnectWithoutSamplesInput
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1f2937" />
|
||||
<title>iBoard - 经济数据与策略</title>
|
||||
<title>iBoard - 个人看板</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
"db:migrate": "prisma migrate deploy",
|
||||
"db:seed": "node scripts/seed.cjs",
|
||||
"registry:sync": "node scripts/sync-registry.mjs",
|
||||
"data:refresh": "node --no-warnings scripts/refresh-indicators.cjs",
|
||||
"data:discover": "node --no-warnings scripts/discover-mcp-tools.cjs",
|
||||
"icons": "node scripts/generate-icons.mjs",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- V0.2.2: 宏观「经济数据 / 策略研究」迁至外接 ifinance 模块, 油模块下线
|
||||
-- 彻底移除本地指标/策略/油价数据表 (数据源改由 tortoise/ifinance 承担)
|
||||
DROP TABLE IF EXISTS "IndicatorPoint" CASCADE;
|
||||
DROP TABLE IF EXISTS "Indicator" CASCADE;
|
||||
DROP TABLE IF EXISTS "Strategy" CASCADE;
|
||||
DROP TABLE IF EXISTS "OilPriceHistory" CASCADE;
|
||||
+1
-55
@@ -44,41 +44,6 @@ model Article {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Strategy {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
description String
|
||||
data Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Indicator {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
groupName String
|
||||
title String @default("")
|
||||
unit String @default("")
|
||||
source String @default("")
|
||||
frequency String @default("month") // year | quarter | month | day
|
||||
hidden Boolean @default(false) // true = 数据/脚本保留, 但不在前端展示
|
||||
points IndicatorPoint[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model IndicatorPoint {
|
||||
id Int @id @default(autoincrement())
|
||||
indicatorId Int
|
||||
indicator Indicator @relation(fields: [indicatorId], references: [id], onDelete: Cascade)
|
||||
period String // "2024" | "2024-Q1" | "2024-01" | "2024-01-15"
|
||||
value Float
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([indicatorId, period])
|
||||
}
|
||||
|
||||
|
||||
// 新闻: 每天 3 点由 mx_finance_search_news 拉取, 按影响大小排序, 累加多日供首页无限滚动
|
||||
model News {
|
||||
@@ -96,7 +61,7 @@ model News {
|
||||
@@index([publishedAt])
|
||||
}
|
||||
|
||||
// 报警: refresh 写库时比对同 (indicatorName, period) 的跨源数据, 差异超阈值记录
|
||||
// 报警: 定时任务失败等异常写入 (展示于「监控」报警 tab)
|
||||
model Alert {
|
||||
id Int @id @default(autoincrement())
|
||||
indicatorName String
|
||||
@@ -194,25 +159,6 @@ model ScraperCache {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// 浙江省油价历史 (apizero price 接口每日抓取)
|
||||
// 每天 04:00 cron 触发: 检查 schedule 里待定且 <= 昨天的调价日, 抓 price?province=浙江 存本表
|
||||
// unique (date, province, fuel) 防重复
|
||||
model OilPriceHistory {
|
||||
id Int @id @default(autoincrement())
|
||||
date DateTime // 调价日次日 (即数据实际抓取日)
|
||||
province String // '浙江'
|
||||
fuel String // '92' | '95' | '0'
|
||||
price Float // 油价 (元/升, 4 位小数足够)
|
||||
source String @default("apizero")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([date, province, fuel])
|
||||
@@index([province, fuel, date])
|
||||
}
|
||||
|
||||
|
||||
// ==================== 环境监控:米家 ====================
|
||||
|
||||
// 米家授权状态 (DiskCache 存凭证, 本表只存状态/错误供前端展示)
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
{
|
||||
"schema": 2,
|
||||
"name": "ifinance",
|
||||
"version": "0.2.0",
|
||||
"buildId": "20261030.1442",
|
||||
"buildHash": "76353f01070db7938dd7225ea9719c004052f0f5c740d4bcddb13ca880328fe0",
|
||||
"generatedAt": "2026-10-30T14:42:19+08:00",
|
||||
"generator": "tortoise/tools/ifinance/build.py",
|
||||
"source": {
|
||||
"project": "tortoise",
|
||||
"upstream": "iboard-prod-pg-1"
|
||||
},
|
||||
"cache": {
|
||||
"mode": "hash",
|
||||
"digest": "sha256",
|
||||
"manifest": "files",
|
||||
"key": "path",
|
||||
"revalidate": "info.json"
|
||||
},
|
||||
"categories": [
|
||||
{
|
||||
"id": "econ",
|
||||
"name": "经济数据",
|
||||
"count": 8,
|
||||
"items": [
|
||||
{
|
||||
"id": "china-bond-10y",
|
||||
"kind": "indicator",
|
||||
"title": "中国国债 10 年期收益率",
|
||||
"group": "china-macro",
|
||||
"unit": "%",
|
||||
"frequency": "month",
|
||||
"subtitle": "1.71% · 2026-08",
|
||||
"points": 32,
|
||||
"html": "public/econ/china-bond-10y.html",
|
||||
"json": "public/econ/china-bond-10y.json",
|
||||
"hash": "3b5fef761b59afb6869fa2a73fe117fb69dcbefea3d39d7cd1859004162a52e7",
|
||||
"bytes": 5630,
|
||||
"jsonHash": "72b493417070df9c772d6afc8604b6db99e5889d36c59d45bcd92f2dafa261ca"
|
||||
},
|
||||
{
|
||||
"id": "china-bond-1y",
|
||||
"kind": "indicator",
|
||||
"title": "中国国债 1 年期收益率",
|
||||
"group": "china-macro",
|
||||
"unit": "%",
|
||||
"frequency": "month",
|
||||
"subtitle": "1.17% · 2026-08",
|
||||
"points": 32,
|
||||
"html": "public/econ/china-bond-1y.html",
|
||||
"json": "public/econ/china-bond-1y.json",
|
||||
"hash": "7639c558bba083cc9a25f8a156419138937d8712612dee34ed2b89890a4d9fec",
|
||||
"bytes": 5628,
|
||||
"jsonHash": "1eb0575b5eac06c78104974b75b8f106cb92bc6d87113652f7f457ee5c988fc2"
|
||||
},
|
||||
{
|
||||
"id": "china-cpi",
|
||||
"kind": "indicator",
|
||||
"title": "中国 CPI",
|
||||
"group": "china-macro",
|
||||
"unit": "%",
|
||||
"frequency": "year",
|
||||
"subtitle": "0.06% · 2025",
|
||||
"points": 11,
|
||||
"html": "public/econ/china-cpi.html",
|
||||
"json": "public/econ/china-cpi.json",
|
||||
"hash": "9ff611705ff82a56acbbf96e337e0a79bfe2f2a22fc8c5b852928af85d7b3de2",
|
||||
"bytes": 4236,
|
||||
"jsonHash": "6007ad45542047c0e343c0cc590d9fcde63d9c820ff99f0a93f61a8560209ebf"
|
||||
},
|
||||
{
|
||||
"id": "china-cpi-yoy",
|
||||
"kind": "indicator",
|
||||
"title": "中国 CPI 同比",
|
||||
"group": "china-macro",
|
||||
"unit": "%",
|
||||
"frequency": "month",
|
||||
"subtitle": "1% · 2026-06",
|
||||
"points": 36,
|
||||
"html": "public/econ/china-cpi-yoy.html",
|
||||
"json": "public/econ/china-cpi-yoy.json",
|
||||
"hash": "cb51b54b2f6bf172aa3e87ac368ce67c7398dbc51e2f033b0eac0d367cb62a0e",
|
||||
"bytes": 5627,
|
||||
"jsonHash": "16735f992a47c448fe1b175abd15174c4c6a19df4e8f1084580f9d1882009d44"
|
||||
},
|
||||
{
|
||||
"id": "china-lpr-1y",
|
||||
"kind": "indicator",
|
||||
"title": "中国 LPR 1 年期",
|
||||
"group": "china-macro",
|
||||
"unit": "%",
|
||||
"frequency": "month",
|
||||
"subtitle": "3% · 2026-07",
|
||||
"points": 31,
|
||||
"html": "public/econ/china-lpr-1y.html",
|
||||
"json": "public/econ/china-lpr-1y.json",
|
||||
"hash": "5f7254dc86f98bf32cfd06d37d9b329bcff87513057583bdf669b8691a3f4bcf",
|
||||
"bytes": 5327,
|
||||
"jsonHash": "7300679627550b75fa2fae62888b79e6f12a8386d2d3f2dec9bd2925387d8aa7"
|
||||
},
|
||||
{
|
||||
"id": "china-lpr-5y",
|
||||
"kind": "indicator",
|
||||
"title": "中国 LPR 5 年期",
|
||||
"group": "china-macro",
|
||||
"unit": "%",
|
||||
"frequency": "month",
|
||||
"subtitle": "3.5% · 2026-07",
|
||||
"points": 31,
|
||||
"html": "public/econ/china-lpr-5y.html",
|
||||
"json": "public/econ/china-lpr-5y.json",
|
||||
"hash": "e32519d06f0ca7a601e933ce704d44d59a0ab3b2fb046a6754ad4ef3ed71b742",
|
||||
"bytes": 5498,
|
||||
"jsonHash": "99d4b4d900e5a99f60b294eb9fca87145ad20b44995ef2178061cc832c2599e8"
|
||||
},
|
||||
{
|
||||
"id": "us-bond-1y",
|
||||
"kind": "indicator",
|
||||
"title": "美国国债1年",
|
||||
"group": "us-bond",
|
||||
"unit": "%",
|
||||
"frequency": "month",
|
||||
"subtitle": "4.45% · 2024-06",
|
||||
"points": 6,
|
||||
"html": "public/econ/us-bond-1y.html",
|
||||
"json": "public/econ/us-bond-1y.json",
|
||||
"hash": "c972cd658b5db614ec94135dc8f7816366a8ed526360514cfb53c64f9df09dfa",
|
||||
"bytes": 3736,
|
||||
"jsonHash": "82a7af636dd597bde08bc9cb7e22ab4e9de6754ffc30614d78ba1a96e6715a4a"
|
||||
},
|
||||
{
|
||||
"id": "usa-cpi",
|
||||
"kind": "indicator",
|
||||
"title": "美国 CPI",
|
||||
"group": "usa-macro",
|
||||
"unit": "%",
|
||||
"frequency": "year",
|
||||
"subtitle": "2.95% · 2024",
|
||||
"points": 10,
|
||||
"html": "public/econ/usa-cpi.html",
|
||||
"json": "public/econ/usa-cpi.json",
|
||||
"hash": "f4aec9f8c2aee8835166e71a7799c87c8f0288f0fc9760b85620bc98c7bd9721",
|
||||
"bytes": 4187,
|
||||
"jsonHash": "0273f88e376d166528e60338beff1e0878e98336c26c759f36daa078acb92d87"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "strategy",
|
||||
"name": "策略研究",
|
||||
"count": 5,
|
||||
"items": [
|
||||
{
|
||||
"id": "china-debt-gdp",
|
||||
"kind": "strategy",
|
||||
"title": "中国政府债务/GDP分析",
|
||||
"subtitle": "债务/GDP 75.7% · 2024",
|
||||
"points": 6,
|
||||
"html": "public/strategy/china-debt-gdp.html",
|
||||
"json": "public/strategy/china-debt-gdp.json",
|
||||
"hash": "acffe06524dbfee764411b8690d02393bbaa1d7ad2275a58ac132fe256ff69c8",
|
||||
"bytes": 7331,
|
||||
"jsonHash": "6b2c3da5e4751c0c13dc2cc10d706c08ee6400eca8a9e6636ea0a25dca374590"
|
||||
},
|
||||
{
|
||||
"id": "us-debt-gdp",
|
||||
"kind": "strategy",
|
||||
"title": "美国政府债务/GDP分析",
|
||||
"subtitle": "债务/GDP 123.9% · 2024",
|
||||
"points": 6,
|
||||
"html": "public/strategy/us-debt-gdp.html",
|
||||
"json": "public/strategy/us-debt-gdp.json",
|
||||
"hash": "cfb9ec15e457633e6d13689ab9802d646201a8fba9be3fb69710370a40ace365",
|
||||
"bytes": 7477,
|
||||
"jsonHash": "69d6d977057faac7ab10a5f43e4f5feb5e47539d6dc77e7fd93b533653abc50a"
|
||||
},
|
||||
{
|
||||
"id": "a500_chart",
|
||||
"kind": "dashboard",
|
||||
"title": "中证 A500 综合看板",
|
||||
"subtitle": "价格 / PE-PB-股息率 四面板 + 持仓 + ETF",
|
||||
"html": "public/dashboards/a500_chart.html",
|
||||
"hash": "43d3a8849a6a83137f07d51602972b3f51226aa4ffaf765d4423b82298dd07c2",
|
||||
"bytes": 1508412
|
||||
},
|
||||
{
|
||||
"id": "cni_fcf_chart",
|
||||
"kind": "dashboard",
|
||||
"title": "国证自由现金流看板",
|
||||
"subtitle": "980092 · 可与中证自由现金流叠加对比",
|
||||
"html": "public/dashboards/cni_fcf_chart.html",
|
||||
"hash": "9c239f41f525959a41594578efa041dfca5c76f652b0777fe79caae023643ed3",
|
||||
"bytes": 1392856
|
||||
},
|
||||
{
|
||||
"id": "csi_fcf_chart",
|
||||
"kind": "dashboard",
|
||||
"title": "中证自由现金流看板",
|
||||
"subtitle": "932365 · 可与国证自由现金流叠加对比",
|
||||
"html": "public/dashboards/csi_fcf_chart.html",
|
||||
"hash": "4b0b6e24b01550ae7e1ab2d85237ff6cda9349d004ec35df73511d2a32bd88ec",
|
||||
"bytes": 1344984
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "public/dashboards/a500_chart.html",
|
||||
"hash": "43d3a8849a6a83137f07d51602972b3f51226aa4ffaf765d4423b82298dd07c2",
|
||||
"bytes": 1508412,
|
||||
"type": "text/html"
|
||||
},
|
||||
{
|
||||
"path": "public/dashboards/cni_fcf_chart.html",
|
||||
"hash": "9c239f41f525959a41594578efa041dfca5c76f652b0777fe79caae023643ed3",
|
||||
"bytes": 1392856,
|
||||
"type": "text/html"
|
||||
},
|
||||
{
|
||||
"path": "public/dashboards/csi_fcf_chart.html",
|
||||
"hash": "4b0b6e24b01550ae7e1ab2d85237ff6cda9349d004ec35df73511d2a32bd88ec",
|
||||
"bytes": 1344984,
|
||||
"type": "text/html"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/china-bond-10y.html",
|
||||
"hash": "3b5fef761b59afb6869fa2a73fe117fb69dcbefea3d39d7cd1859004162a52e7",
|
||||
"bytes": 5630,
|
||||
"type": "text/html"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/china-bond-10y.json",
|
||||
"hash": "72b493417070df9c772d6afc8604b6db99e5889d36c59d45bcd92f2dafa261ca",
|
||||
"bytes": 1803,
|
||||
"type": "application/json"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/china-bond-1y.html",
|
||||
"hash": "7639c558bba083cc9a25f8a156419138937d8712612dee34ed2b89890a4d9fec",
|
||||
"bytes": 5628,
|
||||
"type": "text/html"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/china-bond-1y.json",
|
||||
"hash": "1eb0575b5eac06c78104974b75b8f106cb92bc6d87113652f7f457ee5c988fc2",
|
||||
"bytes": 1806,
|
||||
"type": "application/json"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/china-cpi-yoy.html",
|
||||
"hash": "cb51b54b2f6bf172aa3e87ac368ce67c7398dbc51e2f033b0eac0d367cb62a0e",
|
||||
"bytes": 5627,
|
||||
"type": "text/html"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/china-cpi-yoy.json",
|
||||
"hash": "16735f992a47c448fe1b175abd15174c4c6a19df4e8f1084580f9d1882009d44",
|
||||
"bytes": 1932,
|
||||
"type": "application/json"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/china-cpi.html",
|
||||
"hash": "9ff611705ff82a56acbbf96e337e0a79bfe2f2a22fc8c5b852928af85d7b3de2",
|
||||
"bytes": 4236,
|
||||
"type": "text/html"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/china-cpi.json",
|
||||
"hash": "6007ad45542047c0e343c0cc590d9fcde63d9c820ff99f0a93f61a8560209ebf",
|
||||
"bytes": 667,
|
||||
"type": "application/json"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/china-lpr-1y.html",
|
||||
"hash": "5f7254dc86f98bf32cfd06d37d9b329bcff87513057583bdf669b8691a3f4bcf",
|
||||
"bytes": 5327,
|
||||
"type": "text/html"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/china-lpr-1y.json",
|
||||
"hash": "7300679627550b75fa2fae62888b79e6f12a8386d2d3f2dec9bd2925387d8aa7",
|
||||
"bytes": 1664,
|
||||
"type": "application/json"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/china-lpr-5y.html",
|
||||
"hash": "e32519d06f0ca7a601e933ce704d44d59a0ab3b2fb046a6754ad4ef3ed71b742",
|
||||
"bytes": 5498,
|
||||
"type": "text/html"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/china-lpr-5y.json",
|
||||
"hash": "99d4b4d900e5a99f60b294eb9fca87145ad20b44995ef2178061cc832c2599e8",
|
||||
"bytes": 1693,
|
||||
"type": "application/json"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/us-bond-1y.html",
|
||||
"hash": "c972cd658b5db614ec94135dc8f7816366a8ed526360514cfb53c64f9df09dfa",
|
||||
"bytes": 3736,
|
||||
"type": "text/html"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/us-bond-1y.json",
|
||||
"hash": "82a7af636dd597bde08bc9cb7e22ab4e9de6754ffc30614d78ba1a96e6715a4a",
|
||||
"bytes": 444,
|
||||
"type": "application/json"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/usa-cpi.html",
|
||||
"hash": "f4aec9f8c2aee8835166e71a7799c87c8f0288f0fc9760b85620bc98c7bd9721",
|
||||
"bytes": 4187,
|
||||
"type": "text/html"
|
||||
},
|
||||
{
|
||||
"path": "public/econ/usa-cpi.json",
|
||||
"hash": "0273f88e376d166528e60338beff1e0878e98336c26c759f36daa078acb92d87",
|
||||
"bytes": 616,
|
||||
"type": "application/json"
|
||||
},
|
||||
{
|
||||
"path": "public/strategy/china-debt-gdp.html",
|
||||
"hash": "acffe06524dbfee764411b8690d02393bbaa1d7ad2275a58ac132fe256ff69c8",
|
||||
"bytes": 7331,
|
||||
"type": "text/html"
|
||||
},
|
||||
{
|
||||
"path": "public/strategy/china-debt-gdp.json",
|
||||
"hash": "6b2c3da5e4751c0c13dc2cc10d706c08ee6400eca8a9e6636ea0a25dca374590",
|
||||
"bytes": 841,
|
||||
"type": "application/json"
|
||||
},
|
||||
{
|
||||
"path": "public/strategy/us-debt-gdp.html",
|
||||
"hash": "cfb9ec15e457633e6d13689ab9802d646201a8fba9be3fb69710370a40ace365",
|
||||
"bytes": 7477,
|
||||
"type": "text/html"
|
||||
},
|
||||
{
|
||||
"path": "public/strategy/us-debt-gdp.json",
|
||||
"hash": "69d6d977057faac7ab10a5f43e4f5feb5e47539d6dc77e7fd93b533653abc50a",
|
||||
"bytes": 843,
|
||||
"type": "application/json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
const fs = require('fs')
|
||||
const content = `import cron from 'node-cron'
|
||||
import { prisma } from './db.js'
|
||||
import mysql from 'mysql2/promise'
|
||||
import { fetchNewsList, persistNews } from '../scripts/fetchers/news.fetcher.js'
|
||||
import { checkAfterRefresh } from './alert-engine.js'
|
||||
|
||||
function parseUrl(url) {
|
||||
const u = new URL(url)
|
||||
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\\//, '') }
|
||||
}
|
||||
|
||||
async function openConn() {
|
||||
return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
}
|
||||
|
||||
// 每天凌晨 3:00 (本地时间) 拉取新闻 + 跑跨源对比
|
||||
function scheduleNews() {
|
||||
cron.schedule('0 3 * * *', async () => {
|
||||
console.log('[cron] 3:00 触发')
|
||||
try {
|
||||
const list = await fetchNewsList()
|
||||
const r = await persistNews(list)
|
||||
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
|
||||
const conn = await openConn()
|
||||
try {
|
||||
const a = await checkAfterRefresh(conn)
|
||||
console.log('[cron] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
|
||||
} finally { await conn.end() }
|
||||
} catch (e) {
|
||||
console.error('[cron] 任务失败:', e.message)
|
||||
}
|
||||
})
|
||||
console.log('[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测')
|
||||
}
|
||||
|
||||
// 每 2 分钟: 拉取 komari 节点 + 服务自检
|
||||
function scheduleMonitors() {
|
||||
cron.schedule('*/2 * * * *', async () => {
|
||||
try {
|
||||
const k = require('./komari-monitor.cjs')
|
||||
const r = await k.runOnce()
|
||||
console.log('[cron] komari: nodes=' + r.nodes + ' snapshots=' + r.snapshots + ' history=' + r.history + ' failed=' + r.failed)
|
||||
} catch (e) { console.error('[cron] komari 任务失败:', e.message) }
|
||||
try {
|
||||
const h = require('./health-check.cjs')
|
||||
const arr = await h.runOnce()
|
||||
const down = arr.filter(x => x.status !== 'up').map(x => x.name)
|
||||
console.log('[cron] health: ' + arr.length + ' 项, down=' + (down.length ? down.join(',') : '无'))
|
||||
} catch (e) { console.error('[cron] health 任务失败:', e.message) }
|
||||
})
|
||||
console.log('[cron] 已注册: 每 2 分钟 komari + 服务自检')
|
||||
}
|
||||
|
||||
// 每小时: 清理 24h 前的 komari 历史
|
||||
function scheduleMonitorCleanup() {
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
try {
|
||||
const k = require('./komari-monitor.cjs')
|
||||
const n = await k.cleanupOld()
|
||||
console.log('[cron] 监控历史清理: 删除 ' + n + ' 条')
|
||||
} catch (e) { console.error('[cron] 监控清理失败:', e.message) }
|
||||
})
|
||||
console.log('[cron] 已注册: 每小时清理 24h 前监控历史')
|
||||
}
|
||||
|
||||
scheduleNews()
|
||||
scheduleMonitors()
|
||||
scheduleMonitorCleanup()
|
||||
`
|
||||
fs.writeFileSync('server/cron.js', content, 'utf8')
|
||||
console.log('OK len=' + content.length)
|
||||
@@ -1,17 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
(async () => {
|
||||
const u = new URL(process.env.DATABASE_URL);
|
||||
const c = await mysql.createConnection({
|
||||
host: u.hostname, port: Number(u.port),
|
||||
user: decodeURIComponent(u.username),
|
||||
password: decodeURIComponent(u.password),
|
||||
database: u.pathname.replace(/^\//, ''),
|
||||
});
|
||||
// 保留 id 最小的, 删掉其余重名行
|
||||
for (const t of ['Strategy', 'Indicator']) {
|
||||
const [r] = await c.query('DELETE FROM ' + t + ' WHERE id NOT IN (SELECT min_id FROM (SELECT MIN(id) AS min_id FROM ' + t + ' GROUP BY name) AS keep)');
|
||||
console.log('[' + t + '] 删除 ' + r.affectedRows + ' 条重复行');
|
||||
}
|
||||
await c.end();
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 列出东方财富妙想 MCP 暴露的所有工具
|
||||
* 用法: npm run data:discover
|
||||
* 输出: tool 名称 + 描述 + 输入 schema
|
||||
*/
|
||||
require('dotenv').config()
|
||||
|
||||
async function main() {
|
||||
if (!process.env.EM_API_KEY) {
|
||||
console.error('缺少 EM_API_KEY (在 .env 配)')
|
||||
process.exit(1)
|
||||
}
|
||||
const { listAvailableTools } = await import('./fetchers/eastmoney-mcp.fetcher.js')
|
||||
console.log('[Discover] 调妙想 MCP tools/list...')
|
||||
const tools = await listAvailableTools()
|
||||
console.log(`[Discover] 找到 ${tools.length} 个工具\n`)
|
||||
for (const t of tools) {
|
||||
console.log(`── ${t.name} ${'─'.repeat(Math.max(0, 60 - t.name.length))}`)
|
||||
if (t.description) console.log(` ${t.description}`)
|
||||
if (t.inputSchema) {
|
||||
console.log(' inputSchema:')
|
||||
const s = JSON.stringify(t.inputSchema, null, 2)
|
||||
s.split('\n').forEach((line) => console.log(' ' + line))
|
||||
}
|
||||
console.log('')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[Discover] 失败:', e.message)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -27,9 +27,6 @@ const TABLES = [
|
||||
['TaskMember', 'taskMember'],
|
||||
['TaskTag', 'taskTag'],
|
||||
['Article', 'article'],
|
||||
['Strategy', 'strategy'],
|
||||
['Indicator', 'indicator'],
|
||||
['IndicatorPoint', 'indicatorPoint'],
|
||||
['News', 'news'],
|
||||
['Alert', 'alert'],
|
||||
['ServerNode', 'serverNode'],
|
||||
@@ -37,7 +34,6 @@ const TABLES = [
|
||||
['ServiceCheck', 'serviceCheck'],
|
||||
['Econ_SowInventory', 'econ_SowInventory'],
|
||||
['ScraperCache', 'scraperCache'],
|
||||
['OilPriceHistory', 'oilPriceHistory'],
|
||||
['MihomeAccount', 'mihomeAccount'],
|
||||
['MihomeHome', 'mihomeHome'],
|
||||
['MihomeDevice', 'mihomeDevice'],
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
async function runScraper(scraperPath, startDate, endDate) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [scraperPath];
|
||||
if (startDate) {
|
||||
args.push('--start', startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
args.push('--end', endDate);
|
||||
}
|
||||
|
||||
const proc = spawn('bun', args, { stdio: 'inherit' });
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchOneYearData() {
|
||||
console.log('='.repeat(70));
|
||||
console.log('📊 获取近一年数据');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
const currentDate = new Date();
|
||||
const startDate = new Date(currentDate);
|
||||
startDate.setFullYear(startDate.getFullYear() - 1);
|
||||
|
||||
const startDateStr = startDate.toISOString().split('T')[0];
|
||||
const endDateStr = currentDate.toISOString().split('T')[0];
|
||||
|
||||
console.log(`时间范围: ${startDateStr} 至 ${endDateStr}`);
|
||||
console.log('');
|
||||
|
||||
console.log('='.repeat(70));
|
||||
console.log('💱 开始获取 USD-CNY 汇率数据...');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
let usdSuccess = false;
|
||||
try {
|
||||
await runScraper('./scraper/usd-cny-rate.scraper.js', startDateStr, endDateStr);
|
||||
console.log(`✅ USD-CNY 汇率数据更新完成`);
|
||||
usdSuccess = true;
|
||||
} catch (error) {
|
||||
console.error(`❌ USD-CNY 汇率数据更新失败:`, error.message);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('='.repeat(70));
|
||||
console.log('📈 开始获取沪深300指数数据...');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
let sh300Success = false;
|
||||
try {
|
||||
await runScraper('./scraper/sh300.scraper.js', startDateStr, endDateStr);
|
||||
console.log(`✅ 沪深300指数数据更新完成`);
|
||||
sh300Success = true;
|
||||
} catch (error) {
|
||||
console.error(`❌ 沪深300指数数据更新失败:`, error.message);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('='.repeat(70));
|
||||
console.log('📊 数据获取完成');
|
||||
console.log('='.repeat(70));
|
||||
console.log(`USD-CNY 汇率: ${usdSuccess ? '✅ 成功' : '❌ 失败'}`);
|
||||
console.log(`沪深300指数: ${sh300Success ? '✅ 成功' : '❌ 失败'}`);
|
||||
console.log('='.repeat(70));
|
||||
}
|
||||
|
||||
fetchOneYearData();
|
||||
@@ -1,303 +0,0 @@
|
||||
// 东方财富妙想 MCP 数据源
|
||||
// 协议: StreamableHttp (MCP 2025-03-26 spec), HTTP POST + JSON-RPC 2.0
|
||||
// - 认证: header `em_api_key: <EM_API_KEY>`
|
||||
// - 妙想 MCP 是"自然语言驱动": 传 query 字符串, 内部 LLM 解析 + 调底层数据接口
|
||||
// - 重要: 每次调用 5-20s + LLM token 成本, 必须配合"DB 优先" (refresh 时已存在的指标跳过)
|
||||
//
|
||||
// 需要的 .env:
|
||||
// EM_API_KEY=xxx 必填
|
||||
// EASTMONEY_MCP_BASE=... 可选, 默认 https://mxapi.eastmoney.com/mxds/mcp
|
||||
//
|
||||
// INDICATORS 数组: 配置要拉的指标
|
||||
// 每项:
|
||||
// name → Indicator.name (主键)
|
||||
// groupName → Indicator.groupName
|
||||
// title → data.title
|
||||
// unit → data.unit
|
||||
// tool → MCP tool 名
|
||||
// query → 自然语言查询
|
||||
// pickSheet(sheets) → 选一张 sheet (默认取第一张)
|
||||
// parseSheet(sheet) → [{period, value}, ...] 默认按宽表转长表 (列=日期, 行=值)
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
const DEFAULT_BASE = 'https://mxapi.eastmoney.com/mxds/mcp'
|
||||
const TIMEOUT = 60000
|
||||
|
||||
// ====== INDICATORS 配置 ======
|
||||
// 跑 npm run data:discover 看可用 tool, 再加新指标
|
||||
const INDICATORS = [
|
||||
{
|
||||
name: 'china-cpi-yoy',
|
||||
groupName: 'china-macro',
|
||||
title: '中国 CPI 同比',
|
||||
unit: '%',
|
||||
tool: 'mx_macro_data',
|
||||
query: '查询中国 2015 年至今 CPI 月度同比数据,每一个月的数值',
|
||||
pickSheet: (sheets) =>
|
||||
sheets.find((s) => s.items?.[0]?.[0]?.includes('CPI') && s.items[0][0].includes('同比'))
|
||||
|| sheets.find((s) => s.sheetName?.includes('宏观数据'))
|
||||
|| sheets[0],
|
||||
},
|
||||
{
|
||||
name: 'china-gdp-yoy',
|
||||
groupName: 'china-macro',
|
||||
title: '中国 GDP 同比',
|
||||
unit: '%',
|
||||
tool: 'mx_macro_data',
|
||||
query: '查询中国 2015-2025 年 GDP 不变价同比增速,季度数据,每一季度的数值',
|
||||
pickSheet: (sheets) =>
|
||||
sheets.find((s) => s.items?.[0]?.[0]?.includes('GDP') && s.items[0][0].includes('同比'))
|
||||
|| sheets[0],
|
||||
},
|
||||
{
|
||||
// 国债收益率: MCP 返回日频, 用月末降采样成月频点
|
||||
name: 'china-bond-10y',
|
||||
groupName: 'china-macro',
|
||||
title: '中国国债 10 年期收益率',
|
||||
unit: '%',
|
||||
frequency: 'month',
|
||||
tool: 'mx_macro_data',
|
||||
query: '查询中国10年期国债到期收益率 2024年至今 每日数据',
|
||||
pickSheet: (sheets) =>
|
||||
sheets.find((s) => s.items?.some?.((row) => String(row[0] || '').includes('国债到期收益率:10年')))
|
||||
|| sheets[0],
|
||||
parseSheet: (sheet) => monthEndSample(sheet, '国债到期收益率:10年'),
|
||||
},
|
||||
{
|
||||
name: 'china-bond-1y',
|
||||
groupName: 'china-macro',
|
||||
title: '中国国债 1 年期收益率',
|
||||
unit: '%',
|
||||
frequency: 'month',
|
||||
tool: 'mx_macro_data',
|
||||
query: '查询中国1年期国债到期收益率 2024年至今 每日数据',
|
||||
pickSheet: (sheets) =>
|
||||
sheets.find((s) => s.items?.some?.((row) => String(row[0] || '').includes('国债到期收益率:1年')))
|
||||
|| sheets[0],
|
||||
parseSheet: (sheet) => monthEndSample(sheet, '国债到期收益率:1年'),
|
||||
},
|
||||
{
|
||||
name: 'china-lpr-1y',
|
||||
groupName: 'china-macro',
|
||||
title: '中国 LPR 1 年期',
|
||||
unit: '%',
|
||||
frequency: 'month',
|
||||
tool: 'mx_macro_data',
|
||||
query: '查询中国LPR 1年期贷款市场报价利率 2024年至今 月度数据,每一个月的数值',
|
||||
pickSheet: (sheets) =>
|
||||
sheets.find((s) => s.sheetName?.includes('宏观数据(月)'))
|
||||
|| sheets[0],
|
||||
parseSheet: (sheet) => pickRowByKeyword(sheet, '(LPR):1年'),
|
||||
},
|
||||
{
|
||||
name: 'china-lpr-5y',
|
||||
groupName: 'china-macro',
|
||||
title: '中国 LPR 5 年期',
|
||||
unit: '%',
|
||||
frequency: 'month',
|
||||
tool: 'mx_macro_data',
|
||||
query: '查询中国LPR 5年期以上贷款市场报价利率 2024年至今 月度数据,每一个月的数值',
|
||||
pickSheet: (sheets) =>
|
||||
sheets.find((s) => s.sheetName?.includes('宏观数据(月)'))
|
||||
|| sheets[0],
|
||||
parseSheet: (sheet) => pickRowByKeyword(sheet, '(LPR):5年'),
|
||||
},
|
||||
]
|
||||
|
||||
export const META = {
|
||||
name: 'eastmoney-mcp',
|
||||
description: '东方财富妙想 MCP (StreamableHttp, NL 查询)',
|
||||
docs: 'https://mxapi.eastmoney.com/mxds/doc/mcp-install.md',
|
||||
}
|
||||
|
||||
// ====== StreamableHttp 客户端 ======
|
||||
|
||||
class McpClient {
|
||||
constructor(baseUrl, apiKey) {
|
||||
this.baseUrl = baseUrl
|
||||
this.apiKey = apiKey
|
||||
this.id = 0
|
||||
}
|
||||
async _request(method, params) {
|
||||
const payload = { jsonrpc: '2.0', id: ++this.id, method, ...(params ? { params } : {}) }
|
||||
const resp = await axios.post(this.baseUrl, payload, {
|
||||
timeout: TIMEOUT,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
'em_api_key': this.apiKey,
|
||||
},
|
||||
responseType: 'text',
|
||||
transformResponse: [(d) => d],
|
||||
validateStatus: () => true,
|
||||
})
|
||||
if (resp.status >= 400) {
|
||||
throw new Error(`HTTP ${resp.status}: ${String(resp.data).slice(0, 300)}`)
|
||||
}
|
||||
const parsed = parseMcpResponse(String(resp.data || ''))
|
||||
// JSON-RPC 信封 {jsonrpc,id,result|error}, 统一解包 result
|
||||
if (parsed.error) {
|
||||
throw new Error(`MCP error ${parsed.error.code}: ${parsed.error.message}`)
|
||||
}
|
||||
return parsed.result || parsed
|
||||
}
|
||||
async listTools() {
|
||||
const r = await this._request('tools/list')
|
||||
return r.tools || []
|
||||
}
|
||||
async callTool(name, args = {}) {
|
||||
const r = await this._request('tools/call', { name, arguments: args })
|
||||
if (r.isError) {
|
||||
const msg = (r.content || []).map((c) => c.text || '').join('; ')
|
||||
throw new Error(`tool ${name} error: ${msg || JSON.stringify(r).slice(0, 200)}`)
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
// 解析响应: JSON 或 SSE
|
||||
function parseMcpResponse(text) {
|
||||
const t = text.trim()
|
||||
if (t.startsWith('{')) return JSON.parse(t)
|
||||
if (t.startsWith('data:')) {
|
||||
const lines = t.split('\n').filter((l) => l.startsWith('data:'))
|
||||
let last = null
|
||||
for (const line of lines) {
|
||||
const json = line.slice(5).trim()
|
||||
if (!json || json === '[DONE]') continue
|
||||
try { last = JSON.parse(json) } catch { /* skip */ }
|
||||
}
|
||||
if (!last) throw new Error('SSE 无有效 data 块: ' + text.slice(0, 200))
|
||||
return last
|
||||
}
|
||||
throw new Error('未知响应格式: ' + t.slice(0, 200))
|
||||
}
|
||||
|
||||
// 默认 sheet 解析: 宽表 (列=日期, 行=值) → 长表
|
||||
// 例: { columns:["指标","来源","2024-12","2024-11"], items:[["CPI","统计局","0.1","0.2"]] }
|
||||
// → [{period:"2024-12", value:0.1}, {period:"2024-11", value:0.2}]
|
||||
// 返回时反转成从老到新
|
||||
function defaultParseSheet(sheet) {
|
||||
if (!sheet || !sheet.columns || !sheet.items || !sheet.items[0]) return []
|
||||
const cols = sheet.columns
|
||||
const row = sheet.items[0]
|
||||
const dates = cols.slice(2) // 跳过 [指标名, 来源]
|
||||
const values = row.slice(2)
|
||||
const out = []
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const v = parseFloat(values[i])
|
||||
if (!isNaN(v)) out.push({ period: dates[i], value: v })
|
||||
}
|
||||
return out.reverse() // 从老到新
|
||||
}
|
||||
|
||||
// 宽表 → 长表, 指定 item 行 (按指标全名前缀匹配), 日频列降采样为月末值
|
||||
// 例: columns=["指标","来源","2026-08-05","2026-08-04",...] → [{period:"2024-01",value},...]
|
||||
function monthEndSample(sheet, keyword) {
|
||||
if (!sheet || !sheet.columns || !sheet.items) return []
|
||||
const row = sheet.items.find((r) => String(r[0] || '').includes(keyword))
|
||||
if (!row) return []
|
||||
const cols = sheet.columns
|
||||
const dates = cols.slice(2) // 新 → 老
|
||||
const values = row.slice(2)
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const v = parseFloat(values[i])
|
||||
const m = String(dates[i] || '').slice(0, 7) // "2024-01-02" → "2024-01"
|
||||
if (!/^\d{4}-\d{2}$/.test(m) || Number.isNaN(v)) continue
|
||||
if (seen.has(m)) continue // 列是降序, 第一次遇到的即当月最后一个交易日
|
||||
seen.add(m)
|
||||
out.push({ period: m, value: v })
|
||||
}
|
||||
return out.reverse() // 从老到新
|
||||
}
|
||||
|
||||
// 宽表 → 长表, 指定 item 行 (按指标全名前缀匹配), 保留原频率 (月/季/年)
|
||||
function pickRowByKeyword(sheet, keyword) {
|
||||
if (!sheet || !sheet.columns || !sheet.items) return []
|
||||
const row = sheet.items.find((r) => String(r[0] || '').includes(keyword))
|
||||
if (!row) return []
|
||||
const cols = sheet.columns
|
||||
const dates = cols.slice(2)
|
||||
const values = row.slice(2)
|
||||
const out = []
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const v = parseFloat(values[i])
|
||||
if (!Number.isNaN(v)) out.push({ period: String(dates[i]), value: v })
|
||||
}
|
||||
return out.reverse() // 从老到新
|
||||
}
|
||||
|
||||
// ====== fetcher 入口 ======
|
||||
|
||||
export async function listAvailableTools() {
|
||||
const key = process.env.EM_API_KEY
|
||||
if (!key) throw new Error('缺少 EM_API_KEY')
|
||||
const base = process.env.EASTMONEY_MCP_BASE || DEFAULT_BASE
|
||||
const client = new McpClient(base, key)
|
||||
return client.listTools()
|
||||
}
|
||||
|
||||
// opts.skipNames: Set<string> — DB 已存在的指标名, 跳过不调 MCP (省 token)
|
||||
// opts.onlyNames: Set<string> — 白名单, 只跑这些 (用于补跑单个)
|
||||
export async function fetchAll(opts = {}) {
|
||||
if (INDICATORS.length === 0) {
|
||||
throw new Error('eastmoney-mcp: INDICATORS 数组为空, 加新指标见文件顶部注释')
|
||||
}
|
||||
const skip = opts.skipNames || new Set()
|
||||
const force = opts.forceNames || new Set()
|
||||
const only = opts.onlyNames || null
|
||||
|
||||
const key = process.env.EM_API_KEY
|
||||
if (!key) throw new Error('缺少 EM_API_KEY')
|
||||
const base = process.env.EASTMONEY_MCP_BASE || DEFAULT_BASE
|
||||
const client = new McpClient(base, key)
|
||||
|
||||
const results = []
|
||||
for (const cfg of INDICATORS) {
|
||||
if (skip.has(cfg.name) && !force.has(cfg.name)) {
|
||||
console.log(`[MCP] ${cfg.name} 跳过 (DB 已存在)`)
|
||||
continue
|
||||
}
|
||||
if (only && !only.has(cfg.name)) continue
|
||||
try {
|
||||
console.log(`[MCP] ${cfg.name} 调用 ${cfg.tool}: ${cfg.query}`)
|
||||
const r = await client.callTool(cfg.tool, { query: cfg.query })
|
||||
const text = (r.content || []).map((c) => c.text || '').join('')
|
||||
let payload
|
||||
try { payload = JSON.parse(text) } catch { payload = { raw: text } }
|
||||
const sheets = payload.data || (Array.isArray(payload) ? payload : [payload])
|
||||
const sheet = cfg.pickSheet ? cfg.pickSheet(sheets) : sheets[0]
|
||||
const parsed = cfg.parseSheet ? cfg.parseSheet(sheet) : defaultParseSheet(sheet)
|
||||
if (parsed.length === 0) {
|
||||
console.log(`[MCP] ${cfg.name} 返回空 rows, 跳过`)
|
||||
continue
|
||||
}
|
||||
results.push({
|
||||
name: cfg.name,
|
||||
groupName: cfg.groupName,
|
||||
data: {
|
||||
unit: cfg.unit,
|
||||
title: cfg.title,
|
||||
source: 'eastmoney-mcp',
|
||||
rows: parsed,
|
||||
},
|
||||
})
|
||||
console.log(`[MCP] ${cfg.name} OK, ${parsed.length} rows (${parsed[0]?.period}..${parsed.at(-1)?.period})`)
|
||||
} catch (e) {
|
||||
console.error(`[MCP] ${cfg.name} FAIL: ${e.message}`)
|
||||
}
|
||||
}
|
||||
// 全部被 DB 优先跳过时, 返回显式标记而不是空数组 (避免被误判为失败)
|
||||
if (results.length === 0 && INDICATORS.every((cfg) => skip.has(cfg.name) && !force.has(cfg.name))) {
|
||||
return { skipped: true, reason: 'DB 已存在全部指标' }
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// Fetcher 注册表: 列出所有启用的数据源
|
||||
// 加新数据源只要:
|
||||
// 1. 写一个 scripts/fetchers/<source>.fetcher.js, 导出 META + fetchAll
|
||||
// 2. 在本文件 FETCHERS 数组里加一行
|
||||
// 3. 完事, refresh 脚本会自动跑
|
||||
|
||||
// 每项的 schema:
|
||||
// name: 数据源标识 (用于日志)
|
||||
// module: fetcher 模块路径 (相对 scripts/fetchers/)
|
||||
// enabled: true/false, 可由 env 关闭 (如 ENABLE_EASTMONEY_MCP=0)
|
||||
// env: 需要的环境变量名列表, 缺一即跳过
|
||||
export const FETCHERS = [
|
||||
{
|
||||
name: 'wb-indicators',
|
||||
module: './wb-indicators.fetcher.js',
|
||||
enabled: true,
|
||||
env: [],
|
||||
},
|
||||
{
|
||||
name: 'eastmoney-mcp',
|
||||
module: './eastmoney-mcp.fetcher.js',
|
||||
// 默认开启, 有 APIKey 才真正能跑 (fetcher 内部会自检)
|
||||
enabled: process.env.ENABLE_EASTMONEY_MCP !== '0',
|
||||
env: ['EM_API_KEY'],
|
||||
},
|
||||
// 后续在这里追加:
|
||||
// { name: 'fred', module: './fred.fetcher.js', enabled: true, env: ['FRED_API_KEY'] },
|
||||
// { name: 'tushare', module: './tushare.fetcher.js', enabled: true, env: ['TUSHARE_TOKEN'] },
|
||||
]
|
||||
|
||||
// 校验环境变量, 返回 { ok, missing[] }
|
||||
export function checkEnv(required) {
|
||||
const missing = required.filter((k) => !process.env[k] || !process.env[k].trim())
|
||||
return { ok: missing.length === 0, missing }
|
||||
}
|
||||
|
||||
// 加载并运行所有启用的 fetcher, 返回 [{ name, ok, items, error, skipped }]
|
||||
// 错误隔离: 一个 fetcher 挂了不影响其他
|
||||
// opts.skipNames: Set<string> — DB 优先, 已存在的指标名, 透传给 fetcher
|
||||
// opts.forceNames: Set<string> — 强制刷新名单, 即使 skipNames 里有也照拉
|
||||
export async function runFetchers(opts = {}) {
|
||||
const results = []
|
||||
for (const meta of FETCHERS) {
|
||||
if (!meta.enabled) {
|
||||
results.push({ name: meta.name, skipped: true, reason: 'disabled' })
|
||||
console.log(`[Fetcher] ${meta.name} 跳过 (disabled)`)
|
||||
continue
|
||||
}
|
||||
const envCheck = checkEnv(meta.env)
|
||||
if (!envCheck.ok) {
|
||||
results.push({ name: meta.name, skipped: true, reason: `缺少 env: ${envCheck.missing.join(', ')}` })
|
||||
console.log(`[Fetcher] ${meta.name} 跳过 (${envCheck.missing.join(', ')})`)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const mod = await import(meta.module)
|
||||
if (!mod.META || !mod.fetchAll) {
|
||||
throw new Error('fetcher 必须导出 META 和 fetchAll')
|
||||
}
|
||||
console.log(`[Fetcher] ${meta.name} 开始...`)
|
||||
const out = await mod.fetchAll({ skipNames: opts.skipNames, onlyNames: opts.onlyNames, forceNames: opts.forceNames })
|
||||
// fetcher 可返回 { skipped: true, reason } 表示"全部被 DB 优先跳过", 不算失败
|
||||
if (out && out.skipped) {
|
||||
results.push({ name: meta.name, skipped: true, reason: out.reason })
|
||||
console.log(`[Fetcher] ${meta.name} 跳过 (${out.reason})`)
|
||||
continue
|
||||
}
|
||||
const items = Array.isArray(out) ? out : []
|
||||
// 校验返回结构
|
||||
const valid = items.filter((it) => it && it.name && it.data && Array.isArray(it.data.rows))
|
||||
if (valid.length === 0) {
|
||||
throw new Error('fetcher 未返回任何有效指标 (需包含 name/data.rows)')
|
||||
}
|
||||
console.log(`[Fetcher] ${meta.name} 拉取 ${valid.length} 个指标`)
|
||||
results.push({ name: meta.name, ok: true, items: valid })
|
||||
} catch (e) {
|
||||
results.push({ name: meta.name, ok: false, error: e.message })
|
||||
console.error(`[Fetcher] ${meta.name} 失败: ${e.message}`)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
async function fetch(startDate = null, endDate = null, retryCount = 3) {
|
||||
try {
|
||||
const currentDate = new Date();
|
||||
|
||||
if (!startDate) {
|
||||
startDate = new Date(currentDate);
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
startDate = startDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
if (!endDate) {
|
||||
endDate = currentDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
console.log('正在获取沪深300指数数据...');
|
||||
console.log(`时间范围: ${startDate} 至 ${endDate}`);
|
||||
console.log(`数据源: 腾讯财经API`);
|
||||
|
||||
// 沪深300指数代码
|
||||
const symbol = '000300';
|
||||
|
||||
// 使用腾讯财经的API端点获取历史数据
|
||||
const response = await axios.get('https://web.ifzq.gtimg.cn/appstock/app/kline/kline?param=sh' + symbol + ',day,' + startDate + ',' + endDate + ',640', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
},
|
||||
timeout: 10000 // 设置10秒超时
|
||||
});
|
||||
|
||||
console.log(`\nAPI状态: ${response.status}`);
|
||||
|
||||
const data = response.data;
|
||||
console.log(`响应数据:`, JSON.stringify(data, null, 2));
|
||||
|
||||
const allData = [];
|
||||
|
||||
if (data && data.data && data.data['sh' + symbol] && data.data['sh' + symbol].day) {
|
||||
const klineData = data.data['sh' + symbol].day;
|
||||
console.log(`数据条数: ${klineData.length}`);
|
||||
|
||||
for (const item of klineData) {
|
||||
allData.push({
|
||||
date: item[0],
|
||||
open: parseFloat(item[1]),
|
||||
close: parseFloat(item[2]),
|
||||
high: parseFloat(item[3]),
|
||||
low: parseFloat(item[4]),
|
||||
volume: parseFloat(item[5]),
|
||||
amount: 0, // 腾讯财经API没有返回成交额数据,设置为0
|
||||
source: '腾讯财经API',
|
||||
fetchTime: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('数据条数: 0');
|
||||
}
|
||||
|
||||
allData.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
console.log(`\n共获取到 ${allData.length} 条沪深300指数数据`);
|
||||
|
||||
return {
|
||||
symbol: 'sh000300',
|
||||
name: '沪深300指数',
|
||||
startDate,
|
||||
endDate,
|
||||
count: allData.length,
|
||||
data: allData,
|
||||
fetchTime: new Date().toISOString(),
|
||||
note: '数据来源: 腾讯财经API'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取沪深300指数数据失败:', error.message);
|
||||
if (error.response) {
|
||||
console.error(`HTTP状态码: ${error.response.status}`);
|
||||
console.error(`响应数据:`, error.response.data);
|
||||
}
|
||||
|
||||
// 重试机制
|
||||
if (retryCount > 0) {
|
||||
console.log(`正在重试... (剩余重试次数: ${retryCount - 1})`);
|
||||
// 等待1秒后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return fetch(startDate, endDate, retryCount - 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
fetch
|
||||
};
|
||||
|
||||
const isMainModule = () => {
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const mainFile = process.argv[1];
|
||||
return currentFile === mainFile;
|
||||
};
|
||||
|
||||
function parseCommandLineArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let startDate = null;
|
||||
let endDate = null;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--start' && i + 1 < args.length) {
|
||||
startDate = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--end' && i + 1 < args.length) {
|
||||
endDate = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--help') {
|
||||
console.log('使用方法:');
|
||||
console.log(' bun sh300.fetcher.js [选项]');
|
||||
console.log('');
|
||||
console.log('选项:');
|
||||
console.log(' --start YYYY-MM-DD 指定开始日期(默认:30天前)');
|
||||
console.log(' --end YYYY-MM-DD 指定结束日期(默认:今天)');
|
||||
console.log(' --help 显示帮助信息');
|
||||
console.log('');
|
||||
console.log('示例:');
|
||||
console.log(' bun sh300.fetcher.js');
|
||||
console.log(' bun sh300.fetcher.js --start 2024-01-01 --end 2024-01-31');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const { startDate, endDate } = parseCommandLineArgs();
|
||||
|
||||
console.log('='.repeat(50));
|
||||
console.log('沪深300指数数据获取脚本');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
fetch(startDate, endDate)
|
||||
.then(result => {
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('获取结果:');
|
||||
console.log(` 指数代码: ${result.symbol}`);
|
||||
console.log(` 指数名称: ${result.name}`);
|
||||
console.log(` 开始日期: ${result.startDate}`);
|
||||
console.log(` 结束日期: ${result.endDate}`);
|
||||
console.log(` 数据条数: ${result.count}`);
|
||||
console.log(` 获取时间: ${result.fetchTime}`);
|
||||
console.log(` 提示: ${result.note}`);
|
||||
if (result.count > 0) {
|
||||
console.log('-'.repeat(50));
|
||||
console.log('指数数据:');
|
||||
result.data.forEach(item => {
|
||||
console.log(` ${item.date} 开盘: ${item.open} 最高: ${item.high} 最低: ${item.low} 收盘: ${item.close}`);
|
||||
});
|
||||
}
|
||||
console.log('='.repeat(50));
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const FRANKFURTER_API = 'https://api.frankfurter.app';
|
||||
|
||||
async function fetch(startDate = null, endDate = null) {
|
||||
try {
|
||||
const currentDate = new Date();
|
||||
|
||||
if (!startDate) {
|
||||
startDate = new Date(currentDate);
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
startDate = startDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
if (!endDate) {
|
||||
endDate = currentDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
console.log('正在获取人民币美元汇率数据...');
|
||||
console.log(`时间范围: ${startDate} 至 ${endDate}`);
|
||||
console.log(`数据源: Frankfurter API (欧洲央行汇率数据)`);
|
||||
|
||||
const response = await axios.get(`${FRANKFURTER_API}/${startDate}..${endDate}`, {
|
||||
params: {
|
||||
from: 'USD',
|
||||
to: 'CNY'
|
||||
},
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
console.log(`\nAPI状态: ${response.status}`);
|
||||
console.log(`数据条数: ${Object.keys(data.rates || {}).length}`);
|
||||
|
||||
const allRates = [];
|
||||
|
||||
if (data.rates) {
|
||||
for (const [date, rateObj] of Object.entries(data.rates)) {
|
||||
const rateValue = rateObj.CNY;
|
||||
allRates.push({
|
||||
date: date,
|
||||
currency: 'USD/CNY',
|
||||
centerPrice: parseFloat(rateValue.toFixed(4)),
|
||||
sellingRate: parseFloat(rateValue.toFixed(4)),
|
||||
buyingRate: parseFloat(rateValue.toFixed(4)),
|
||||
source: '欧洲央行 (Frankfurter API)',
|
||||
fetchTime: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
allRates.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
console.log(`\n共获取到 ${allRates.length} 条汇率数据`);
|
||||
|
||||
return {
|
||||
currency: 'USD/CNY',
|
||||
startDate,
|
||||
endDate,
|
||||
count: allRates.length,
|
||||
data: allRates,
|
||||
fetchTime: new Date().toISOString(),
|
||||
note: '数据来源: 欧洲央行, 通过 Frankfurter API 提供'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取汇率数据失败:', error.message);
|
||||
if (error.response) {
|
||||
console.error(`HTTP状态码: ${error.response.status}`);
|
||||
console.error(`响应数据:`, error.response.data);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
fetch
|
||||
};
|
||||
|
||||
const isMainModule = () => {
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const mainFile = process.argv[1];
|
||||
return currentFile === mainFile;
|
||||
};
|
||||
|
||||
function parseCommandLineArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let startDate = null;
|
||||
let endDate = null;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--start' && i + 1 < args.length) {
|
||||
startDate = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--end' && i + 1 < args.length) {
|
||||
endDate = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--help') {
|
||||
console.log('使用方法:');
|
||||
console.log(' bun usd-cny-rate.fetcher.js [选项]');
|
||||
console.log('');
|
||||
console.log('选项:');
|
||||
console.log(' --start YYYY-MM-DD 指定开始日期(默认:30天前)');
|
||||
console.log(' --end YYYY-MM-DD 指定结束日期(默认:今天)');
|
||||
console.log(' --help 显示帮助信息');
|
||||
console.log('');
|
||||
console.log('示例:');
|
||||
console.log(' bun usd-cny-rate.fetcher.js');
|
||||
console.log(' bun usd-cny-rate.fetcher.js --start 2024-01-01 --end 2024-01-31');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const { startDate, endDate } = parseCommandLineArgs();
|
||||
|
||||
console.log('='.repeat(50));
|
||||
console.log('人民币美元汇率获取脚本');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
fetch(startDate, endDate)
|
||||
.then(result => {
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('获取结果:');
|
||||
console.log(` 货币对: ${result.currency}`);
|
||||
console.log(` 开始日期: ${result.startDate}`);
|
||||
console.log(` 结束日期: ${result.endDate}`);
|
||||
console.log(` 数据条数: ${result.count}`);
|
||||
console.log(` 获取时间: ${result.fetchTime}`);
|
||||
console.log(` 提示: ${result.note}`);
|
||||
if (result.count > 0) {
|
||||
console.log('-'.repeat(50));
|
||||
console.log('汇率数据:');
|
||||
result.data.forEach(item => {
|
||||
console.log(` ${item.date} 中间价: ${item.centerPrice}`);
|
||||
});
|
||||
}
|
||||
console.log('='.repeat(50));
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// World Bank API 数据源
|
||||
// 文档: https://datahelpdesk.worldbank.org/knowledgebase/articles/898581
|
||||
// 优点: 官方、JSON、免 key、稳定, 中国数据滞后约 1 年 (这是全球宏观数据物理上限)
|
||||
// 接口: GET /v2/country/{code}/indicator/{id}?format=json&date={startYear}:{endYear}&per_page=100
|
||||
|
||||
const WB_BASE = 'https://api.worldbank.org/v2'
|
||||
const TIMEOUT = 15000
|
||||
|
||||
// 6 个核心宏观指标
|
||||
// - 中国 GDP (现价美元) - 国民经济总规模
|
||||
// - 中国 GDP 增速 - 增长动能
|
||||
// - 中国 CPI 同比 - 通胀
|
||||
// - 中国失业率 - 就业
|
||||
// - 美国 GDP (现价美元) - 对标
|
||||
// - 美国 CPI 同比 - 对标
|
||||
export const WB_INDICATORS = [
|
||||
{ id: 'NY.GDP.MKTP.CD', name: 'china-gdp-usd', groupName: 'china-macro', title: '中国 GDP', unit: '万亿美元', scale: 1e-8, digits: 2 },
|
||||
{ id: 'NY.GDP.MKTP.KD.ZG',name: 'china-gdp-growth', groupName: 'china-macro', title: '中国 GDP 增速', unit: '%', scale: 1, digits: 2 },
|
||||
{ id: 'FP.CPI.TOTL.ZG', name: 'china-cpi', groupName: 'china-macro', title: '中国 CPI', unit: '%', scale: 1, digits: 2 },
|
||||
{ id: 'SL.UEM.TOTL.ZS', name: 'china-unemploy', groupName: 'china-macro', title: '中国失业率', unit: '%', scale: 1, digits: 2 },
|
||||
{ id: 'NY.GDP.MKTP.CD', name: 'usa-gdp-usd', groupName: 'usa-macro', title: '美国 GDP', unit: '万亿美元', scale: 1e-8, digits: 2 },
|
||||
{ id: 'FP.CPI.TOTL.ZG', name: 'usa-cpi', groupName: 'usa-macro', title: '美国 CPI', unit: '%', scale: 1, digits: 2 },
|
||||
]
|
||||
|
||||
// World Bank 中国是 CHN, 美国是 USA
|
||||
function countryCodeOf(name) {
|
||||
if (name.startsWith('china-')) return 'CHN'
|
||||
if (name.startsWith('usa-')) return 'USA'
|
||||
throw new Error('Unknown country for ' + name)
|
||||
}
|
||||
|
||||
// 拉单个指标从 startYear 到当前年
|
||||
// 返回 { name, groupName, title, unit, rows: [{ year, value }] }
|
||||
// 注意: WB 对连续请求会限流返回 400 Invalid value, 加 3 次退避重试
|
||||
export async function fetchIndicator(meta, startYear = 2015) {
|
||||
const country = countryCodeOf(meta.name)
|
||||
const currentYear = new Date().getFullYear()
|
||||
const url = `${WB_BASE}/country/${country}/indicator/${meta.id}?format=json&date=${startYear}:${currentYear}&per_page=200`
|
||||
|
||||
let resp
|
||||
const delays = [0, 1500, 4000]
|
||||
for (let attempt = 0; attempt < delays.length; attempt++) {
|
||||
if (delays[attempt]) await new Promise((r) => setTimeout(r, delays[attempt]))
|
||||
try {
|
||||
resp = await axios.get(url, { timeout: TIMEOUT, headers: { 'User-Agent': 'iboard-refresh/1.0' } })
|
||||
break
|
||||
} catch (e) {
|
||||
if (attempt === delays.length - 1) throw e
|
||||
console.log(`[WB] ${meta.name} 请求失败, ${delays[attempt + 1] / 1000}s 后重试 (${e.message})`)
|
||||
}
|
||||
}
|
||||
// WB 返回: [{page,pages,per_page,total,...}, [{indicator, country, value, date}, ...]]
|
||||
const data = resp.data
|
||||
if (!Array.isArray(data) || data.length < 2 || !Array.isArray(data[1])) {
|
||||
throw new Error('World Bank 返回结构异常: ' + JSON.stringify(data).slice(0, 200))
|
||||
}
|
||||
const points = data[1]
|
||||
.filter((p) => p.value !== null && p.value !== undefined)
|
||||
.map((p) => ({
|
||||
year: String(p.date),
|
||||
value: Number((Number(p.value) * meta.scale).toFixed(meta.digits)),
|
||||
}))
|
||||
.sort((a, b) => Number(a.year) - Number(b.year))
|
||||
|
||||
return {
|
||||
name: meta.name,
|
||||
groupName: meta.groupName,
|
||||
data: {
|
||||
unit: meta.unit,
|
||||
title: meta.title,
|
||||
source: 'World Bank API',
|
||||
rows: points,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const META = {
|
||||
name: 'wb-indicators',
|
||||
description: 'World Bank 官方 API 宏观数据',
|
||||
docs: 'https://datahelpdesk.worldbank.org/knowledgebase/articles/898581',
|
||||
}
|
||||
|
||||
// 拉全部 (返回符合 fetcher 契约的 [{name, groupName, data}, ...])
|
||||
// 错误隔离: 单个指标失败 -> 跳过 + 记日志, 不影响其他
|
||||
// opts.skipNames: Set<string> — DB 已存在的指标名, 跳过不拉 (WB 免费但也要省请求)
|
||||
export async function fetchAll(opts = {}) {
|
||||
const skip = opts.skipNames || new Set()
|
||||
const startYear = 2015
|
||||
const results = []
|
||||
for (const m of WB_INDICATORS) {
|
||||
if (skip.has(m.name)) {
|
||||
console.log(`[WB] ${m.name} 跳过 (DB 已存在)`)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const r = await fetchIndicator(m, startYear)
|
||||
console.log(`[WB] ${r.name} OK, ${r.data.rows.length} rows (${r.data.rows[0]?.year}..${r.data.rows.at(-1)?.year})`)
|
||||
results.push(r)
|
||||
} catch (e) {
|
||||
console.error(`[WB] ${m.name} FAIL: ${e.message}`)
|
||||
}
|
||||
}
|
||||
// 全部被 DB 优先跳过时, 返回显式标记而不是空数组 (避免被误判为失败)
|
||||
if (results.length === 0 && WB_INDICATORS.every((m) => skip.has(m.name))) {
|
||||
return { skipped: true, reason: 'DB 已存在全部指标' }
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// 一次性导入浙江省油价历史 (2025-2026)
|
||||
// 来源: 手工整理 (省发改委公告 / 潮新闻 / 钱江晚报 / 杭州日报 / 杭州网 等)
|
||||
// 用法: node --experimental-strip-types --no-warnings scripts/import-oil-history.mjs [--dry]
|
||||
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import 'dotenv/config'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
// 接入 prisma (用 file:// URL 避免 Windows 绝对路径 import 报错)
|
||||
const adapterMod = await import('@prisma/adapter-mariadb')
|
||||
const clientPath = pathToFileURL(path.join(ROOT, 'generated/prisma/client.ts')).href
|
||||
const { PrismaClient } = await import(clientPath)
|
||||
|
||||
const adapter = new adapterMod.PrismaMariaDb((() => {
|
||||
const u = new URL(process.env.DATABASE_URL)
|
||||
return {
|
||||
host: u.hostname,
|
||||
port: Number(u.port) || 3306,
|
||||
user: decodeURIComponent(u.username),
|
||||
password: decodeURIComponent(u.password),
|
||||
database: u.pathname.replace(/^\//, ''),
|
||||
}
|
||||
})())
|
||||
const prisma = new PrismaClient({ adapter })
|
||||
|
||||
// 数据: [调价日, 92, 95, 0, source]
|
||||
const RAW = [
|
||||
['2026-07-31', 7.94, 8.44, 7.62, 'manual'],
|
||||
['2026-07-17', 7.39, 7.86, 7.06, 'manual'],
|
||||
['2026-06-18', 7.91, 8.41, 7.59, 'manual'],
|
||||
['2026-06-04', 8.32, 8.85, 8.02, 'manual'],
|
||||
['2026-05-21', 8.74, 9.30, 8.45, 'manual'],
|
||||
['2026-02-03', 6.91, 7.35, 6.56, 'manual'],
|
||||
['2025-12-22', 6.68, 7.11, 6.32, 'manual'],
|
||||
['2025-12-08', 6.82, 7.25, 6.46, 'manual'],
|
||||
['2025-11-24', 6.86, null, null, 'manual'],
|
||||
['2025-11-10', 6.92, 7.36, 6.57, 'manual'],
|
||||
['2025-10-13', 7.03, 7.48, 6.68, 'manual'],
|
||||
['2025-06-17', 7.15, 7.60, 6.81, 'manual'],
|
||||
['2025-06-03', 6.94, 7.38, 6.59, 'manual'],
|
||||
]
|
||||
|
||||
const PROVINCE = '浙江'
|
||||
const FUEL_COLS = [
|
||||
{ fuel: '92', idx: 1 },
|
||||
{ fuel: '95', idx: 2 },
|
||||
{ fuel: '0', idx: 3 },
|
||||
]
|
||||
|
||||
const DRY = process.argv.includes('--dry')
|
||||
|
||||
async function main() {
|
||||
console.log('=== 浙江省油价历史导入 ===')
|
||||
console.log('dry run:', DRY)
|
||||
console.log('')
|
||||
|
||||
let inserted = 0
|
||||
let updated = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const row of RAW) {
|
||||
const dateStr = row[0]
|
||||
const source = row[4]
|
||||
for (const { fuel, idx } of FUEL_COLS) {
|
||||
const price = row[idx]
|
||||
if (price == null || Number.isNaN(price)) {
|
||||
console.log('[SKIP] ' + dateStr + ' ' + fuel + ': 价格为空')
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
if (DRY) {
|
||||
console.log('[DRY] ' + dateStr + ' ' + fuel + ' = ' + price + ' (source=' + source + ')')
|
||||
inserted++
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await prisma.oilPriceHistory.create({
|
||||
data: { date, province: PROVINCE, fuel, price, source },
|
||||
})
|
||||
console.log('[OK] ' + dateStr + ' ' + fuel + ' = ' + price)
|
||||
inserted++
|
||||
} catch (e) {
|
||||
if (e?.code === 'P2002') {
|
||||
try {
|
||||
await prisma.oilPriceHistory.update({
|
||||
where: { date_province_fuel: { date, province: PROVINCE, fuel } },
|
||||
data: { price, source },
|
||||
})
|
||||
console.log('[UPD] ' + dateStr + ' ' + fuel + ' = ' + price)
|
||||
updated++
|
||||
} catch (e2) {
|
||||
console.log('[ERR] ' + dateStr + ' ' + fuel + ': ' + e2.message)
|
||||
}
|
||||
} else {
|
||||
console.log('[ERR] ' + dateStr + ' ' + fuel + ': ' + e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('')
|
||||
console.log('=== 汇总 ===')
|
||||
console.log('插入: ' + inserted)
|
||||
console.log('更新: ' + updated)
|
||||
console.log('跳过: ' + skipped)
|
||||
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* 一次性数据迁移: 把 Indicator.data (JSON) 拆分到 IndicatorPoint 明细表
|
||||
* 用法: 在迁移 A (建表+加列) 之后、迁移 B (删 data 列) 之前运行
|
||||
* 幂等: 同 (indicatorId, period) 重复插入会更新 value, 可安全重跑
|
||||
*/
|
||||
require('dotenv').config()
|
||||
const mysql = require('mysql2/promise')
|
||||
|
||||
function parseUrl(url) {
|
||||
const u = new URL(url)
|
||||
return {
|
||||
host: u.hostname,
|
||||
port: Number(u.port) || 3306,
|
||||
user: decodeURIComponent(u.username),
|
||||
password: decodeURIComponent(u.password),
|
||||
database: u.pathname.replace(/^\//, ''),
|
||||
}
|
||||
}
|
||||
|
||||
// 根据 row 的时间字段推断频率: year | quarter | month | day
|
||||
function inferFrequency(row) {
|
||||
if (row && row.month !== undefined) return 'month'
|
||||
if (row && row.year !== undefined) return 'year'
|
||||
const p = String((row && row.period) || '')
|
||||
if (/^\d{4}$/.test(p)) return 'year'
|
||||
if (/^\d{4}-Q[1-4]$/.test(p)) return 'quarter'
|
||||
if (/^\d{4}-\d{2}$/.test(p)) return 'month'
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(p)) return 'day'
|
||||
return 'month'
|
||||
}
|
||||
|
||||
// 统一 row 的时间字段为 period
|
||||
function periodOf(row) {
|
||||
if (!row) return ''
|
||||
return String(row.period !== undefined ? row.period : (row.month !== undefined ? row.month : row.year))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
try {
|
||||
const [rows] = await conn.execute('SELECT id, name, groupName, data FROM Indicator')
|
||||
console.log('[Migrate] 读取', rows.length, '个 Indicator')
|
||||
|
||||
let points = 0, updated = 0
|
||||
for (const ind of rows) {
|
||||
// mysql2 对 JSON 列已自动解析为对象, 兼容字符串兜底
|
||||
let data = ind.data
|
||||
if (typeof data === 'string') {
|
||||
try { data = JSON.parse(data) } catch { data = null }
|
||||
}
|
||||
const title = (data && data.title) || ind.name
|
||||
const unit = (data && data.unit) || ''
|
||||
const source = (data && data.source) || ''
|
||||
const rowsArr = (data && Array.isArray(data.rows)) ? data.rows : []
|
||||
const freq = rowsArr.length ? inferFrequency(rowsArr[0]) : 'month'
|
||||
|
||||
// 更新 Indicator 元数据列
|
||||
await conn.execute(
|
||||
'UPDATE Indicator SET title=?, unit=?, source=?, frequency=?, updatedAt=NOW(3) WHERE id=?',
|
||||
[title, unit, source, freq, ind.id],
|
||||
)
|
||||
updated++
|
||||
|
||||
// 写入明细
|
||||
for (const r of rowsArr) {
|
||||
const period = periodOf(r)
|
||||
const value = Number(r.value)
|
||||
if (!period || Number.isNaN(value)) continue
|
||||
const [res] = await conn.execute(
|
||||
'INSERT INTO IndicatorPoint (indicatorId, period, value, createdAt, updatedAt) VALUES (?, ?, ?, NOW(3), NOW(3)) ' +
|
||||
'ON DUPLICATE KEY UPDATE value=VALUES(value), updatedAt=NOW(3)',
|
||||
[ind.id, period, value],
|
||||
)
|
||||
points += (res.affectedRows > 0 ? 1 : 0)
|
||||
}
|
||||
console.log('[Migrate]', ind.name, '->', rowsArr.length, '个点, 频率', freq)
|
||||
}
|
||||
console.log('[Migrate] 完成: 更新', updated, '个 Indicator, 写入/更新', points, '个明细点')
|
||||
} finally {
|
||||
await conn.end()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('[Migrate] 失败:', e); process.exit(1) })
|
||||
@@ -1,185 +0,0 @@
|
||||
/**
|
||||
* 拉所有启用的数据源, upsert 到 Indicator + IndicatorPoint 明细表
|
||||
* 用法:
|
||||
* npm run data:refresh # 只拉 DB 里没有的指标
|
||||
* npm run data:refresh -- --force a,b,c # 强制刷新指定指标 (忽略 DB 已有)
|
||||
*
|
||||
* 数据结构 (2026-08 重构):
|
||||
* Indicator 元数据: name/groupName/title/unit/source/frequency/hidden
|
||||
* IndicatorPoint 明细: (indicatorId, period, value), period 兼容年/季/月/日
|
||||
*
|
||||
* DB 优先策略:
|
||||
* 1. 先从 DB 查已存在的 name 集合
|
||||
* 2. 把集合传给每个 fetcher, fetcher 跳过已存在的
|
||||
* 3. 只对 DB 没有的指标调远程拉取, 节省 token (尤其妙想 MCP 这种 LLM 接口)
|
||||
*
|
||||
* 加新数据源: 写 scripts/fetchers/<name>.fetcher.js 导出 META + fetchAll(opts),
|
||||
* 然后在 scripts/fetchers/index.js 的 FETCHERS 注册
|
||||
*/
|
||||
require('dotenv').config()
|
||||
const mysql = require('mysql2/promise')
|
||||
|
||||
// 隐藏指标: 数据与获取脚本保留, 但不参与前端展示 (API 过滤)
|
||||
const HIDDEN_INDICATORS = new Set([
|
||||
'china-gdp-usd', // 中国 GDP (现价美元)
|
||||
'china-gdp-growth', // 中国 GDP 同比 (World Bank)
|
||||
'china-gdp-yoy', // 中国 GDP 同比 (妙想季度)
|
||||
'china-unemploy', // 中国失业率
|
||||
'usa-gdp-usd', // 美国 GDP
|
||||
])
|
||||
|
||||
function parseUrl(url) {
|
||||
const u = new URL(url)
|
||||
return {
|
||||
host: u.hostname,
|
||||
port: Number(u.port) || 3306,
|
||||
user: decodeURIComponent(u.username),
|
||||
password: decodeURIComponent(u.password),
|
||||
database: u.pathname.replace(/^\//, ''),
|
||||
}
|
||||
}
|
||||
|
||||
function parseForceArg(argv) {
|
||||
const i = argv.indexOf('--force')
|
||||
if (i === -1) return new Set()
|
||||
return new Set(String(argv[i + 1] || '').split(',').map((s) => s.trim()).filter(Boolean))
|
||||
}
|
||||
|
||||
// 推断频率: year | quarter | month | day (优先用 fetcher 显式声明)
|
||||
function inferFrequency(data) {
|
||||
if (data && data.frequency) return data.frequency
|
||||
const p = String(((data && data.rows) || [])[0]?.period || '')
|
||||
if (/^\d{4}$/.test(p)) return 'year'
|
||||
if (/^\d{4}-Q[1-4]$/.test(p)) return 'quarter'
|
||||
if (/^\d{4}-\d{2}$/.test(p)) return 'month'
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(p)) return 'day'
|
||||
return 'month'
|
||||
}
|
||||
|
||||
// 写 Indicator 元数据 + 全量替换明细点 (每指标一个事务)
|
||||
async function upsertIndicatorWithPoints(conn, payload) {
|
||||
const { name, groupName, data, hidden } = payload
|
||||
const title = data.title || name
|
||||
const unit = data.unit || ''
|
||||
const source = data.source || ''
|
||||
const frequency = inferFrequency(data)
|
||||
const rows = Array.isArray(data.rows) ? data.rows : []
|
||||
|
||||
await conn.beginTransaction()
|
||||
try {
|
||||
const [res] = await conn.execute(
|
||||
'INSERT INTO `Indicator` (`name`, `groupName`, `title`, `unit`, `source`, `frequency`, `hidden`, `createdAt`, `updatedAt`) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, NOW(3), NOW(3)) ' +
|
||||
'ON DUPLICATE KEY UPDATE `groupName` = VALUES(`groupName`), `title` = VALUES(`title`), ' +
|
||||
'`unit` = VALUES(`unit`), `source` = VALUES(`source`), `frequency` = VALUES(`frequency`), ' +
|
||||
'`hidden` = VALUES(`hidden`), `updatedAt` = NOW(3)',
|
||||
[name, groupName, title, unit, source, frequency, hidden ? 1 : 0],
|
||||
)
|
||||
const indicatorId = res.insertId
|
||||
|
||||
// 明细: 先删旧点再插入新点 (全量替换, 保证与数据源一致)
|
||||
await conn.execute('DELETE FROM `IndicatorPoint` WHERE `indicatorId` = ?', [indicatorId])
|
||||
let points = 0
|
||||
for (const r of rows) {
|
||||
const period = String(r.period !== undefined ? r.period : (r.month !== undefined ? r.month : r.year))
|
||||
const value = Number(r.value)
|
||||
if (!period || Number.isNaN(value)) continue
|
||||
await conn.execute(
|
||||
'INSERT INTO `IndicatorPoint` (`indicatorId`, `period`, `value`, `createdAt`, `updatedAt`) VALUES (?, ?, ?, NOW(3), NOW(3))',
|
||||
[indicatorId, period, value],
|
||||
)
|
||||
points++
|
||||
}
|
||||
await conn.commit()
|
||||
return { indicatorId, points }
|
||||
} catch (e) {
|
||||
await conn.rollback()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchExistingNames(conn) {
|
||||
const [rows] = await conn.execute('SELECT `name` FROM `Indicator`')
|
||||
return new Set(rows.map((r) => r.name))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('缺少 DATABASE_URL 环境变量')
|
||||
process.exit(1)
|
||||
}
|
||||
const force = parseForceArg(process.argv.slice(2))
|
||||
if (force.size) console.log('[Refresh] 强制刷新: ' + [...force].join(', '))
|
||||
|
||||
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
try {
|
||||
// 1) 查 DB 已存在的指标名
|
||||
const existing = await fetchExistingNames(conn)
|
||||
console.log('[Refresh] DB 已有 ' + existing.size + ' 个指标: ' + ([...existing].join(', ') || '(空)'))
|
||||
|
||||
// 2) 跑所有 fetcher, 传入 existing + force (DB 优先, force 覆盖)
|
||||
const { runFetchers } = await import('./fetchers/index.js')
|
||||
const fetcherResults = await runFetchers({ skipNames: existing, forceNames: force })
|
||||
|
||||
// 3) 汇总: force 名单里的指标即使 DB 已有也写入
|
||||
const allItems = []
|
||||
let skipped = 0, failed = 0
|
||||
for (const r of fetcherResults) {
|
||||
if (r.skipped) { skipped++; continue }
|
||||
if (!r.ok) { failed++; continue }
|
||||
for (const it of r.items) {
|
||||
if (existing.has(it.name) && !force.has(it.name)) continue
|
||||
allItems.push(it)
|
||||
}
|
||||
}
|
||||
console.log('\n[Refresh] 汇总: ' + allItems.length + ' 个指标待写 (跳过 ' + skipped + ' 个数据源, 失败 ' + failed + ' 个, DB 已存在 ' + existing.size + ' 个)')
|
||||
|
||||
if (failed > 0) {
|
||||
console.error('[Refresh] 警告: 有数据源失败, 仅写入成功的指标')
|
||||
}
|
||||
|
||||
// 5) 同步隐藏标记: 即使指标被 DB 优先跳过, 名单内的也确保 hidden=true
|
||||
if (HIDDEN_INDICATORS.size > 0) {
|
||||
const names = [...HIDDEN_INDICATORS]
|
||||
const ph = names.map(() => '?').join(',')
|
||||
const sql = "UPDATE `Indicator` SET `hidden` = 1, `updatedAt` = NOW(3) WHERE `name` IN (" + ph + ")"
|
||||
await conn.execute(sql, names)
|
||||
console.log('[Refresh] 同步隐藏标记: ' + names.length + ' 个指标 hidden=true (数据保留, 不展示)')
|
||||
}
|
||||
|
||||
// 6) 跨源对比报警: 全量跑一次 (即便无新指标也跑, 防止上次漏检)
|
||||
try {
|
||||
const m = await import('../server/alert-engine.js')
|
||||
const a = await m.checkAfterRefresh(conn)
|
||||
console.log('[Refresh] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
|
||||
} catch (e) { console.error('[Refresh] 报警检测异常:', e.message) }
|
||||
|
||||
if (allItems.length === 0) {
|
||||
console.log('[Refresh] 无指标待写, 不写库 (DB 优先策略生效)')
|
||||
return
|
||||
}
|
||||
|
||||
// 4) 写库
|
||||
console.log('[Refresh] 写入数据库...')
|
||||
let ins = 0, upd = 0, totalPoints = 0
|
||||
for (const it of allItems) {
|
||||
const hidden = HIDDEN_INDICATORS.has(it.name)
|
||||
const { points } = await upsertIndicatorWithPoints(conn, { name: it.name, groupName: it.groupName, data: it.data, hidden })
|
||||
if (points > 0) ins++
|
||||
else upd++
|
||||
totalPoints += points
|
||||
console.log('[Refresh] ' + it.name + (hidden ? ' (hidden)' : '') + ': ' + points + ' 个明细点')
|
||||
}
|
||||
|
||||
|
||||
} finally {
|
||||
await conn.end()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[Refresh] 异常:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
import { createPool } from 'mariadb';
|
||||
import { fetch as fetchSH300Data } from '../fetchers/sh300.fetcher.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
let pool;
|
||||
|
||||
function loadDatabaseConfig() {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
console.log('读取 .env 文件...');
|
||||
const envContent = fs.readFileSync(envPath, 'utf-8');
|
||||
const lines = envContent.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine.startsWith('DATABASE_URL=')) {
|
||||
const url = trimmedLine.substring('DATABASE_URL='.length).replace(/^"|"$/g, '');
|
||||
console.log('从 .env 文件获取数据库配置成功!');
|
||||
console.log(`DATABASE_URL: ${url}`);
|
||||
|
||||
// 解析URL
|
||||
const parts = url.split('://')[1].split('@');
|
||||
const auth = parts[0].split(':');
|
||||
const hostAndDb = parts[1].split('/');
|
||||
const hostAndPort = hostAndDb[0].split(':');
|
||||
|
||||
return {
|
||||
user: auth[0],
|
||||
password: auth[1],
|
||||
host: hostAndPort[0],
|
||||
port: parseInt(hostAndPort[1]),
|
||||
database: hostAndDb[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('未找到 .env 文件或 DATABASE_URL 配置,使用默认配置');
|
||||
return {
|
||||
host: '192.168.111.111',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
password: 'fullstack',
|
||||
database: 'iboard'
|
||||
};
|
||||
}
|
||||
|
||||
async function initPool() {
|
||||
if (!pool) {
|
||||
console.log('初始化数据库连接池...');
|
||||
const config = loadDatabaseConfig();
|
||||
|
||||
console.log('数据库配置:');
|
||||
console.log(` 主机: ${config.host}`);
|
||||
console.log(` 端口: ${config.port}`);
|
||||
console.log(` 用户: ${config.user}`);
|
||||
console.log(` 数据库: ${config.database}`);
|
||||
|
||||
pool = createPool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
connectionLimit: 5
|
||||
});
|
||||
|
||||
try {
|
||||
const conn = await pool.getConnection();
|
||||
console.log('数据库连接成功!');
|
||||
conn.release();
|
||||
} catch (error) {
|
||||
console.error('数据库连接失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
async function checkAndFillSH300Data(startDate = null, endDate = null) {
|
||||
try {
|
||||
await initPool();
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log('开始检查和补充沪深300指数数据...');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const currentDate = new Date();
|
||||
|
||||
if (!startDate) {
|
||||
startDate = new Date(currentDate);
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
startDate = startDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
if (!endDate) {
|
||||
endDate = currentDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
console.log(`检查时间范围: ${startDate} 至 ${endDate}`);
|
||||
|
||||
// 1. 检查数据库中是否存在沪深300指数数据表
|
||||
const hasTable = await checkTableExists();
|
||||
if (!hasTable) {
|
||||
console.log('创建沪深300指数数据表...');
|
||||
await createSH300Table();
|
||||
}
|
||||
|
||||
// 2. 检查数据库中已有的数据
|
||||
const existingData = await getExistingSH300Data(startDate, endDate);
|
||||
console.log(`数据库中已有 ${existingData.length} 条数据`);
|
||||
|
||||
// 3. 计算缺失的日期
|
||||
const missingDates = calculateMissingDates(startDate, endDate, existingData);
|
||||
console.log(`缺失 ${missingDates.length} 天的数据`);
|
||||
|
||||
// 4. 如果有缺失,调用 fetcher 补足
|
||||
if (missingDates.length > 0) {
|
||||
console.log('\n开始获取缺失的沪深300指数数据...');
|
||||
|
||||
const firstMissingDate = missingDates[0];
|
||||
const lastMissingDate = missingDates[missingDates.length - 1];
|
||||
|
||||
console.log(`获取范围: ${firstMissingDate} 至 ${lastMissingDate}`);
|
||||
|
||||
const fetchedData = await fetchSH300Data(firstMissingDate, lastMissingDate);
|
||||
|
||||
console.log(`\n获取到 ${fetchedData.count} 条数据`);
|
||||
|
||||
// 5. 存储新数据到数据库
|
||||
const storedCount = await storeSH300Data(fetchedData.data);
|
||||
console.log(`成功存储 ${storedCount} 条数据`);
|
||||
} else {
|
||||
console.log('\n数据库数据完整,无需补充');
|
||||
}
|
||||
|
||||
// 6. 验证最终数据完整性
|
||||
const finalData = await getExistingSH300Data(startDate, endDate);
|
||||
console.log(`\n验证后的数据总量: ${finalData.length} 条`);
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
existingCount: existingData.length,
|
||||
missingCount: missingDates.length,
|
||||
finalCount: finalData.length,
|
||||
success: true
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('检查和补充沪深300指数数据失败:', error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
console.log('数据库连接池已关闭');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkTableExists() {
|
||||
try {
|
||||
console.log('检查沪深300指数数据表是否存在...');
|
||||
const result = await pool.query('SHOW TABLES LIKE ?', ['econ_SH300Index']);
|
||||
return result.length > 0;
|
||||
} catch (error) {
|
||||
console.error('检查表存在失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSH300Table() {
|
||||
try {
|
||||
console.log('执行创建沪深300指数数据表SQL...');
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS econ_SH300Index (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
date DATE UNIQUE NOT NULL,
|
||||
symbol VARCHAR(10) NOT NULL,
|
||||
open DECIMAL(10,2) NOT NULL,
|
||||
close DECIMAL(10,2) NOT NULL,
|
||||
high DECIMAL(10,2) NOT NULL,
|
||||
low DECIMAL(10,2) NOT NULL,
|
||||
volume DECIMAL(20,2) NOT NULL,
|
||||
amount DECIMAL(20,2) NOT NULL,
|
||||
source VARCHAR(100) NOT NULL,
|
||||
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
console.log('沪深300指数数据表创建成功');
|
||||
} catch (error) {
|
||||
console.error('创建沪深300指数数据表失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getExistingSH300Data(startDate, endDate) {
|
||||
try {
|
||||
console.log('查询已有沪深300指数数据...');
|
||||
const result = await pool.query(
|
||||
'SELECT date FROM econ_SH300Index WHERE date BETWEEN ? AND ? ORDER BY date',
|
||||
[startDate, endDate]
|
||||
);
|
||||
const dates = result.map(row => {
|
||||
if (row.date instanceof Date) {
|
||||
return row.date.toISOString().split('T')[0];
|
||||
} else if (typeof row.date === 'string') {
|
||||
return row.date;
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
console.log(`查询到 ${dates.length} 条数据`);
|
||||
return dates;
|
||||
} catch (error) {
|
||||
console.error('查询已有沪深300指数数据失败:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function calculateMissingDates(startDate, endDate, existingDates) {
|
||||
const existingSet = new Set(existingDates);
|
||||
const missingDates = [];
|
||||
|
||||
const currentDate = new Date(startDate);
|
||||
const finalDate = new Date(endDate);
|
||||
|
||||
while (currentDate <= finalDate) {
|
||||
const dateStr = currentDate.toISOString().split('T')[0];
|
||||
const dayOfWeek = currentDate.getDay();
|
||||
|
||||
// 跳过周末(周末没有股市数据)
|
||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
||||
if (!existingSet.has(dateStr)) {
|
||||
missingDates.push(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
return missingDates;
|
||||
}
|
||||
|
||||
async function storeSH300Data(data) {
|
||||
let storedCount = 0;
|
||||
|
||||
for (const item of data) {
|
||||
try {
|
||||
await pool.query(
|
||||
'INSERT INTO econ_SH300Index (date, symbol, open, close, high, low, volume, amount, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE open = VALUES(open), close = VALUES(close), high = VALUES(high), low = VALUES(low), volume = VALUES(volume), amount = VALUES(amount), source = VALUES(source)',
|
||||
[item.date, 'sh000300', item.open, item.close, item.high, item.low, item.volume, item.amount, item.source]
|
||||
);
|
||||
storedCount++;
|
||||
} catch (error) {
|
||||
console.error(`存储沪深300指数数据 ${item.date} 失败:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return storedCount;
|
||||
}
|
||||
|
||||
export async function trigger(startDate = null, endDate = null) {
|
||||
return await checkAndFillSH300Data(startDate, endDate);
|
||||
}
|
||||
|
||||
function parseCommandLineArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let startDate = null;
|
||||
let endDate = null;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--start' && i + 1 < args.length) {
|
||||
startDate = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--end' && i + 1 < args.length) {
|
||||
endDate = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--help') {
|
||||
console.log('使用方法:');
|
||||
console.log(' bun sh300.scraper.js [选项]');
|
||||
console.log('');
|
||||
console.log('选项:');
|
||||
console.log(' --start YYYY-MM-DD 指定开始日期');
|
||||
console.log(' --end YYYY-MM-DD 指定结束日期');
|
||||
console.log(' --help 显示帮助信息');
|
||||
console.log('');
|
||||
console.log('示例:');
|
||||
console.log(' bun sh300.scraper.js');
|
||||
console.log(' bun sh300.scraper.js --start 2024-01-01 --end 2024-01-31');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
console.log('='.repeat(60));
|
||||
console.log('测试 sh300.scraper.js');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const { startDate, endDate } = parseCommandLineArgs();
|
||||
|
||||
if (startDate) {
|
||||
console.log(`\n使用命令行参数:`);
|
||||
console.log(` 开始日期: ${startDate}`);
|
||||
console.log(` 结束日期: ${endDate || '默认'}`);
|
||||
}
|
||||
|
||||
console.log('\n测试数据库配置读取...');
|
||||
const config = loadDatabaseConfig();
|
||||
console.log('配置读取结果:');
|
||||
console.log(` 主机: ${config.host}`);
|
||||
console.log(` 端口: ${config.port}`);
|
||||
console.log(` 用户: ${config.user}`);
|
||||
console.log(` 数据库: ${config.database}`);
|
||||
console.log('配置读取测试完成!');
|
||||
|
||||
try {
|
||||
const result = await trigger(startDate, endDate);
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('测试结果:');
|
||||
console.log(` 开始日期: ${result.startDate}`);
|
||||
console.log(` 结束日期: ${result.endDate}`);
|
||||
console.log(` 原有数据: ${result.existingCount} 条`);
|
||||
console.log(` 缺失数据: ${result.missingCount} 条`);
|
||||
console.log(` 最终数据: ${result.finalCount} 条`);
|
||||
console.log(` 操作成功: ${result.success}`);
|
||||
console.log('='.repeat(60));
|
||||
} catch (error) {
|
||||
console.error('测试失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTest();
|
||||
@@ -1,335 +0,0 @@
|
||||
import { createPool } from 'mariadb';
|
||||
import { fetch as fetchUSDToCNYRate } from '../fetchers/usd-cny-rate.fetcher.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
let pool;
|
||||
|
||||
function loadDatabaseConfig() {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
console.log('读取 .env 文件...');
|
||||
const envContent = fs.readFileSync(envPath, 'utf-8');
|
||||
const lines = envContent.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine.startsWith('DATABASE_URL=')) {
|
||||
const url = trimmedLine.substring('DATABASE_URL='.length).replace(/^"|"$/g, '');
|
||||
console.log('从 .env 文件获取数据库配置成功!');
|
||||
console.log(`DATABASE_URL: ${url}`);
|
||||
|
||||
// 解析URL
|
||||
const parts = url.split('://')[1].split('@');
|
||||
const auth = parts[0].split(':');
|
||||
const hostAndDb = parts[1].split('/');
|
||||
const hostAndPort = hostAndDb[0].split(':');
|
||||
|
||||
return {
|
||||
user: auth[0],
|
||||
password: auth[1],
|
||||
host: hostAndPort[0],
|
||||
port: parseInt(hostAndPort[1]),
|
||||
database: hostAndDb[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('未找到 .env 文件或 DATABASE_URL 配置,使用默认配置');
|
||||
return {
|
||||
host: '192.168.111.111',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
password: 'fullstack',
|
||||
database: 'iboard'
|
||||
};
|
||||
}
|
||||
|
||||
async function initPool() {
|
||||
if (!pool) {
|
||||
console.log('初始化数据库连接池...');
|
||||
const config = loadDatabaseConfig();
|
||||
|
||||
console.log('数据库配置:');
|
||||
console.log(` 主机: ${config.host}`);
|
||||
console.log(` 端口: ${config.port}`);
|
||||
console.log(` 用户: ${config.user}`);
|
||||
console.log(` 数据库: ${config.database}`);
|
||||
|
||||
pool = createPool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
connectionLimit: 5
|
||||
});
|
||||
|
||||
try {
|
||||
const conn = await pool.getConnection();
|
||||
console.log('数据库连接成功!');
|
||||
conn.release();
|
||||
} catch (error) {
|
||||
console.error('数据库连接失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
async function checkAndFillUSDCNYRate(startDate = null, endDate = null) {
|
||||
try {
|
||||
await initPool();
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log('开始检查和补充人民币美元汇率数据...');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const currentDate = new Date();
|
||||
|
||||
if (!startDate) {
|
||||
startDate = new Date(currentDate);
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
startDate = startDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
if (!endDate) {
|
||||
endDate = currentDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
console.log(`检查时间范围: ${startDate} 至 ${endDate}`);
|
||||
|
||||
// 1. 检查数据库中是否存在汇率数据表
|
||||
const hasTable = await checkTableExists();
|
||||
if (!hasTable) {
|
||||
console.log('创建汇率数据表...');
|
||||
await createExchangeRateTable();
|
||||
}
|
||||
|
||||
// 2. 检查数据库中已有的数据
|
||||
const existingData = await getExistingExchangeRates(startDate, endDate);
|
||||
console.log(`数据库中已有 ${existingData.length} 条数据`);
|
||||
|
||||
// 3. 计算缺失的日期
|
||||
const missingDates = calculateMissingDates(startDate, endDate, existingData);
|
||||
console.log(`缺失 ${missingDates.length} 天的数据`);
|
||||
|
||||
// 4. 如果有缺失,调用 fetcher 补足
|
||||
if (missingDates.length > 0) {
|
||||
console.log('\n开始获取缺失的汇率数据...');
|
||||
|
||||
const firstMissingDate = missingDates[0];
|
||||
const lastMissingDate = missingDates[missingDates.length - 1];
|
||||
|
||||
console.log(`获取范围: ${firstMissingDate} 至 ${lastMissingDate}`);
|
||||
|
||||
const fetchedData = await fetchUSDToCNYRate(firstMissingDate, lastMissingDate);
|
||||
|
||||
console.log(`\n获取到 ${fetchedData.count} 条数据`);
|
||||
|
||||
// 5. 存储新数据到数据库
|
||||
const storedCount = await storeExchangeRates(fetchedData.data);
|
||||
console.log(`成功存储 ${storedCount} 条数据`);
|
||||
} else {
|
||||
console.log('\n数据库数据完整,无需补充');
|
||||
}
|
||||
|
||||
// 6. 验证最终数据完整性
|
||||
const finalData = await getExistingExchangeRates(startDate, endDate);
|
||||
console.log(`\n验证后的数据总量: ${finalData.length} 条`);
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
existingCount: existingData.length,
|
||||
missingCount: missingDates.length,
|
||||
finalCount: finalData.length,
|
||||
success: true
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('检查和补充汇率数据失败:', error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
console.log('数据库连接池已关闭');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkTableExists() {
|
||||
try {
|
||||
console.log('检查汇率数据表是否存在...');
|
||||
const result = await pool.query('SHOW TABLES LIKE ?', ['econ_ExchangeRate']);
|
||||
return result.length > 0;
|
||||
} catch (error) {
|
||||
console.error('检查表存在失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createExchangeRateTable() {
|
||||
try {
|
||||
console.log('执行创建汇率数据表SQL...');
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS econ_ExchangeRate (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
date DATE UNIQUE NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
centerPrice DECIMAL(10,4) NOT NULL,
|
||||
sellingRate DECIMAL(10,4) NOT NULL,
|
||||
buyingRate DECIMAL(10,4) NOT NULL,
|
||||
source VARCHAR(100) NOT NULL,
|
||||
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
console.log('汇率数据表创建成功');
|
||||
} catch (error) {
|
||||
console.error('创建汇率数据表失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getExistingExchangeRates(startDate, endDate) {
|
||||
try {
|
||||
console.log('查询已有汇率数据...');
|
||||
const result = await pool.query(
|
||||
'SELECT date FROM econ_ExchangeRate WHERE date BETWEEN ? AND ? ORDER BY date',
|
||||
[startDate, endDate]
|
||||
);
|
||||
const dates = result.map(row => {
|
||||
if (row.date instanceof Date) {
|
||||
return row.date.toISOString().split('T')[0];
|
||||
} else if (typeof row.date === 'string') {
|
||||
return row.date;
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
console.log(`查询到 ${dates.length} 条数据`);
|
||||
return dates;
|
||||
} catch (error) {
|
||||
console.error('查询已有汇率数据失败:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function calculateMissingDates(startDate, endDate, existingDates) {
|
||||
const existingSet = new Set(existingDates);
|
||||
const missingDates = [];
|
||||
|
||||
const currentDate = new Date(startDate);
|
||||
const finalDate = new Date(endDate);
|
||||
|
||||
while (currentDate <= finalDate) {
|
||||
const dateStr = currentDate.toISOString().split('T')[0];
|
||||
const dayOfWeek = currentDate.getDay();
|
||||
|
||||
// 跳过周末(周末没有汇率数据)
|
||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
||||
if (!existingSet.has(dateStr)) {
|
||||
missingDates.push(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
return missingDates;
|
||||
}
|
||||
|
||||
async function storeExchangeRates(rates) {
|
||||
let storedCount = 0;
|
||||
|
||||
for (const rate of rates) {
|
||||
try {
|
||||
await pool.query(
|
||||
'INSERT INTO econ_ExchangeRate (date, currency, centerPrice, sellingRate, buyingRate, source) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE centerPrice = VALUES(centerPrice), sellingRate = VALUES(sellingRate), buyingRate = VALUES(buyingRate), source = VALUES(source)',
|
||||
[rate.date, rate.currency, rate.centerPrice, rate.sellingRate, rate.buyingRate, rate.source]
|
||||
);
|
||||
storedCount++;
|
||||
} catch (error) {
|
||||
console.error(`存储汇率数据 ${rate.date} 失败:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return storedCount;
|
||||
}
|
||||
|
||||
export async function trigger(startDate = null, endDate = null) {
|
||||
return await checkAndFillUSDCNYRate(startDate, endDate);
|
||||
}
|
||||
|
||||
function parseCommandLineArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let startDate = null;
|
||||
let endDate = null;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--start' && i + 1 < args.length) {
|
||||
startDate = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--end' && i + 1 < args.length) {
|
||||
endDate = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--help') {
|
||||
console.log('使用方法:');
|
||||
console.log(' bun usd-cny-rate.scraper.js [选项]');
|
||||
console.log('');
|
||||
console.log('选项:');
|
||||
console.log(' --start YYYY-MM-DD 指定开始日期');
|
||||
console.log(' --end YYYY-MM-DD 指定结束日期');
|
||||
console.log(' --help 显示帮助信息');
|
||||
console.log('');
|
||||
console.log('示例:');
|
||||
console.log(' bun usd-cny-rate.scraper.js');
|
||||
console.log(' bun usd-cny-rate.scraper.js --start 2024-01-01 --end 2024-01-31');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
console.log('='.repeat(60));
|
||||
console.log('测试 usd-cny-rate.scraper.js');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const { startDate, endDate } = parseCommandLineArgs();
|
||||
|
||||
if (startDate) {
|
||||
console.log(`\n使用命令行参数:`);
|
||||
console.log(` 开始日期: ${startDate}`);
|
||||
console.log(` 结束日期: ${endDate || '默认'}`);
|
||||
}
|
||||
|
||||
console.log('\n测试数据库配置读取...');
|
||||
const config = loadDatabaseConfig();
|
||||
console.log('配置读取结果:');
|
||||
console.log(` 主机: ${config.host}`);
|
||||
console.log(` 端口: ${config.port}`);
|
||||
console.log(` 用户: ${config.user}`);
|
||||
console.log(` 数据库: ${config.database}`);
|
||||
console.log('配置读取测试完成!');
|
||||
|
||||
try {
|
||||
const result = await trigger(startDate, endDate);
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('测试结果:');
|
||||
console.log(` 开始日期: ${result.startDate}`);
|
||||
console.log(` 结束日期: ${result.endDate}`);
|
||||
console.log(` 原有数据: ${result.existingCount} 条`);
|
||||
console.log(` 缺失数据: ${result.missingCount} 条`);
|
||||
console.log(` 最终数据: ${result.finalCount} 条`);
|
||||
console.log(` 操作成功: ${result.success}`);
|
||||
console.log('='.repeat(60));
|
||||
} catch (error) {
|
||||
console.error('测试失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTest();
|
||||
File diff suppressed because one or more lines are too long
+1
-47
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* iBoard 基础数据初始化脚本
|
||||
* 用法: npm run db:seed
|
||||
* 幂等: 主键/唯一键冲突跳过, Strategy/Indicator 按 name 存在则更新
|
||||
* 幂等: 主键/唯一键冲突跳过
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
@@ -35,19 +35,6 @@ async function insertIgnore(conn, sql, params) {
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertByName(conn, table, payload, keyField) {
|
||||
const fields = Object.keys(payload);
|
||||
const placeholders = fields.map(() => '?').join(', ');
|
||||
const updateClause = fields
|
||||
.filter((f) => f !== keyField)
|
||||
.map((f) => '`' + f + '` = VALUES(`' + f + '`)')
|
||||
.join(', ');
|
||||
const sql = 'INSERT INTO `' + table + '` (' + fields.map((f) => '`' + f + '`').join(', ') + ') VALUES (' + placeholders + ') ON DUPLICATE KEY UPDATE ' + updateClause;
|
||||
const params = fields.map((f) => payload[f]);
|
||||
const [r] = await conn.execute(sql, params);
|
||||
return r.affectedRows;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cfg = parseUrl(process.env.DATABASE_URL);
|
||||
if (!cfg.host) throw new Error('DATABASE_URL 未配置或解析失败');
|
||||
@@ -62,39 +49,6 @@ async function main() {
|
||||
);
|
||||
console.log('[User] 新增管理员: ' + n1 + ' 行 (admin@iboard.local / admin123)');
|
||||
|
||||
const strategies = [
|
||||
{ name: 'us-debt-gdp', description: '美国政府债务/GDP分析',
|
||||
data: { source: 'strategy.js', unit: '万亿美元', rows: [
|
||||
{ year: '2019', debt: 22.7, gdp: 21.4, debtToGdp: 106.1, interestToGdp: 1.8 },
|
||||
{ year: '2020', debt: 26.9, gdp: 20.9, debtToGdp: 128.7, interestToGdp: 1.6 },
|
||||
{ year: '2021', debt: 28.4, gdp: 23.0, debtToGdp: 123.5, interestToGdp: 1.5 },
|
||||
{ year: '2022', debt: 30.9, gdp: 25.4, debtToGdp: 121.6, interestToGdp: 1.6 },
|
||||
{ year: '2023', debt: 33.1, gdp: 26.9, debtToGdp: 123.0, interestToGdp: 1.9 },
|
||||
{ year: '2024', debt: 35.3, gdp: 28.5, debtToGdp: 123.9, interestToGdp: 2.1 },
|
||||
]},
|
||||
},
|
||||
{ name: 'china-debt-gdp', description: '中国政府债务/GDP分析',
|
||||
data: { source: 'strategy.js', unit: '万亿美元', rows: [
|
||||
{ year: '2019', debt: 8.8, gdp: 14.3, debtToGdp: 61.5, interestToGdp: 1.9 },
|
||||
{ year: '2020', debt: 10.3, gdp: 14.7, debtToGdp: 70.1, interestToGdp: 1.8 },
|
||||
{ year: '2021', debt: 11.7, gdp: 16.1, debtToGdp: 72.7, interestToGdp: 1.7 },
|
||||
{ year: '2022', debt: 13.1, gdp: 17.9, debtToGdp: 73.2, interestToGdp: 1.6 },
|
||||
{ year: '2023', debt: 14.5, gdp: 19.4, debtToGdp: 74.7, interestToGdp: 1.6 },
|
||||
{ year: '2024', debt: 15.9, gdp: 21.0, debtToGdp: 75.7, interestToGdp: 1.5 },
|
||||
]},
|
||||
},
|
||||
];
|
||||
let n2 = 0;
|
||||
for (const s of strategies) {
|
||||
n2 += await upsertByName(conn, 'Strategy',
|
||||
{ name: s.name, description: s.description, data: JSON.stringify(s.data), updatedAt: new Date() },
|
||||
'name');
|
||||
}
|
||||
console.log('[Strategy] upsert 策略: ' + n2 + ' 行 (1=新增, 2=更新)');
|
||||
|
||||
// Indicator 表: 改由 scripts/refresh-indicators.cjs 管理 (2026-08 重构后数据存 IndicatorPoint 明细表)
|
||||
// 本脚本不再写入 Indicator, 如需初始化指标请运行 npm run data:refresh
|
||||
|
||||
const article = {
|
||||
title: '欢迎使用 iBoard',
|
||||
slug: 'welcome-to-iboard',
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
(async () => {
|
||||
const u = new URL(process.env.DATABASE_URL);
|
||||
const c = await mysql.createConnection({
|
||||
host: u.hostname, port: Number(u.port),
|
||||
user: decodeURIComponent(u.username),
|
||||
password: decodeURIComponent(u.password),
|
||||
database: u.pathname.replace(/^\//, ''),
|
||||
});
|
||||
const [users] = await c.query('SELECT id,name,email,LEFT(password,20) AS pwd_prefix,createdAt FROM User');
|
||||
console.log('User:'); console.table(users);
|
||||
const [sow] = await c.query('SELECT * FROM Econ_SowInventory ORDER BY month');
|
||||
console.log('Econ_SowInventory:'); console.table(sow);
|
||||
const [str] = await c.query('SELECT id,name,description,JSON_LENGTH(data,"$.rows") AS row_count FROM Strategy');
|
||||
console.log('Strategy:'); console.table(str);
|
||||
const [ind] = await c.query('SELECT id,name,groupName,JSON_LENGTH(data,"$.rows") AS row_count FROM Indicator');
|
||||
console.log('Indicator:'); console.table(ind);
|
||||
const [art] = await c.query('SELECT id,title,slug,date FROM Article');
|
||||
console.log('Article:'); console.table(art);
|
||||
await c.end();
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -1,31 +0,0 @@
|
||||
import 'dotenv/config'
|
||||
import { PrismaMariaDb } from '@prisma/adapter-mariadb'
|
||||
import { PrismaClient } from '../generated/prisma/client.ts'
|
||||
|
||||
function parseDatabaseUrl(url) {
|
||||
const u = new URL(url)
|
||||
return {
|
||||
host: u.hostname,
|
||||
port: Number(u.port) || 3306,
|
||||
user: decodeURIComponent(u.username),
|
||||
password: decodeURIComponent(u.password),
|
||||
database: u.pathname.replace(/^\//, ''),
|
||||
}
|
||||
}
|
||||
|
||||
const p = new PrismaClient({ adapter: new PrismaMariaDb(parseDatabaseUrl(process.env.DATABASE_URL)) })
|
||||
try {
|
||||
const users = await p.user.findMany()
|
||||
console.log('User count:', users.length, '->', users.map((u) => u.email).join(','))
|
||||
const strategies = await p.strategy.findMany({ select: { id: true, name: true } })
|
||||
console.log('Strategy count:', strategies.length, '->', strategies.map((s) => s.name).join(','))
|
||||
const indicators = await p.indicator.findMany({ select: { id: true, name: true } })
|
||||
console.log('Indicator count:', indicators.length, '->', indicators.map((i) => i.name).join(','))
|
||||
const sow = await p.econ_SowInventory.findMany({ orderBy: { month: 'asc' } })
|
||||
console.log('SowInventory count:', sow.length, '->', sow.map((s) => s.month + '=' + s.inventory).join(','))
|
||||
} catch (e) {
|
||||
console.error('ERR:', e.message)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await p.$disconnect()
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
(async () => {
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
const conn = await mysql.createConnection({
|
||||
host: url.hostname,
|
||||
port: Number(url.port),
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ''),
|
||||
});
|
||||
const [tables] = await conn.query('SHOW TABLES');
|
||||
console.log('TABLES:');
|
||||
for (const row of tables) console.log(' -', Object.values(row)[0]);
|
||||
const tableNames = ['User', 'Article', 'Strategy', 'Indicator', 'Econ_SowInventory'];
|
||||
for (const t of tableNames) {
|
||||
const [r] = await conn.query('SELECT COUNT(*) AS c FROM ' + t + '');
|
||||
console.log(t + ': ' + r[0].c + ' rows');
|
||||
}
|
||||
await conn.end();
|
||||
})().catch((e) => { console.error('ERR:', e.message); process.exit(1); });
|
||||
@@ -1,15 +0,0 @@
|
||||
const { PrismaClient } = require('../generated/prisma');
|
||||
(async () => {
|
||||
const p = new PrismaClient();
|
||||
const tables = await p.$queryRawUnsafe('SHOW TABLES');
|
||||
console.log('TABLES:', JSON.stringify(tables, null, 2));
|
||||
const counts = {
|
||||
User: await p.user.count(),
|
||||
Article: await p.article.count(),
|
||||
Strategy: await p.strategy.count(),
|
||||
Indicator: await p.indicator.count(),
|
||||
Econ_SowInventory: await p.econ_SowInventory.count(),
|
||||
};
|
||||
console.log('COUNTS:', JSON.stringify(counts, null, 2));
|
||||
await p.$disconnect();
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -1,22 +0,0 @@
|
||||
import 'dotenv/config';
|
||||
import { PrismaClient } from '../generated/prisma/client.ts';
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
const p = new PrismaClient({ datasourceUrl: url });
|
||||
try {
|
||||
const tables = await p.$queryRawUnsafe('SHOW TABLES');
|
||||
console.log('TABLES:', JSON.stringify(tables, null, 2));
|
||||
const counts = {
|
||||
User: await p.user.count(),
|
||||
Article: await p.article.count(),
|
||||
Strategy: await p.strategy.count(),
|
||||
Indicator: await p.indicator.count(),
|
||||
Econ_SowInventory: await p.econ_SowInventory.count(),
|
||||
};
|
||||
console.log('COUNTS:', JSON.stringify(counts, null, 2));
|
||||
} catch (e) {
|
||||
console.error('ERR:', e.message);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await p.$disconnect();
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { prisma } from './db.js'
|
||||
|
||||
// 跨源对比: 同 (indicatorName, period) 多源数据差异超阈值则插 Alert
|
||||
// conn: mysql2 连接
|
||||
// 阈值: 绝对差 >= 0.01 且 相对差 >= 1%
|
||||
// 返回 { inserted, scanned }
|
||||
const ABS_TOL = 0.01
|
||||
const REL_TOL = 0.01
|
||||
|
||||
export async function checkAfterRefresh(conn) {
|
||||
// conn 是 mysql2 连接, 走原始 SQL
|
||||
const [rows] = await conn.execute(`
|
||||
SELECT ia.name AS indicatorName, a.period, ia.source, a.value
|
||||
FROM IndicatorPoint a
|
||||
JOIN Indicator ia ON a.indicatorId = ia.id
|
||||
ORDER BY ia.name, a.period
|
||||
`)
|
||||
const groups = new Map()
|
||||
for (const r of rows) {
|
||||
const k = r.indicatorName + '|' + r.period
|
||||
if (!groups.has(k)) groups.set(k, [])
|
||||
groups.get(k).push({ source: r.source, value: Number(r.value) })
|
||||
}
|
||||
let inserted = 0
|
||||
for (const [k, arr] of groups) {
|
||||
if (arr.length < 2) continue
|
||||
const sources = new Set(arr.map((x) => x.source))
|
||||
if (sources.size < 2) continue
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
for (let j = i + 1; j < arr.length; j++) {
|
||||
const A = arr[i], B = arr[j]
|
||||
const diff = Math.abs(A.value - B.value)
|
||||
const base = Math.max(Math.abs(A.value), Math.abs(B.value), 1e-9)
|
||||
const ratio = diff / base
|
||||
if (diff < ABS_TOL || ratio < REL_TOL) continue
|
||||
const [name, period] = k.split('|')
|
||||
const [r] = await conn.execute(
|
||||
'INSERT INTO `Alert` (`indicatorName`, `period`, `valueA`, `valueB`, `sourceA`, `sourceB`, `diff`, `diffRatio`, `createdAt`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(3))',
|
||||
[name, period, A.value, B.value, A.source, B.source, diff, ratio],
|
||||
)
|
||||
if (r.affectedRows === 1) inserted++
|
||||
}
|
||||
}
|
||||
}
|
||||
return { inserted, scanned: rows.length }
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// apizero 接口的文件缓存
|
||||
// - 按 key 存 JSON 到磁盘: <APIZERO_CACHE_DIR>/<key>.json
|
||||
// - 存在性检查: 文件在即视为有效 (用户要求: "同一个年份只要存在就不再调用接口")
|
||||
// - 不做 TTL: 用户没要求, 也避免误命中过期数据
|
||||
//
|
||||
// 环境变量:
|
||||
// APIZERO_CACHE_DIR 缓存目录 (默认 <cwd>/cache/apizero)
|
||||
//
|
||||
// 未来若要加 TTL / DB 后端, 替换本文件即可, 上层调用方不变.
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
function dir() {
|
||||
return process.env.APIZERO_CACHE_DIR
|
||||
? path.resolve(process.env.APIZERO_CACHE_DIR)
|
||||
: path.resolve(process.cwd(), 'cache', 'apizero')
|
||||
}
|
||||
|
||||
// 读缓存, 不存在返回 null
|
||||
export function read(key) {
|
||||
const p = path.join(dir(), key + '.json')
|
||||
if (!fs.existsSync(p)) return null
|
||||
try {
|
||||
const raw = fs.readFileSync(p, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch (e) {
|
||||
console.warn(`[apizero-cache] 读 ${key} 失败, 忽略:`, e.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 写缓存 (原子: 先写 .tmp 再 rename)
|
||||
export function write(key, data) {
|
||||
const d = dir()
|
||||
fs.mkdirSync(d, { recursive: true })
|
||||
const final = path.join(d, key + '.json')
|
||||
const tmp = final + '.tmp.' + process.pid
|
||||
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8')
|
||||
fs.renameSync(tmp, final)
|
||||
return final
|
||||
}
|
||||
|
||||
// 存在性检查
|
||||
export function has(key) {
|
||||
return fs.existsSync(path.join(dir(), key + '.json'))
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
// apizero (https://apizero.cn) 通用 HTTP 客户端
|
||||
// - 统一从 env APIZERO_KEY 读取 API key (test key 也能用)
|
||||
// - 统一 QPS 节流 (apizero 限制 3 req/s)
|
||||
// - 统一错误格式: 抛出 ApizeroError(code, message, httpStatus)
|
||||
// - 所有 apizero 接口都进这一层, 后续新增模块复用
|
||||
//
|
||||
// 依赖: 原生 fetch (Node 18+)
|
||||
|
||||
// 简易令牌桶, 限速 3 req/s
|
||||
let lastCallAt = 0
|
||||
const MIN_INTERVAL_MS = 1000 / 3 // ~333ms
|
||||
|
||||
async function throttle() {
|
||||
const now = Date.now()
|
||||
const wait = lastCallAt + MIN_INTERVAL_MS - now
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
|
||||
lastCallAt = Date.now()
|
||||
}
|
||||
|
||||
// apizero 标准错误
|
||||
export class ApizeroError extends Error {
|
||||
constructor(code, message, httpStatus) {
|
||||
super(message || `apizero error code=${code}`)
|
||||
this.name = 'ApizeroError'
|
||||
this.code = code
|
||||
this.httpStatus = httpStatus
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_URL = 'https://v1.apizero.cn/api'
|
||||
|
||||
// 读 key: 优先 env APIZERO_KEY, 否则用空 (匿名调用, 配额更小)
|
||||
function getKey() {
|
||||
return process.env.APIZERO_KEY || ''
|
||||
}
|
||||
|
||||
// 通用 GET 入口
|
||||
// - endpoint: 'oil-price-forecast' (不带 /api 前缀)
|
||||
// - params: 普通对象
|
||||
// 返回: apizero 的 data 字段 (已解一层)
|
||||
export async function get(endpoint, params = {}) {
|
||||
await throttle()
|
||||
|
||||
const qs = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v))
|
||||
}
|
||||
const key = getKey()
|
||||
if (key) qs.set('key', key)
|
||||
|
||||
const url = `${BASE_URL}/${endpoint}?${qs.toString()}`
|
||||
let res
|
||||
try {
|
||||
res = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } })
|
||||
} catch (e) {
|
||||
throw new ApizeroError('NETWORK', `apizero 网络错误: ${e.message}`, 0)
|
||||
}
|
||||
|
||||
// HTTP 层错误
|
||||
if (!res.ok) {
|
||||
let body = ''
|
||||
try { body = await res.text() } catch { /* ignore */ }
|
||||
throw new ApizeroError('HTTP', `apizero HTTP ${res.status}: ${body.substring(0, 200)}`, res.status)
|
||||
}
|
||||
|
||||
// 解析 body
|
||||
let json
|
||||
try { json = await res.json() }
|
||||
catch (e) { throw new ApizeroError('PARSE', 'apizero 响应非 JSON: ' + e.message, res.status) }
|
||||
|
||||
// apizero 业务层错误: code !== 0
|
||||
if (json && typeof json.code === 'number' && json.code !== 0) {
|
||||
throw new ApizeroError(json.code, json.msg || 'apizero 业务错误', res.status)
|
||||
}
|
||||
|
||||
return json && json.data !== undefined ? json.data : json
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// apizero 模块统一出口
|
||||
// 后续新增 apizero 接口就在这里再 export 一行
|
||||
export * as oil from './oil-price-forecast.js'
|
||||
export * as history from './oil-history.js'
|
||||
export { ApizeroError } from './client.js'
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// 浙江省油价历史: 走 Prisma 落库 + apizero price 接口写入
|
||||
// 设计:
|
||||
// - 每天 04:00 cron 检查 schedule, 待定且 <= 昨天的调价日 -> 抓 price?province=浙江 存本表
|
||||
// - unique (date, province, fuel) 防重复
|
||||
// - 查询 API: 取某省某油品最近 N 天 (默认 180)
|
||||
import { prisma } from '../db.js'
|
||||
import * as client from './client.js'
|
||||
|
||||
const PROVINCE = '浙江'
|
||||
// 油品 -> apizero prices 对象里的 key (price action 返回 "92号汽油" 这种带后缀的 key)
|
||||
const FUEL_KEYS = {
|
||||
'92': '92号汽油',
|
||||
'95': '95号汽油',
|
||||
'0': '0号柴油',
|
||||
}
|
||||
|
||||
// 解析 apizero price action 返回的 prices (中文 key) -> 标准 fuel code
|
||||
function parsePrice(data) {
|
||||
if (!data || !data.prices) throw new Error('apizero 响应无 prices 字段')
|
||||
const out = {}
|
||||
for (const [fuel, label] of Object.entries(FUEL_KEYS)) {
|
||||
const raw = data.prices[label]
|
||||
if (typeof raw === 'string') {
|
||||
// "7.64 元/升" -> 7.64
|
||||
const m = raw.match(/^([\d.]+)/)
|
||||
if (m) out[fuel] = parseFloat(m[1])
|
||||
} else if (typeof raw === 'number') {
|
||||
out[fuel] = raw
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 抓一次浙江当前价并入库 (date = 今日)
|
||||
// 返回 { inserted, skipped } (按 unique 约束: 已存在则 skip)
|
||||
export async function fetchAndStore() {
|
||||
const data = await client.get('oil-price-forecast', { action: 'price', province: PROVINCE })
|
||||
const prices = parsePrice(data)
|
||||
if (Object.keys(prices).length === 0) {
|
||||
throw new Error('apizero 返回价格为空')
|
||||
}
|
||||
const date = new Date()
|
||||
// 抹掉时分秒, 便于按天比较
|
||||
date.setHours(0, 0, 0, 0)
|
||||
|
||||
let inserted = 0
|
||||
let skipped = 0
|
||||
for (const [fuel, price] of Object.entries(prices)) {
|
||||
try {
|
||||
await prisma.oilPriceHistory.create({
|
||||
data: { date, province: PROVINCE, fuel, price, source: 'apizero' },
|
||||
})
|
||||
inserted++
|
||||
} catch (e) {
|
||||
// P2002: unique 冲突 -> 跳过
|
||||
if (e?.code === 'P2002') skipped++
|
||||
else throw e
|
||||
}
|
||||
}
|
||||
return { inserted, skipped, date: date.toISOString().slice(0, 10) }
|
||||
}
|
||||
|
||||
// 查询某省某油品最近 N 天
|
||||
// province 必填, 默认 浙江
|
||||
// fuel 可选, 留空返回 4 个油品
|
||||
// days 默认 180
|
||||
export async function query({ province = PROVINCE, fuel, days = 180 }) {
|
||||
const since = new Date()
|
||||
since.setDate(since.getDate() - days)
|
||||
since.setHours(0, 0, 0, 0)
|
||||
|
||||
const where = { province, date: { gte: since } }
|
||||
if (fuel) where.fuel = fuel
|
||||
|
||||
const rows = await prisma.oilPriceHistory.findMany({
|
||||
where,
|
||||
orderBy: [{ date: 'asc' }],
|
||||
})
|
||||
|
||||
// 整形: Decimal -> number
|
||||
return rows.map((r) => ({
|
||||
date: r.date.toISOString().slice(0, 10),
|
||||
province: r.province,
|
||||
fuel: r.fuel,
|
||||
price: Number(r.price),
|
||||
}))
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// apizero: 今日油价 (oil-price-forecast)
|
||||
// 4 个 action:
|
||||
// forecast - 油价 + 国际原油 + 下次调价预测 (每次都查实时, 不缓存)
|
||||
// price - 单省油价
|
||||
// price-all - 32 省全部油价
|
||||
// schedule - 某年调价日历 (按 year 缓存到文件, 同年份不重复调)
|
||||
//
|
||||
// 文档: https://apizero.cn/aidocs/oil-price-forecast/raw.md
|
||||
import * as client from './client.js'
|
||||
import * as cache from './cache.js'
|
||||
|
||||
// 调价日历: 按年份缓存, 文件存在即视为有效, 不重复调用
|
||||
// cache key 形如 "oil-schedule-2026"
|
||||
export async function getSchedule(year) {
|
||||
const y = Number(year)
|
||||
if (!Number.isInteger(y) || y < 2025 || y > 2026) {
|
||||
throw new client.ApizeroError(4000, `year 不在 2025-2026 范围: ${year}`, 400)
|
||||
}
|
||||
const cacheKey = `oil-schedule-${y}`
|
||||
const hit = cache.read(cacheKey)
|
||||
if (hit) return { ...hit, _cached: true }
|
||||
const data = await client.get('oil-price-forecast', { action: 'schedule', year: y })
|
||||
cache.write(cacheKey, data)
|
||||
return { ...data, _cached: false }
|
||||
}
|
||||
|
||||
// 实时: 国际原油 + 下次调价预测
|
||||
export async function getForecast() {
|
||||
return client.get('oil-price-forecast', { action: 'forecast' })
|
||||
}
|
||||
|
||||
// 单省油价
|
||||
export async function getPrice(province) {
|
||||
if (!province) throw new client.ApizeroError(4000, 'province 必填', 400)
|
||||
return client.get('oil-price-forecast', { action: 'price', province })
|
||||
}
|
||||
|
||||
// 32 省全部油价
|
||||
export async function getPriceAll() {
|
||||
return client.get('oil-price-forecast', { action: 'price-all' })
|
||||
}
|
||||
+3
-61
@@ -1,8 +1,4 @@
|
||||
import cron from 'node-cron'
|
||||
import { prisma } from './db.js'
|
||||
import mysql from 'mysql2/promise'
|
||||
import { checkAfterRefresh } from './alert-engine.js'
|
||||
import { ApizeroError, oil, history } from './apizero/index.js'
|
||||
|
||||
// scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载,
|
||||
// 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。
|
||||
@@ -20,16 +16,7 @@ async function alertError(taskName, message) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseUrl(url) {
|
||||
const u = new URL(url)
|
||||
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
|
||||
}
|
||||
|
||||
async function openConn() {
|
||||
return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
}
|
||||
|
||||
// 每天 6:00 (Asia/Shanghai) 拉取新闻 + 跑跨源对比, 出错生成报警
|
||||
// 每天 6:00 (Asia/Shanghai) 拉取新闻, 出错生成报警
|
||||
function scheduleNews() {
|
||||
cron.schedule('0 6 * * *', async () => {
|
||||
console.log('[cron] 6:00 触发')
|
||||
@@ -38,56 +25,12 @@ function scheduleNews() {
|
||||
const list = await fetchNewsList()
|
||||
const r = await persistNews(list)
|
||||
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
|
||||
const conn = await openConn()
|
||||
try {
|
||||
const a = await checkAfterRefresh(conn)
|
||||
console.log('[cron] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
|
||||
} finally { await conn.end() }
|
||||
} catch (e) {
|
||||
console.error('[cron] 任务失败:', e.message)
|
||||
await alertError('新闻拉取+报警检测', e.message)
|
||||
await alertError('新闻拉取', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每天 06:00 拉取新闻 + 跨源报警检测')
|
||||
}
|
||||
|
||||
// 每天 6:10 (Asia/Shanghai) 抓浙江油价历史, 出错生成报警
|
||||
// 逻辑: 拉今年 schedule, 检查待定且 <= 昨天的调价日, 命中就抓 price?province=浙江 存库
|
||||
// - 0 命中: 不调 apizero (省配额)
|
||||
// - >0 命中: 调一次, 4 个油品批量入库 (unique 兜底去重)
|
||||
function scheduleOilHistory() {
|
||||
cron.schedule('10 6 * * *', async () => {
|
||||
console.log('[cron] 6:10 油价历史触发')
|
||||
try {
|
||||
const year = new Date().getFullYear()
|
||||
const sched = await oil.getSchedule(year)
|
||||
const list = sched.schedule || []
|
||||
// 待定 且 日期 <= 昨天 (用 Asia/Shanghai 时区推导, 与 cron 触发时区对齐)
|
||||
const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
|
||||
const today = new Date(todayStr + 'T00:00:00+08:00')
|
||||
const yesterdayMs = today.getTime() - 86400000
|
||||
const pendingPast = list.filter((it) => {
|
||||
if (it.status !== '待定') return false
|
||||
const t = new Date(it.date + 'T00:00:00').getTime()
|
||||
return Number.isFinite(t) && t <= yesterdayMs
|
||||
})
|
||||
console.log('[cron] 待定且已过的调价日: ' + pendingPast.length + ' 个')
|
||||
if (pendingPast.length === 0) {
|
||||
console.log('[cron] 油价历史: 0 命中, 跳过 apizero 调用')
|
||||
return
|
||||
}
|
||||
const r = await history.fetchAndStore()
|
||||
console.log('[cron] 油价历史入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped + ' (date=' + r.date + ')')
|
||||
} catch (e) {
|
||||
if (e instanceof ApizeroError) {
|
||||
console.error('[cron] 油价历史 apizero 错误: code=' + e.code + ' msg=' + e.message)
|
||||
} else {
|
||||
console.error('[cron] 油价历史任务失败:', e.message)
|
||||
}
|
||||
await alertError('浙江油价历史抓取', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每天 06:10 浙江油价历史抓取')
|
||||
console.log('[cron] 已注册: 每天 06:00 拉取新闻')
|
||||
}
|
||||
|
||||
// 每小时兜底: 任务自动归档扫描 (T-ARC-02; 项目加载时另有读时惰性执行)
|
||||
@@ -106,5 +49,4 @@ function scheduleAutoArchive() {
|
||||
}
|
||||
|
||||
scheduleNews()
|
||||
scheduleOilHistory()
|
||||
scheduleAutoArchive()
|
||||
|
||||
@@ -9,7 +9,6 @@ import newsRouter from './routes/news.js'
|
||||
import alertsRouter from './routes/alerts.js'
|
||||
import articlesRouter from './routes/articles.js'
|
||||
import otaRouter from './routes/ota.js'
|
||||
import apizeroRouter from './routes/apizero.js'
|
||||
import monitorsRouter from './routes/monitors.js'
|
||||
import mihomeRouter from './routes/mihome.js'
|
||||
import checkinRouter from './routes/checkin.js'
|
||||
@@ -43,7 +42,6 @@ app.use('/api', dataRouter)
|
||||
app.use('/api', newsRouter)
|
||||
app.use('/api', alertsRouter)
|
||||
app.use('/api', articlesRouter)
|
||||
app.use('/api', apizeroRouter)
|
||||
app.use('/api', monitorsRouter)
|
||||
app.use('/api/mihome', mihomeRouter)
|
||||
// V0.2 打卡 / 日历 / 清单
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// apizero 代理路由 (前端不直接调 apizero, 一律走后端, key 不外泄)
|
||||
//
|
||||
// GET /api/apizero/oil/forecast - 油价 + 预测 (实时)
|
||||
// GET /api/apizero/oil/price-all - 32 省油价
|
||||
// GET /api/apizero/oil/price?province= - 单省油价
|
||||
// GET /api/apizero/oil/schedule?year= - 某年调价日历 (按年缓存)
|
||||
//
|
||||
// 错误: apizero 错误统一包成 502 (上游错误) 或 400 (参数错误)
|
||||
import { Router } from 'express'
|
||||
import { authRequired } from '../middleware.js'
|
||||
import { ApizeroError, oil, history } from '../apizero/index.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
function handleApizeroError(e, res, next) {
|
||||
if (e instanceof ApizeroError) {
|
||||
// 参数错 -> 400, 上游错 -> 502, QPS 限流 / 额度用完 -> 503
|
||||
if (e.httpStatus === 400) return res.status(400).json({ error: e.message, apizero_code: e.code })
|
||||
if (e.code === 4029 || e.code === 4030) return res.status(503).json({ error: e.message, apizero_code: e.code })
|
||||
return res.status(502).json({ error: e.message, apizero_code: e.code })
|
||||
}
|
||||
next(e)
|
||||
}
|
||||
|
||||
// 实时: 油价 + 国际原油 + 下次调价预测
|
||||
router.get('/apizero/oil/forecast', async (req, res, next) => {
|
||||
try { res.json(await oil.getForecast()) }
|
||||
catch (e) { handleApizeroError(e, res, next) }
|
||||
})
|
||||
|
||||
// 32 省油价
|
||||
router.get('/apizero/oil/price-all', async (req, res, next) => {
|
||||
try { res.json(await oil.getPriceAll()) }
|
||||
catch (e) { handleApizeroError(e, res, next) }
|
||||
})
|
||||
|
||||
// 单省油价
|
||||
router.get('/apizero/oil/price', async (req, res, next) => {
|
||||
try { res.json(await oil.getPrice(req.query.province)) }
|
||||
catch (e) { handleApizeroError(e, res, next) }
|
||||
})
|
||||
|
||||
// 调价日历 (按年缓存)
|
||||
router.get('/apizero/oil/schedule', async (req, res, next) => {
|
||||
try { res.json(await oil.getSchedule(req.query.year)) }
|
||||
catch (e) { handleApizeroError(e, res, next) }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
// 浙江省油价历史 (DB)
|
||||
// GET /api/apizero/oil/history?fuel=92&days=180
|
||||
// - fuel: 92 / 95 / 0 (留空 = 3 个油品都返回)
|
||||
// - days: 默认 180 (半年)
|
||||
router.get('/apizero/oil/history', async (req, res, next) => {
|
||||
try {
|
||||
const fuel = req.query.fuel ? String(req.query.fuel) : undefined
|
||||
const days = Math.max(1, Math.min(Number(req.query.days) || 180, 730))
|
||||
const items = await history.query({ fuel, days })
|
||||
res.json({ items, fuel: fuel || 'all', days })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
@@ -122,11 +122,15 @@ router.get('/habits', async (req, res, next) => {
|
||||
const userId = req.user.sub
|
||||
const today = toDateStr()
|
||||
const date = normDate(req.query.date)
|
||||
const habits = await loadActiveHabits(userId)
|
||||
const habits = (await loadActiveHabits(userId))
|
||||
// 回看历史日期时, 当天还不存在的打卡项不应出现 (与 /calendar 口径一致)
|
||||
.filter((h) => toDateStr(h.createdAt) <= date)
|
||||
const ranges = habits.map((h) => periodRange(h.period, date))
|
||||
const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }])
|
||||
const items = habits.map((h) => buildItem(h, grouped.get(h.id) || new Map(), date, today))
|
||||
const doneCount = items.filter((i) => i.completed).length
|
||||
// summary 是"当天进度": 按当日份额 (countOnDate >= expectedPerDay) 统计,
|
||||
// 不能用 completed (周期完成度), 否则历史日期会把同周期后续日期的打卡算进来。
|
||||
const doneCount = items.filter((i) => i.dayDone).length
|
||||
res.json({ today, date, items, summary: { total: items.length, done: doneCount } })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
@@ -291,7 +295,14 @@ router.get('/calendar', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const today = toDateStr()
|
||||
const month = MONTH_RE.test(String(req.query.month || '')) ? String(req.query.month) : today.slice(0, 7)
|
||||
// 兼容旧版客户端: addMonths 曾把 "YYYY-MM" 算成 "YYYY-MM-01", 请求月份带了日号,
|
||||
// 旧逻辑判为非法后静默回退到本月, 导致切到上月时看不到任何记录。这里截回月份。
|
||||
const rawMonth = String(req.query.month || '')
|
||||
const month = MONTH_RE.test(rawMonth)
|
||||
? rawMonth
|
||||
: DATE_RE.test(rawMonth)
|
||||
? rawMonth.slice(0, 7)
|
||||
: today.slice(0, 7)
|
||||
const start = month + '-01'
|
||||
const end = addDays(monthStartNext(month), -1)
|
||||
const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] })
|
||||
|
||||
+1
-30
@@ -6,36 +6,7 @@ const router = Router()
|
||||
|
||||
router.use(authRequired)
|
||||
|
||||
// 指标列表: 元数据 + 明细点, 过滤 hidden (数据与脚本保留, 仅不展示)
|
||||
router.get('/indicators', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = await prisma.indicator.findMany({
|
||||
where: { hidden: false },
|
||||
include: { points: { orderBy: { period: 'asc' } } },
|
||||
orderBy: [{ groupName: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
// 组装前端友好结构: points 保持从老到新
|
||||
const indicators = rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
groupName: r.groupName,
|
||||
title: r.title,
|
||||
unit: r.unit,
|
||||
source: r.source,
|
||||
frequency: r.frequency,
|
||||
points: r.points.map((p) => ({ period: p.period, value: p.value })),
|
||||
}))
|
||||
res.json({ indicators })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
router.get('/strategies', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = await prisma.strategy.findMany({ orderBy: { name: 'asc' } })
|
||||
res.json({ strategies: rows })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// 能繁母猪存栏 (独立抓取域, 不属于宏观「经济数据」外接模块)
|
||||
router.get('/sow', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = await prisma.econ_SowInventory.findMany({ orderBy: { month: 'asc' } })
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' data: blob: asset: http://asset.localhost; connect-src 'self' ipc: http://ipc.localhost https://iboard.honor3.com http://bh.vps.honor3.com:3001 https://bh.vps.honor3.com:3001; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' data: blob: asset: http://asset.localhost https://*.honor3.com http://*.honor3.com; connect-src 'self' ipc: http://ipc.localhost https://iboard.honor3.com http://bh.vps.honor3.com:3001 https://bh.vps.honor3.com:3001 https://ifinance.honor3.com http://ifinance.honor3.com https://*.honor3.com https://*.honor3.com:* http://*.honor3.com http://*.honor3.com:*; frame-src 'self' https://ifinance.honor3.com http://ifinance.honor3.com https://*.honor3.com https://*.honor3.com:* http://*.honor3.com http://*.honor3.com:*; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ export default function App() {
|
||||
<Route path="/task" element={<TaskPage />} />
|
||||
<Route path="/task/list" element={<TaskPage />} />
|
||||
<Route path="/task/:pid" element={<TaskPage />} />
|
||||
{/* 宏观: 报警 / 新闻 / 油价 / 经济数据 / 策略研究 / 经济文章 (V0.2 原首页并入, 默认新闻) */}
|
||||
{/* 宏观: 新闻 / 经济文章 + 外接 ifinance 分类 (动态 tab); 报警已迁至监控 (V0.2.2) */}
|
||||
<Route path="/macro" element={<Navigate to="/macro/news" replace />} />
|
||||
<Route path="/macro/*" element={<MacroPage />} />
|
||||
<Route path="/monitor" element={<MonitorPage />} />
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// 前端油价 API: 一律走后端 /api/apizero/oil/*, 不直接调 apizero (key 不外泄)
|
||||
import { api, getApiBase } from './client.js'
|
||||
|
||||
// 实时: 油价 + 国际原油 + 下次调价预测
|
||||
export async function getOilForecast() {
|
||||
return api('/apizero/oil/forecast')
|
||||
}
|
||||
|
||||
// 32 省油价 (可暂未在 UI 用, 留作 API 暴露)
|
||||
export async function getOilPriceAll() {
|
||||
return api('/apizero/oil/price-all')
|
||||
}
|
||||
|
||||
// 单省油价
|
||||
export async function getOilPrice(province) {
|
||||
return api('/apizero/oil/price?province=' + encodeURIComponent(province))
|
||||
}
|
||||
|
||||
// 某年调价日历 (后端按年缓存, 同年份不会重复调 apizero)
|
||||
export async function getOilSchedule(year) {
|
||||
return api('/apizero/oil/schedule?year=' + encodeURIComponent(year))
|
||||
}
|
||||
|
||||
// 浙江省油价历史 (走 DB, 由 cron 每天 04:00 抓取并落库)
|
||||
// fuel: '92' | '95' | '0' | undefined (留空 = 3 个油品)
|
||||
// days: 默认 180 (半年)
|
||||
export async function getOilHistory({ fuel, days = 180 } = {}) {
|
||||
const qs = new URLSearchParams()
|
||||
if (fuel) qs.set('fuel', fuel)
|
||||
qs.set('days', String(days))
|
||||
return api('/apizero/oil/history?' + qs.toString())
|
||||
}
|
||||
+27
-8
@@ -142,6 +142,8 @@ function projectSummary(p, memberCount, taskCount) {
|
||||
updatedAt: p.updatedAt,
|
||||
taskKinds: normTaskKinds(parseJson(p.taskKinds, null)) || DEFAULT_TASK_KINDS,
|
||||
icon: p.icon || null,
|
||||
// 自动归档档位 (T-ARC-02): 必须回传给前端, 否则该设置在界面上永远显示默认的「永不」
|
||||
autoArchive: p.autoArchive || 'never',
|
||||
memberCount,
|
||||
taskCount,
|
||||
}
|
||||
@@ -275,6 +277,11 @@ export const projectApi = {
|
||||
content: String(n?.content ?? '').slice(0, 20000),
|
||||
})))
|
||||
}
|
||||
if (body?.autoArchive !== undefined) {
|
||||
// 自动归档档位 (T-ARC-02): 与服务端 AUTO_ARCHIVE_OPTIONS 同口径
|
||||
if (!['3d', '1w', '1m', 'never'].includes(body.autoArchive)) throw new Error('自动归档档位非法')
|
||||
data.autoArchive = body.autoArchive
|
||||
}
|
||||
const sets = Object.keys(data).map((k) => `"${k}" = ?`)
|
||||
if (sets.length) {
|
||||
await db().execute(
|
||||
@@ -708,14 +715,16 @@ export const taskApi = {
|
||||
archive: async (id) => {
|
||||
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
|
||||
if (!task) throw new Error('任务不存在或无权限')
|
||||
const now = now()
|
||||
// 不能写 const now = now(): 同名 const 会遮蔽模块级 now, 右侧求值落入 TDZ,
|
||||
// 压缩后报 "Cannot access 'a' before initialization"
|
||||
const ts = now()
|
||||
await db().writeTransaction(async (tx) => {
|
||||
const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)])
|
||||
const ids = [String(task.id), ...kids.map((k) => String(k.id))]
|
||||
const ph = ids.map(() => '?').join(',')
|
||||
await tx.execute(
|
||||
`UPDATE Task SET archived = 1, archivedAt = ?, archiveSource = 'manual', updatedAt = ? WHERE id IN (${ph})`,
|
||||
[now, now, ...ids],
|
||||
[ts, ts, ...ids],
|
||||
)
|
||||
})
|
||||
return { ok: true, count: 1 }
|
||||
@@ -725,14 +734,14 @@ export const taskApi = {
|
||||
unarchive: async (id) => {
|
||||
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
|
||||
if (!task) throw new Error('任务不存在或无权限')
|
||||
const now = now()
|
||||
const ts = now()
|
||||
await db().writeTransaction(async (tx) => {
|
||||
const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)])
|
||||
const ids = [String(task.id), ...kids.map((k) => String(k.id))]
|
||||
const ph = ids.map(() => '?').join(',')
|
||||
await tx.execute(
|
||||
`UPDATE Task SET archived = 0, archivedAt = NULL, archiveSource = NULL, updatedAt = ? WHERE id IN (${ph})`,
|
||||
[now, ...ids],
|
||||
[ts, ...ids],
|
||||
)
|
||||
})
|
||||
return { ok: true, count: 1 }
|
||||
@@ -761,13 +770,23 @@ export const taskApi = {
|
||||
where.push('columnId = ?')
|
||||
args.push(String(p.columnId))
|
||||
}
|
||||
// 日期边界: 传 'YYYY-MM-DD' 时按**本地时区**的那一天换算成 UTC 瞬时再比较。
|
||||
// (原来直接拼 'T00:00:00Z', 本地选的日期会偏移 8 小时, 导致当天凌晨的归档被漏掉)
|
||||
const dayToIso = (v, endOfDay) => {
|
||||
const s = String(v)
|
||||
if (s.includes('T')) return s // 已带时间的 ISO, 原样使用
|
||||
const d = new Date(`${s}T00:00:00`) // 本地 00:00
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
if (endOfDay) d.setHours(23, 59, 59, 999)
|
||||
return d.toISOString()
|
||||
}
|
||||
if (p.from) {
|
||||
where.push('archivedAt >= ?')
|
||||
args.push(String(p.from) + 'T00:00:00+08:00'.replace('+08:00', 'Z'))
|
||||
const iso = dayToIso(p.from, false)
|
||||
if (iso) { where.push('archivedAt >= ?'); args.push(iso) }
|
||||
}
|
||||
if (p.to) {
|
||||
where.push('archivedAt <= ?')
|
||||
args.push(String(p.to) + 'T23:59:59.999Z')
|
||||
const iso = dayToIso(p.to, true)
|
||||
if (iso) { where.push('archivedAt <= ?'); args.push(iso) }
|
||||
}
|
||||
if (p.cursor) {
|
||||
const [ts, cid] = String(p.cursor).split('|')
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// 趋势徽标: 上升 / 下降 / 持平 / 震荡
|
||||
// props:
|
||||
// trend: 'up' | 'down' | 'flat' | 'volatile'
|
||||
// showText: 是否显示文字 (默认 true)
|
||||
const META = {
|
||||
up: { icon: '\u2191', text: '上升' },
|
||||
down: { icon: '\u2193', text: '下降' },
|
||||
flat: { icon: '=', text: '持平' },
|
||||
volatile: { icon: '\u223F', text: '震荡' },
|
||||
}
|
||||
|
||||
// 根据最近 N 个数据点计算趋势:
|
||||
// - 数据不足 2 个 → flat
|
||||
// - 取最近 6 个点的变化, 整体涨幅 < 1% → flat
|
||||
// - 单调递增 → up, 单调递减 → down
|
||||
// - 其余 (有升有降) → volatile
|
||||
// 返回: { trend, change } (change 为百分比变化, 保留 1 位小数)
|
||||
export function calcTrend(rows) {
|
||||
if (!rows || rows.length < 2) return { trend: 'flat', change: 0 }
|
||||
const N = Math.min(6, rows.length)
|
||||
const recent = rows.slice(-N).map((r) => r.value).filter((v) => typeof v === 'number')
|
||||
if (recent.length < 2) return { trend: 'flat', change: 0 }
|
||||
const first = recent[0]
|
||||
const last = recent[recent.length - 1]
|
||||
const change = first === 0 ? 0 : ((last - first) / Math.abs(first)) * 100
|
||||
if (Math.abs(change) < 1) return { trend: 'flat', change }
|
||||
let up = 0, down = 0
|
||||
for (let i = 1; i < recent.length; i++) {
|
||||
if (recent[i] > recent[i - 1]) up++
|
||||
else if (recent[i] < recent[i - 1]) down++
|
||||
}
|
||||
if (up === recent.length - 1) return { trend: 'up', change }
|
||||
if (down === recent.length - 1) return { trend: 'down', change }
|
||||
return { trend: 'volatile', change }
|
||||
}
|
||||
|
||||
// 取数据序列最后一个有效点: { month, value } 或 null
|
||||
export function lastPoint(data) {
|
||||
const rows = (data && data.rows) || []
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
if (rows[i] && typeof rows[i].value === 'number') return rows[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default function TrendBadge({ trend, showText = true }) {
|
||||
const m = META[trend] || META.flat
|
||||
return (
|
||||
<span className={'trend-badge trend-' + (trend || 'flat')}>
|
||||
<span className="trend-icon">{m.icon}</span>
|
||||
{showText && <span>{m.text}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -2,16 +2,31 @@
|
||||
//
|
||||
// 为什么是环而不是圆点:
|
||||
// 旧实现是 4px 小圆点, 只按状态上色 —— 3/5 和 5/5 都是同一个绿点,
|
||||
// 看不出"当天做到几成"。改成环形后 **弧长 = 当天完成比例**, 颜色仍表状态:
|
||||
// 0 < actual < expected -> 弧长按比例 (部分完成 / 半填充)
|
||||
// actual >= expected -> 整圈 (全部完成 / 实心)
|
||||
// actual = 0 且 expected > 0 -> 只有底环 (未开始 / 空心)
|
||||
// 看不出"当天做到几成"。改成环形后 **弧长 = 当天完成比例**, 颜色表状态:
|
||||
// 0 < actual < expected -> 弧长按比例 (部分完成)
|
||||
// actual >= expected -> 整圈 (全部完成)
|
||||
// actual = 0 且 expected > 0 -> 只有底环 (未开始)
|
||||
// expected = 0 由父组件判断, 不渲染本组件 (当天没有应打卡项)
|
||||
//
|
||||
// 颜色不写在这里: 由 CSS 按 `.mv-day` 作用域上色 (各视图调色板可能不同)。
|
||||
// 颜色分级 (4 档, 由 CSS 按 `.mv-day` 作用域上色, 各视图可各自调色板):
|
||||
// 小半 (ratio < 0.5) -> 红
|
||||
// 大半 (0.5 <= r < 1) -> 橘
|
||||
// 完成 (r = 1) -> 蓝
|
||||
// 超额 -> 绿
|
||||
// 注意: 服务端统计时把 actual 封顶在 expected (`Math.min(cnt, exp)`),
|
||||
// 所以 ratio 永远 <= 1, **超额只能靠 status === 'over' 判断**, 不能看比例。
|
||||
const DEFAULT_SIZE = 30
|
||||
const DEFAULT_STROKE = 2.5
|
||||
|
||||
/** 4 档分级: 小半 / 大半 / 完成 / 超额 */
|
||||
function tierOf(status, p) {
|
||||
if (status === 'over') return 'over'
|
||||
if (status === 'full') return 'full'
|
||||
if (p <= 0) return 'empty'
|
||||
if (p >= 1) return 'full'
|
||||
return p < 0.5 ? 'low' : 'high'
|
||||
}
|
||||
|
||||
export default function DayRing({
|
||||
status = 'partial',
|
||||
ratio = 0,
|
||||
@@ -19,6 +34,7 @@ export default function DayRing({
|
||||
stroke = DEFAULT_STROKE,
|
||||
}) {
|
||||
const p = Math.max(0, Math.min(1, Number(ratio) || 0))
|
||||
const tier = tierOf(status, p)
|
||||
const r = (size - stroke) / 2
|
||||
const c = 2 * Math.PI * r
|
||||
const half = size / 2
|
||||
@@ -37,7 +53,7 @@ export default function DayRing({
|
||||
/>
|
||||
{p > 0 && (
|
||||
<circle
|
||||
className={'day-ring-bar ' + status}
|
||||
className={'day-ring-bar ' + tier}
|
||||
cx={half} cy={half} r={r}
|
||||
fill="none" strokeWidth={stroke}
|
||||
strokeDasharray={c}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// ifinance 外接模块 (宿主侧)
|
||||
// ------------------------------------------------------------
|
||||
// ifinance 是静态托管模块包, 唯一索引是 info.json。宿主约定:
|
||||
// - 根地址固定为 https://ifinance.honor3.com (宿主常量, 不写进 info.json)
|
||||
// - 没有统一入口页, 每个 html 都是独立入口 (在 WebView/iframe 中打开)
|
||||
// - categories[] 对应 App 的 tab, items[] 是条目; 未知字段忽略
|
||||
//
|
||||
// 本模块采用「纯前端直连」方式: 直接 fetch 远端 info.json, 条目 HTML 直接
|
||||
// 用 iframe 加载远端地址, 不做端上文件缓存 (始终最新)。
|
||||
// 开发环境 (vite dev) 走 /ifinance-api 代理以规避跨域; 生产环境直连。
|
||||
// 若远端 info.json 拉取失败, 依次回退: 同源内置快照 (public/ifinance-info.json)
|
||||
// → localStorage 上次成功快照, 保证分类 tab 仍可渲染。
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// 根地址: 生产直连; 开发用 vite 代理前缀 (见 vite.config.js)
|
||||
export const FINANCE_BASE = import.meta.env.DEV
|
||||
? '/ifinance-api'
|
||||
: 'https://ifinance.honor3.com'
|
||||
|
||||
// 同源兜底快照 (构建时由 ifinance/info.json 拷贝到 public/, 仅在远端不可用时使用)
|
||||
const FALLBACK_INFO_URL = '/ifinance-info.json'
|
||||
|
||||
const INFO_CACHE_KEY = 'ifinance_info_v1'
|
||||
|
||||
// 模块级缓存: 一次会话内多处 (宏观页 / 详情页) 共享, 避免重复请求
|
||||
let memoInfo = null
|
||||
let inflight = null
|
||||
|
||||
// 拼接 info.json 中给出的相对路径 (如 public/econ/x.html) 为可加载的绝对 URL
|
||||
export function financeUrl(relPath) {
|
||||
const p = String(relPath || '').replace(/^\/+/, '')
|
||||
return FINANCE_BASE + '/' + p
|
||||
}
|
||||
|
||||
// 条目 HTML 地址: Cloudflare Pages 走 clean URL, 去掉 .html 后缀直达 (避免 307 重定向)
|
||||
export function financePageUrl(relPath) {
|
||||
return financeUrl(String(relPath || '').replace(/\.html?$/i, ''))
|
||||
}
|
||||
|
||||
function readLocalInfo() {
|
||||
try {
|
||||
const raw = localStorage.getItem(INFO_CACHE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && parsed.info && Array.isArray(parsed.info.categories)) return parsed.info
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
|
||||
function writeLocalInfo(info) {
|
||||
try {
|
||||
localStorage.setItem(INFO_CACHE_KEY, JSON.stringify({ ts: Date.now(), info }))
|
||||
} catch { /* 配额满等忽略 */ }
|
||||
}
|
||||
|
||||
async function fetchInfoFrom(url) {
|
||||
const res = await fetch(url, { cache: 'no-cache' })
|
||||
if (!res.ok) throw new Error('ifinance 索引加载失败 (' + res.status + ')')
|
||||
const info = await res.json()
|
||||
if (!info || !Array.isArray(info.categories)) throw new Error('ifinance 索引格式无效')
|
||||
return info
|
||||
}
|
||||
|
||||
// 拉取 (并缓存) info.json: 远端优先, 失败回退同源内置快照
|
||||
export async function fetchIfinanceInfo() {
|
||||
if (inflight) return inflight
|
||||
inflight = (async () => {
|
||||
let info
|
||||
try {
|
||||
info = await fetchInfoFrom(financeUrl('info.json'))
|
||||
} catch (remoteErr) {
|
||||
// 兜底: 同源内置快照 (ifinance 未开 CORS / 临时不可达时仍可渲染分类)
|
||||
try {
|
||||
info = await fetchInfoFrom(FALLBACK_INFO_URL)
|
||||
} catch {
|
||||
throw remoteErr
|
||||
}
|
||||
}
|
||||
memoInfo = info
|
||||
writeLocalInfo(info)
|
||||
return info
|
||||
})()
|
||||
try {
|
||||
return await inflight
|
||||
} finally {
|
||||
inflight = null
|
||||
}
|
||||
}
|
||||
|
||||
// React Hook: 获取分类索引。优先内存 → localStorage 快照 → 远端
|
||||
export function useIfinanceInfo() {
|
||||
const [state, setState] = useState(() => {
|
||||
if (memoInfo) return { info: memoInfo, loading: false, error: '' }
|
||||
const cached = readLocalInfo()
|
||||
return { info: cached, loading: true, error: '' }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState((s) => ({ ...s, loading: true }))
|
||||
fetchIfinanceInfo()
|
||||
.then((info) => { if (alive) setState({ info, loading: false, error: '' }) })
|
||||
.catch((e) => {
|
||||
if (!alive) return
|
||||
const cached = readLocalInfo()
|
||||
setState({
|
||||
info: cached,
|
||||
loading: false,
|
||||
error: cached ? '' : (e?.message || '外接模块加载失败'),
|
||||
})
|
||||
})
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
+45
-53
@@ -1,43 +1,31 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
|
||||
import { Badge } from 'antd-mobile'
|
||||
import PageHeader from '../components/PageHeader.jsx'
|
||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||
import DataView from './macro/DataView.jsx'
|
||||
import StrategyView from './macro/StrategyView.jsx'
|
||||
import ArticlesView from './macro/ArticlesView.jsx'
|
||||
import ArticleDetail from './macro/ArticleDetail.jsx'
|
||||
import OilPage from './macro/OilPage.jsx'
|
||||
import AlertsView from './macro/AlertsView.jsx'
|
||||
import NewsView from './macro/NewsView.jsx'
|
||||
import NewsDetail from './news/NewsDetail.jsx'
|
||||
import FinanceCategoryView from './macro/FinanceCategoryView.jsx'
|
||||
import FinanceItemView from './macro/FinanceItemView.jsx'
|
||||
import { useIfinanceInfo } from '../lib/ifinance.js'
|
||||
|
||||
// 宏观: 顶栏 6 tab, 默认 新闻
|
||||
// /macro → 显示 news tab (activeFromPath 决定)
|
||||
// /macro/alerts → 报警 (V0.2: 原「首页」tab 合并进来, 置最左)
|
||||
// /macro/news → 新闻 (V0.2: 原「首页」tab 合并进来, 置第二并作为默认)
|
||||
// /macro/oil → 油价
|
||||
// /macro/data → 经济数据
|
||||
// /macro/strategy → 策略研究
|
||||
// /macro/articles → 经济文章
|
||||
// 详情: /macro/data/:name · /macro/strategy/:name · /macro/articles/:slug · /macro/news/:id
|
||||
// 说明: tab 数量已超出屏宽, SwipeTabs 会在右侧显示渐变 + 箭头提示可继续滑动
|
||||
const TABS = [
|
||||
{ key: 'alerts', label: '报警', path: '/macro/alerts' },
|
||||
// 宏观: 本地固定 2 个 tab (新闻 / 经济文章) + 外接 ifinance 分类动态挂载为 tab
|
||||
// /macro/news → 新闻 (默认)
|
||||
// /macro/articles → 经济文章
|
||||
// /macro/finance/:catId → 外接分类 (经济数据 / 策略研究 / …)
|
||||
// 详情: /macro/news/:id · /macro/articles/:slug · /macro/finance/:catId/:itemId
|
||||
// 说明: 外接分类不写死, 由 info.json 的 categories[] 决定; ifinance 新增分类后
|
||||
// 本页自动多出新 tab, 宿主无需改代码。报警已迁到「监控」。
|
||||
const BASE_TABS = [
|
||||
{ key: 'news', label: '新闻', path: '/macro/news' },
|
||||
{ key: 'oil', label: '油价', path: '/macro/oil' },
|
||||
{ key: 'data', label: '经济数据', path: '/macro/data' },
|
||||
{ key: 'strategy', label: '策略研究', path: '/macro/strategy' },
|
||||
{ key: 'articles', label: '经济文章', path: '/macro/articles' },
|
||||
]
|
||||
|
||||
function activeFromPath(pathname) {
|
||||
if (pathname.startsWith('/macro/alerts')) return 'alerts'
|
||||
if (pathname.startsWith('/macro/news')) return 'news'
|
||||
if (pathname.startsWith('/macro/oil')) return 'oil'
|
||||
if (pathname.startsWith('/macro/data')) return 'data'
|
||||
if (pathname.startsWith('/macro/strategy')) return 'strategy'
|
||||
if (pathname.startsWith('/macro/articles')) return 'articles'
|
||||
const m = pathname.match(/^\/macro\/finance\/([^/]+)/)
|
||||
if (m) return 'finance:' + m[1]
|
||||
// 父路由 /macro 走默认 tab (新闻)
|
||||
return 'news'
|
||||
}
|
||||
@@ -45,43 +33,47 @@ function activeFromPath(pathname) {
|
||||
export default function MacroPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { info } = useIfinanceInfo()
|
||||
const active = activeFromPath(location.pathname)
|
||||
// 报警角标: 由 AlertsView 回传总数
|
||||
const [alertCount, setAlertCount] = useState(0)
|
||||
const categories = info?.categories || []
|
||||
|
||||
const tabs = TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.key === 'alerts'
|
||||
? <span className="tab-label">报警{alertCount > 0 && <Badge content={alertCount} />}</span>
|
||||
: t.label,
|
||||
render: () => {
|
||||
if (t.key === 'oil') return <OilPage />
|
||||
if (t.key === 'data') return <DataView />
|
||||
if (t.key === 'strategy') return <StrategyView />
|
||||
if (t.key === 'articles') return <ArticlesView />
|
||||
if (t.key === 'alerts') return <AlertsView onCount={setAlertCount} />
|
||||
if (t.key === 'news') return <NewsView />
|
||||
return null
|
||||
},
|
||||
}))
|
||||
|
||||
function onChange(key) {
|
||||
const next = TABS.find((t) => t.key === key)
|
||||
if (next) navigate(next.path)
|
||||
}
|
||||
|
||||
// 详情页单独走 React Router 渲染, 不参与滑屏
|
||||
if (location.pathname.match(/^\/macro\/(articles|data|strategy|news)\/[^/]+$/)) {
|
||||
// 详情页单独走 React Router 渲染 (需要 Route 提供 params), 不参与滑屏
|
||||
const p = location.pathname
|
||||
const isNewsDetail = /^\/macro\/news\/[^/]+$/.test(p)
|
||||
const isArticleDetail = /^\/macro\/articles\/[^/]+$/.test(p)
|
||||
const isFinanceDetail = /^\/macro\/finance\/[^/]+\/[^/]+$/.test(p)
|
||||
if (isNewsDetail || isArticleDetail || isFinanceDetail) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="data/:name" element={<DataView />} />
|
||||
<Route path="strategy/:name" element={<StrategyView />} />
|
||||
<Route path="articles/:slug" element={<ArticleDetail />} />
|
||||
<Route path="news/:id" element={<NewsDetail />} />
|
||||
<Route path="articles/:slug" element={<ArticleDetail />} />
|
||||
<Route path="finance/:catId/:itemId" element={<FinanceItemView />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
...BASE_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
render: () => (t.key === 'news' ? <NewsView /> : <ArticlesView />),
|
||||
})),
|
||||
...categories.map((c) => ({
|
||||
key: 'finance:' + c.id,
|
||||
label: c.name,
|
||||
render: () => <FinanceCategoryView category={c} />,
|
||||
})),
|
||||
]
|
||||
|
||||
function onChange(key) {
|
||||
if (key.startsWith('finance:')) {
|
||||
navigate('/macro/finance/' + key.slice('finance:'.length))
|
||||
return
|
||||
}
|
||||
const next = BASE_TABS.find((t) => t.key === key)
|
||||
if (next) navigate(next.path)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
<PageHeader title="宏观" />
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Empty, DotLoading } from 'antd-mobile'
|
||||
import { Badge, Card, Empty, DotLoading } from 'antd-mobile'
|
||||
import { api } from '../api/client.js'
|
||||
import PageHeader from '../components/PageHeader.jsx'
|
||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js'
|
||||
import EnvView from './monitor/EnvView.jsx'
|
||||
import AlertsView from './monitor/AlertsView.jsx'
|
||||
|
||||
// 监控页: 两个横向分类
|
||||
// 监控页: 四个横向分类
|
||||
// - 主机监控: 实时数据来自 komari (服务端实时代理, 不落库), 点击节点看 24h 时序
|
||||
// - 服务监控: 暂留空占位
|
||||
// - 环境监控: 米家温湿度
|
||||
// - 报警: V0.2.2 从「宏观」迁入, 数据差异/任务异常按时间倒序
|
||||
function fmtTime(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
@@ -40,10 +43,21 @@ function pct(used, total) {
|
||||
|
||||
export default function MonitorPage() {
|
||||
const [active, setActive] = useState('servers')
|
||||
// 报警角标: 由 AlertsView 回传总数
|
||||
const [alertCount, setAlertCount] = useState(0)
|
||||
const tabs = [
|
||||
{ key: 'servers', label: '主机监控', render: () => <ServerView /> },
|
||||
{ key: 'services', label: '服务监控', render: () => <ServiceView /> },
|
||||
{ key: 'env', label: '环境监控', render: () => <EnvView /> },
|
||||
{
|
||||
key: 'alerts',
|
||||
label: (
|
||||
<span className="tab-label">
|
||||
报警{alertCount > 0 && <Badge content={alertCount} />}
|
||||
</span>
|
||||
),
|
||||
render: () => <AlertsView onCount={setAlertCount} />,
|
||||
},
|
||||
]
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
|
||||
@@ -130,7 +130,14 @@ export default function MergedView({ date, onDateChange }) {
|
||||
}
|
||||
}
|
||||
|
||||
const summary = detail?.summary || { total: 0, done: 0 }
|
||||
// 当日进度 = 当天已打卡项 / 当天打卡项. 用 dayDone 而非 completed:
|
||||
// completed 是"周期目标已达成" (每周/每月项可能在本周期其他天完成), 放到
|
||||
// 历史日期的标题上会把当天之后、同周期内的打卡也算进来。按 items 现算还能
|
||||
// 让乐观更新 (打卡/撤销) 立即反映到标题, 不必等 /habits 重新拉取。
|
||||
const summary = useMemo(
|
||||
() => ({ total: items.length, done: items.filter((i) => i.dayDone).length }),
|
||||
[items],
|
||||
)
|
||||
const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
|
||||
|
||||
// ---------- 打卡 / 撤销 (乐观更新) ----------
|
||||
@@ -256,7 +263,7 @@ export default function MergedView({ date, onDateChange }) {
|
||||
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, -1))}>‹</span>
|
||||
<b>{fmtMonth(month)}</b>
|
||||
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, 1))}>›</span>
|
||||
<span className="today" onClick={() => onDateChange(today)}>回到今天</span>
|
||||
<span className="today" onClick={() => { setMonth(today.slice(0, 7)); onDateChange(today) }}>回到今天</span>
|
||||
</div>
|
||||
|
||||
<div className="mv-grid mv-wd">
|
||||
@@ -292,9 +299,10 @@ export default function MergedView({ date, onDateChange }) {
|
||||
{cal && (
|
||||
<div className="mv-rate">
|
||||
<span>本月完成率 <b>{Math.round((cal.rate || 0) * 100)}%</b></span>
|
||||
{/* 图例: 弧长=完成比例, 颜色=状态 */}
|
||||
{/* 图例: 弧长=完成比例, 颜色=4 档 (小半/大半/完成/超额) */}
|
||||
<span className="mv-legend">
|
||||
<i className="partial" />部分
|
||||
<i className="low" />小半
|
||||
<i className="high" />大半
|
||||
<i className="full" />完成
|
||||
<i className="over" />超额
|
||||
</span>
|
||||
|
||||
@@ -25,13 +25,22 @@ export function addDays(s, n) {
|
||||
return fromUTC(d)
|
||||
}
|
||||
|
||||
/**
|
||||
* 月份加减. 同时接受月粒度 "YYYY-MM" 与日粒度 "YYYY-MM-DD":
|
||||
* - 月粒度必须原样返回 "YYYY-MM", 不能变成 "YYYY-MM-01" —— 否则
|
||||
* `/checkin/calendar?month=2026-10-01` 会被服务端 MONTH_RE 判为非法并回退到当月,
|
||||
* 日历切到上个月时就会"看不到任何记录"。
|
||||
* - 日粒度保留原日号, 超出目标月天数时收敛到月末。
|
||||
*/
|
||||
export function addMonths(s, n) {
|
||||
const [y, m] = String(s).split('-').map(Number)
|
||||
const str = String(s)
|
||||
const [y, m, d] = str.split('-').map(Number)
|
||||
const day = d || 1
|
||||
const first = new Date(Date.UTC(y, (m || 1) - 1 + n, 1))
|
||||
const day = Number(String(s).slice(8, 10)) || 1
|
||||
const lastDay = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate()
|
||||
first.setUTCDate(Math.min(day, lastDay))
|
||||
return fromUTC(first)
|
||||
const out = fromUTC(first)
|
||||
return str.length === 7 ? out.slice(0, 7) : out
|
||||
}
|
||||
|
||||
/** 周一为一周第一天 */
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
import { Card, List, NavBar } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
import TrendBadge, { calcTrend, lastPoint } from '../../components/TrendBadge.jsx'
|
||||
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
||||
|
||||
// 经济数据
|
||||
// 列表态 (默认): 列出所有指标, 每行显示最新值 + 趋势徽标, 点击进入详情
|
||||
// 详情态: 顶部 NavBar (返回) + 折线图 + 数据表
|
||||
// 频率中文标签 (对应 Indicator.frequency)
|
||||
const FREQ_LABEL = { year: '年', quarter: '季', month: '月', day: '日' }
|
||||
|
||||
// 统一数据行的时间字段: 兼容 {month}/{year}/{period} 三种存储形状
|
||||
function periodOf(r) {
|
||||
return (r && (r.period || r.month || r.year)) || ''
|
||||
}
|
||||
|
||||
export default function DataView() {
|
||||
const [indicators, setIndicators] = useState([])
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
// 路由驱动: /macro/data 列表, /macro/data/:name 详情, 浏览器/手机返回键天然可用
|
||||
const { name } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true)
|
||||
api('/indicators')
|
||||
.then((data) => { setErr(''); setIndicators(data.indicators || []) })
|
||||
.catch((e) => setErr(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
// 后台回前台时重新拉取
|
||||
useRefreshOnVisible(load)
|
||||
|
||||
if (loading) return <div className="loading">加载中...</div>
|
||||
if (err) return <div className="error-banner">{err}</div>
|
||||
if (!indicators.length) return <div className="empty">暂无指标数据, 请先执行 npm run db:seed</div>
|
||||
|
||||
// 详情态 (路径 /macro/data/:name)
|
||||
if (name) {
|
||||
const cur = indicators.find((i) => i.name === name)
|
||||
if (cur) return <DataDetail indicator={cur} onBack={() => navigate('/macro/data')} />
|
||||
}
|
||||
|
||||
// 列表态
|
||||
const labelOf = (i) => i.title || i.name
|
||||
|
||||
return (
|
||||
<div className="list-page">
|
||||
<div className="page-title">经济数据</div>
|
||||
<List>
|
||||
{indicators.map((i) => {
|
||||
const lp = lastPoint({ rows: i.points || [] })
|
||||
const { trend, change } = calcTrend(i.points || [])
|
||||
const unit = i.unit || ''
|
||||
return (
|
||||
<List.Item
|
||||
key={i.name}
|
||||
title={labelOf(i)}
|
||||
description={lp ? <><b>{lp.value}{unit}</b> · {periodOf(lp)}</> : '暂无数据'}
|
||||
extra={<TrendBadge trend={trend} />}
|
||||
|
||||
arrowIcon
|
||||
clickable
|
||||
onClick={() => navigate('/macro/data/' + i.name)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 详情: 折线图 + 数据表
|
||||
function DataDetail({ indicator, onBack }) {
|
||||
const i = indicator
|
||||
const rows = (i.points || [])
|
||||
// 图表 X 轴统一用 period 字段
|
||||
const chartRows = rows.map((r) => ({ ...r, period: periodOf(r) }))
|
||||
// 表格按时间反序: 最新在前
|
||||
const tableRows = [...rows].reverse()
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const visibleRows = showAll ? tableRows : tableRows.slice(0, 10)
|
||||
const lp = lastPoint({ rows })
|
||||
const { trend } = calcTrend(rows)
|
||||
const title = i.title || i.name
|
||||
return (
|
||||
<div className="detail-page">
|
||||
<NavBar onBack={onBack}>{title}</NavBar>
|
||||
<Card className="detail-card">
|
||||
<div className="detail-meta">
|
||||
单位 {i.unit || '-'} · 频率 {FREQ_LABEL[i.frequency] || i.frequency || '-'} · 来源 {i.source || 'seed'} ·
|
||||
{lp && <> 最新 <b style={{ color: 'var(--color-text)' }}>{lp.value}{i.unit || ''}</b> ({periodOf(lp)}) </>}
|
||||
<TrendBadge trend={trend} />
|
||||
</div>
|
||||
<div className="chart-wrap">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={chartRows} margin={{ top: 10, right: 16, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="period" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="value" name={i.unit || '值'} stroke="#1677ff" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className={'data-table-wrap' + (showAll ? ' scroll' : '')}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr><th>月份</th><th style={{ textAlign: 'right' }}>数值</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.map((r) => (
|
||||
<tr key={periodOf(r)}><td>{periodOf(r)}</td><td style={{ textAlign: 'right' }}>{r.value}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{tableRows.length > 10 && !showAll && (
|
||||
<div className="table-more" onClick={() => setShowAll(true)}>
|
||||
展开全部(共 {tableRows.length} 行)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { List, DotLoading } from 'antd-mobile'
|
||||
import { useIfinanceInfo } from '../../lib/ifinance.js'
|
||||
|
||||
// 外接模块分类列表 (ifinance categories[].items[])
|
||||
// - 数据来源: info.json (运行时动态拉取), 当前分类由 MacroPage 传入
|
||||
// - 点击条目 → /macro/finance/:catId/:itemId 打开该条目独立 HTML
|
||||
const KIND_LABEL = { indicator: '指标', strategy: '策略', dashboard: '看板' }
|
||||
|
||||
export default function FinanceCategoryView({ category }) {
|
||||
const navigate = useNavigate()
|
||||
const { info, loading, error } = useIfinanceInfo()
|
||||
|
||||
// MacroPage 拿到的 category 可能来自缓存; 若为空则用 hook 里的最新数据兜底
|
||||
const cat = category || (info?.categories || []).find((c) => c.id === category?.id)
|
||||
const items = cat?.items || []
|
||||
|
||||
if (!cat && loading) return <div className="loading"><DotLoading /> 加载中...</div>
|
||||
if (!cat && error) return <div className="error-banner">{error}</div>
|
||||
if (!items.length) return <div className="empty">暂无内容</div>
|
||||
|
||||
return (
|
||||
<div className="list-page">
|
||||
<List>
|
||||
{items.map((it) => (
|
||||
<List.Item
|
||||
key={it.id}
|
||||
title={it.title || it.id}
|
||||
description={it.subtitle || ''}
|
||||
extra={<span className="finance-kind">{KIND_LABEL[it.kind] || it.kind || ''}</span>}
|
||||
arrowIcon
|
||||
clickable
|
||||
onClick={() => navigate('/macro/finance/' + cat.id + '/' + it.id)}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { NavBar, DotLoading } from 'antd-mobile'
|
||||
import { useIfinanceInfo, financePageUrl } from '../../lib/ifinance.js'
|
||||
|
||||
// 外接条目详情: 用 iframe 加载 ifinance 生成的独立 HTML。
|
||||
// - 页面本身无导航/头部 (符合 ifinance 规范), 由宿主提供 NavBar 返回
|
||||
// - 直接指向远端地址 (纯前端直连, 不做端上缓存)
|
||||
export default function FinanceItemView() {
|
||||
const { catId, itemId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { info, loading, error } = useIfinanceInfo()
|
||||
|
||||
const cat = (info?.categories || []).find((c) => c.id === catId)
|
||||
const item = (cat?.items || []).find((i) => i.id === itemId)
|
||||
const title = item?.title || '详情'
|
||||
|
||||
return (
|
||||
<div className="finance-item-page">
|
||||
<NavBar onBack={() => navigate('/macro/finance/' + catId)}>{title}</NavBar>
|
||||
{loading && !item && <div className="loading"><DotLoading /> 加载中...</div>}
|
||||
{!loading && error && <div className="error-banner">{error}</div>}
|
||||
{!loading && !error && !item && <div className="empty">内容不存在</div>}
|
||||
{item?.html && (
|
||||
<iframe
|
||||
className="finance-frame"
|
||||
title={title}
|
||||
src={financePageUrl(item.html)}
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card, DotLoading, Empty, ErrorBlock } from 'antd-mobile'
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { getOilForecast, getOilHistory, getOilSchedule } from '../../api/oil.js'
|
||||
|
||||
// 油价页 (新):
|
||||
// - 上: 下次调价预测 (无 WTI / 无柱状图)
|
||||
// - 中: 浙江省最近半年价格走势 (3 油品折线, 一张图)
|
||||
// - 下: 调价日历 (按年切换)
|
||||
// 数据源:
|
||||
// - 预测/日历: apizero 实时 (后端代理)
|
||||
// - 走势: DB oil_price_history 表 (cron 每天 04:00 抓浙江 3 油品入库)
|
||||
|
||||
const FUEL_LABELS = {
|
||||
'92': '92# 汽油',
|
||||
'95': '95# 汽油',
|
||||
'0': '0# 柴油',
|
||||
}
|
||||
// 3 油品各自配色 (与原柱状图色调保持一致)
|
||||
const FUEL_COLORS = {
|
||||
'92': '#1677ff',
|
||||
'95': '#52c41a',
|
||||
'0': '#888',
|
||||
}
|
||||
|
||||
function n(v, d = 2) { return Number.isFinite(Number(v)) ? Number(v).toFixed(d) : '-' }
|
||||
|
||||
// 调价预测 (保留, 但不显示 WTI / 布伦特)
|
||||
function ForecastPanel() {
|
||||
const [forecast, setForecast] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setErr('')
|
||||
getOilForecast()
|
||||
.then((d) => { if (!cancelled) setForecast(d) })
|
||||
.catch((e) => !cancelled && setErr(e.message || '加载失败'))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="monitor-loading"><DotLoading /> 加载中...</div>
|
||||
if (err) return <ErrorBlock status="default" title="加载失败" description={err} />
|
||||
|
||||
const p = forecast?.prediction
|
||||
return (
|
||||
<Card className="oil-card">
|
||||
<div className="oil-card-title">下次调价预测</div>
|
||||
<div className="oil-prediction">
|
||||
<div className="oil-pred-row">
|
||||
<span className="oil-pred-label">调价日</span>
|
||||
<span className="oil-pred-value">{forecast?.next_adjust_date || '-'}</span>
|
||||
<span className="oil-pred-tag">剩 {forecast?.days_remaining ?? '-'} 天</span>
|
||||
</div>
|
||||
<div className="oil-pred-row">
|
||||
<span className="oil-pred-label">方向</span>
|
||||
<span className={'oil-pred-direction ' + (p?.direction || '')}>
|
||||
{p?.direction_emoji || ''} {p?.direction || '-'}
|
||||
</span>
|
||||
<span className="oil-pred-tag">置信度 {p?.confidence || '-'}</span>
|
||||
</div>
|
||||
<div className="oil-pred-row">
|
||||
<span className="oil-pred-label">预计幅度</span>
|
||||
<span className="oil-pred-value">
|
||||
{p?.estimated_change_per_ton != null
|
||||
? `${p.estimated_change_per_ton > 0 ? '+' : ''}${p.estimated_change_per_ton} 元/吨`
|
||||
: '-'}
|
||||
{p?.estimated_change_per_liter != null && (
|
||||
<span className="oil-pred-sub"> ({p.estimated_change_per_liter > 0 ? '+' : ''}{p.estimated_change_per_liter} 元/升)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{p?.analysis && (
|
||||
<div className="oil-pred-analysis">{p.analysis}</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 浙江省最近半年价格走势
|
||||
function HistoryPanel() {
|
||||
const [data, setData] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setErr('')
|
||||
getOilHistory({ days: 180 })
|
||||
.then((d) => { if (!cancelled) setData(d.items || []) })
|
||||
.catch((e) => !cancelled && setErr(e.message || '加载失败'))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
// 整形: rows = [{date, fuel, price}] -> 3 油品并排
|
||||
// pivot: [{date, 92, 95, 0}]
|
||||
const chartData = useMemo(() => {
|
||||
const m = new Map()
|
||||
for (const r of data) {
|
||||
if (!m.has(r.date)) m.set(r.date, { date: r.date })
|
||||
m.get(r.date)[r.fuel] = r.price
|
||||
}
|
||||
return Array.from(m.values()).sort((a, b) => a.date.localeCompare(b.date))
|
||||
}, [data])
|
||||
|
||||
// 3 油品都有数据?
|
||||
const hasData = chartData.length > 0
|
||||
|
||||
return (
|
||||
<Card className="oil-card">
|
||||
<div className="oil-card-title">浙江省最近半年价格走势</div>
|
||||
{loading && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
||||
{err && <ErrorBlock status="default" title="加载失败" description={err} />}
|
||||
{!loading && !err && !hasData && (
|
||||
<Empty description="暂无历史数据 (每天 04:00 自动抓取, 调价次日入库)" />
|
||||
)}
|
||||
{!loading && !err && hasData && (
|
||||
<>
|
||||
<div className="oil-chart">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={chartData} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} domain={['dataMin - 0.1', 'dataMax + 0.1']} unit="" />
|
||||
<Tooltip formatter={(v) => `${Number(v).toFixed(3)} 元/升`} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{Object.keys(FUEL_LABELS).map((k) => (
|
||||
<Line
|
||||
key={k}
|
||||
type="monotone"
|
||||
dataKey={k}
|
||||
name={FUEL_LABELS[k]}
|
||||
stroke={FUEL_COLORS[k]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="oil-chart-tip">共 {chartData.length} 个数据点 (调价次日入库)</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 调价日历 (不变)
|
||||
function SchedulePanel() {
|
||||
const [year, setYear] = useState(new Date().getFullYear())
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setErr('')
|
||||
getOilSchedule(year)
|
||||
.then((d) => { if (!cancelled) setData(d) })
|
||||
.catch((e) => !cancelled && setErr(e.message || '加载失败'))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
return () => { cancelled = true }
|
||||
}, [year])
|
||||
|
||||
const byMonth = useMemo(() => {
|
||||
const m = {}
|
||||
if (!data?.schedule) return m
|
||||
for (const it of data.schedule) {
|
||||
const mm = it.date.slice(0, 7)
|
||||
if (!m[mm]) m[mm] = []
|
||||
m[mm].push(it)
|
||||
}
|
||||
return m
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<Card className="oil-card">
|
||||
<div className="oil-card-title-row">
|
||||
<div className="oil-card-title">调价日历</div>
|
||||
<div className="oil-year-tabs">
|
||||
{[2025, 2026].map((y) => (
|
||||
<button
|
||||
key={y}
|
||||
className={'oil-year-tab ' + (y === year ? 'active' : '')}
|
||||
onClick={() => setYear(y)}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{data?._cached && <div className="oil-cache-hint">本次来自本地缓存, 不消耗 apizero 配额</div>}
|
||||
{loading && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
||||
{err && <ErrorBlock status="default" title="加载失败" description={err} />}
|
||||
{!loading && !err && data && (
|
||||
<>
|
||||
<div className="oil-schedule-meta">
|
||||
{data.year} 年共 <b>{data.total_adjustments}</b> 个调价窗口
|
||||
{data.next_adjust_date && (
|
||||
<span className="oil-pred-tag">下一个: {data.next_adjust_date} (剩 {data.days_remaining} 天)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="oil-schedule-list">
|
||||
{Object.keys(byMonth).sort().map((m) => (
|
||||
<div key={m} className="oil-month">
|
||||
<div className="oil-month-label">{m}</div>
|
||||
<div className="oil-month-items">
|
||||
{byMonth[m].map((it) => (
|
||||
<div key={it.date} className={'oil-day ' + (it.status === '已过' ? 'past' : 'future')}>
|
||||
<div className="oil-day-date">{it.date.slice(8)}</div>
|
||||
<div className="oil-day-status">{it.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 油价页入口: 预测 + 走势 + 日历
|
||||
export default function OilPage() {
|
||||
return (
|
||||
<div className="oil-page">
|
||||
<ForecastPanel />
|
||||
<HistoryPanel />
|
||||
<SchedulePanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
import { Card, List, NavBar } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
import TrendBadge, { calcTrend } from '../../components/TrendBadge.jsx'
|
||||
|
||||
// 策略研究
|
||||
// 列表态 (默认): 列出所有策略, 每行显示主指标 (债务/GDP) 最新值 + 趋势
|
||||
// 详情态: 顶部 NavBar (返回) + 4 张图 (debt/gdp/debtToGdp/interestToGdp) + 表格
|
||||
const SERIES = [
|
||||
{ key: 'debt', label: '政府债务 (万亿美元)', color: '#8884d8' },
|
||||
{ key: 'gdp', label: 'GDP (万亿美元)', color: '#3cb44b' },
|
||||
{ key: 'debtToGdp', label: '债务/GDP (%)', color: '#4363d8' },
|
||||
{ key: 'interestToGdp', label: '债务利息/GDP (%)', color: '#f58231' },
|
||||
]
|
||||
|
||||
// 取最后一个有效数据点
|
||||
function lastRow(rows) {
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
if (rows[i] && typeof rows[i].debtToGdp === 'number') return rows[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default function StrategyView() {
|
||||
const [strategies, setStrategies] = useState([])
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
// 路由驱动: /macro/strategy 列表, /macro/strategy/:name 详情, 浏览器/手机返回键天然可用
|
||||
const { name } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
api('/strategies')
|
||||
.then((data) => { if (!cancelled) setStrategies(data.strategies || []) })
|
||||
.catch((e) => !cancelled && setErr(e.message))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="loading">加载中...</div>
|
||||
if (err) return <div className="error-banner">{err}</div>
|
||||
if (!strategies.length) return <div className="empty">暂无策略数据</div>
|
||||
|
||||
// 详情态 (路径 /macro/strategy/:name)
|
||||
if (name) {
|
||||
const cur = strategies.find((s) => s.name === name)
|
||||
if (cur) return <StrategyDetail strategy={cur} onBack={() => navigate('/macro/strategy')} />
|
||||
}
|
||||
|
||||
// 列表态: 主指标 = 债务/GDP, 趋势基于其最近变化
|
||||
return (
|
||||
<div className="list-page">
|
||||
<div className="page-title">策略研究</div>
|
||||
<List>
|
||||
{strategies.map((s) => {
|
||||
const rows = (s.data && s.data.rows) || []
|
||||
const last = lastRow(rows)
|
||||
const { trend, change } = calcTrend(rows)
|
||||
return (
|
||||
<List.Item
|
||||
key={s.name}
|
||||
title={s.description || s.name}
|
||||
description={last
|
||||
? <><b>{last.debtToGdp}%</b> · 债务利息/GDP {last.interestToGdp}% · {last.year}</>
|
||||
: '暂无数据'}
|
||||
extra={<TrendBadge trend={trend} />}
|
||||
arrowIcon
|
||||
clickable
|
||||
onClick={() => navigate('/macro/strategy/' + s.name)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 详情: 4 张图 + 表格
|
||||
function StrategyDetail({ strategy, onBack }) {
|
||||
const s = strategy
|
||||
const rows = (s.data && s.data.rows) || []
|
||||
const last = lastRow(rows)
|
||||
const { trend } = calcTrend(rows)
|
||||
return (
|
||||
<div className="detail-page">
|
||||
<NavBar onBack={onBack}>{s.description || s.name}</NavBar>
|
||||
<Card className="detail-card">
|
||||
<div className="detail-meta">
|
||||
数据 {rows.length} 条 · 来源 {s.data.source || 'seed'} ·
|
||||
{last && <> 最新 <b style={{ color: 'var(--color-text)' }}>{last.year}</b> 债务/GDP <b style={{ color: 'var(--color-text)' }}>{last.debtToGdp}%</b> </>}
|
||||
<TrendBadge trend={trend} />
|
||||
</div>
|
||||
{SERIES.map((s2) => (
|
||||
<div key={s2.key} style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>{s2.label}</div>
|
||||
<div className="chart-wrap">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={rows} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="year" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey={s2.key} name={s2.label} stroke={s2.color} strokeWidth={2} dot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ maxHeight: 220, overflow: 'auto', marginTop: 8 }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>年份</th>
|
||||
<th style={{ textAlign: 'right' }}>债务</th>
|
||||
<th style={{ textAlign: 'right' }}>GDP</th>
|
||||
<th style={{ textAlign: 'right' }}>债务/GDP</th>
|
||||
<th style={{ textAlign: 'right' }}>利息/GDP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.year}>
|
||||
<td>{r.year}</td>
|
||||
<td style={{ textAlign: 'right' }}>{r.debt}</td>
|
||||
<td style={{ textAlign: 'right' }}>{r.gdp}</td>
|
||||
<td style={{ textAlign: 'right' }}>{r.debtToGdp}</td>
|
||||
<td style={{ textAlign: 'right' }}>{r.interestToGdp}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import { api } from '../../api/client.js'
|
||||
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
||||
import { fmtDateTime } from '../../utils/format.js'
|
||||
|
||||
// 报警 (V0.2 从「首页」并入「宏观」Tab)
|
||||
// - 数据: World Bank 与妙想 MCP 跨源同 (指标, 周期) 数据差异超阈值, 按时间倒序
|
||||
// 报警 (V0.2.2 从「宏观」迁到「监控」第 4 个 tab)
|
||||
// - 数据: 跨源同 (指标, 周期) 数据差异超阈值 / cron 任务异常, 按时间倒序
|
||||
// - 无限滚动: 触底自动加载下一页
|
||||
// - onCount: 把总数回传给父级 (宏观 Tab 上显示角标)
|
||||
// - onCount: 把总数回传给父级 (监控的报警 tab 上显示角标)
|
||||
export default function AlertsView({ onCount }) {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -1,29 +1,67 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Button, DatePicker, Dialog, DotLoading, Input, Popup, Selector, Toast } from 'antd-mobile'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, DatePicker, Dialog, DotLoading, Input, Popup, Toast } from 'antd-mobile'
|
||||
import { taskApi } from '../../api/task.js'
|
||||
import { kindColor } from './kind.js'
|
||||
|
||||
// 已归档任务查询 (T-ARC-04/05/06)
|
||||
// 过滤: 关键词(标题) / 任务类型 / 看板列 / 归档时间范围
|
||||
// 过滤: 关键词(标题) / 看板列 / 归档时间 (含 本周/本月/本季度 快捷项)
|
||||
// 交互: 条件先编辑 -> 点「筛选」才提交
|
||||
// (原实现改任一条件就即时重查, 连点会连发请求, 输关键词时还会闪)
|
||||
// 游标分页: 「加载更多」追加; 行内 恢复 / 彻底删除
|
||||
// 不再提供「任务类型」过滤: 类型是弱维度, 归档场景几乎不按类型检索
|
||||
const LIMIT = 20
|
||||
|
||||
const EMPTY = { keyword: '', columnId: '', time: 'all', from: '', to: '' }
|
||||
|
||||
const TIME_OPTIONS = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'week', label: '本周' },
|
||||
{ key: 'month', label: '本月' },
|
||||
{ key: 'quarter', label: '本季度' },
|
||||
{ key: 'custom', label: '自定义' },
|
||||
]
|
||||
const TIME_LABEL = { week: '本周', month: '本月', quarter: '本季度' }
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const fmtDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
|
||||
/**
|
||||
* 快捷时间 -> [from, to]。本地日期,区间截止到今天(不跨到未来)。
|
||||
* 纯函数,便于单测。
|
||||
*/
|
||||
export function presetRange(key, now = new Date()) {
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
if (key === 'week') {
|
||||
const dow = (today.getDay() + 6) % 7 // 周一为 0
|
||||
const mon = new Date(today)
|
||||
mon.setDate(today.getDate() - dow)
|
||||
return [fmtDate(mon), fmtDate(today)]
|
||||
}
|
||||
if (key === 'month') {
|
||||
return [fmtDate(new Date(today.getFullYear(), today.getMonth(), 1)), fmtDate(today)]
|
||||
}
|
||||
if (key === 'quarter') {
|
||||
const qm = Math.floor(today.getMonth() / 3) * 3 // 当季首月
|
||||
return [fmtDate(new Date(today.getFullYear(), qm, 1)), fmtDate(today)]
|
||||
}
|
||||
return ['', '']
|
||||
}
|
||||
|
||||
export default function ArchivedTasks({ project, visible, onClose, onChanged }) {
|
||||
const [items, setItems] = useState(null)
|
||||
const [nextCursor, setNextCursor] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filters, setFilters] = useState({ keyword: '', kind: '', columnId: '', from: '', to: '' })
|
||||
const [draft, setDraft] = useState(EMPTY) // 表单里正在编辑的条件
|
||||
const [applied, setApplied] = useState(EMPTY) // 已提交、真正参与查询的条件
|
||||
const [fromVisible, setFromVisible] = useState(false)
|
||||
const [toVisible, setToVisible] = useState(false)
|
||||
const cursorRef = useRef('')
|
||||
|
||||
const columns = project?.columns || []
|
||||
const kinds = project?.taskKinds || []
|
||||
|
||||
const buildParams = (f, cursor) => {
|
||||
const p = { limit: LIMIT }
|
||||
if (f.keyword.trim()) p.keyword = f.keyword.trim()
|
||||
if (f.kind) p.kind = f.kind
|
||||
if (f.keyword) p.keyword = f.keyword
|
||||
if (f.columnId) p.columnId = f.columnId
|
||||
if (f.from) p.from = f.from
|
||||
if (f.to) p.to = f.to
|
||||
@@ -35,7 +73,7 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
setLoading(true)
|
||||
try {
|
||||
const cursor = reset ? '' : cursorRef.current
|
||||
const r = await taskApi.archivedList(project.id, buildParams(filters, cursor))
|
||||
const r = await taskApi.archivedList(project.id, buildParams(applied, cursor))
|
||||
setItems((prev) => (reset || !prev ? r.items : [...prev, ...r.items]))
|
||||
cursorRef.current = r.nextCursor || ''
|
||||
setNextCursor(r.nextCursor || null)
|
||||
@@ -45,7 +83,7 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [project.id, filters])
|
||||
}, [project.id, applied])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
@@ -54,7 +92,51 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
load(true)
|
||||
}, [visible, load])
|
||||
|
||||
const setF = (patch) => setFilters((p) => ({ ...p, ...patch }))
|
||||
const setD = (patch) => setDraft((p) => ({ ...p, ...patch }))
|
||||
const colName = (id) => columns.find((c) => String(c.id) === String(id))?.name || ''
|
||||
const fmt = (iso) => (iso ? String(iso).slice(0, 10) : '')
|
||||
|
||||
const activeCount =
|
||||
(applied.keyword ? 1 : 0) + (applied.columnId ? 1 : 0) + (applied.time !== 'all' ? 1 : 0)
|
||||
|
||||
const appliedTags = useMemo(() => {
|
||||
const tags = []
|
||||
if (applied.keyword) tags.push({ key: 'keyword', text: `标题: ${applied.keyword}` })
|
||||
if (applied.columnId) tags.push({ key: 'columnId', text: `看板列: ${colName(applied.columnId)}` })
|
||||
if (applied.time === 'custom') {
|
||||
if (applied.from || applied.to) {
|
||||
tags.push({ key: 'time', text: `${applied.from || '…'} ~ ${applied.to || '…'}` })
|
||||
}
|
||||
} else if (applied.time !== 'all') {
|
||||
tags.push({ key: 'time', text: TIME_LABEL[applied.time] })
|
||||
}
|
||||
return tags
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [applied, columns])
|
||||
|
||||
function submit() {
|
||||
const [f, t] = draft.time === 'custom' ? [draft.from, draft.to] : presetRange(draft.time)
|
||||
setApplied({ ...draft, keyword: draft.keyword.trim(), from: f, to: t })
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setDraft(EMPTY)
|
||||
setApplied(EMPTY)
|
||||
}
|
||||
|
||||
// 去掉某一条生效条件 (即时生效, 不必再点按钮)
|
||||
function dropTag(key) {
|
||||
const nextA = { ...applied }
|
||||
const nextD = { ...draft }
|
||||
if (key === 'keyword') { nextA.keyword = ''; nextD.keyword = '' }
|
||||
if (key === 'columnId') { nextA.columnId = ''; nextD.columnId = '' }
|
||||
if (key === 'time') {
|
||||
nextA.time = 'all'; nextA.from = ''; nextA.to = ''
|
||||
nextD.time = 'all'; nextD.from = ''; nextD.to = ''
|
||||
}
|
||||
setApplied(nextA)
|
||||
setDraft(nextD)
|
||||
}
|
||||
|
||||
async function doRestore(item) {
|
||||
try {
|
||||
@@ -62,7 +144,6 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
Toast.show({ content: '已恢复' })
|
||||
setItems((prev) => (prev || []).filter((x) => x.id !== item.id))
|
||||
onChanged?.()
|
||||
load(true)
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message || '恢复失败' })
|
||||
}
|
||||
@@ -71,7 +152,9 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
async function doRemove(item) {
|
||||
const subs = item.children?.length || 0
|
||||
const ok = await Dialog.confirm({
|
||||
content: subs ? `彻底删除「${item.title}」?将一并删除 ${subs} 条子任务,不可恢复。` : `彻底删除「${item.title}」?不可恢复。`,
|
||||
content: subs
|
||||
? `彻底删除「${item.title}」?将一并删除 ${subs} 条子任务,不可恢复。`
|
||||
: `彻底删除「${item.title}」?不可恢复。`,
|
||||
confirmText: '删除',
|
||||
})
|
||||
if (!ok) return
|
||||
@@ -85,8 +168,7 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = (iso) => (iso ? iso.slice(0, 10) : '')
|
||||
const colName = (id) => columns.find((c) => c.id === id)?.name || ''
|
||||
const draftRange = draft.time === 'custom' ? ['', ''] : presetRange(draft.time)
|
||||
|
||||
return (
|
||||
<Popup
|
||||
@@ -99,43 +181,77 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
<div className="navbar-sub">
|
||||
<span className="back" onClick={onClose}>‹</span>
|
||||
<span className="title">已归档任务</span>
|
||||
{project?.name ? <span className="arc-proj">{project.name}</span> : null}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Input
|
||||
placeholder="搜索任务标题"
|
||||
value={filters.keyword}
|
||||
clearable
|
||||
onEnter={() => load(true)}
|
||||
onChange={(v) => setF({ keyword: v })}
|
||||
onBlur={() => load(true)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Selector
|
||||
options={[{ label: '全部类型', value: '' }, ...kinds.map((k) => ({ label: k, value: k }))]}
|
||||
value={[filters.kind]}
|
||||
onSelect={(_, ext) => { setF({ kind: ext.items[0].value }); setTimeout(() => load(true), 0) }}
|
||||
/>
|
||||
</div>
|
||||
{/* 筛选卡 */}
|
||||
<div className="arc-fcard">
|
||||
<div className="arc-frow">
|
||||
<span className="lbl">标题</span>
|
||||
<span className="ctl">
|
||||
<span className="arc-kw">
|
||||
<Input
|
||||
placeholder="输入关键词"
|
||||
value={draft.keyword}
|
||||
clearable
|
||||
onChange={(v) => setD({ keyword: v })}
|
||||
onEnter={submit}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Selector
|
||||
options={[{ label: '全部看板列', value: '' }, ...columns.map((c) => ({ label: c.name, value: String(c.id) }))]}
|
||||
value={[filters.columnId]}
|
||||
onSelect={(_, ext) => { setF({ columnId: ext.items[0].value }); setTimeout(() => load(true), 0) }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 12, color: '#999' }}>
|
||||
<span>归档</span>
|
||||
<span className="mini-btn" style={{ padding: '3px 10px' }} onClick={() => setFromVisible(true)}>
|
||||
{filters.from || '开始日期'}
|
||||
|
||||
<div className="arc-frow">
|
||||
<span className="lbl">看板列</span>
|
||||
<span className="ctl">
|
||||
<span className="arc-chips">
|
||||
<span
|
||||
className={'arc-chip' + (draft.columnId === '' ? ' on' : '')}
|
||||
onClick={() => setD({ columnId: '' })}
|
||||
>全部</span>
|
||||
{columns.map((c) => (
|
||||
<span
|
||||
key={c.id}
|
||||
className={'arc-chip' + (String(draft.columnId) === String(c.id) ? ' on' : '')}
|
||||
onClick={() => setD({ columnId: String(c.id) })}
|
||||
>{c.name}</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
<span>~</span>
|
||||
<span className="mini-btn" style={{ padding: '3px 10px' }} onClick={() => setToVisible(true)}>
|
||||
{filters.to || '结束日期'}
|
||||
</div>
|
||||
|
||||
<div className="arc-frow">
|
||||
<span className="lbl">归档时间</span>
|
||||
<span className="ctl">
|
||||
<span className="arc-chips">
|
||||
{TIME_OPTIONS.map((o) => (
|
||||
<span
|
||||
key={o.key}
|
||||
className={'arc-chip' + (draft.time === o.key ? ' on' : '')}
|
||||
onClick={() => setD({ time: o.key, ...(o.key === 'custom' ? {} : { from: '', to: '' }) })}
|
||||
>{o.label}</span>
|
||||
))}
|
||||
</span>
|
||||
{draft.time === 'custom' && (
|
||||
<span className="arc-range">
|
||||
<span className={'dp' + (draft.from ? ' on' : '')} onClick={() => setFromVisible(true)}>
|
||||
{draft.from || '开始日期'}
|
||||
</span>
|
||||
<span className="sep">~</span>
|
||||
<span className={'dp' + (draft.to ? ' on' : '')} onClick={() => setToVisible(true)}>
|
||||
{draft.to || '结束日期'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{draftRange[0] && <div className="arc-hint">{draftRange[0]} ~ {draftRange[1]}</div>}
|
||||
</span>
|
||||
{(filters.from || filters.to) && (
|
||||
<span className="link" onClick={() => { setF({ from: '', to: '' }); setTimeout(() => load(true), 0) }}>清除</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="arc-actions">
|
||||
<button type="button" className="arc-btn ghost" onClick={reset}>重置</button>
|
||||
<button type="button" className="arc-btn primary" onClick={submit}>
|
||||
筛选{activeCount ? <span className="badge">{activeCount}</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,61 +259,72 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
|
||||
visible={fromVisible}
|
||||
onClose={() => setFromVisible(false)}
|
||||
precision="day"
|
||||
onConfirm={(d) => {
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
setF({ from: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` })
|
||||
setFromVisible(false)
|
||||
setTimeout(() => load(true), 0)
|
||||
}}
|
||||
onConfirm={(d) => { setD({ from: fmtDate(d) }); setFromVisible(false) }}
|
||||
/>
|
||||
<DatePicker
|
||||
visible={toVisible}
|
||||
onClose={() => setToVisible(false)}
|
||||
precision="day"
|
||||
onConfirm={(d) => {
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
setF({ to: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` })
|
||||
setToVisible(false)
|
||||
setTimeout(() => load(true), 0)
|
||||
}}
|
||||
onConfirm={(d) => { setD({ to: fmtDate(d) }); setToVisible(false) }}
|
||||
/>
|
||||
|
||||
<div style={{ padding: '0 14px 24px', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{items === null && loading && <div style={{ textAlign: 'center', padding: 30 }}><DotLoading /> 加载中...</div>}
|
||||
{items !== null && items.length === 0 && !loading && (
|
||||
<div style={{ textAlign: 'center', color: '#b8bcc4', padding: 40 }}>没有符合条件的归档任务</div>
|
||||
{/* 已应用条件 (可单独撤销) */}
|
||||
{appliedTags.length > 0 && (
|
||||
<div className="arc-applied">
|
||||
{appliedTags.map((t) => (
|
||||
<span key={t.key} className="tag">
|
||||
{t.text}
|
||||
<span className="x" onClick={() => dropTag(t.key)}>✕</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="cnt">共 {items?.length || 0} 条</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="arc-list">
|
||||
{items === null && loading && (
|
||||
<div className="arc-empty"><DotLoading /> 加载中…</div>
|
||||
)}
|
||||
{(items || []).map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
style={{
|
||||
background: '#fff', borderRadius: 9, padding: '10px 12px', display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,.04)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, wordBreak: 'break-word' }}>{t.title}</div>
|
||||
<div style={{ fontSize: 10.5, color: '#999', marginTop: 4, display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{t.kind && <span className="kind-tag" style={{ background: kindColor(t.kind) }}>{t.kind}</span>}
|
||||
<span>{colName(t.columnId)}</span>
|
||||
<span>归档于 {fmt(t.archivedAt)}</span>
|
||||
</div>
|
||||
|
||||
{items !== null && items.length === 0 && !loading && (
|
||||
appliedTags.length === 0 ? (
|
||||
<div className="arc-empty">
|
||||
<div className="ico">📭</div>
|
||||
这个项目还没有归档任务
|
||||
<div className="sub">任务完成后可在它的 ⋯ 菜单里归档</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, flexShrink: 0 }}>
|
||||
<Button size="mini" color="primary" fill="outline" onClick={() => doRestore(t)}>恢复</Button>
|
||||
<Button size="mini" color="danger" fill="none" onClick={() => doRemove(t)}>删除</Button>
|
||||
) : (
|
||||
<div className="arc-empty">
|
||||
<div className="ico">🗂</div>
|
||||
没有符合条件的归档任务
|
||||
<div className="sub">试试放宽时间范围或清空关键词</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{(items || []).map((t) => (
|
||||
<div key={t.id} className="arc-item">
|
||||
<div className="top">
|
||||
<div className="ttl">{t.title}</div>
|
||||
{t.number != null && <span className="num-tag">#{t.number}</span>}
|
||||
</div>
|
||||
<div className="meta">
|
||||
{t.kind && (
|
||||
<span className="kind-tag" style={{ background: kindColor(t.kind) }}>{t.kind}</span>
|
||||
)}
|
||||
{colName(t.columnId) && <span className="col">{colName(t.columnId)}</span>}
|
||||
<span className="dot" />
|
||||
<span>归档于 {fmt(t.archivedAt)}</span>
|
||||
</div>
|
||||
<div className="acts">
|
||||
<span className="mini" onClick={() => doRestore(t)}>恢复</span>
|
||||
<span className="mini danger" onClick={() => doRemove(t)}>删除</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{nextCursor && (
|
||||
<Button
|
||||
block
|
||||
fill="outline"
|
||||
size="small"
|
||||
loading={loading}
|
||||
onClick={() => load(false)}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
<Button block fill="outline" size="small" loading={loading} onClick={() => load(false)}>
|
||||
加载更多
|
||||
</Button>
|
||||
)}
|
||||
|
||||
+113
-201
@@ -242,7 +242,7 @@ input, textarea, select { font: inherit; }
|
||||
.list-item-meta { font-size: 12px; color: var(--color-muted); margin-bottom: 4px; }
|
||||
.list-item-excerpt { font-size: 13px; color: var(--color-muted); line-height: 1.5; }
|
||||
|
||||
/* ================== 列表视图 (DataView/StrategyView 列表态) ================== */
|
||||
/* ================== 列表视图 (外接分类 FinanceCategoryView) ================== */
|
||||
.list-page { padding: 0 0 16px; }
|
||||
.list-page .page-title { padding: 14px 16px 4px; }
|
||||
.list-page .adm-list { margin: 0 12px; background: transparent; }
|
||||
@@ -256,41 +256,29 @@ input, textarea, select { font: inherit; }
|
||||
.list-page .adm-list .adm-list-item:last-child { margin-bottom: 0; }
|
||||
.list-page .adm-list-item-title { font-size: 15px; font-weight: 600; color: var(--color-text); }
|
||||
.list-page .adm-list-item-description { color: var(--color-muted); margin-top: 4px; }
|
||||
.list-page .row-meta { display: flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; }
|
||||
.list-page .row-change { font-size: 12px; color: var(--color-muted); }
|
||||
/* 趋势徽标: 上升(绿) / 下降(红) / 持平(灰) / 震荡(橙) */
|
||||
.trend-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
|
||||
/* 外接分类条目右侧类型徽标 */
|
||||
.finance-kind {
|
||||
font-size: 11px;
|
||||
color: var(--color-muted);
|
||||
background: rgba(0,0,0,.05);
|
||||
border-radius: 4px;
|
||||
background: rgba(0,0,0,.04);
|
||||
white-space: nowrap;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.trend-badge .trend-icon { font-weight: 700; }
|
||||
.trend-up { color: #00b578; background: rgba(0,181,120,.1); }
|
||||
.trend-down { color: #ff3141; background: rgba(255,49,65,.1); }
|
||||
.trend-flat { color: #666; background: rgba(0,0,0,.05); }
|
||||
.trend-volatile { color: #ff8f1f; background: rgba(255,143,31,.12); }
|
||||
|
||||
/* 详情页 (DataView/StrategyView 详情态) */
|
||||
.detail-page { display: flex; flex-direction: column; min-height: 100%; }
|
||||
.detail-page .adm-nav-bar { background: #fff; border-bottom: 1px solid var(--color-border); }
|
||||
.detail-card.adm-card { margin: 12px; border-radius: 10px; }
|
||||
.detail-card.adm-card .adm-card-body { padding: 12px; }
|
||||
.detail-meta { font-size: 12px; color: var(--color-muted); margin-bottom: 8px; }
|
||||
/* 外接条目详情: iframe 铺满 (NavBar 45px + 底部 TabBar 已计入) */
|
||||
.finance-item-page { display: flex; flex-direction: column; height: 100%; background: #f5f5f5; }
|
||||
.finance-item-page .adm-nav-bar { flex: 0 0 auto; background: #fff; border-bottom: 1px solid var(--color-border); }
|
||||
.finance-frame {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background: #f5f5f5;
|
||||
height: calc(100dvh - var(--safe-top) - var(--tabbar-h) - var(--safe-bottom) - 45px);
|
||||
}
|
||||
|
||||
/* 图表 / 表格 */
|
||||
/* 图表容器 (环境监控等仍在用) */
|
||||
.chart-wrap { background: #fafafa; border-radius: 8px; padding: 8px; margin: 8px 0 16px; }
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.data-table th, .data-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--color-border); }
|
||||
.data-table th { background: #f8f8f8; font-weight: 600; }
|
||||
.data-table-wrap { position: relative; }
|
||||
.data-table-wrap.scroll { max-height: 300px; overflow: auto; }
|
||||
.table-more { text-align: center; padding: 10px 0 2px; font-size: 13px; color: var(--color-primary); cursor: pointer; }
|
||||
|
||||
/* 页面标题 */
|
||||
.page-title { font-size: 20px; font-weight: 700; padding: 14px 16px 4px; }
|
||||
@@ -459,170 +447,6 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
}
|
||||
|
||||
|
||||
/* ================== 油价页 (Macro/Oil) ================== */
|
||||
.oil-page {
|
||||
padding: 0 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.oil-card {
|
||||
margin: 0 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.oil-card .adm-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
.oil-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.oil-card-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.oil-card-title-row .oil-card-title { margin-bottom: 0; }
|
||||
.oil-fuel-picker {
|
||||
font-size: 13px;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.oil-crude {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.oil-crude-item {
|
||||
background: #f7f8fa;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.oil-crude-label { font-size: 12px; color: var(--color-muted); }
|
||||
.oil-crude-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.oil-crude-value.up { color: #ff3141; }
|
||||
.oil-crude-value.down { color: #00b578; }
|
||||
.oil-crude-unit { font-size: 12px; font-weight: 400; color: var(--color-muted); margin-left: 2px; }
|
||||
.oil-crude-change { font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.oil-crude-change.up { color: #ff3141; }
|
||||
.oil-crude-change.down { color: #00b578; }
|
||||
|
||||
.oil-prediction {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.oil-pred-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.oil-pred-label { color: var(--color-muted); min-width: 76px; }
|
||||
.oil-pred-value { color: var(--color-text); flex: 1; font-weight: 600; }
|
||||
.oil-pred-sub { color: var(--color-muted); font-weight: 400; }
|
||||
.oil-pred-tag {
|
||||
font-size: 11px;
|
||||
background: #f0f5ff;
|
||||
color: var(--color-primary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.oil-pred-direction {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.oil-pred-direction.上涨 { color: #ff3141; }
|
||||
.oil-pred-direction.下跌 { color: #00b578; }
|
||||
.oil-pred-direction.搁浅 { color: #888; }
|
||||
.oil-pred-analysis {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
line-height: 1.55;
|
||||
background: #f7f8fa;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.oil-chart { margin: 0 -4px; }
|
||||
.oil-chart-tip {
|
||||
font-size: 11px;
|
||||
color: var(--color-muted);
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.oil-year-tabs { display: flex; gap: 4px; }
|
||||
.oil-year-tab {
|
||||
border: 1px solid var(--color-border);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
padding: 4px 10px;
|
||||
border-radius: 14px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.oil-year-tab.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.oil-cache-hint {
|
||||
font-size: 11px;
|
||||
color: #00b578;
|
||||
background: #e6f7ee;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.oil-schedule-meta {
|
||||
font-size: 13px;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.oil-schedule-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.oil-month-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.oil-month-items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.oil-day {
|
||||
border-radius: 6px;
|
||||
padding: 6px 4px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.oil-day-date { font-weight: 600; font-size: 13px; }
|
||||
.oil-day-status { color: var(--color-muted); margin-top: 2px; }
|
||||
.oil-day.past { background: #f5f5f5; color: #999; }
|
||||
.oil-day.future {
|
||||
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.oil-day.future .oil-day-status { color: rgba(255,255,255,.85); }
|
||||
|
||||
/* ================== 打卡 / 日历 / 清单 (V0.2) ================== */
|
||||
.checkin-content { padding: 0 0 16px; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
.checkin-content .adm-tabs-content { --content-padding: 0; }
|
||||
@@ -883,9 +707,11 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
position: relative; width: 26px; height: 26px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.mv-day .day-ring-bar.partial { stroke: #ff8f1f; }
|
||||
.mv-day .day-ring-bar.full { stroke: #00b578; }
|
||||
.mv-day .day-ring-bar.over { stroke: #ff6b6b; }
|
||||
/* 环颜色 4 档: 小半(红) / 大半(橘) / 完成(蓝) / 超额(绿) */
|
||||
.mv-day .day-ring-bar.low { stroke: #ff3141; }
|
||||
.mv-day .day-ring-bar.high { stroke: #ff8f1f; }
|
||||
.mv-day .day-ring-bar.full { stroke: #1677ff; }
|
||||
.mv-day .day-ring-bar.over { stroke: #00b578; }
|
||||
/* 选中日: 蓝底之上改用白色环, 弧长仍表达完成度 */
|
||||
.mv-day.sel .day-ring-bg { stroke: rgba(255, 255, 255, .38); }
|
||||
.mv-day.sel .day-ring-bar { stroke: #fff; }
|
||||
@@ -895,9 +721,10 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
.mv-legend { display: inline-flex; align-items: center; gap: 4px; color: #b0b6bf; }
|
||||
.mv-legend i { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-left: 5px; }
|
||||
.mv-legend i:first-child { margin-left: 0; }
|
||||
.mv-legend i.partial { background: #ff8f1f; }
|
||||
.mv-legend i.full { background: #00b578; }
|
||||
.mv-legend i.over { background: #ff6b6b; }
|
||||
.mv-legend i.low { background: #ff3141; }
|
||||
.mv-legend i.high { background: #ff8f1f; }
|
||||
.mv-legend i.full { background: #1677ff; }
|
||||
.mv-legend i.over { background: #00b578; }
|
||||
|
||||
/* ---- 当日栏 ---- */
|
||||
.mv-daybar { display: flex; align-items: center; gap: 8px; padding: 10px 14px 2px; font-size: 13px; font-weight: 600; }
|
||||
@@ -1312,3 +1139,88 @@ textarea.inp { resize: vertical; }
|
||||
border-radius: 999px; padding: 2px 9px; flex-shrink: 0; white-space: nowrap;
|
||||
}
|
||||
.drag-pill.cur { background: #c0c7d0; }
|
||||
|
||||
/* ---- 已归档任务 (T-ARC-04/05/06) ----
|
||||
结构: 筛选卡(标题/看板列/归档时间 + 重置/筛选) -> 已应用条件 -> 列表
|
||||
交互: 条件先编辑, 点「筛选」统一提交; 生效条件可在列表上方单独撤销 */
|
||||
.arc-proj {
|
||||
margin-left: auto; font-size: 11px; color: #999; background: #f5f6f8;
|
||||
border-radius: 999px; padding: 2px 9px; max-width: 130px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.arc-fcard {
|
||||
background: #fff; margin: 10px 12px 0; border-radius: 12px; padding: 4px 14px 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .04);
|
||||
}
|
||||
.arc-frow { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #f4f5f7; }
|
||||
.arc-frow:last-of-type { border-bottom: 0; }
|
||||
.arc-frow > .lbl { width: 56px; flex-shrink: 0; font-size: 12.5px; font-weight: 600; color: var(--color-text); }
|
||||
.arc-frow > .ctl { flex: 1; min-width: 0; }
|
||||
|
||||
/* 关键词输入框: 前面有常驻标签「标题」, 框内是占位提示 */
|
||||
.arc-kw {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
background: #f7f8fa; border: 1px solid #eceef1; border-radius: 8px; padding: 6px 9px;
|
||||
}
|
||||
.arc-kw:focus-within { border-color: var(--color-primary); background: #fff; }
|
||||
.arc-kw .adm-input { flex: 1; min-width: 0; border: 0; background: transparent; padding: 0; --font-size: 13px; }
|
||||
.arc-kw .adm-input-element { font-size: 13px; }
|
||||
|
||||
.arc-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.arc-chip {
|
||||
font-size: 12px; padding: 4px 10px; border-radius: 999px;
|
||||
border: 1px solid #e6e8eb; background: #fafbfc; color: var(--color-muted);
|
||||
cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.arc-chip.on { background: #eaf3ff; border-color: var(--color-primary); color: var(--color-primary); font-weight: 600; }
|
||||
|
||||
.arc-range { display: flex; align-items: center; gap: 7px; margin-top: 8px; }
|
||||
.arc-range .dp {
|
||||
font-size: 12px; padding: 4px 10px; border: 1px dashed #d9dee5; border-radius: 7px;
|
||||
color: #999; cursor: pointer; background: #fff;
|
||||
}
|
||||
.arc-range .dp.on { color: var(--color-text); border-style: solid; border-color: #d9dee5; }
|
||||
.arc-range .sep { color: #c2c6cc; font-size: 12px; }
|
||||
.arc-hint { font-size: 11px; color: #9aa4b2; margin-top: 6px; }
|
||||
|
||||
.arc-actions { display: flex; gap: 9px; padding-top: 12px; }
|
||||
.arc-btn { height: 36px; border-radius: 9px; border: 0; font-size: 13.5px; font-weight: 600; cursor: pointer; }
|
||||
.arc-btn.ghost { flex: 0 0 84px; background: #f4f5f7; color: var(--color-muted); }
|
||||
.arc-btn.primary { flex: 1; background: var(--color-primary); color: #fff; }
|
||||
.arc-btn .badge {
|
||||
display: inline-block; min-width: 16px; height: 16px; line-height: 16px; border-radius: 8px;
|
||||
background: rgba(255, 255, 255, .28); font-size: 10.5px; margin-left: 5px; padding: 0 4px;
|
||||
}
|
||||
|
||||
.arc-applied { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 11px 14px 3px; }
|
||||
.arc-applied .tag {
|
||||
font-size: 11.5px; background: #eaf3ff; color: var(--color-primary);
|
||||
border-radius: 6px; padding: 2px 7px; display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.arc-applied .tag .x { cursor: pointer; opacity: .6; }
|
||||
.arc-applied .cnt { margin-left: auto; font-size: 11.5px; color: #999; }
|
||||
|
||||
.arc-list { padding: 8px 12px 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.arc-item { background: #fff; border-radius: 10px; padding: 11px 12px; box-shadow: 0 1px 2px rgba(0, 0, 0, .04); }
|
||||
.arc-item .top { display: flex; gap: 8px; align-items: flex-start; }
|
||||
.arc-item .ttl { flex: 1; min-width: 0; font-size: 13.5px; font-weight: 600; line-height: 1.42; word-break: break-word; }
|
||||
.arc-item .meta {
|
||||
display: flex; gap: 7px; align-items: center; flex-wrap: wrap;
|
||||
margin-top: 7px; font-size: 10.5px; color: #999;
|
||||
}
|
||||
.arc-item .meta .col { color: var(--color-muted); }
|
||||
.arc-item .meta .dot { width: 3px; height: 3px; border-radius: 50%; background: #d5d9e0; }
|
||||
.arc-item .acts {
|
||||
display: flex; gap: 7px; justify-content: flex-end;
|
||||
margin-top: 9px; padding-top: 9px; border-top: 1px dashed #f0f1f3;
|
||||
}
|
||||
.arc-item .acts .mini {
|
||||
font-size: 11.5px; padding: 3px 12px; border-radius: 999px;
|
||||
border: 1px solid var(--color-primary); color: var(--color-primary);
|
||||
background: #fff; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.arc-item .acts .mini.danger { border-color: #ffd4d7; color: var(--color-danger); }
|
||||
|
||||
.arc-empty { text-align: center; padding: 52px 20px; color: #b8bcc4; font-size: 13px; }
|
||||
.arc-empty .ico { font-size: 34px; opacity: .35; margin-bottom: 10px; }
|
||||
.arc-empty .sub { font-size: 11.5px; color: #c2c6cc; margin-top: 6px; }
|
||||
|
||||
@@ -37,6 +37,12 @@ export default defineConfig({
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
// ifinance 外接模块 (开发期代理, 规避跨域; 生产环境前端直连)
|
||||
'/ifinance-api': {
|
||||
target: 'https://ifinance.honor3.com',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/ifinance-api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user