6 Commits
Author SHA1 Message Date
jif2.zhang 5622e4b3d4 fix(打卡): 修复日历切上月看不到记录; 当日进度改按当天份额统计
Docker Build and Push / build-image (push) Successful in 1m40s
- date.js: addMonths 对 "YYYY-MM" 月粒度保持月粒度, 不再生成 "YYYY-MM-01"。
  旧逻辑使日历切上月时请求 month=2026-10-01, 服务端判为非法后静默回退本月,
  表现为上个月一片空白。
- MergedView: 「回到今天」同时把月份切回当前月; 日标题「已完成 x/y」改为
  按 items 的 dayDone 现算, 避免把同周期后续日期的打卡计入历史日期,
  并让打卡/撤销的乐观更新立即反映到标题。
- server /calendar: 兼容旧的 month=YYYY-MM-DD 请求并截回月份, 不再静默回退。
- server /habits: 回看历史日期时过滤当天尚未创建的打卡项, 与 /calendar 口径一致;
  summary.done 改用 dayDone (当天份额) 而非 completed (周期完成度)。

验证: Vite dev + 生产数据 (admin) 2026-10 显示 10 个环 / 完成率 32%,
10-22 的标题由错误的 3/14 修正为 1/14; 「回到今天」回到 2026-11 (14%)。
2026-11-02 13:54:59 +08:00
jif2.zhang afbb9bc603 feat(宏观): ifinance 分类动态挂载为宏观 tab, 报警迁入监控, 下线油价/本地经济数据/策略研究
Docker Build and Push / build-image (push) Successful in 2m37s
- 宏观只保留本地 新闻/经济文章, 外接 ifinance categories[] 动态成为 tab (纯前端直连, CORS/_headers, https)
- 报警从宏观迁到监控第 4 个 tab (保留 Alert 模块, 供未来新增报警源)
- 彻底删除油价/经济数据/策略研究: 前端组件+api、后端 apizero/data 路由、cron、fetchers、种子数据
- 新增迁移 DROP Indicator/IndicatorPoint/Strategy/OilPriceHistory; 重新生成 Prisma Client
- CSP 放开 *.honor3.com (含端口通配), 避免新增子域反复重装外壳
2026-10-30 17:17:10 +08:00
jif2.zhang 82c4ae8a84 fix(项目设置): 修复「隐藏的任务自动归档」提示成功但不生效
Docker Build and Push / build-image (push) Successful in 1m34s
现象: 项目设置 -> 归档设置 -> 隐藏的任务自动归档, 选完档位提示「已保存」,
重新打开仍是「永不」, 且不会产生任何归档。

根因 (读写两条路都断了, 缺一不可):
1. 读: projectSummary() 没有回传 autoArchive, 而 ProjectManage 读的是
   project.autoArchive -> 永远是 undefined -> 界面恒显示默认值「永不」;
2. 写: projectApi.update() 的字段白名单里没有 autoArchive 分支
   (只有 name/code/taskKinds/icon/archived/notes), 传进来直接被忽略,
   data 为空 -> 一条 SQL 都不执行 -> 但函数正常返回, 调用方照常弹「已保存」。

服务端一直是对的: powersync 上传分支处理了 autoArchive, REST 也有校验,
所以不是同步丢字段。

修复:
- projectSummary 增加 autoArchive: p.autoArchive || 'never';
- projectApi.update 增加 autoArchive 分支, 校验取值与 AUTO_ARCHIVE_OPTIONS 同口径。

验证 (真实 Windows 外壳, 临时暴露 API 直调被修改的函数):
- update({autoArchive:'3d'}) 返回 3d; 重新 list/detail 仍为 3d (已落库);
- 进项目设置, 该行显示「一周后 ›」(设成 1w 后), 不再恒为「永不」;
- 还原为 never 后界面同步回「永不」。
- 顺带确认「同样隐藏已完成」的列级设置 retention 读写都正常:
  Done 列 retention=3d, 服务端 26 条已完成中 9 条超 3 天,
  App 实际只返回 14 条 -> 过滤本身是生效的, 此前不生效的只有自动归档档位。
- 控制台无异常; 验证用的临时钩子已移除。
2026-10-28 18:32:40 +08:00
jif2.zhang c5fc328f2b feat(任务/打卡): 已归档页面按原型重做; 打卡日历完成度改 4 档配色
Docker Build and Push / build-image (push) Successful in 1m27s
一、已归档任务管理: 按评审通过的原型落地 (docs/V0.2/任务模块/已归档任务管理-原型.html)

- 去掉「任务类型」过滤: 类型是弱维度, 归档场景几乎不按它检索, 反而占掉一整行;
  类型标签仍在行内展示。
- 关键词输入框前加常驻标签「标题」, 框内占位改为「输入关键词」。
- 归档时间新增快捷项: 全部 / 本周 / 本月 / 本季度 / 自定义;
  选中后下方灰字回显实际区间 (本周=周一起算, 区间截至今天, 不跨未来);
  「自定义」才展开两个日期选择器。
- 补上「筛选」按钮 (带生效条件数徽标) 与「重置」: 条件先编辑, 点按钮统一提交。
  原实现改任一条件就即时重查, 连点会连发请求, 输关键词时列表会闪。
- 列表上方显示已应用条件标签 (可单独 ✕ 撤销) + 命中条数。
- 列表行重排: 标题独占一行, 元信息收成一行 (#编号 · 看板列 · 归档于日期),
  「恢复 / 删除」由右侧竖排改到底部右侧横排, 降低行高。
- 空状态区分「这个项目还没有归档任务」(提示去 ⋯ 菜单归档) 与
  「没有符合条件的归档任务」(建议放宽条件)。
- 修正日期边界: archivedList 的 from/to 原来直接拼 'T00:00:00Z',
  本地选的日期会偏移 8 小时, 当天凌晨的归档被漏掉; 改为按本地时区换算成 UTC 瞬时。

二、打卡日历完成度配色: 由 3 档改 4 档

    小半 (ratio < 0.5)  -> 红 #ff3141
    大半 (0.5 <= r < 1) -> 橘 #ff8f1f
    完成 (r = 1)        -> 蓝 #1677ff
    超额                -> 绿 #00b578

  注意服务端统计时把 actual 封顶在 expected (`Math.min(cnt, exp)`),
  所以 ratio 永远 <= 1,**超额只能靠 status === 'over' 判断**,不能看比例;
  DayRing 内新增 tierOf() 统一分级,图例同步改为四档。

验证 (真实 Windows 外壳 + CDP):
- 打卡页: 图例显示「小半/大半/完成/超额」, 计算样式分别为
  rgb(255,49,65) / rgb(255,143,31) / rgb(22,119,255) / rgb(0,181,120);
  当月实际渲染出 low=5 / high=1 / over=1。
- 已归档页: 筛选行标签为 标题/看板列/归档时间; 时间选项五项齐全;
  按钮为 重置/筛选; 点「本周」回显 2026-10-26 ~ 2026-10-28, 点「筛选」后
  徽标显示 1、条件标签可撤销、命中条数正确。
- 归档 → 已归档列表可见 → 恢复 全链路走通 (含上次修好的 TDZ 问题),
  服务端确认 archived 由 t 变回 f。
- 控制台无异常。
2026-10-28 10:19:17 +08:00
jif2.zhang 77e057c580 docs(任务): 已归档任务管理界面重设计原型 (待评审, 未改业务代码)
Docker Build and Push / build-image (push) Successful in 1m29s
按评审意见重做「已归档任务」全屏弹层的界面, 产出可交互原型:
docs/V0.2/任务模块/已归档任务管理-原型.html

四点改动:
1. 去掉「类型」过滤 —— 类型是弱维度, 归档场景几乎不按类型检索, 反而占掉一整行;
2. 关键词输入框前加常驻标签「标题」, 框内占位改为「输入关键词」;
3. 归档时间新增快捷预设: 全部 / 本周 / 本月 / 本季度 (+ 自定义才展开日期区间),
   选完在下方灰字回显实际区间, 避免"以为选的是整周";
4. 补上「筛选」按钮 —— 原版改任何条件都立即查询且没有确认动作; 改为条件填完一次性提交,
   按钮带生效条件数徽标, 另有「重置」。

附带改进:
- 列表上方显示已应用条件标签(可单独 × 撤销) + 命中条数;
- 列表行重排: 标题独占一行, 元信息收成一行(#编号 · 看板列 · 归档于日期),
  「恢复/删除」从右侧竖排改到底部右侧横排, 降低行高;
- 空状态区分「还没归档过」与「筛选无结果」, 分别给出下一步提示;
- 类型标签仍在行内展示, 只是不再作为筛选项。

原型为单文件 HTML (无外部依赖), 可交互: 点预设/筛选/恢复/删除均有反馈。
已用无头浏览器验证: 无 JS 错误, 本周预设算出 2026-10-26~2026-10-28, 筛选命中条数正确。

业务代码未改动, 等待评审确认后再落地。
2026-10-28 09:51:00 +08:00
jif2.zhang 75cabe8339 fix(任务): 修复归档/恢复归档报 "Cannot access 'a' before initialization"
根因: api/task.js 的 archive / unarchive 里写了 `const now = now()`。
同一函数作用域内的 `const now` 会遮蔽模块级的 now 函数, 而初始化表达式的右侧
在求值时该 const 正处于 TDZ, 于是抛 ReferenceError。
源码里报错信息是 "Cannot access 'now' before initialization",
经 vite 压缩后变量被重命名为单字母, 用户端看到的就是 "Cannt access 'a' before i"。

影响: 点「归档」确认后必然失败 (弹错误框); 恢复归档同理。删除/编辑不受影响。

修复: 局部变量改名为 ts, 并补注释避免后人再写回同名。

验证 (真实 Windows 外壳 + CDP 走 UI):
- 点任务行 ⋯ → 归档 → 确认: 无异常, 行从列表消失, 任务数减少;
- 服务端确认已落库: archived=t / archivedAt / archiveSource=manual;
- 测试数据已还原 (archived=f)。

顺带全仓搜索 `const X = X()` 自遮蔽写法, 仅这两处, 无其它同类隐患。
2026-10-28 09:45:43 +08:00
74 changed files with 2019 additions and 10035 deletions
+9 -13
View File
@@ -2,25 +2,21 @@
跨平台个人看板 (Tauri + React + Express + MariaDB)。用于收集和整理经济数据,但是以策略为主体重新组织。 跨平台个人看板 (Tauri + React + Express + MariaDB)。用于收集和整理经济数据,但是以策略为主体重新组织。
- 首页主要是放经济文章 - 宏观:新闻 / 经济文章(本地),以及外接 ifinance 模块的分类(经济数据 / 策略研究 / 看板,运行时动态挂载为 tab)
- 数据主要是放各种经济数据和图表 - 数据与策略由外接 ifinance 模块承担(静态包,见 `../ifinance/readme.md`)
- 策略是放按照经济理论组织的相关数据和图表的对比 - 报警并入「监控」;油价模块已下线
## 首页 ## 宏观
首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。 宏观页固定「新闻 / 经济文章」两个本地 tab,并动态挂载外接 ifinance `info.json` 的 `categories[]` 作为额外 tab:
### 新闻模块 - 新闻:每日夜间 3 点通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序 10 条,存入数据库;列表按时间倒序无限滚动。
- 经济文章:由 `content/articles/*.md` 承载的长文。
增加一个新闻模块,数据来源是每日夜间 3 点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序 10 条。存入数据库。 - 经济数据 / 策略研究 / …:交给外接 ifinance 模块,宿主只读取 `info.json` 并用 iframe 打开条目页(纯前端直连,见 `ifinance/readme.md` §9)。iBoard 本地不再保存指标 / 策略数据。
### 报警模块
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
## 监控 ## 监控
监控分为服务器监控和服务监控两部分。服务器监控对接 komari,地址:<https://komari.honor3.com/> 监控分为主机监控 / 服务监控 / 环境监控 / 报警四个 tab。主机监控对接 komari:<https://komari.honor3.com/>;报警 tab 展示跨源数据差异与定时任务异常,按时间倒序。
--- ---
@@ -360,7 +360,7 @@
| --- | --- | | --- | --- |
| 任务 `⋯` 菜单 | 新增「归档」(`Dialog.confirm`,含子任务时提示连带条数);成功后列表/看板乐观移除 | | 任务 `⋯` 菜单 | 新增「归档」(`Dialog.confirm`,含子任务时提示连带条数);成功后列表/看板乐观移除 |
| 项目管理 | 「归档设置」分区:`Selector` 四档(3 天后/一周后/一月后/永不),保存即生效 | | 项目管理 | 「归档设置」分区:`Selector` 四档(3 天后/一周后/一月后/永不),保存即生效 |
| 项目视图头部 | 项目栏新增「已归档」入口 → 全屏弹层:过滤条(关键词/类型/列/归档时段)+ 分页「加载更多」+ 行内「恢复 / 删除」 | | 项目视图头部 | 项目栏新增「已归档」入口 → 全屏弹层:过滤条(**关键词 / 看板列 / 归档时段**;时段含「本周 / 本月 / 本季度」快捷项与自定义区间;条件编辑后点「筛选」统一提交,另有「重置」;**不提供类型过滤**)+ 已应用条件标签(可单独撤销)与命中条数 + 分页「加载更多」+ 行内「恢复 / 删除」 |
| 主视图 | 列表/看板无需感知归档(数据源已排除);恢复后经 `load()` 回到原位 | | 主视图 | 列表/看板无需感知归档(数据源已排除);恢复后经 `load()` 回到原位 |
### 15.5 边界 ### 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>
-20
View File
@@ -27,21 +27,6 @@ export type User = Prisma.UserModel
* *
*/ */
export type Article = Prisma.ArticleModel 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 * Model News
* *
@@ -77,11 +62,6 @@ export type Econ_SowInventory = Prisma.Econ_SowInventoryModel
* *
*/ */
export type ScraperCache = Prisma.ScraperCacheModel export type ScraperCache = Prisma.ScraperCacheModel
/**
* Model OilPriceHistory
*
*/
export type OilPriceHistory = Prisma.OilPriceHistoryModel
/** /**
* Model MihomeAccount * Model MihomeAccount
* *
-20
View File
@@ -51,21 +51,6 @@ export type User = Prisma.UserModel
* *
*/ */
export type Article = Prisma.ArticleModel 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 * Model News
* *
@@ -101,11 +86,6 @@ export type Econ_SowInventory = Prisma.Econ_SowInventoryModel
* *
*/ */
export type ScraperCache = Prisma.ScraperCacheModel export type ScraperCache = Prisma.ScraperCacheModel
/**
* Model OilPriceHistory
*
*/
export type OilPriceHistory = Prisma.OilPriceHistoryModel
/** /**
* Model MihomeAccount * Model MihomeAccount
* *
+144 -144
View File
@@ -99,97 +99,6 @@ export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedDateTimeFilter<$PrismaModel> _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> = { export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
@@ -228,6 +137,33 @@ export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedStringNullableFilter<$PrismaModel> _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> = { export type BigIntFilter<$PrismaModel = never> = {
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel> equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
in?: bigint[] | number[] | Prisma.ListBigIntFieldRefInput<$PrismaModel> in?: bigint[] | number[] | Prisma.ListBigIntFieldRefInput<$PrismaModel>
@@ -328,6 +264,11 @@ export type DateTimeNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null 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> = { export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
@@ -358,6 +299,14 @@ export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> _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> = { export type FloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
@@ -385,6 +334,57 @@ export type FloatNullableWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel> _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> = { export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
@@ -479,59 +479,6 @@ export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedDateTimeFilter<$PrismaModel> _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> = { export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
@@ -574,6 +521,22 @@ export type NestedIntNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null 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> = { export type NestedBigIntFilter<$PrismaModel = never> = {
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel> equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
in?: bigint[] | number[] | Prisma.ListBigIntFieldRefInput<$PrismaModel> in?: bigint[] | number[] | Prisma.ListBigIntFieldRefInput<$PrismaModel>
@@ -636,6 +599,11 @@ export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null 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> = { export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
@@ -677,6 +645,14 @@ export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> _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> = { export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
@@ -693,4 +669,28 @@ export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel> _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
+37 -394
View File
@@ -386,9 +386,6 @@ type FieldRefInputType<Model, FieldType> = Model extends never ? never : FieldRe
export const ModelName = { export const ModelName = {
User: 'User', User: 'User',
Article: 'Article', Article: 'Article',
Strategy: 'Strategy',
Indicator: 'Indicator',
IndicatorPoint: 'IndicatorPoint',
News: 'News', News: 'News',
Alert: 'Alert', Alert: 'Alert',
ServerNode: 'ServerNode', ServerNode: 'ServerNode',
@@ -396,7 +393,6 @@ export const ModelName = {
ServiceCheck: 'ServiceCheck', ServiceCheck: 'ServiceCheck',
Econ_SowInventory: 'Econ_SowInventory', Econ_SowInventory: 'Econ_SowInventory',
ScraperCache: 'ScraperCache', ScraperCache: 'ScraperCache',
OilPriceHistory: 'OilPriceHistory',
MihomeAccount: 'MihomeAccount', MihomeAccount: 'MihomeAccount',
MihomeHome: 'MihomeHome', MihomeHome: 'MihomeHome',
MihomeDevice: 'MihomeDevice', MihomeDevice: 'MihomeDevice',
@@ -428,7 +424,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions omit: GlobalOmitOptions
} }
meta: { 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 txIsolationLevel: TransactionIsolationLevel
} }
model: { 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: { News: {
payload: Prisma.$NewsPayload<ExtArgs> payload: Prisma.$NewsPayload<ExtArgs>
fields: Prisma.NewsFieldRefs 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: { MihomeAccount: {
payload: Prisma.$MihomeAccountPayload<ExtArgs> payload: Prisma.$MihomeAccountPayload<ExtArgs>
fields: Prisma.MihomeAccountFieldRefs fields: Prisma.MihomeAccountFieldRefs
@@ -2642,46 +2342,6 @@ export const ArticleScalarFieldEnum = {
export type ArticleScalarFieldEnum = (typeof ArticleScalarFieldEnum)[keyof typeof 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 = { export const NewsScalarFieldEnum = {
id: 'id', id: 'id',
title: 'title', title: 'title',
@@ -2801,19 +2461,6 @@ export const ScraperCacheScalarFieldEnum = {
export type ScraperCacheScalarFieldEnum = (typeof ScraperCacheScalarFieldEnum)[keyof typeof 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 = { export const MihomeAccountScalarFieldEnum = {
id: 'id', id: 'id',
key: 'key', key: 'key',
@@ -3076,13 +2723,6 @@ export const SortOrder = {
export type SortOrder = (typeof SortOrder)[keyof typeof 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 = { export const NullableJsonNullValueInput = {
DbNull: DbNull, DbNull: DbNull,
JsonNull: JsonNull JsonNull: JsonNull
@@ -3091,6 +2731,13 @@ export const NullableJsonNullValueInput = {
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof 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 = { export const QueryMode = {
default: 'default', default: 'default',
insensitive: 'insensitive' insensitive: 'insensitive'
@@ -3099,6 +2746,14 @@ export const QueryMode = {
export type QueryMode = (typeof QueryMode)[keyof typeof 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 = { export const JsonNullValueFilter = {
DbNull: DbNull, DbNull: DbNull,
JsonNull: JsonNull, JsonNull: JsonNull,
@@ -3108,14 +2763,6 @@ export const JsonNullValueFilter = {
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof 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 * 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' * Reference to a field of type 'Float'
*/ */
@@ -3212,6 +2838,27 @@ export type BigIntFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel,
export type ListBigIntFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'BigInt[]'> 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 * Batch Payload for updateMany & deleteMany & createMany
*/ */
@@ -3309,9 +2956,6 @@ export type PrismaClientOptions = ({
export type GlobalOmitConfig = { export type GlobalOmitConfig = {
user?: Prisma.UserOmit user?: Prisma.UserOmit
article?: Prisma.ArticleOmit article?: Prisma.ArticleOmit
strategy?: Prisma.StrategyOmit
indicator?: Prisma.IndicatorOmit
indicatorPoint?: Prisma.IndicatorPointOmit
news?: Prisma.NewsOmit news?: Prisma.NewsOmit
alert?: Prisma.AlertOmit alert?: Prisma.AlertOmit
serverNode?: Prisma.ServerNodeOmit serverNode?: Prisma.ServerNodeOmit
@@ -3319,7 +2963,6 @@ export type GlobalOmitConfig = {
serviceCheck?: Prisma.ServiceCheckOmit serviceCheck?: Prisma.ServiceCheckOmit
econ_SowInventory?: Prisma.Econ_SowInventoryOmit econ_SowInventory?: Prisma.Econ_SowInventoryOmit
scraperCache?: Prisma.ScraperCacheOmit scraperCache?: Prisma.ScraperCacheOmit
oilPriceHistory?: Prisma.OilPriceHistoryOmit
mihomeAccount?: Prisma.MihomeAccountOmit mihomeAccount?: Prisma.MihomeAccountOmit
mihomeHome?: Prisma.MihomeHomeOmit mihomeHome?: Prisma.MihomeHomeOmit
mihomeDevice?: Prisma.MihomeDeviceOmit mihomeDevice?: Prisma.MihomeDeviceOmit
@@ -53,9 +53,6 @@ export const AnyNull = runtime.AnyNull
export const ModelName = { export const ModelName = {
User: 'User', User: 'User',
Article: 'Article', Article: 'Article',
Strategy: 'Strategy',
Indicator: 'Indicator',
IndicatorPoint: 'IndicatorPoint',
News: 'News', News: 'News',
Alert: 'Alert', Alert: 'Alert',
ServerNode: 'ServerNode', ServerNode: 'ServerNode',
@@ -63,7 +60,6 @@ export const ModelName = {
ServiceCheck: 'ServiceCheck', ServiceCheck: 'ServiceCheck',
Econ_SowInventory: 'Econ_SowInventory', Econ_SowInventory: 'Econ_SowInventory',
ScraperCache: 'ScraperCache', ScraperCache: 'ScraperCache',
OilPriceHistory: 'OilPriceHistory',
MihomeAccount: 'MihomeAccount', MihomeAccount: 'MihomeAccount',
MihomeHome: 'MihomeHome', MihomeHome: 'MihomeHome',
MihomeDevice: 'MihomeDevice', MihomeDevice: 'MihomeDevice',
@@ -123,46 +119,6 @@ export const ArticleScalarFieldEnum = {
export type ArticleScalarFieldEnum = (typeof ArticleScalarFieldEnum)[keyof typeof 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 = { export const NewsScalarFieldEnum = {
id: 'id', id: 'id',
title: 'title', title: 'title',
@@ -282,19 +238,6 @@ export const ScraperCacheScalarFieldEnum = {
export type ScraperCacheScalarFieldEnum = (typeof ScraperCacheScalarFieldEnum)[keyof typeof 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 = { export const MihomeAccountScalarFieldEnum = {
id: 'id', id: 'id',
key: 'key', key: 'key',
@@ -557,13 +500,6 @@ export const SortOrder = {
export type SortOrder = (typeof SortOrder)[keyof typeof 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 = { export const NullableJsonNullValueInput = {
DbNull: DbNull, DbNull: DbNull,
JsonNull: JsonNull JsonNull: JsonNull
@@ -572,6 +508,13 @@ export const NullableJsonNullValueInput = {
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof 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 = { export const QueryMode = {
default: 'default', default: 'default',
insensitive: 'insensitive' insensitive: 'insensitive'
@@ -580,6 +523,14 @@ export const QueryMode = {
export type QueryMode = (typeof QueryMode)[keyof typeof 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 = { export const JsonNullValueFilter = {
DbNull: DbNull, DbNull: DbNull,
JsonNull: JsonNull, JsonNull: JsonNull,
@@ -588,11 +539,3 @@ export const JsonNullValueFilter = {
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof 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]
-4
View File
@@ -10,9 +10,6 @@
*/ */
export type * from './models/User.ts' export type * from './models/User.ts'
export type * from './models/Article.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/News.ts'
export type * from './models/Alert.ts' export type * from './models/Alert.ts'
export type * from './models/ServerNode.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/ServiceCheck.ts'
export type * from './models/Econ_SowInventory.ts' export type * from './models/Econ_SowInventory.ts'
export type * from './models/ScraperCache.ts' export type * from './models/ScraperCache.ts'
export type * from './models/OilPriceHistory.ts'
export type * from './models/MihomeAccount.ts' export type * from './models/MihomeAccount.ts'
export type * from './models/MihomeHome.ts' export type * from './models/MihomeHome.ts'
export type * from './models/MihomeDevice.ts' export type * from './models/MihomeDevice.ts'
+8
View File
@@ -480,6 +480,14 @@ export type AlertSumOrderByAggregateInput = {
diffRatio?: Prisma.SortOrder 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<{ 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
+4
View File
@@ -737,6 +737,10 @@ export type NullableDateTimeFieldUpdateOperationsInput = {
set?: Date | string | null set?: Date | string | null
} }
export type BoolFieldUpdateOperationsInput = {
set?: boolean
}
export type MihomeDeviceCreateNestedOneWithoutSamplesInput = { export type MihomeDeviceCreateNestedOneWithoutSamplesInput = {
create?: Prisma.XOR<Prisma.MihomeDeviceCreateWithoutSamplesInput, Prisma.MihomeDeviceUncheckedCreateWithoutSamplesInput> create?: Prisma.XOR<Prisma.MihomeDeviceCreateWithoutSamplesInput, Prisma.MihomeDeviceUncheckedCreateWithoutSamplesInput>
connectOrCreate?: Prisma.MihomeDeviceCreateOrConnectWithoutSamplesInput 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
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <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="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#1f2937" /> <meta name="theme-color" content="#1f2937" />
<title>iBoard - 经济数据与策略</title> <title>iBoard - 个人看板</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
-2
View File
@@ -18,8 +18,6 @@
"db:migrate": "prisma migrate deploy", "db:migrate": "prisma migrate deploy",
"db:seed": "node scripts/seed.cjs", "db:seed": "node scripts/seed.cjs",
"registry:sync": "node scripts/sync-registry.mjs", "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", "icons": "node scripts/generate-icons.mjs",
"tauri:dev": "tauri dev", "tauri:dev": "tauri dev",
"tauri:build": "tauri build", "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
View File
@@ -44,41 +44,6 @@ model Article {
updatedAt DateTime @updatedAt 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 拉取, 按影响大小排序, 累加多日供首页无限滚动 // 新闻: 每天 3 点由 mx_finance_search_news 拉取, 按影响大小排序, 累加多日供首页无限滚动
model News { model News {
@@ -96,7 +61,7 @@ model News {
@@index([publishedAt]) @@index([publishedAt])
} }
// 报警: refresh 写库时比对同 (indicatorName, period) 的跨源数据, 差异超阈值记录 // 报警: 定时任务失败等异常写入 (展示于「监控」报警 tab)
model Alert { model Alert {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
indicatorName String 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 存凭证, 本表只存状态/错误供前端展示) // 米家授权状态 (DiskCache 存凭证, 本表只存状态/错误供前端展示)
+347
View File
@@ -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"
}
]
}
-72
View File
@@ -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)
-17
View File
@@ -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)});
-32
View File
@@ -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)
})
-4
View File
@@ -27,9 +27,6 @@ const TABLES = [
['TaskMember', 'taskMember'], ['TaskMember', 'taskMember'],
['TaskTag', 'taskTag'], ['TaskTag', 'taskTag'],
['Article', 'article'], ['Article', 'article'],
['Strategy', 'strategy'],
['Indicator', 'indicator'],
['IndicatorPoint', 'indicatorPoint'],
['News', 'news'], ['News', 'news'],
['Alert', 'alert'], ['Alert', 'alert'],
['ServerNode', 'serverNode'], ['ServerNode', 'serverNode'],
@@ -37,7 +34,6 @@ const TABLES = [
['ServiceCheck', 'serviceCheck'], ['ServiceCheck', 'serviceCheck'],
['Econ_SowInventory', 'econ_SowInventory'], ['Econ_SowInventory', 'econ_SowInventory'],
['ScraperCache', 'scraperCache'], ['ScraperCache', 'scraperCache'],
['OilPriceHistory', 'oilPriceHistory'],
['MihomeAccount', 'mihomeAccount'], ['MihomeAccount', 'mihomeAccount'],
['MihomeHome', 'mihomeHome'], ['MihomeHome', 'mihomeHome'],
['MihomeDevice', 'mihomeDevice'], ['MihomeDevice', 'mihomeDevice'],
-79
View File
@@ -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();
-303
View File
@@ -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
}
-83
View File
@@ -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
}
-168
View File
@@ -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);
});
}
-150
View File
@@ -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);
});
}
-113
View File
@@ -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
}
-119
View File
@@ -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)
})
-84
View File
@@ -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) })
-185
View File
@@ -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)
})
-338
View File
@@ -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();
-335
View File
@@ -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();
-1
View File
File diff suppressed because one or more lines are too long
+86 -132
View File
@@ -1,133 +1,87 @@
/** /**
* iBoard 基础数据初始化脚本 * iBoard 基础数据初始化脚本
* 用法: npm run db:seed * 用法: npm run db:seed
* 幂等: 主键/唯一键冲突跳过, Strategy/Indicator 按 name 存在则更新 * 幂等: 主键/唯一键冲突跳过
*/ */
require('dotenv').config(); require('dotenv').config();
const crypto = require('crypto'); const crypto = require('crypto');
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
function hashPassword(plain) { function hashPassword(plain) {
const salt = crypto.randomBytes(16).toString('hex'); const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(plain, salt, 64).toString('hex'); const hash = crypto.scryptSync(plain, salt, 64).toString('hex');
return 'scrypt$' + salt + '$' + hash; return 'scrypt$' + salt + '$' + hash;
} }
function parseUrl(url) { function parseUrl(url) {
const u = new URL(url); const u = new URL(url);
return { return {
host: u.hostname, host: u.hostname,
port: Number(u.port) || 3306, port: Number(u.port) || 3306,
user: decodeURIComponent(u.username), user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password), password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''), database: u.pathname.replace(/^\//, ''),
}; };
} }
async function insertIgnore(conn, sql, params) { async function insertIgnore(conn, sql, params) {
try { try {
const [r] = await conn.execute(sql, params); const [r] = await conn.execute(sql, params);
return r.affectedRows; return r.affectedRows;
} catch (e) { } catch (e) {
if (e.code === 'ER_DUP_ENTRY') return 0; if (e.code === 'ER_DUP_ENTRY') return 0;
throw e; throw e;
} }
} }
async function upsertByName(conn, table, payload, keyField) { async function main() {
const fields = Object.keys(payload); const cfg = parseUrl(process.env.DATABASE_URL);
const placeholders = fields.map(() => '?').join(', '); if (!cfg.host) throw new Error('DATABASE_URL 未配置或解析失败');
const updateClause = fields const conn = await mysql.createConnection(cfg);
.filter((f) => f !== keyField) console.log('已连接:', cfg.host + ':' + cfg.port + '/' + cfg.database);
.map((f) => '`' + f + '` = VALUES(`' + f + '`)')
.join(', '); const adminPwd = hashPassword('admin123');
const sql = 'INSERT INTO `' + table + '` (' + fields.map((f) => '`' + f + '`').join(', ') + ') VALUES (' + placeholders + ') ON DUPLICATE KEY UPDATE ' + updateClause; const n1 = await insertIgnore(
const params = fields.map((f) => payload[f]); conn,
const [r] = await conn.execute(sql, params); 'INSERT INTO `User` (name, email, password, createdAt, updatedAt) VALUES (?, ?, ?, NOW(3), NOW(3))',
return r.affectedRows; ['Admin', 'admin@iboard.local', adminPwd],
} );
console.log('[User] 新增管理员: ' + n1 + ' 行 (admin@iboard.local / admin123)');
async function main() {
const cfg = parseUrl(process.env.DATABASE_URL); const article = {
if (!cfg.host) throw new Error('DATABASE_URL 未配置或解析失败'); title: '欢迎使用 iBoard',
const conn = await mysql.createConnection(cfg); slug: 'welcome-to-iboard',
console.log('已连接:', cfg.host + ':' + cfg.port + '/' + cfg.database); content: 'iBoard 用于收集和整理经济数据,以策略为主体重新组织。\n\n本文为示例条目,正式文章仍由 content/articles/*.md 文件承载。',
date: '2026-08-03',
const adminPwd = hashPassword('admin123'); };
const n1 = await insertIgnore( const n4 = await insertIgnore(
conn, conn,
'INSERT INTO `User` (name, email, password, createdAt, updatedAt) VALUES (?, ?, ?, NOW(3), NOW(3))', 'INSERT INTO `Article` (title, slug, content, date, createdAt, updatedAt) VALUES (?, ?, ?, ?, NOW(3), NOW(3))',
['Admin', 'admin@iboard.local', adminPwd], [article.title, article.slug, article.content, article.date],
); );
console.log('[User] 新增管理员: ' + n1 + ' 行 (admin@iboard.local / admin123)'); console.log('[Article] 新增示例文章: ' + n4 + ' 行');
const strategies = [ const sowRows = [
{ name: 'us-debt-gdp', description: '美国政府债务/GDP分析', { month: '2024-01', inventory: 4067 },
data: { source: 'strategy.js', unit: '万亿美元', rows: [ { month: '2024-02', inventory: 4042 },
{ year: '2019', debt: 22.7, gdp: 21.4, debtToGdp: 106.1, interestToGdp: 1.8 }, { month: '2024-03', inventory: 3992 },
{ year: '2020', debt: 26.9, gdp: 20.9, debtToGdp: 128.7, interestToGdp: 1.6 }, { month: '2024-04', inventory: 3986 },
{ year: '2021', debt: 28.4, gdp: 23.0, debtToGdp: 123.5, interestToGdp: 1.5 }, { month: '2024-05', inventory: 3996 },
{ year: '2022', debt: 30.9, gdp: 25.4, debtToGdp: 121.6, interestToGdp: 1.6 }, { month: '2024-06', inventory: 4038 },
{ 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 }, let n5 = 0;
]}, for (const r of sowRows) {
}, n5 += await insertIgnore(
{ name: 'china-debt-gdp', description: '中国政府债务/GDP分析', conn,
data: { source: 'strategy.js', unit: '万亿美元', rows: [ 'INSERT INTO `Econ_SowInventory` (month, inventory, createdAt, updatedAt) VALUES (?, ?, NOW(3), NOW(3))',
{ year: '2019', debt: 8.8, gdp: 14.3, debtToGdp: 61.5, interestToGdp: 1.9 }, [r.month, r.inventory],
{ 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 }, console.log('[Econ_SowInventory] 新增: ' + n5 + ' 行');
{ 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 }, await conn.end();
]}, console.log('\nSeed 完成.');
}, }
];
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',
content: 'iBoard 用于收集和整理经济数据,以策略为主体重新组织。\n\n本文为示例条目,正式文章仍由 content/articles/*.md 文件承载。',
date: '2026-08-03',
};
const n4 = await insertIgnore(
conn,
'INSERT INTO `Article` (title, slug, content, date, createdAt, updatedAt) VALUES (?, ?, ?, ?, NOW(3), NOW(3))',
[article.title, article.slug, article.content, article.date],
);
console.log('[Article] 新增示例文章: ' + n4 + ' 行');
const sowRows = [
{ month: '2024-01', inventory: 4067 },
{ month: '2024-02', inventory: 4042 },
{ month: '2024-03', inventory: 3992 },
{ month: '2024-04', inventory: 3986 },
{ month: '2024-05', inventory: 3996 },
{ month: '2024-06', inventory: 4038 },
];
let n5 = 0;
for (const r of sowRows) {
n5 += await insertIgnore(
conn,
'INSERT INTO `Econ_SowInventory` (month, inventory, createdAt, updatedAt) VALUES (?, ?, NOW(3), NOW(3))',
[r.month, r.inventory],
);
}
console.log('[Econ_SowInventory] 新增: ' + n5 + ' 行');
await conn.end();
console.log('\nSeed 完成.');
}
main().catch((e) => { console.error('Seed 失败:', e); process.exit(1); }); main().catch((e) => { console.error('Seed 失败:', e); process.exit(1); });
-22
View File
@@ -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)});
-31
View File
@@ -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()
}
-22
View File
@@ -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); });
-15
View File
@@ -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); });
-22
View File
@@ -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();
}
-46
View File
@@ -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 }
}
-46
View File
@@ -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'))
}
-77
View File
@@ -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
}
-6
View File
@@ -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'
-87
View File
@@ -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),
}))
}
-41
View File
@@ -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' })
}
+52 -110
View File
@@ -1,110 +1,52 @@
import cron from 'node-cron' import cron from 'node-cron'
import { prisma } from './db.js'
import mysql from 'mysql2/promise' // scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载,
import { checkAfterRefresh } from './alert-engine.js' // 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。
import { ApizeroError, oil, history } from './apizero/index.js' async function importNewsFetcher() {
const mod = await import('../scripts/fetchers/news.fetcher.js')
// scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载, return { fetchNewsList: mod.fetchNewsList, persistNews: mod.persistNews }
// 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。 }
async function importNewsFetcher() {
const mod = await import('../scripts/fetchers/news.fetcher.js') async function alertError(taskName, message) {
return { fetchNewsList: mod.fetchNewsList, persistNews: mod.persistNews } try {
} const { insertCronAlert } = await import('../scripts/lib/cron-alert.js')
await insertCronAlert(taskName, message)
async function alertError(taskName, message) { } catch (e) {
try { console.error('[cron] 报警写入失败(忽略):', e.message)
const { insertCronAlert } = await import('../scripts/lib/cron-alert.js') }
await insertCronAlert(taskName, message) }
} catch (e) {
console.error('[cron] 报警写入失败(忽略):', e.message) // 每天 6:00 (Asia/Shanghai) 拉取新闻, 出错生成报警
} function scheduleNews() {
} cron.schedule('0 6 * * *', async () => {
console.log('[cron] 6:00 触发')
function parseUrl(url) { try {
const u = new URL(url) const { fetchNewsList, persistNews } = await importNewsFetcher()
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') } const list = await fetchNewsList()
} const r = await persistNews(list)
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
async function openConn() { } catch (e) {
return mysql.createConnection(parseUrl(process.env.DATABASE_URL)) console.error('[cron] 任务失败:', e.message)
} await alertError('新闻拉取', e.message)
}
// 每天 6:00 (Asia/Shanghai) 拉取新闻 + 跑跨源对比, 出错生成报警 }, { timezone: 'Asia/Shanghai' })
function scheduleNews() { console.log('[cron] 已注册: 每天 06:00 拉取新闻')
cron.schedule('0 6 * * *', async () => { }
console.log('[cron] 6:00 触发')
try { // 每小时兜底: 任务自动归档扫描 (T-ARC-02; 项目加载时另有读时惰性执行)
const { fetchNewsList, persistNews } = await importNewsFetcher() function scheduleAutoArchive() {
const list = await fetchNewsList() cron.schedule('30 * * * *', async () => {
const r = await persistNews(list) try {
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped) const { runAutoArchiveSweep } = await import('./task-domain/archive.js')
const conn = await openConn() const n = await runAutoArchiveSweep()
try { if (n > 0) console.log('[cron] 自动归档:', n, '条')
const a = await checkAfterRefresh(conn) } catch (e) {
console.log('[cron] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')') console.error('[cron] 自动归档失败:', e.message)
} finally { await conn.end() } await alertError('任务自动归档', e.message)
} catch (e) { }
console.error('[cron] 任务失败:', e.message) }, { timezone: 'Asia/Shanghai' })
await alertError('新闻拉取+报警检测', e.message) console.log('[cron] 已注册: 每小时 30 分 任务自动归档扫描')
} }
}, { timezone: 'Asia/Shanghai' })
console.log('[cron] 已注册: 每天 06:00 拉取新闻 + 跨源报警检测') scheduleNews()
} scheduleAutoArchive()
// 每天 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 浙江油价历史抓取')
}
// 每小时兜底: 任务自动归档扫描 (T-ARC-02; 项目加载时另有读时惰性执行)
function scheduleAutoArchive() {
cron.schedule('30 * * * *', async () => {
try {
const { runAutoArchiveSweep } = await import('./task-domain/archive.js')
const n = await runAutoArchiveSweep()
if (n > 0) console.log('[cron] 自动归档:', n, '条')
} catch (e) {
console.error('[cron] 自动归档失败:', e.message)
await alertError('任务自动归档', e.message)
}
}, { timezone: 'Asia/Shanghai' })
console.log('[cron] 已注册: 每小时 30 分 任务自动归档扫描')
}
scheduleNews()
scheduleOilHistory()
scheduleAutoArchive()
-2
View File
@@ -9,7 +9,6 @@ import newsRouter from './routes/news.js'
import alertsRouter from './routes/alerts.js' import alertsRouter from './routes/alerts.js'
import articlesRouter from './routes/articles.js' import articlesRouter from './routes/articles.js'
import otaRouter from './routes/ota.js' import otaRouter from './routes/ota.js'
import apizeroRouter from './routes/apizero.js'
import monitorsRouter from './routes/monitors.js' import monitorsRouter from './routes/monitors.js'
import mihomeRouter from './routes/mihome.js' import mihomeRouter from './routes/mihome.js'
import checkinRouter from './routes/checkin.js' import checkinRouter from './routes/checkin.js'
@@ -43,7 +42,6 @@ app.use('/api', dataRouter)
app.use('/api', newsRouter) app.use('/api', newsRouter)
app.use('/api', alertsRouter) app.use('/api', alertsRouter)
app.use('/api', articlesRouter) app.use('/api', articlesRouter)
app.use('/api', apizeroRouter)
app.use('/api', monitorsRouter) app.use('/api', monitorsRouter)
app.use('/api/mihome', mihomeRouter) app.use('/api/mihome', mihomeRouter)
// V0.2 打卡 / 日历 / 清单 // V0.2 打卡 / 日历 / 清单
-63
View File
@@ -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) }
})
+14 -3
View File
@@ -122,11 +122,15 @@ router.get('/habits', async (req, res, next) => {
const userId = req.user.sub const userId = req.user.sub
const today = toDateStr() const today = toDateStr()
const date = normDate(req.query.date) 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 ranges = habits.map((h) => periodRange(h.period, date))
const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: 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 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 } }) res.json({ today, date, items, summary: { total: items.length, done: doneCount } })
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
@@ -291,7 +295,14 @@ router.get('/calendar', async (req, res, next) => {
try { try {
const userId = req.user.sub const userId = req.user.sub
const today = toDateStr() 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 start = month + '-01'
const end = addDays(monthStartNext(month), -1) const end = addDays(monthStartNext(month), -1)
const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }) const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] })
+2 -31
View File
@@ -6,36 +6,7 @@ const router = Router()
router.use(authRequired) 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) => { router.get('/sow', async (_req, res, next) => {
try { try {
const rows = await prisma.econ_SowInventory.findMany({ orderBy: { month: 'asc' } }) const rows = await prisma.econ_SowInventory.findMany({ orderBy: { month: 'asc' } })
@@ -43,4 +14,4 @@ router.get('/sow', async (_req, res, next) => {
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
export default router export default router
+1 -1
View File
@@ -32,7 +32,7 @@
} }
], ],
"security": { "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": { "bundle": {
+1 -1
View File
@@ -43,7 +43,7 @@ export default function App() {
<Route path="/task" element={<TaskPage />} /> <Route path="/task" element={<TaskPage />} />
<Route path="/task/list" element={<TaskPage />} /> <Route path="/task/list" element={<TaskPage />} />
<Route path="/task/:pid" 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={<Navigate to="/macro/news" replace />} />
<Route path="/macro/*" element={<MacroPage />} /> <Route path="/macro/*" element={<MacroPage />} />
<Route path="/monitor" element={<MonitorPage />} /> <Route path="/monitor" element={<MonitorPage />} />
-32
View File
@@ -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
View File
@@ -142,6 +142,8 @@ function projectSummary(p, memberCount, taskCount) {
updatedAt: p.updatedAt, updatedAt: p.updatedAt,
taskKinds: normTaskKinds(parseJson(p.taskKinds, null)) || DEFAULT_TASK_KINDS, taskKinds: normTaskKinds(parseJson(p.taskKinds, null)) || DEFAULT_TASK_KINDS,
icon: p.icon || null, icon: p.icon || null,
// 自动归档档位 (T-ARC-02): 必须回传给前端, 否则该设置在界面上永远显示默认的「永不」
autoArchive: p.autoArchive || 'never',
memberCount, memberCount,
taskCount, taskCount,
} }
@@ -275,6 +277,11 @@ export const projectApi = {
content: String(n?.content ?? '').slice(0, 20000), 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}" = ?`) const sets = Object.keys(data).map((k) => `"${k}" = ?`)
if (sets.length) { if (sets.length) {
await db().execute( await db().execute(
@@ -708,14 +715,16 @@ export const taskApi = {
archive: async (id) => { archive: async (id) => {
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)]) const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
if (!task) throw new Error('任务不存在或无权限') 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) => { await db().writeTransaction(async (tx) => {
const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)]) 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 ids = [String(task.id), ...kids.map((k) => String(k.id))]
const ph = ids.map(() => '?').join(',') const ph = ids.map(() => '?').join(',')
await tx.execute( await tx.execute(
`UPDATE Task SET archived = 1, archivedAt = ?, archiveSource = 'manual', updatedAt = ? WHERE id IN (${ph})`, `UPDATE Task SET archived = 1, archivedAt = ?, archiveSource = 'manual', updatedAt = ? WHERE id IN (${ph})`,
[now, now, ...ids], [ts, ts, ...ids],
) )
}) })
return { ok: true, count: 1 } return { ok: true, count: 1 }
@@ -725,14 +734,14 @@ export const taskApi = {
unarchive: async (id) => { unarchive: async (id) => {
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)]) const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
if (!task) throw new Error('任务不存在或无权限') if (!task) throw new Error('任务不存在或无权限')
const now = now() const ts = now()
await db().writeTransaction(async (tx) => { await db().writeTransaction(async (tx) => {
const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)]) 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 ids = [String(task.id), ...kids.map((k) => String(k.id))]
const ph = ids.map(() => '?').join(',') const ph = ids.map(() => '?').join(',')
await tx.execute( await tx.execute(
`UPDATE Task SET archived = 0, archivedAt = NULL, archiveSource = NULL, updatedAt = ? WHERE id IN (${ph})`, `UPDATE Task SET archived = 0, archivedAt = NULL, archiveSource = NULL, updatedAt = ? WHERE id IN (${ph})`,
[now, ...ids], [ts, ...ids],
) )
}) })
return { ok: true, count: 1 } return { ok: true, count: 1 }
@@ -761,13 +770,23 @@ export const taskApi = {
where.push('columnId = ?') where.push('columnId = ?')
args.push(String(p.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) { if (p.from) {
where.push('archivedAt >= ?') const iso = dayToIso(p.from, false)
args.push(String(p.from) + 'T00:00:00+08:00'.replace('+08:00', 'Z')) if (iso) { where.push('archivedAt >= ?'); args.push(iso) }
} }
if (p.to) { if (p.to) {
where.push('archivedAt <= ?') const iso = dayToIso(p.to, true)
args.push(String(p.to) + 'T23:59:59.999Z') if (iso) { where.push('archivedAt <= ?'); args.push(iso) }
} }
if (p.cursor) { if (p.cursor) {
const [ts, cid] = String(p.cursor).split('|') const [ts, cid] = String(p.cursor).split('|')
-54
View File
@@ -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>
)
}
+22 -6
View File
@@ -2,16 +2,31 @@
// //
// 为什么是环而不是圆点: // 为什么是环而不是圆点:
// 旧实现是 4px 小圆点, 只按状态上色 —— 3/5 和 5/5 都是同一个绿点, // 旧实现是 4px 小圆点, 只按状态上色 —— 3/5 和 5/5 都是同一个绿点,
// 看不出"当天做到几成"。改成环形后 **弧长 = 当天完成比例**, 颜色仍表状态: // 看不出"当天做到几成"。改成环形后 **弧长 = 当天完成比例**, 颜色表状态:
// 0 < actual < expected -> 弧长按比例 (部分完成 / 半填充) // 0 < actual < expected -> 弧长按比例 (部分完成)
// actual >= expected -> 整圈 (全部完成 / 实心) // actual >= expected -> 整圈 (全部完成)
// actual = 0 且 expected > 0 -> 只有底环 (未开始 / 空心) // actual = 0 且 expected > 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_SIZE = 30
const DEFAULT_STROKE = 2.5 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({ export default function DayRing({
status = 'partial', status = 'partial',
ratio = 0, ratio = 0,
@@ -19,6 +34,7 @@ export default function DayRing({
stroke = DEFAULT_STROKE, stroke = DEFAULT_STROKE,
}) { }) {
const p = Math.max(0, Math.min(1, Number(ratio) || 0)) const p = Math.max(0, Math.min(1, Number(ratio) || 0))
const tier = tierOf(status, p)
const r = (size - stroke) / 2 const r = (size - stroke) / 2
const c = 2 * Math.PI * r const c = 2 * Math.PI * r
const half = size / 2 const half = size / 2
@@ -37,7 +53,7 @@ export default function DayRing({
/> />
{p > 0 && ( {p > 0 && (
<circle <circle
className={'day-ring-bar ' + status} className={'day-ring-bar ' + tier}
cx={half} cy={half} r={r} cx={half} cy={half} r={r}
fill="none" strokeWidth={stroke} fill="none" strokeWidth={stroke}
strokeDasharray={c} strokeDasharray={c}
+116
View File
@@ -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
View File
@@ -1,43 +1,31 @@
import { useState } from 'react'
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom' import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
import { Badge } from 'antd-mobile'
import PageHeader from '../components/PageHeader.jsx' import PageHeader from '../components/PageHeader.jsx'
import SwipeTabs from '../components/SwipeTabs.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 ArticlesView from './macro/ArticlesView.jsx'
import ArticleDetail from './macro/ArticleDetail.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 NewsView from './macro/NewsView.jsx'
import NewsDetail from './news/NewsDetail.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, 默认 新闻 // 宏观: 本地固定 2 个 tab (新闻 / 经济文章) + 外接 ifinance 分类动态挂载为 tab
// /macro → 显示 news tab (activeFromPath 决定) // /macro/news → 新闻 (默认)
// /macro/alerts → 报警 (V0.2: 原「首页」tab 合并进来, 置最左) // /macro/articles → 经济文章
// /macro/news → 新闻 (V0.2: 原「首页」tab 合并进来, 置第二并作为默认) // /macro/finance/:catId → 外接分类 (经济数据 / 策略研究 / …)
// /macro/oil → 油价 // 详情: /macro/news/:id · /macro/articles/:slug · /macro/finance/:catId/:itemId
// /macro/data → 经济数据 // 说明: 外接分类不写死, 由 info.json 的 categories[] 决定; ifinance 新增分类后
// /macro/strategy → 策略研究 // 本页自动多出新 tab, 宿主无需改代码。报警已迁到「监控」。
// /macro/articles → 经济文章 const BASE_TABS = [
// 详情: /macro/data/:name · /macro/strategy/:name · /macro/articles/:slug · /macro/news/:id
// 说明: tab 数量已超出屏宽, SwipeTabs 会在右侧显示渐变 + 箭头提示可继续滑动
const TABS = [
{ key: 'alerts', label: '报警', path: '/macro/alerts' },
{ key: 'news', label: '新闻', path: '/macro/news' }, { 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' }, { key: 'articles', label: '经济文章', path: '/macro/articles' },
] ]
function activeFromPath(pathname) { function activeFromPath(pathname) {
if (pathname.startsWith('/macro/alerts')) return 'alerts'
if (pathname.startsWith('/macro/news')) return 'news' 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' if (pathname.startsWith('/macro/articles')) return 'articles'
const m = pathname.match(/^\/macro\/finance\/([^/]+)/)
if (m) return 'finance:' + m[1]
// 父路由 /macro 走默认 tab (新闻) // 父路由 /macro 走默认 tab (新闻)
return 'news' return 'news'
} }
@@ -45,43 +33,47 @@ function activeFromPath(pathname) {
export default function MacroPage() { export default function MacroPage() {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { info } = useIfinanceInfo()
const active = activeFromPath(location.pathname) const active = activeFromPath(location.pathname)
// 报警角标: 由 AlertsView 回传总数 const categories = info?.categories || []
const [alertCount, setAlertCount] = useState(0)
const tabs = TABS.map((t) => ({ // 详情页单独走 React Router 渲染 (需要 Route 提供 params), 不参与滑屏
key: t.key, const p = location.pathname
label: t.key === 'alerts' const isNewsDetail = /^\/macro\/news\/[^/]+$/.test(p)
? <span className="tab-label">报警{alertCount > 0 && <Badge content={alertCount} />}</span> const isArticleDetail = /^\/macro\/articles\/[^/]+$/.test(p)
: t.label, const isFinanceDetail = /^\/macro\/finance\/[^/]+\/[^/]+$/.test(p)
render: () => { if (isNewsDetail || isArticleDetail || isFinanceDetail) {
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)\/[^/]+$/)) {
return ( return (
<Routes> <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="news/:id" element={<NewsDetail />} />
<Route path="articles/:slug" element={<ArticleDetail />} />
<Route path="finance/:catId/:itemId" element={<FinanceItemView />} />
</Routes> </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 ( return (
<div className="page-with-navbar"> <div className="page-with-navbar">
<PageHeader title="宏观" /> <PageHeader title="宏观" />
+16 -2
View File
@@ -1,15 +1,18 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom' 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 { api } from '../api/client.js'
import PageHeader from '../components/PageHeader.jsx' import PageHeader from '../components/PageHeader.jsx'
import SwipeTabs from '../components/SwipeTabs.jsx' import SwipeTabs from '../components/SwipeTabs.jsx'
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js' import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js'
import EnvView from './monitor/EnvView.jsx' import EnvView from './monitor/EnvView.jsx'
import AlertsView from './monitor/AlertsView.jsx'
// 监控页: 两个横向分类 // 监控页: 四个横向分类
// - 主机监控: 实时数据来自 komari (服务端实时代理, 不落库), 点击节点看 24h 时序 // - 主机监控: 实时数据来自 komari (服务端实时代理, 不落库), 点击节点看 24h 时序
// - 服务监控: 暂留空占位 // - 服务监控: 暂留空占位
// - 环境监控: 米家温湿度
// - 报警: V0.2.2 从「宏观」迁入, 数据差异/任务异常按时间倒序
function fmtTime(s) { function fmtTime(s) {
if (!s) return '' if (!s) return ''
const d = new Date(s) const d = new Date(s)
@@ -40,10 +43,21 @@ function pct(used, total) {
export default function MonitorPage() { export default function MonitorPage() {
const [active, setActive] = useState('servers') const [active, setActive] = useState('servers')
// 报警角标: 由 AlertsView 回传总数
const [alertCount, setAlertCount] = useState(0)
const tabs = [ const tabs = [
{ key: 'servers', label: '主机监控', render: () => <ServerView /> }, { key: 'servers', label: '主机监控', render: () => <ServerView /> },
{ key: 'services', label: '服务监控', render: () => <ServiceView /> }, { key: 'services', label: '服务监控', render: () => <ServiceView /> },
{ key: 'env', label: '环境监控', render: () => <EnvView /> }, { key: 'env', label: '环境监控', render: () => <EnvView /> },
{
key: 'alerts',
label: (
<span className="tab-label">
报警{alertCount > 0 && <Badge content={alertCount} />}
</span>
),
render: () => <AlertsView onCount={setAlertCount} />,
},
] ]
return ( return (
<div className="page-with-navbar"> <div className="page-with-navbar">
+12 -4
View File
@@ -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 })) 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> <span className="arrow" onClick={() => setMonth((m) => addMonths(m, -1))}>‹</span>
<b>{fmtMonth(month)}</b> <b>{fmtMonth(month)}</b>
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, 1))}>›</span> <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>
<div className="mv-grid mv-wd"> <div className="mv-grid mv-wd">
@@ -292,9 +299,10 @@ export default function MergedView({ date, onDateChange }) {
{cal && ( {cal && (
<div className="mv-rate"> <div className="mv-rate">
<span>本月完成率 <b>{Math.round((cal.rate || 0) * 100)}%</b></span> <span>本月完成率 <b>{Math.round((cal.rate || 0) * 100)}%</b></span>
{/* 图例: 弧长=完成比例, 颜色=状态 */} {/* 图例: 弧长=完成比例, 颜色=4 档 (小半/大半/完成/超额) */}
<span className="mv-legend"> <span className="mv-legend">
<i className="partial" />部分 <i className="low" />小半
<i className="high" />大半
<i className="full" />完成 <i className="full" />完成
<i className="over" />超额 <i className="over" />超额
</span> </span>
+12 -3
View File
@@ -25,13 +25,22 @@ export function addDays(s, n) {
return fromUTC(d) 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) { 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 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() const lastDay = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate()
first.setUTCDate(Math.min(day, lastDay)) first.setUTCDate(Math.min(day, lastDay))
return fromUTC(first) const out = fromUTC(first)
return str.length === 7 ? out.slice(0, 7) : out
} }
/** 周一为一周第一天 */ /** 周一为一周第一天 */
-133
View File
@@ -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>
)
}
+39
View File
@@ -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>
)
}
+33
View File
@@ -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>
)
}
-250
View File
@@ -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>
)
}
-141
View File
@@ -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 useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
import { fmtDateTime } from '../../utils/format.js' import { fmtDateTime } from '../../utils/format.js'
// 报警 (V0.2 从「首页」并入「宏观」Tab) // 报警 (V0.2.2 从「宏观」迁到「监控」第 4 个 tab)
// - 数据: World Bank 与妙想 MCP 跨源同 (指标, 周期) 数据差异超阈值, 按时间倒序 // - 数据: 跨源同 (指标, 周期) 数据差异超阈值 / cron 任务异常, 按时间倒序
// - 无限滚动: 触底自动加载下一页 // - 无限滚动: 触底自动加载下一页
// - onCount: 把总数回传给父级 (宏观 Tab 上显示角标) // - onCount: 把总数回传给父级 (监控的报警 tab 上显示角标)
export default function AlertsView({ onCount }) { export default function AlertsView({ onCount }) {
const [items, setItems] = useState([]) const [items, setItems] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
+215 -88
View File
@@ -1,29 +1,67 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, DatePicker, Dialog, DotLoading, Input, Popup, Selector, Toast } from 'antd-mobile' import { Button, DatePicker, Dialog, DotLoading, Input, Popup, Toast } from 'antd-mobile'
import { taskApi } from '../../api/task.js' import { taskApi } from '../../api/task.js'
import { kindColor } from './kind.js' import { kindColor } from './kind.js'
// 已归档任务查询 (T-ARC-04/05/06) // 已归档任务查询 (T-ARC-04/05/06)
// 过滤: 关键词(标题) / 任务类型 / 看板列 / 归档时间范围 // 过滤: 关键词(标题) / 看板列 / 归档时间 (含 本周/本月/本季度 快捷项)
// 交互: 条件先编辑 -> 点「筛选」才提交
// (原实现改任一条件就即时重查, 连点会连发请求, 输关键词时还会闪)
// 游标分页: 「加载更多」追加; 行内 恢复 / 彻底删除 // 游标分页: 「加载更多」追加; 行内 恢复 / 彻底删除
// 不再提供「任务类型」过滤: 类型是弱维度, 归档场景几乎不按类型检索
const LIMIT = 20 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 }) { export default function ArchivedTasks({ project, visible, onClose, onChanged }) {
const [items, setItems] = useState(null) const [items, setItems] = useState(null)
const [nextCursor, setNextCursor] = useState(null) const [nextCursor, setNextCursor] = useState(null)
const [loading, setLoading] = useState(false) 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 [fromVisible, setFromVisible] = useState(false)
const [toVisible, setToVisible] = useState(false) const [toVisible, setToVisible] = useState(false)
const cursorRef = useRef('') const cursorRef = useRef('')
const columns = project?.columns || [] const columns = project?.columns || []
const kinds = project?.taskKinds || []
const buildParams = (f, cursor) => { const buildParams = (f, cursor) => {
const p = { limit: LIMIT } const p = { limit: LIMIT }
if (f.keyword.trim()) p.keyword = f.keyword.trim() if (f.keyword) p.keyword = f.keyword
if (f.kind) p.kind = f.kind
if (f.columnId) p.columnId = f.columnId if (f.columnId) p.columnId = f.columnId
if (f.from) p.from = f.from if (f.from) p.from = f.from
if (f.to) p.to = f.to if (f.to) p.to = f.to
@@ -35,7 +73,7 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
setLoading(true) setLoading(true)
try { try {
const cursor = reset ? '' : cursorRef.current 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])) setItems((prev) => (reset || !prev ? r.items : [...prev, ...r.items]))
cursorRef.current = r.nextCursor || '' cursorRef.current = r.nextCursor || ''
setNextCursor(r.nextCursor || null) setNextCursor(r.nextCursor || null)
@@ -45,7 +83,7 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
setLoading(false) setLoading(false)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [project.id, filters]) }, [project.id, applied])
useEffect(() => { useEffect(() => {
if (!visible) return if (!visible) return
@@ -54,7 +92,51 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
load(true) load(true)
}, [visible, load]) }, [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) { async function doRestore(item) {
try { try {
@@ -62,7 +144,6 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
Toast.show({ content: '已恢复' }) Toast.show({ content: '已恢复' })
setItems((prev) => (prev || []).filter((x) => x.id !== item.id)) setItems((prev) => (prev || []).filter((x) => x.id !== item.id))
onChanged?.() onChanged?.()
load(true)
} catch (e) { } catch (e) {
Toast.show({ content: e.message || '恢复失败' }) Toast.show({ content: e.message || '恢复失败' })
} }
@@ -71,7 +152,9 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
async function doRemove(item) { async function doRemove(item) {
const subs = item.children?.length || 0 const subs = item.children?.length || 0
const ok = await Dialog.confirm({ const ok = await Dialog.confirm({
content: subs ? `彻底删除「${item.title}」?将一并删除 ${subs} 条子任务,不可恢复。` : `彻底删除「${item.title}」?不可恢复。`, content: subs
? `彻底删除「${item.title}」?将一并删除 ${subs} 条子任务,不可恢复。`
: `彻底删除「${item.title}」?不可恢复。`,
confirmText: '删除', confirmText: '删除',
}) })
if (!ok) return if (!ok) return
@@ -85,8 +168,7 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
} }
} }
const fmt = (iso) => (iso ? iso.slice(0, 10) : '') const draftRange = draft.time === 'custom' ? ['', ''] : presetRange(draft.time)
const colName = (id) => columns.find((c) => c.id === id)?.name || ''
return ( return (
<Popup <Popup
@@ -99,43 +181,77 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
<div className="navbar-sub"> <div className="navbar-sub">
<span className="back" onClick={onClose}>‹</span> <span className="back" onClick={onClose}>‹</span>
<span className="title">已归档任务</span> <span className="title">已归档任务</span>
{project?.name ? <span className="arc-proj">{project.name}</span> : null}
</div> </div>
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}> {/* 筛选卡 */}
<Input <div className="arc-fcard">
placeholder="搜索任务标题" <div className="arc-frow">
value={filters.keyword} <span className="lbl">标题</span>
clearable <span className="ctl">
onEnter={() => load(true)} <span className="arc-kw">
onChange={(v) => setF({ keyword: v })} <Input
onBlur={() => load(true)} placeholder="输入关键词"
/> value={draft.keyword}
<div style={{ display: 'flex', gap: 8 }}> clearable
<div style={{ flex: 1 }}> onChange={(v) => setD({ keyword: v })}
<Selector onEnter={submit}
options={[{ label: '全部类型', value: '' }, ...kinds.map((k) => ({ label: k, value: k }))]} />
value={[filters.kind]} </span>
onSelect={(_, ext) => { setF({ kind: ext.items[0].value }); setTimeout(() => load(true), 0) }} </span>
/>
</div>
</div> </div>
<Selector
options={[{ label: '全部看板列', value: '' }, ...columns.map((c) => ({ label: c.name, value: String(c.id) }))]} <div className="arc-frow">
value={[filters.columnId]} <span className="lbl">看板列</span>
onSelect={(_, ext) => { setF({ columnId: ext.items[0].value }); setTimeout(() => load(true), 0) }} <span className="ctl">
/> <span className="arc-chips">
<div style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 12, color: '#999' }}> <span
<span>归档</span> className={'arc-chip' + (draft.columnId === '' ? ' on' : '')}
<span className="mini-btn" style={{ padding: '3px 10px' }} onClick={() => setFromVisible(true)}> onClick={() => setD({ columnId: '' })}
{filters.from || '开始日期'} >全部</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> </div>
<span className="mini-btn" style={{ padding: '3px 10px' }} onClick={() => setToVisible(true)}>
{filters.to || '结束日期'} <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> </span>
{(filters.from || filters.to) && ( </div>
<span className="link" onClick={() => { setF({ from: '', to: '' }); setTimeout(() => load(true), 0) }}>清除</span>
)} <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>
</div> </div>
@@ -143,61 +259,72 @@ export default function ArchivedTasks({ project, visible, onClose, onChanged })
visible={fromVisible} visible={fromVisible}
onClose={() => setFromVisible(false)} onClose={() => setFromVisible(false)}
precision="day" precision="day"
onConfirm={(d) => { onConfirm={(d) => { setD({ from: fmtDate(d) }); setFromVisible(false) }}
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)
}}
/> />
<DatePicker <DatePicker
visible={toVisible} visible={toVisible}
onClose={() => setToVisible(false)} onClose={() => setToVisible(false)}
precision="day" precision="day"
onConfirm={(d) => { onConfirm={(d) => { setD({ to: fmtDate(d) }); setToVisible(false) }}
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)
}}
/> />
<div style={{ padding: '0 14px 24px', display: 'flex', flexDirection: 'column', gap: 7 }}> {/* 已应用条件 (可单独撤销) */}
{items === null && loading && <div style={{ textAlign: 'center', padding: 30 }}><DotLoading /> 加载中...</div>} {appliedTags.length > 0 && (
{items !== null && items.length === 0 && !loading && ( <div className="arc-applied">
<div style={{ textAlign: 'center', color: '#b8bcc4', padding: 40 }}>没有符合条件的归档任务</div> {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 {items !== null && items.length === 0 && !loading && (
key={t.id} appliedTags.length === 0 ? (
style={{ <div className="arc-empty">
background: '#fff', borderRadius: 9, padding: '10px 12px', display: 'flex', gap: 10, alignItems: 'flex-start', <div className="ico">📭</div>
boxShadow: '0 1px 2px rgba(0,0,0,.04)', 这个项目还没有归档任务
}} <div className="sub">任务完成后可在它的 ⋯ 菜单里归档</div>
>
<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>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, flexShrink: 0 }}> ) : (
<Button size="mini" color="primary" fill="outline" onClick={() => doRestore(t)}>恢复</Button> <div className="arc-empty">
<Button size="mini" color="danger" fill="none" onClick={() => doRemove(t)}>删除</Button> <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>
</div> </div>
))} ))}
{nextCursor && ( {nextCursor && (
<Button <Button block fill="outline" size="small" loading={loading} onClick={() => load(false)}>
block
fill="outline"
size="small"
loading={loading}
onClick={() => load(false)}
style={{ marginTop: 4 }}
>
加载更多 加载更多
</Button> </Button>
)} )}
+113 -201
View File
@@ -242,7 +242,7 @@ input, textarea, select { font: inherit; }
.list-item-meta { font-size: 12px; color: var(--color-muted); margin-bottom: 4px; } .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; } .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 { padding: 0 0 16px; }
.list-page .page-title { padding: 14px 16px 4px; } .list-page .page-title { padding: 14px 16px 4px; }
.list-page .adm-list { margin: 0 12px; background: transparent; } .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 .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-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 .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); } /* 外接分类条目右侧类型徽标 */
/* 趋势徽标: 上升(绿) / 下降(红) / 持平(灰) / 震荡(橙) */ .finance-kind {
.trend-badge { font-size: 11px;
display: inline-flex; color: var(--color-muted);
align-items: center; background: rgba(0,0,0,.05);
gap: 2px;
font-size: 12px;
font-weight: 500;
padding: 1px 6px;
border-radius: 4px; border-radius: 4px;
background: rgba(0,0,0,.04); padding: 1px 6px;
white-space: nowrap;
} }
.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 详情态) */ /* 外接条目详情: iframe 铺满 (NavBar 45px + 底部 TabBar 已计入) */
.detail-page { display: flex; flex-direction: column; min-height: 100%; } .finance-item-page { display: flex; flex-direction: column; height: 100%; background: #f5f5f5; }
.detail-page .adm-nav-bar { background: #fff; border-bottom: 1px solid var(--color-border); } .finance-item-page .adm-nav-bar { flex: 0 0 auto; background: #fff; border-bottom: 1px solid var(--color-border); }
.detail-card.adm-card { margin: 12px; border-radius: 10px; } .finance-frame {
.detail-card.adm-card .adm-card-body { padding: 12px; } width: 100%;
.detail-meta { font-size: 12px; color: var(--color-muted); margin-bottom: 8px; } 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; } .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; } .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) ================== */ /* ================== 打卡 / 日历 / 清单 (V0.2) ================== */
.checkin-content { padding: 0 0 16px; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } .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; } .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; position: relative; width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
} }
.mv-day .day-ring-bar.partial { stroke: #ff8f1f; } /* 环颜色 4 档: 小半(红) / 大半(橘) / 完成(蓝) / 超额(绿) */
.mv-day .day-ring-bar.full { stroke: #00b578; } .mv-day .day-ring-bar.low { stroke: #ff3141; }
.mv-day .day-ring-bar.over { stroke: #ff6b6b; } .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-bg { stroke: rgba(255, 255, 255, .38); }
.mv-day.sel .day-ring-bar { stroke: #fff; } .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 { 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 { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-left: 5px; }
.mv-legend i:first-child { margin-left: 0; } .mv-legend i:first-child { margin-left: 0; }
.mv-legend i.partial { background: #ff8f1f; } .mv-legend i.low { background: #ff3141; }
.mv-legend i.full { background: #00b578; } .mv-legend i.high { background: #ff8f1f; }
.mv-legend i.over { background: #ff6b6b; } .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; } .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; border-radius: 999px; padding: 2px 9px; flex-shrink: 0; white-space: nowrap;
} }
.drag-pill.cur { background: #c0c7d0; } .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; }
+6
View File
@@ -37,6 +37,12 @@ export default defineConfig({
target: 'http://127.0.0.1:3001', target: 'http://127.0.0.1:3001',
changeOrigin: true, changeOrigin: true,
}, },
// ifinance 外接模块 (开发期代理, 规避跨域; 生产环境前端直连)
'/ifinance-api': {
target: 'https://ifinance.honor3.com',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/ifinance-api/, ''),
},
}, },
}, },
build: { build: {