Initial release of SIMKEEP
This commit is contained in:
commit
bb3fa56a48
46 files changed
+4617
No files matched your search
@@ -0,0 +1,17 @@
|
||||
*
|
||||
!Dockerfile
|
||||
!requirements.txt
|
||||
!index.html
|
||||
!style.css
|
||||
!app.js
|
||||
!api.js
|
||||
!core.js
|
||||
!money.js
|
||||
!countries.js
|
||||
!favicon.svg
|
||||
!server/
|
||||
!server/*.py
|
||||
!scripts/
|
||||
!scripts/build.py
|
||||
!scripts/backup.py
|
||||
!scripts/restore.py
|
||||
@@ -0,0 +1,21 @@
|
||||
# 访问地址。远程部署时替换为自己的地址,端口需与 SIMKEEP_PORT 一致。
|
||||
SIMKEEP_PUBLIC_URL=http://localhost:5180
|
||||
SIMKEEP_SECURE_COOKIES=0
|
||||
# 本地运行时的数据库路径;Docker Compose 会覆盖为容器内路径。
|
||||
SIMKEEP_DB=data/simkeep.db
|
||||
# Docker Compose 公网端口;改端口时同步修改 SIMKEEP_PUBLIC_URL。
|
||||
SIMKEEP_PORT=5180
|
||||
|
||||
# 可选的公共默认通知服务;用户也可在网页单独配置自己的服务。
|
||||
# 从 BotFather 获取 Token。可选用户名不包含 @;留空会自动查询。
|
||||
# 每个 Bot 只运行一个 getUpdates 消费者,不要与其他 Bot 程序共用 Token。
|
||||
SIMKEEP_TELEGRAM_TOKEN=
|
||||
SIMKEEP_TELEGRAM_USERNAME=
|
||||
|
||||
# SMTP_MODE: starttls (通常 587)、ssl (通常 465)、plain (仅用于受控邮件中继)。
|
||||
SIMKEEP_SMTP_HOST=
|
||||
SIMKEEP_SMTP_PORT=587
|
||||
SIMKEEP_SMTP_MODE=starttls
|
||||
SIMKEEP_SMTP_USER=
|
||||
SIMKEEP_SMTP_PASSWORD=
|
||||
SIMKEEP_SMTP_FROM=
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Private runtime configuration (only the empty template is public).
|
||||
.env*
|
||||
*.env
|
||||
*.env.*
|
||||
!.env.example
|
||||
# Databases, backups and exports contain account and notification credentials.
|
||||
data/
|
||||
backups/
|
||||
docker-backups/
|
||||
*.db
|
||||
*.db-*
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
*.sqlite3
|
||||
*.sqlite3-*
|
||||
*.sql
|
||||
*.backup
|
||||
*.bak
|
||||
simkeep-*.json
|
||||
# Generated builds, browser traces and local tooling.
|
||||
preview/
|
||||
dist/
|
||||
output/
|
||||
releases/
|
||||
node_modules/
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.playwright-cli/
|
||||
*.log
|
||||
*.zip
|
||||
*.tar
|
||||
*.tar.gz
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
# Local files.
|
||||
.local/
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
FROM python:3.12-slim AS frontend
|
||||
WORKDIR /build
|
||||
COPY index.html style.css app.js api.js core.js money.js countries.js favicon.svg ./
|
||||
COPY scripts/build.py ./scripts/build.py
|
||||
RUN python scripts/build.py
|
||||
|
||||
FROM python:3.12-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 SIMKEEP_DB=/app/data/simkeep.db SIMKEEP_STATIC_DIR=/app/dist
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY server ./server
|
||||
COPY scripts/backup.py scripts/restore.py ./scripts/
|
||||
COPY --from=frontend /build/dist ./dist
|
||||
RUN groupadd --gid 10001 simkeep && useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin simkeep && install -d -m 0700 -o simkeep -g simkeep /app/data
|
||||
USER 10001:10001
|
||||
EXPOSE 5180
|
||||
CMD ["python", "-m", "uvicorn", "server.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "5180", "--workers", "1", "--no-access-log"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 rest-rain
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,169 @@
|
||||
<p align="center">
|
||||
<img src="docs/images/cover.svg" width="100%" alt="SIMKEEP 续卡:记住每张卡的下一次续期">
|
||||
</p>
|
||||
|
||||
# SIMKEEP · 续卡
|
||||
|
||||
**记住每张卡的下一次续期。**
|
||||
|
||||
SIMKEEP 是一个可自行部署的 SIM / eSIM 管理平台。把开通日期、平台注册关系、多币种余额、续期操作和通知放在同一个地方,让每个人管理自己的卡片。
|
||||
|
||||
名字来自 **SIM + Keep**:让需要保留的号码持续保持可用。
|
||||
|
||||
[界面预览](#界面预览) · [Docker 部署](#docker-部署) · [通知配置](#通知配置) · [备份与更新](#备份与更新)
|
||||
|
||||
## 功能
|
||||
|
||||
- **卡片档案**:记录 SIM / eSIM 类型、运营商、地区、号码、开通时间、套餐及备注,支持归档与恢复。
|
||||
- **续期计划**:一张卡设置多条规则,支持重置、购买套餐、打电话、发短信、充值及自定义操作;周期按天或日历月计算。
|
||||
- **下次日期自动计算**:记录完成时间后,按完成日顺延或固定周期更新下一次续期,保留操作历史。
|
||||
- **多币种余额与费用**:支持 23 种币种,分别记录余额、预计费用与实际费用;完成操作时默认扣除同币种余额,也可只记录费用。充值可计入余额。
|
||||
- **平台注册记录**:关联 Telegram、Google、WhatsApp、PayPal 等平台及账号标识,支持搜索和筛选。
|
||||
- **Telegram / 邮件提醒**:每个账号在网页填写自己的 Bot Token 或 SMTP,设置时区、提醒时间、提前天数及逾期提醒,查看发送记录。
|
||||
- **多人使用**:注册、登录和退出;卡片、费用、平台关系、通知设置及凭据按账号隔离。
|
||||
- **桌面与手机**:卡片视图、列表、续期日历、移动端布局和 JSON 导出;界面不依赖外部 CDN。
|
||||
|
||||
金额使用十进制与整数最小单位计算,不进行汇率换算。续期操作由你实际执行,SIMKEEP 负责提醒、记录和计算。
|
||||
|
||||
## 界面预览
|
||||
|
||||
下图使用独立演示账号和虚构资料;号码、邮箱、余额及费用均为示例,运营商周期请以实际条款为准。
|
||||
|
||||
### 卡片总览
|
||||
|
||||
查看各币种余额、近期续期金额、卡片和日历。
|
||||
|
||||

|
||||
|
||||
### 卡片详情与续期历史
|
||||
|
||||
把卡片资料、关联平台、续期规则和实际费用放在一起。
|
||||
|
||||

|
||||
|
||||
### 通知设置
|
||||
|
||||
在网页配置自己的通知服务,再绑定 Telegram 或验证接收邮箱。
|
||||
|
||||

|
||||
|
||||
<details>
|
||||
<summary>查看手机界面</summary>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/mobile.png" width="390" alt="SIMKEEP 手机卡片管理界面">
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
## Docker 部署
|
||||
|
||||
需要 Docker Engine 和 Docker Compose 插件。镜像会自动构建前端,服务器无需安装 Node.js 或 Python。
|
||||
|
||||
下载仓库并进入项目目录后执行:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
编辑 `.env`,把 `SIMKEEP_PUBLIC_URL` 改成自己的访问地址,例如 `http://your-server:5180`。本机试用可保留 `http://localhost:5180`。
|
||||
|
||||
```bash
|
||||
docker compose up -d --build --wait
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
打开 `http://localhost:5180` 或设置的服务器地址,点击“创建账号”。没有预置管理员、初始密码或演示资料。
|
||||
|
||||
| 配置项 | 默认值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `SIMKEEP_PUBLIC_URL` | `http://localhost:5180` | 网页访问地址和提醒中的链接 |
|
||||
| `SIMKEEP_PORT` | `5180` | 宿主机端口;修改时同步调整访问地址 |
|
||||
| `SIMKEEP_BIND_ADDRESS` | `0.0.0.0` | 宿主机监听地址 |
|
||||
| `SIMKEEP_SECURE_COOKIES` | `0` | HTTP 使用 `0`;通过 HTTPS 访问时使用 `1` |
|
||||
|
||||
数据保存于命名卷 `simkeep_simkeep-data` 中的 `/app/data/simkeep.db`。容器重启和镜像更新会保留数据。使用单个 Web worker 和一个提醒调度进程。
|
||||
|
||||
详见 [Docker 部署与恢复说明](docs/DOCKER.md)。
|
||||
|
||||
## 通知配置
|
||||
|
||||
登录后进入 **通知设置**。每个渠道都有配置按钮,保存后立即生效,无需重启 Docker。
|
||||
|
||||
### Telegram
|
||||
|
||||
1. 在 [BotFather](https://t.me/BotFather) 创建 Bot,获取 Token。
|
||||
2. 点击 **配置 Telegram**,填写 Token,然后 **保存并检查**。
|
||||
3. 点击 **绑定 Telegram**,打开 Bot 并点击“开始”,回到网页检查绑定。
|
||||
4. 开启 Telegram 提醒,选择提醒时间并 **保存设置**。
|
||||
|
||||
同一个 Bot Token 只用于一个账号,且需要由 SIMKEEP 独占 `getUpdates`。不要同时运行其他消费者或设置其他 webhook。更换 Token 后需要重新绑定。
|
||||
|
||||
### 邮件
|
||||
|
||||
1. 点击 **配置邮件**,填写 SMTP 服务器、端口、安全方式、发件邮箱、用户名和密码 / 授权码。
|
||||
2. 点击 **保存并检查**。端口 `587` 通常使用 STARTTLS,`465` 通常使用 SSL / TLS;以邮件服务说明为准。
|
||||
3. 保存接收邮箱,点击 **发送验证码**,填写邮件中的验证码。
|
||||
4. 开启邮箱提醒,选择提醒时间并 **保存设置**。
|
||||
|
||||
支持无需登录认证的 SMTP,此时用户名留空。发件邮箱需是邮件服务允许使用的地址。
|
||||
|
||||
**保存并检查**只检查连接与认证,不发送消息。绑定或验证后可以点击 **发送测试** 检查真实投递。已保存的 Token / 密码不会回显,编辑时留空保留原值。
|
||||
|
||||
默认提前 7、3、1 天及到期当天提醒,也可开启每天一次的逾期提醒。提醒按各账号时区和时间运行,关闭网页后仍有效。
|
||||
|
||||
`.env` 中的通知凭据也可作为公共默认服务;个人配置优先。清除个人配置后,如果服务器配置了公共服务,会恢复使用公共服务。
|
||||
|
||||
## 备份与更新
|
||||
|
||||
使用 SQLite backup API 创建一致的备份:
|
||||
|
||||
```bash
|
||||
docker compose exec -T web python scripts/backup.py
|
||||
docker compose cp web:/app/data/backups ./docker-backups
|
||||
```
|
||||
|
||||
备份包含账号、卡片、历史和个人通知密钥,应放在受保护的位置。不要只复制正在使用的主数据库文件而遗漏 WAL。
|
||||
|
||||
更新代码和镜像:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build --wait
|
||||
```
|
||||
|
||||
修改 `.env` 后,需要重建容器加载新环境变量:
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate --wait web
|
||||
```
|
||||
|
||||
`docker compose restart` 不会加载修改后的 `.env`。不要执行 `docker compose down -v`,它会删除数据卷。
|
||||
|
||||
## 本地开发
|
||||
|
||||
后端使用 Python / FastAPI / SQLite,前端使用原生 JavaScript、HTML 和 CSS。
|
||||
|
||||
```bash
|
||||
python3 -m pip install -r requirements.txt
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
本地启动读取进程环境变量;`.env` 由 Docker Compose 加载,本地开发需要自行设置所需变量。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
python3 -m unittest discover -s tests -p 'test_*.py' -q
|
||||
node --check app.js
|
||||
```
|
||||
|
||||
测试使用临时数据库和模拟投递边界,不需要真实 Telegram / SMTP 凭据。
|
||||
|
||||
## 使用范围
|
||||
|
||||
适合小规模单实例部署。当前包含卡片维护与提醒,不自动向运营商拨号、发短信或购买套餐,也不包含共享卡片、密码找回和管理员面板。
|
||||
|
||||
提醒失败会退避重试,最多尝试 5 次;修复凭据后当天未发送的失败任务可重新排队。外部服务已经接收消息、进程尚未写入成功记录时崩溃,恢复后可能重复一次;停机期间错过的历史提醒不会逐条补发。
|
||||
@@ -0,0 +1,44 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(message,status=0,generation) {super(message);this.status=status;this.sessionGeneration=generation;}
|
||||
}
|
||||
export class StaleResponse extends Error {
|
||||
constructor() {super('账号已切换,忽略旧请求');this.name='StaleResponse';}
|
||||
}
|
||||
|
||||
export class API {
|
||||
constructor(transport=globalThis.fetch.bind(globalThis)) {
|
||||
this.transport=transport;
|
||||
this.csrf='';
|
||||
this.generation=0;
|
||||
}
|
||||
clear() {this.csrf='';this.generation++;}
|
||||
assertCurrent(result) {if(result._sessionGeneration!==this.generation) throw new StaleResponse();}
|
||||
async request(method,path,payload) {
|
||||
const generation=this.generation;
|
||||
const current=()=>{if(generation!==this.generation) throw new StaleResponse();};
|
||||
const headers={Accept:'application/json'};
|
||||
if (payload !== undefined) headers['Content-Type']='application/json';
|
||||
if (!['GET','HEAD'].includes(method) && this.csrf) headers['X-CSRF-Token']=this.csrf;
|
||||
let response;
|
||||
try {
|
||||
response=await this.transport(path,{method,headers,credentials:'same-origin',...(payload!==undefined ? {body:JSON.stringify(payload)} : {})});
|
||||
} catch {
|
||||
current();
|
||||
throw new ApiError('无法连接服务器,请检查网络后重试',0,generation);
|
||||
}
|
||||
let result;
|
||||
try {result=await response.json();} catch {current();throw new ApiError('服务器响应异常,请稍后重试',response.status,generation);}
|
||||
current();
|
||||
if (!response.ok) {
|
||||
const detail=result.detail;
|
||||
const message=typeof detail==='string' ? detail : Array.isArray(detail) ? detail[0]?.msg?.replace(/^Value error, /,'') : '';
|
||||
throw new ApiError(message || '操作未完成,请稍后重试',response.status,generation);
|
||||
}
|
||||
if (path==='/api/auth/login' || path==='/api/auth/register') this.generation++;
|
||||
if (result.csrfToken) this.csrf=result.csrfToken;
|
||||
Object.defineProperty(result,'_sessionGeneration',{value:this.generation});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export const api=new API();
|
||||
@@ -0,0 +1,625 @@
|
||||
import {ACTIONS, addCycle, completeRenewal, createId, cycleText, daysUntil, dueStatus, formatDate, getTasks, parseDate, todayString, validateActivationChange} from './core.js';
|
||||
import {COUNTRIES} from './countries.js';
|
||||
import {api} from './api.js';
|
||||
import {CURRENCIES,COUNTRY_CURRENCY,normalizeAmount,sumMoney,balanceAfter,formatMoney} from './money.js';
|
||||
|
||||
const paths = {
|
||||
grid:'<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
|
||||
sim:'<path d="M7 3h7l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a2 2 0 0 1 2-2Z"/><rect x="8" y="11" width="8" height="6" rx="1.5"/><path d="M12 11v6m-4-3h8"/>',
|
||||
esim:'<path d="M7 3h7l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a2 2 0 0 1 2-2Z"/><path d="m13 10-4 5h4l-2 4"/>',
|
||||
calendar:'<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M7 3v4m10-4v4M3 11h18m-13 4h2m4 0h2m-8 3h2"/>',
|
||||
layers:'<path d="m12 3 9 5-9 5-9-5 9-5Zm-9 9 9 5 9-5M3 16l9 5 9-5"/>',
|
||||
bell:'<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"/>',
|
||||
search:'<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4.5 4.5"/>',
|
||||
chevron:'<path d="m9 5 7 7-7 7"/>',
|
||||
left:'<path d="m15 5-7 7 7 7"/>',
|
||||
down:'<path d="m7 10 5 5 5-5"/>',
|
||||
plus:'<path d="M12 5v14M5 12h14"/>',
|
||||
export:'<path d="M12 3v12m-4-4 4 4 4-4M5 15v5h14v-5"/>',
|
||||
more:'<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
|
||||
list:'<path d="M9 6h12M9 12h12M9 18h12M3 6h1m-1 6h1m-1 6h1"/>',
|
||||
message:'<path d="M5 4h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8l-5 3V6a2 2 0 0 1 2-2Z"/><path d="M7 9h10m-10 4h6"/>',
|
||||
phone:'<path d="m8 3 3 5-3 3c1.5 2.4 2.6 3.5 5 5l3-3 5 3c1 5-3 5-5 4C9 18 6 15 3 8 2 6 3 2 8 3Z"/>',
|
||||
bag:'<path d="M4 7h16l1 14H3L4 7Z"/><path d="M8 8V6a4 4 0 0 1 8 0v2"/>',
|
||||
refresh:'<path d="M20 10a8 8 0 0 0-14-5L3 8m0-5v5h5M4 14a8 8 0 0 0 14 5l3-3m0 5v-5h-5"/>',
|
||||
wallet:'<path d="M20 7H5a2 2 0 0 1 0-4h13v4M3 5v14a2 2 0 0 0 2 2h15V7m0 5h-5v5h5"/><path d="M16 14.5h1"/>',
|
||||
check:'<path d="m5 12 4 4L19 6"/>',
|
||||
checkCircle:'<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
|
||||
clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||||
link:'<path d="m10 13 4-4m-7 6-1 1a3.5 3.5 0 0 1-5-5l4-4a3.5 3.5 0 0 1 5 0m4 2 1-1a3.5 3.5 0 0 1 5 5l-4 4a3.5 3.5 0 0 1-5 0" transform="translate(1 1)"/>',
|
||||
telegram:'<path d="M21 3 3 10l7 3 3 7 8-17Z"/><path d="m10 13 6-6"/>',
|
||||
mail:'<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 6 9 7 9-7"/>',
|
||||
shield:'<path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6l8-3Z"/><path d="m8 12 3 3 5-6"/>',
|
||||
globe:'<circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><path d="M3 12h18"/>',
|
||||
close:'<path d="m6 6 12 12M6 18 18 6"/>',
|
||||
edit:'<path d="m14 5 5 5M4 20l5-1L21 7a2 2 0 0 0-4-4L5 15l-1 5Z"/>',
|
||||
settings:'<path d="m10 3-.5 3-2 1.2L4.7 6 2.8 9.3l2.3 1.8v2L2.8 15l1.9 3.3 2.8-1.2 2 1.2.5 2.7h4l.5-2.7 2-1.2 2.8 1.2 1.9-3.3-2.3-1.9v-2l2.3-1.8L19.3 6l-2.8 1.2-2-1.2L14 3Z"/><circle cx="12" cy="12" r="3"/>',
|
||||
archive:'<path d="M4 8h16v13H4V8Z"/><path d="M3 3h18v5H3zm6 10h6"/>',
|
||||
trash:'<path d="M4 6h16M9 6V3h6v3M6 6l1 15h10l1-15M10 10v7m4-7v7"/>',
|
||||
menu:'<path d="M4 6h16M4 12h16M4 18h16"/>',
|
||||
help:'<circle cx="12" cy="12" r="9"/><path d="M9 8a3 3 0 0 1 6 0c0 2-3 2-3 4m0 4h.01"/>',
|
||||
spark:'<path d="m12 3 2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3Z"/>',
|
||||
};
|
||||
const icon = name => `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">${paths[name] || paths.sim}</svg>`;
|
||||
const e = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const uid = createId;
|
||||
const el = selector => document.querySelector(selector);
|
||||
const NAV = [
|
||||
{id:'overview',label:'总览',icon:'grid'}, {id:'cards',label:'我的卡片',icon:'sim'},
|
||||
{id:'renewals',label:'续期计划',icon:'calendar'}, {id:'platforms',label:'关联平台',icon:'layers'},
|
||||
{id:'settings',label:'通知设置',icon:'bell'},
|
||||
];
|
||||
const colors = {Telegram:'#63a9d9',WhatsApp:'#4cac88',Google:'#5e8be3',PayPal:'#5b80b7',Apple:'#7e8593',Amazon:'#da9b43',Discord:'#7987d9',WeChat:'#65b684',AlipayHK:'#609ee2',GitHub:'#697487',Microsoft:'#6d9dce',MobiMatter:'#e6935e',My3:'#5c87df',Tello:'#56a294',Airalo:'#ce7689'};
|
||||
|
||||
let serverUser = null, authMode = 'login', isMutating = false;
|
||||
let view = 'overview', filter = 'all', layout = 'grid', query = '', showArchived = false, taskFilter = 'all';
|
||||
const emptyWorkspace = () => ({version:2,cards:[],events:[],settings:{timezone:'Asia/Shanghai',time:'09:00',offsets:[7,3,1],overdue:true,telegram:false,email:false,emailAddress:'',emailVerified:false,telegramVerified:false,chatId:''},services:{telegram:{configured:false},email:{configured:false}},notifications:[]});
|
||||
let data = emptyWorkspace();
|
||||
let monthDate = parseDate(`${today().slice(0,7)}-01`), selectedDate = null;
|
||||
let previousFocus = null, overlayCleanup = null, toastTimer, detailReturn = null, telegramBindingCode = '';
|
||||
|
||||
function today() {return todayString(data?.settings?.timezone || 'Asia/Shanghai');}
|
||||
const user = () => serverUser;
|
||||
const findCard = id => data.cards.find(card => card.id === id);
|
||||
const tasks = () => getTasks(data.cards);
|
||||
const nearest = card => [...card.rules].sort((a,b) => a.dueDate.localeCompare(b.dueDate))[0];
|
||||
function savedMessage(message) {toast(message);}
|
||||
const configured = channel => Boolean(data.services?.[channel]?.configured);
|
||||
const verified = channel => Boolean(data.settings[channel === 'telegram' ? 'telegramVerified' : 'emailVerified']);
|
||||
const channelReady = channel => configured(channel) && verified(channel) && data.settings[channel];
|
||||
const channelStatus = channel => !configured(channel) ? '服务待配置' : !verified(channel) ? channel === 'telegram' ? '待绑定' : '待验证' : data.settings[channel] ? '已启用' : '未启用';
|
||||
async function mutation(method,path,payload) {
|
||||
if (isMutating) throw new Error('上一项操作正在保存,请稍候');
|
||||
isMutating = true;
|
||||
try {const result = await api.request(method,path,payload);api.assertCurrent(result);if (result.workspace) data = result.workspace;return result;}
|
||||
finally {isMutating = false;}
|
||||
}
|
||||
function reportError(error,form=null) {
|
||||
if (error.name === 'StaleResponse') return;
|
||||
if (error.sessionGeneration !== undefined && error.sessionGeneration !== api.generation) return;
|
||||
if (error.status === 401 && serverUser) {
|
||||
closeOverlay(false);serverUser = null;data = emptyWorkspace();api.clear();renderAuth('登录已过期,请重新登录');return;
|
||||
}
|
||||
if (form?.isConnected && form.querySelector('.form-error')) formError(form,error.message);else toast(error.message,true);
|
||||
}
|
||||
async function refreshWorkspace() {
|
||||
const result = await api.request('GET','/api/workspace');
|
||||
api.assertCurrent(result);data = result.workspace;
|
||||
}
|
||||
|
||||
function flag(country) {
|
||||
const graphics = {
|
||||
GB:'<rect width="60" height="40" fill="#344579"/><path d="m0 0 60 40m0-40L0 40" stroke="#fff" stroke-width="9"/><path d="m0 0 60 40m0-40L0 40" stroke="#d95364" stroke-width="3"/><path d="M30 0v40M0 20h60" stroke="#fff" stroke-width="13"/><path d="M30 0v40M0 20h60" stroke="#d95364" stroke-width="7"/>',
|
||||
US:'<rect width="60" height="40" fill="#fff"/><path d="M0 3h60M0 10h60M0 17h60M0 24h60M0 31h60M0 38h60" stroke="#d46b77" stroke-width="3.5"/><rect width="26" height="22" fill="#5477b2"/><path d="M5 5h1m5 0h1m5 0h1m-13 6h1m5 0h1m5 0h1m-13 6h1m5 0h1m5 0h1" stroke="#fff" stroke-width="2"/>',
|
||||
HK:'<rect width="60" height="40" fill="#dc6268"/><g fill="#fff" transform="translate(30 20)"><ellipse rx="4" ry="8" transform="translate(0 -6)"/><ellipse rx="4" ry="8" transform="rotate(72) translate(0 -6)"/><ellipse rx="4" ry="8" transform="rotate(144) translate(0 -6)"/><ellipse rx="4" ry="8" transform="rotate(216) translate(0 -6)"/><ellipse rx="4" ry="8" transform="rotate(288) translate(0 -6)"/></g>',
|
||||
JP:'<rect width="60" height="40" fill="#fff"/><circle cx="30" cy="20" r="11" fill="#d95b6a"/>',
|
||||
CN:'<rect width="60" height="40" fill="#dc6268"/><path d="m12 7 2 5 5 .2-4 3 1.4 5L12 17l-4.4 3.2 1.4-5-4-3 5-.2Z" fill="#f2d077"/>',
|
||||
DE:'<rect width="60" height="14" fill="#3f4450"/><rect y="14" width="60" height="13" fill="#d66b72"/><rect y="27" width="60" height="13" fill="#edc660"/>',
|
||||
SG:'<rect width="60" height="20" fill="#d96b74"/><rect y="20" width="60" height="20" fill="#fff"/><circle cx="15" cy="10" r="7" fill="#fff"/><circle cx="18" cy="9" r="6" fill="#d96b74"/>',
|
||||
};
|
||||
return `<span class="flag" title="${e(COUNTRIES[country] || COUNTRIES.OTHER)}"><svg viewBox="0 0 60 40" aria-hidden="true">${graphics[country] || '<rect width="60" height="40" fill="#aab7cf"/><circle cx="30" cy="20" r="12" fill="none" stroke="white" stroke-width="3"/>'}</svg></span>`;
|
||||
}
|
||||
const provider = (card, mini = false) => `<span class="${mini ? 'provider-mini' : 'provider-logo'}" style="--brand-color:${e(card.color)}" data-provider="${e(card.provider)}">${e(card.mark)}</span>`;
|
||||
const typeTag = card => `<span class="type-tag ${card.type === 'SIM' ? 'physical' : ''}">${icon(card.type === 'SIM' ? 'sim' : 'esim')}${e(card.type)}</span>`;
|
||||
const statusBadge = date => {const status = dueStatus(date,today());return `<span class="status ${status.tone}">${status.label}</span>`;};
|
||||
const platformBadge = registration => `<span class="platform-tag"><span class="platform-dot" style="--platform-color:${colors[registration.name] || '#8e9ab3'}">${e(registration.name[0])}</span>${e(registration.name)}</span>`;
|
||||
const moneyTags = values => values.length ? values.map(value=>`<span class="money-tag">${e(formatMoney(value))}</span>`).join('') : '<span class="money-unknown">未记录余额</span>';
|
||||
const cardCurrency = card => card?.balances?.[0]?.currency || COUNTRY_CURRENCY[card?.country] || 'USD';
|
||||
|
||||
function renderMoneySummary() {
|
||||
const balances = sumMoney(data.cards.filter(card=>!card.archived).flatMap(card=>card.balances || []));
|
||||
const upcoming = tasks().filter(task=>daysUntil(task.rule.dueDate,today())<=30);
|
||||
const costs = sumMoney(upcoming.map(task=>task.rule.cost));
|
||||
const unpriced = upcoming.filter(task=>!task.rule.cost).length;
|
||||
return `<section class="money-summary" aria-label="余额与续期金额"><div><h2>${icon('wallet')}在用卡片余额</h2><div class="money-tags">${moneyTags(balances)}</div><p>按币种分别汇总</p></div><div><h2>${icon('calendar')}近 30 天待办金额</h2><div class="money-tags">${costs.length ? moneyTags(costs) : '<span class="money-unknown">暂无已填写金额的待办</span>'}</div><p>含逾期${unpriced ? ` · ${unpriced} 项未填写金额` : ''}</p></div></section>`;
|
||||
}
|
||||
|
||||
function renderAuth(message = '') {
|
||||
const registering = authMode === 'register';
|
||||
el('#app').innerHTML = `<main class="auth-shell"><section class="auth-story"><a class="brand" href="#" aria-label="续卡 SIMKEEP"><span class="brand-symbol">${icon('sim')}</span><span class="brand-name">续卡<small>SIMKEEP</small></span></a><div class="auth-story-content"><h1>每个号码,<br>都有它的下一次。</h1><p>从第一次开通,到每一次续期。<br>把卡片、账号与提醒,放在自己的空间里。</p><div class="connection-illustration" aria-hidden="true"><div class="illustration-sim">${icon('sim')}<span>SIM & eSIM</span><div class="sim-chip"><i></i><i></i><i></i><i></i><i></i><i></i></div></div><div class="illustration-timeline"><div><span class="timeline-dot done"></span><strong>开通连接</strong><small>留下最初的日期</small></div><div><span class="timeline-dot"></span><strong>安排下次续期</strong><small>短信、通话、重置或购买套餐</small></div><div><span class="timeline-dot hollow"></span><strong>提醒,准时到达</strong><small>Telegram 与邮箱陪你记得</small></div></div></div></div><p class="auth-story-foot">每一条连接,都有迹可循</p></section><section class="auth-panel"><div class="auth-form-wrap"><div class="auth-tabs" role="group" aria-label="账号入口"><button data-action="auth-login" class="${!registering ? 'active' : ''}" aria-pressed="${!registering}">登录</button><button data-action="auth-register" class="${registering ? 'active' : ''}" aria-pressed="${registering}">创建账号</button></div><h2>${registering ? '开启你的卡片空间' : '回来照顾你的连接'}</h2><p class="auth-intro">${registering ? '每个人都有独立的卡片、平台与续期记录。' : '登录后,接着管理你的 SIM 与 eSIM。'}</p><form id="auth-form"><div class="form-error" ${message ? '' : 'hidden'} role="alert">${e(message)}</div>${registering ? field('auth-name','你的名字',`<input id="auth-name" name="name" autocomplete="nickname" required maxlength="45" placeholder="怎么称呼你">`) : ''}${field('auth-email','邮箱',`<input id="auth-email" name="email" type="email" autocomplete="username" required maxlength="120" placeholder="you@example.com">`)}${field('auth-password','密码',`<input id="auth-password" name="password" type="password" autocomplete="${registering ? 'new-password' : 'current-password'}" minlength="${registering ? '10' : '1'}" maxlength="128" required placeholder="${registering ? '至少 10 个字符' : '输入密码'}">`)}<button class="btn btn-primary auth-submit" type="submit">${registering ? '创建账号,开始管理' : '登录我的空间'}${icon('chevron')}</button></form><p class="auth-account-note">${icon('shield')}你的卡片记录随账号保存</p></div><footer class="auth-footer">续卡 SIMKEEP · SIM 与 eSIM 管理</footer></section></main>`;
|
||||
}
|
||||
async function bootstrap() {
|
||||
const generation = api.generation;
|
||||
el('#app').innerHTML = `<main class="bootstrap"><span class="brand-symbol">${icon('sim')}</span><p>正在打开你的卡片空间…</p></main>`;
|
||||
try {
|
||||
const session = await api.request('GET','/api/auth/me');
|
||||
const result = await api.request('GET','/api/workspace');
|
||||
api.assertCurrent(session);api.assertCurrent(result);
|
||||
serverUser = session.user;data = result.workspace;
|
||||
monthDate = parseDate(`${today().slice(0,7)}-01`);renderApp();
|
||||
} catch (error) {
|
||||
if (generation !== api.generation) return;
|
||||
if (error.name === 'StaleResponse') return;
|
||||
if (error.status === 401) renderAuth();
|
||||
else el('#app').innerHTML = `<main class="bootstrap"><span class="brand-symbol">${icon('sim')}</span><p>${e(error.message)}</p><button class="btn btn-primary" data-action="retry-bootstrap">重新连接</button></main>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderApp(keepSearch = false) {
|
||||
if (!serverUser) {renderAuth();return;}
|
||||
const pos = keepSearch ? el('#global-search')?.selectionStart : null;
|
||||
const count = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7).length;
|
||||
el('#app').innerHTML = `<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="主导航">
|
||||
<a class="brand" href="#overview" data-nav="overview"><span class="brand-symbol">${icon('sim')}</span><span class="brand-name">续卡<small>SIMKEEP</small></span></a>
|
||||
<div class="nav-caption">工作空间</div><nav class="nav-list">${NAV.map(item => `<button class="nav-button ${view === item.id ? 'active' : ''}" data-nav="${item.id}" title="${item.label}" ${view === item.id ? 'aria-current="page"' : ''}>${icon(item.icon)}<span>${item.label}</span>${item.id === 'renewals' && count ? `<span class="nav-count">${count}</span>` : ''}</button>`).join('')}</nav>
|
||||
<div class="sidebar-spacer"></div>
|
||||
<button class="nav-button" data-action="help" title="帮助与说明">${icon('help')}<span>帮助与说明</span></button>
|
||||
<div class="sidebar-note"><div class="note-icon">${icon('shield')}</div><h4>你的号码,你的空间</h4><p>卡片与平台记录随账号管理<br>每一条连接都有自己的归属</p><button data-action="account">查看当前账号 ${icon('chevron')}</button></div>
|
||||
<button class="sidebar-user" data-action="account" aria-label="我的账号"><span class="avatar">${e(user().initial)}</span><span class="user-text"><strong>${e(user().name)}</strong><small>我的个人空间</small></span>${icon('down')}</button>
|
||||
</aside>
|
||||
<div class="main-shell"><header class="topbar"><button class="icon-button mobile-menu" data-action="menu" aria-label="打开导航">${icon('menu')}</button><div class="breadcrumbs"><span>工作空间</span>${icon('chevron')}<strong>${NAV.find(item => item.id === view)?.label}</strong></div>
|
||||
<div class="topbar-right"><label class="global-search" for="global-search">${icon('search')}<input id="global-search" name="global-search" value="${e(query)}" placeholder="搜索卡片、号码或平台" aria-label="搜索卡片、号码或平台"><kbd>⌘ K</kbd></label><button class="icon-button" data-action="refresh" aria-label="刷新卡片资料" title="刷新卡片资料">${icon('refresh')}</button><button class="icon-button notification-button" data-action="reminders" aria-label="查看近期提醒">${icon('bell')}</button><span class="topbar-divider"></span><button class="topbar-avatar" data-action="account" aria-label="我的账号"><span class="avatar">${e(user().initial)}</span>${icon('down')}</button></div>
|
||||
</header><main class="content" id="content">${renderContent()}</main></div>
|
||||
</div>`;
|
||||
if (keepSearch) {el('#global-search').focus();if (pos !== null) el('#global-search').setSelectionRange(pos,pos);}
|
||||
}
|
||||
|
||||
function heading(title, subtitle, actions = true) {
|
||||
return `<div class="page-heading"><div><div class="heading-title"><h1>${title}</h1></div><p>${subtitle}</p></div>${actions ? `<div class="heading-actions"><button class="btn" data-action="export">${icon('export')}导出</button><button class="btn btn-primary" data-action="add-card">${icon('plus')}添加卡片</button></div>` : ''}</div>`;
|
||||
}
|
||||
function footer() {return `<footer class="page-footer"><span>续卡 SIMKEEP <span>·</span> 每一条连接,都有迹可循</span><span>${icon('shield')}资料已保存到你的账号</span></footer>`;}
|
||||
function renderContent() {
|
||||
if (view === 'settings') return renderSettings() + footer();
|
||||
if (view === 'renewals') return renderRenewals() + footer();
|
||||
if (view === 'platforms') return renderPlatforms() + footer();
|
||||
return heading(view === 'overview' ? '卡片总览' : '我的卡片', view === 'overview' ? '管理每一条连接,让重要的号码一直在线。' : '号码、开通时间与平台记录,放在一起更清楚。') +
|
||||
(view === 'overview' ? renderStats() + renderMoneySummary() + renderUpcoming() : '') +
|
||||
`<div class="workspace-grid"><section aria-label="卡片集合">${renderCollection()}</section><aside class="right-rail">${renderCalendar()}${renderReminderPanel()}<p class="rail-tip">${icon('clock')}所有日期按 ${e(data.settings.timezone)} 计算</p></aside></div>` + footer();
|
||||
}
|
||||
function renderStats() {
|
||||
const active = data.cards.filter(card => !card.archived);
|
||||
const soon = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7).length;
|
||||
const registrations = active.reduce((sum,card) => sum + card.platforms.length,0);
|
||||
return `<section class="stat-strip" aria-label="管理概况">
|
||||
<div class="stat"><div class="stat-label">${icon('sim')}卡片总数</div><div class="stat-line"><span class="stat-value">${active.length.toString().padStart(2,'0')}</span><span class="stat-unit">张</span></div><p class="stat-foot"><span class="stat-colored">${active.filter(card => card.type === 'eSIM').length} eSIM</span> <span> / </span> ${active.filter(card => card.type === 'SIM').length} 实体 SIM</p></div>
|
||||
<div class="stat"><div class="stat-label">${icon('calendar')}近期续期</div><div class="stat-line"><span class="stat-value">${soon.toString().padStart(2,'0')}</span><span class="stat-unit">项</span></div><p class="stat-foot">未来 7 天内需要照顾的连接</p></div>
|
||||
<div class="stat"><div class="stat-label">${icon('layers')}平台绑定</div><div class="stat-line"><span class="stat-value">${registrations.toString().padStart(2,'0')}</span><span class="stat-unit">条</span></div><p class="stat-foot">重要账号与号码一一对应</p></div>
|
||||
<div class="stat"><div class="stat-label">${icon('bell')}提醒渠道</div><div class="stat-line"><span class="stat-value">${['telegram','email'].filter(channelReady).length.toString().padStart(2,'0')}</span><span class="stat-unit">个</span></div><p class="stat-foot">Telegram 和邮箱自动提醒</p></div>
|
||||
</section>`;
|
||||
}
|
||||
function renderUpcoming() {
|
||||
const soon = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7);
|
||||
return `<section class="renewal-strip" aria-label="近期续期任务"><span class="renewal-strip-icon">${icon(soon.length ? 'calendar' : 'checkCircle')}</span><div class="renewal-intro"><h3>${soon.length ? `${soon.length} 项续期,记得安排` : '本周的连接都已照顾好'}</h3><p>${soon.length ? '做一个小动作,让号码继续在线' : '没有临近到期的任务,安心使用'}</p></div><div class="renewal-pills">${soon.slice(0,3).map(({card,rule}) => `<button class="renewal-pill" data-action="complete" data-card="${e(card.id)}" data-rule="${e(rule.id)}" title="记录 ${e(card.name)} 的${ACTIONS[rule.action].label}">${provider(card,true)}<span><strong>${e(card.provider)}</strong><small>${formatDate(rule.dueDate,true)} · ${ACTIONS[rule.action].label}</small></span></button>`).join('')}</div><button class="text-button" data-nav="renewals">查看计划 ${icon('chevron')}</button></section>`;
|
||||
}
|
||||
function matches(card) {
|
||||
const haystack = [card.name,card.provider,card.phone,COUNTRIES[card.country],...card.platforms.flatMap(platform => [platform.name,platform.account])].join(' ').toLowerCase();
|
||||
return haystack.includes(query.trim().toLowerCase());
|
||||
}
|
||||
function visibleCards() {return data.cards.filter(card => (showArchived ? card.archived : !card.archived) && (filter === 'all' || card.type === filter) && matches(card));}
|
||||
function renderCollection() {
|
||||
const cards = visibleCards();
|
||||
const all = data.cards.filter(card => showArchived ? card.archived : !card.archived);
|
||||
const count = type => all.filter(card => card.type === type).length;
|
||||
return `<div class="section-header"><h2 class="section-title">${showArchived ? '已归档卡片' : '我的卡片'}<span class="count">${cards.length}</span></h2><div class="section-controls"><div class="segmented" aria-label="按卡片类型筛选">${[['all','全部',all.length],['eSIM','eSIM',count('eSIM')],['SIM','SIM',count('SIM')]].map(([value,label,n]) => `<button data-filter="${value}" class="${filter === value ? 'active' : ''}" aria-pressed="${filter === value}">${label}<span>${n}</span></button>`).join('')}</div><div class="view-toggle" aria-label="卡片视图"><button data-layout="grid" class="${layout === 'grid' ? 'active' : ''}" aria-label="网格视图" aria-pressed="${layout === 'grid'}">${icon('grid')}</button><button data-layout="list" class="${layout === 'list' ? 'active' : ''}" aria-label="列表视图" aria-pressed="${layout === 'list'}">${icon('list')}</button></div></div></div>
|
||||
${cards.length ? layout === 'grid' ? `<div class="card-grid">${cards.map(renderCard).join('')}</div>` : renderCardTable(cards) : empty(query ? '没有找到匹配的卡片' : showArchived ? '还没有归档的卡片' : '在这里,留下你的第一条连接', query ? '试试搜索运营商、号码尾号或已关联的平台。' : '添加 SIM 或 eSIM,设置续期周期,剩下的日期交给续卡。', !query && !showArchived)}
|
||||
<div class="collection-foot"><span>${query ? `搜索“${e(query)}” · ` : ''}共 ${cards.length} 张${showArchived ? '归档' : ''}卡片</span><button class="text-button" data-action="toggle-archived">${showArchived ? '返回在用卡片' : '查看已归档'}</button></div>`;
|
||||
}
|
||||
function empty(title, description, add = false) {return `<div class="empty-state">${icon('sim')}<h3>${title}</h3><p>${description}</p>${add ? `<button class="btn btn-primary" data-action="add-card">${icon('plus')}添加卡片</button>` : ''}</div>`;}
|
||||
function renderCard(card) {
|
||||
const rule = nearest(card);
|
||||
return `<article class="sim-card"><div class="card-top">${provider(card)}<div class="card-heading"><button data-action="detail" data-card="${e(card.id)}"><h3>${e(card.name)}</h3></button><p>${e(card.provider)}</p></div>${typeTag(card)}<button class="icon-button" data-action="card-actions" data-card="${e(card.id)}" aria-label="${e(card.name)}的更多操作">${icon('more')}</button></div>
|
||||
<div class="card-number ${!card.phone.startsWith('+') ? 'data-number' : ''}">${flag(card.country)}${e(card.phone || '未记录号码')}</div><div class="card-meta"><span>${icon('globe')}${e(COUNTRIES[card.country] || COUNTRIES.OTHER)}</span><span>${icon('clock')}${formatDate(card.activatedAt)} 开通</span></div>
|
||||
<div class="card-rule">${icon(rule ? ACTIONS[rule.action].icon : 'calendar')}<div class="card-rule-name">${rule ? ACTIONS[rule.action].label : '未设置续期'}<span>${rule ? cycleText(rule) : '添加一条规则'}${rule?.cost ? ` · ${e(formatMoney(rule.cost))}` : ''}</span></div><span class="card-rule-date">${rule ? formatDate(rule.dueDate) : '—'}</span></div>
|
||||
<div class="card-balance"><span>余额</span><div class="money-tags">${moneyTags(card.balances || [])}</div></div>
|
||||
<div class="platform-label">${icon('link')}关联平台 ${card.platforms.length ? `<span>(${card.platforms.length})</span>` : ''}${card.rules.length > 1 ? `<span style="margin-left:auto">${card.rules.length} 条续期规则</span>` : ''}</div><div class="platform-tags">${card.platforms.length ? card.platforms.slice(0,3).map(platformBadge).join('') + (card.platforms.length > 3 ? `<span class="platform-tag">+${card.platforms.length-3}</span>` : '') : '<span class="platform-tag">尚未关联平台</span>'}</div>
|
||||
<div class="card-footer">${card.archived ? '<span class="status neutral">已归档</span>' : rule ? statusBadge(rule.dueDate) : '<span class="status neutral">待设置续期</span>'}<button class="text-button" data-action="detail" data-card="${e(card.id)}">查看详情 ${icon('chevron')}</button></div>
|
||||
</article>`;
|
||||
}
|
||||
function renderCardTable(cards) {
|
||||
return `<div class="card-list"><table><thead><tr><th>卡片名称</th><th>类型 / 地区</th><th>开通日期</th><th>余额</th><th>下次操作 / 金额</th><th>下次续期</th><th></th></tr></thead><tbody>${cards.map(card => {const rule = nearest(card);return `<tr><td><button class="list-card-name" data-action="detail" data-card="${e(card.id)}">${provider(card,true)}<span><strong>${e(card.name)}</strong><small>${e(card.phone)}</small></span></button></td><td>${typeTag(card)} <span class="muted">${e(COUNTRIES[card.country])}</span></td><td class="muted">${formatDate(card.activatedAt)}</td><td><div class="money-tags">${moneyTags(card.balances || [])}</div></td><td>${rule ? ACTIONS[rule.action].label : '尚未设置'}${rule?.cost ? `<br><span class="muted">${e(formatMoney(rule.cost))}</span>` : ''}</td><td>${rule ? `${formatDate(rule.dueDate)}<br>${statusBadge(rule.dueDate)}` : '—'}</td><td><button class="icon-button" data-action="detail" data-card="${e(card.id)}" aria-label="查看${e(card.name)}">${icon('chevron')}</button></td></tr>`;}).join('')}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
const year = monthDate.getUTCFullYear(), month = monthDate.getUTCMonth();
|
||||
const start = new Date(Date.UTC(year,month,1));
|
||||
start.setUTCDate(1 - ((start.getUTCDay() + 6) % 7));
|
||||
const dates = Array.from({length:42},(_,i) => {const date = new Date(start);date.setUTCDate(start.getUTCDate()+i);return date;});
|
||||
const upcoming = selectedDate ? tasks().filter(task => task.rule.dueDate === selectedDate) : tasks().filter(task => task.rule.dueDate.slice(0,7) === `${year}-${String(month+1).padStart(2,'0')}`).slice(0,3);
|
||||
return `<section class="panel calendar-panel"><div class="panel-heading"><h2>${icon('calendar')}续期日历</h2><button class="text-button" data-action="calendar-today">今天</button></div><div class="calendar-toolbar"><strong>${year} 年 ${month+1} 月</strong><div class="calendar-controls"><button class="icon-button" data-month="-1" aria-label="上个月">${icon('left')}</button><button class="icon-button" data-month="1" aria-label="下个月">${icon('chevron')}</button></div></div><div class="calendar-grid">${['一','二','三','四','五','六','日'].map(day => `<span class="weekday">${day}</span>`).join('')}${dates.map(date => {const value = date.toISOString().slice(0,10);const n = tasks().filter(task => task.rule.dueDate === value).length;return `<button class="calendar-day ${date.getUTCMonth() !== month ? 'other' : ''} ${value === today() ? 'today' : ''} ${selectedDate === value ? 'selected' : ''} ${n ? 'has-task' : ''}" data-date="${value}" aria-label="${value}${n ? `,${n}项续期` : ',没有续期任务'}" aria-pressed="${selectedDate === value}">${date.getUTCDate()}</button>`;}).join('')}</div><div class="calendar-legend"><span><i class="legend-dot"></i>今天</span><span><i class="legend-dot orange"></i>有续期任务</span></div><div class="calendar-events"><h4>${selectedDate ? `${formatDate(selectedDate,true)}的续期` : '本月接下来的安排'}</h4>${upcoming.length ? upcoming.map(({card,rule}) => `<button class="calendar-event" data-action="complete" data-card="${e(card.id)}" data-rule="${e(rule.id)}"><span class="calendar-event-day"><small>${Number(rule.dueDate.slice(5,7))}月</small>${Number(rule.dueDate.slice(8))}</span><span class="calendar-event-text"><strong>${e(card.name)}</strong><small>${ACTIONS[rule.action].label} · ${cycleText(rule)}</small></span>${icon('chevron')}</button>`).join('') : '<p class="empty-calendar">这段时间没有续期任务。<br>可以安心使用你的号码。</p>'}</div></section>`;
|
||||
}
|
||||
function renderReminderPanel() {
|
||||
return `<section class="panel reminder-panel"><div class="reminder-head"><span class="reminder-icon">${icon('bell')}</span><h3>重要的续期,有人记得</h3><p>提前收到提醒,按照步骤完成操作。<br>让每一张卡保持连接。</p></div><div class="channel-line"><span class="channel-symbol">${icon('telegram')}</span><strong>Telegram Bot</strong><span class="channel-label">${e(channelStatus('telegram'))}</span></div><div class="channel-line"><span class="channel-symbol email">${icon('mail')}</span><strong>邮箱提醒</strong><span class="channel-label">${e(channelStatus('email'))}</span></div><div class="panel-actions"><button class="text-button" data-nav="settings">管理通知 ${icon('chevron')}</button><button class="text-button" data-action="preview">预览提醒</button></div></section>`;
|
||||
}
|
||||
function renderRenewals() {
|
||||
const all = tasks().filter(task => matches(task.card));
|
||||
const shown = all.filter(task => taskFilter === 'all' || (taskFilter === 'soon' ? daysUntil(task.rule.dueDate,today()) <= 7 : daysUntil(task.rule.dueDate,today()) < 0));
|
||||
return heading('续期计划','什么时候,做什么。每一次保号与续订都有清楚的安排。') + `<section class="section-full"><div class="section-header"><h2 class="section-title">待完成的续期<span class="count">${shown.length}</span></h2><div class="segmented">${[['all','全部计划'],['soon','近 7 天'],['overdue','已逾期']].map(([key,label]) => `<button data-task-filter="${key}" class="${taskFilter === key ? 'active' : ''}" aria-pressed="${taskFilter === key}">${label}</button>`).join('')}</div></div><div class="task-list">${shown.length ? shown.map(({card,rule}) => `<article class="task-row"><div class="task-date"><small>${Number(rule.dueDate.slice(5,7))}月</small>${Number(rule.dueDate.slice(8))}</div>${provider(card,true)}<div class="task-info"><h3>${e(card.name)} <span class="muted">/ ${e(card.provider)}</span></h3><p><span>${icon(ACTIONS[rule.action].icon)}${ACTIONS[rule.action].label}</span><span>${cycleText(rule)}</span><span>${rule.anchor === 'scheduled' ? '固定周期' : '完成后顺延'}</span>${rule.cost ? `<span class="renewal-cost">${e(formatMoney(rule.cost))}</span>` : '<span class="muted">金额未填写</span>'}</p></div>${statusBadge(rule.dueDate)}<button class="btn btn-soft btn-small" data-action="complete" data-card="${e(card.id)}" data-rule="${e(rule.id)}">${icon('check')}记录完成</button></article>`).join('') : empty('这段时间没有待办','完成操作后,新的续期日期会自动出现在这里。')}</div></section>`;
|
||||
}
|
||||
function renderPlatforms() {
|
||||
const registrations = data.cards.filter(card => !card.archived && matches(card)).flatMap(card => card.platforms.map(platform => ({card,platform}))).filter(({platform,card}) => !query || `${platform.name} ${platform.account} ${card.name} ${card.phone} ${card.provider}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return heading('关联平台','记住每个号码注册过什么,让账号归属有迹可循。',false) + `<div class="section-header"><h2 class="section-title">平台记录<span class="count">${registrations.length}</span></h2><button class="btn btn-primary btn-small" data-action="choose-platform">${icon('plus')}添加平台关联</button></div>${registrations.length ? `<div class="card-list"><table class="platform-table"><thead><tr><th>平台</th><th>关联卡片</th><th>账号标识</th><th>用途</th><th></th></tr></thead><tbody>${registrations.map(({card,platform}) => `<tr><td><span class="platform-name-cell"><span class="platform-initial" style="--platform-color:${colors[platform.name] || '#8e9ab3'}">${e(platform.name[0])}</span>${e(platform.name)}</span></td><td><button class="text-button" data-action="detail" data-card="${e(card.id)}">${e(card.name)}</button></td><td class="muted">${e(platform.account || '未填写')}</td><td class="muted">${e(platform.purpose || '未填写')}</td><td><button class="icon-button" data-action="remove-platform" data-card="${e(card.id)}" data-platform="${e(platform.id)}" aria-label="移除${e(platform.name)}关联">${icon('trash')}</button></td></tr>`).join('')}</tbody></table></div>` : empty('还没有匹配的平台记录','在卡片详情中关联平台,也可以点击上方按钮添加。')}`;
|
||||
}
|
||||
|
||||
function previewMessage(task = tasks()[0]) {
|
||||
if (!task) return '<p class="preview-note">添加卡片和续期规则后,就可以预览提醒内容。</p>';
|
||||
const {card,rule} = task;
|
||||
const balance = (card.balances || []).find(item=>rule.cost && item.currency===rule.cost.currency);
|
||||
return `<div class="preview-message"><strong>${icon('bell')}续卡提醒 · ${e(card.name)}</strong><div><span class="preview-label">卡片</span>${e(card.provider)} / ${e(card.type)}</div><div><span class="preview-label">号码</span>${card.phone.startsWith('+') ? `尾号 ${e(card.phone.replace(/\s/g,'').slice(-4))}` : '数据卡 / 未填写号码'}</div><div><span class="preview-label">到期时间</span>${formatDate(rule.dueDate)}</div><div><span class="preview-label">续期操作</span>${ACTIONS[rule.action].label}</div><div><span class="preview-label">续期金额</span>${e(formatMoney(rule.cost))}</div>${balance ? `<div><span class="preview-label">同币种余额</span>${e(formatMoney(balance))}</div>` : ''}<div class="preview-instructions">${e(rule.instructions || '完成后在续卡记录操作,自动计算下一次日期。')}</div><button class="preview-link" data-action="detail" data-card="${e(card.id)}" style="width:100%">查看卡片并记录操作</button></div>`;
|
||||
}
|
||||
function renderSettings() {
|
||||
const s = data.settings;
|
||||
const zones = [['Asia/Shanghai','中国标准时间 · UTC+8'],['Asia/Hong_Kong','香港时间 · UTC+8'],['Europe/London','伦敦时间'],['America/New_York','纽约时间'],['Asia/Tokyo','东京时间 · UTC+9']];
|
||||
if (!zones.some(([zone]) => zone === s.timezone)) zones.push([s.timezone,s.timezone]);
|
||||
return heading('通知设置','配置自己的通知服务,再绑定接收账号,按时收到续期提醒。',false) + `<div class="settings-layout"><form id="settings-form" class="settings-main"><div class="form-error" hidden role="alert"></div><section class="panel settings-panel"><h2>${icon('telegram')}通知渠道</h2><p>Bot Token 和 SMTP 按账号单独保存。可以同时启用两种提醒。</p>
|
||||
<div class="settings-channel"><div class="settings-channel-head"><span class="channel-symbol">${icon('telegram')}</span><div><strong>Telegram Bot</strong><p>在 Bot 中点击开始,绑定你的个人账号</p></div><input class="switch" type="checkbox" name="telegram" aria-label="启用 Telegram 提醒" ${s.telegram ? 'checked' : ''}></div>${serviceSetup('telegram')}<div class="channel-actions"><span class="status ${channelReady('telegram') ? 'success' : 'neutral'}">${e(channelStatus('telegram'))}</span><button class="btn btn-small" type="button" data-action="bind-telegram" ${!configured('telegram') ? 'disabled' : ''}>${s.telegramVerified ? '重新绑定' : '绑定 Telegram'}</button>${s.telegramVerified ? `<button class="text-button" type="button" data-action="test-telegram" ${!configured('telegram') ? 'disabled' : ''}>发送测试</button><button class="text-button" type="button" data-action="unbind-telegram">解除绑定</button>` : ''}</div></div>
|
||||
<div class="settings-channel"><div class="settings-channel-head"><span class="channel-symbol email">${icon('mail')}</span><div><strong>邮箱提醒</strong><p>验证接收邮箱,确保提醒送到你手中</p></div><input class="switch" type="checkbox" name="email" aria-label="启用邮箱提醒" ${s.email ? 'checked' : ''}></div>${serviceSetup('email')}<div class="field"><label for="notify-email">接收邮箱</label><input id="notify-email" name="emailAddress" type="email" value="${e(s.emailAddress)}" placeholder="you@example.com" maxlength="120"><small>修改邮箱后先保存设置,再发送验证码。</small></div><div class="channel-actions"><span class="status ${s.emailVerified ? 'success' : 'neutral'}">${s.emailVerified ? '邮箱已验证' : '邮箱待验证'}</span><button class="btn btn-small" type="button" data-action="verify-email" ${!configured('email') ? 'disabled' : ''}>${s.emailVerified ? '重新验证' : '发送验证码'}</button>${s.emailVerified ? `<button class="text-button" type="button" data-action="test-email" ${!configured('email') ? 'disabled' : ''}>发送测试</button>` : ''}</div></div></section>
|
||||
<section class="panel settings-panel"><h2>${icon('clock')}提醒时间</h2><p>按你所在的时区安排提醒。到期当天总会提醒一次。</p><div class="fields-grid"><div class="field"><label for="timezone">时区</label><select name="timezone" id="timezone">${zones.map(([key,label]) => option(key,label,s.timezone)).join('')}</select></div><div class="field"><label for="notify-time">每天提醒时间</label><input type="time" id="notify-time" name="time" value="${e(s.time)}" required></div></div><hr class="form-divider"><span class="field-label">提前几天提醒</span><div class="offsets">${[...new Set([14,7,3,1,...s.offsets])].sort((a,b) => b-a).map(offset => `<label class="offset-check"><input type="checkbox" name="offset" value="${offset}" ${s.offsets.includes(offset) ? 'checked' : ''}>${offset} 天前</label>`).join('')}<span class="offset-check">${icon('check')}到期当天</span></div><label class="checkbox-line"><input type="checkbox" name="overdue" ${s.overdue ? 'checked' : ''}>已逾期的任务,每天提醒一次,直到记录完成</label></section><div class="settings-actions"><button class="btn" type="button" data-action="preview">${icon('mail')}预览提醒</button><button class="btn btn-primary" type="submit">${icon('check')}保存设置</button></div></form>
|
||||
<aside class="panel notification-preview"><h3>${icon('message')}通知看起来会是这样</h3>${previewMessage()}<p class="preview-note">开启渠道、完成绑定或验证并保存设置后,提醒会自动发送。关闭页面也能收到。</p><div class="service-status"><span>Telegram 服务 <strong>${configured('telegram') ? '已配置' : '待配置'}</strong></span><span>邮件服务 <strong>${configured('email') ? '已配置' : '待配置'}</strong></span></div></aside></div>${renderNotificationLog()}`;
|
||||
}
|
||||
function serviceSetup(channel) {
|
||||
const service = data.services[channel];
|
||||
const name = channel === 'telegram' ? 'Telegram' : '邮件';
|
||||
const explanation = service.source === 'account' ? '已保存本账号的配置' : service.source === 'server' ? '当前使用公共服务,可配置自己的服务' : channel === 'telegram' ? '先填写 BotFather 提供的 Bot Token' : '先填写发信服务器与 SMTP 授权码';
|
||||
return `<div class="channel-setup"><p>${icon(service.configured ? 'checkCircle' : 'settings')}${e(explanation)}</p><button type="button" class="btn btn-small service-config-button" data-action="configure-service" data-channel="${channel}">${icon('settings')}配置${name === 'Telegram' ? ' ' : ''}${name}</button></div>`;
|
||||
}
|
||||
function notificationConfigForm(channel) {
|
||||
const service = data.services[channel], telegram = channel === 'telegram';
|
||||
const secretNote = saved => saved ? '已保存,留空保留原值' : '填写后只保存在服务器,不会回显';
|
||||
const body = telegram ? `<div class="config-intro">${icon('telegram')}使用你在 BotFather 创建的 Bot。保存后,回到通知设置绑定 Telegram。</div>${field('telegram-token','Bot Token',`<input id="telegram-token" name="token" type="password" autocomplete="new-password" autocapitalize="off" spellcheck="false" maxlength="160" ${service.tokenSaved ? '' : 'required'} placeholder="${service.tokenSaved ? '已保存,留空保留原 Token' : '123456789:AA…'}"><small>${secretNote(service.tokenSaved)}</small>`)}<p class="channel-help">同一个 Bot 只能用于一个账号。更换 Token 后需要重新绑定。请让续卡独占这个 Bot 的更新读取。</p>` : `<div class="config-intro">${icon('mail')}配置发送邮件的服务。接收提醒的邮箱仍在通知设置中填写。</div><div class="fields-grid">${field('smtp-host','SMTP 服务器',`<input id="smtp-host" name="host" required maxlength="253" value="${e(service.host || '')}" placeholder="smtp.example.com" autocapitalize="off" spellcheck="false">`,true)}${field('smtp-port','端口',`<input id="smtp-port" name="port" type="number" min="1" max="65535" required value="${service.port || 587}">`)}${field('smtp-mode','安全方式',`<select id="smtp-mode" name="mode">${[['starttls','STARTTLS · 通常 587'],['ssl','SSL / TLS · 通常 465'],['plain','无加密']].map(([value,label])=>option(value,label,service.mode || 'starttls')).join('')}</select>`)}${field('smtp-from','发件邮箱',`<input id="smtp-from" name="sender" type="email" required maxlength="120" value="${e(service.from || '')}" placeholder="sender@example.com"><small>填写邮件服务允许使用的发件地址。</small>`,true)}${field('smtp-user','SMTP 用户名',`<input id="smtp-user" name="user" maxlength="254" value="${e(service.user || '')}" autocomplete="off" placeholder="通常为完整邮箱"><small>服务无需登录认证时留空。</small>`,true)}${field('smtp-password','SMTP 密码 / 授权码',`<input id="smtp-password" name="password" type="password" maxlength="1024" autocomplete="new-password" placeholder="${service.passwordSaved ? '已保存,留空保留原密码' : '填写密码或邮箱授权码'}"><small>${secretNote(service.passwordSaved)}</small>`,true)}</div>`;
|
||||
openOverlay(telegram ? '配置 Telegram' : '配置邮件','仅用于当前账号。Token 和密码保存后不会回显。',`<form id="notification-config-form" data-channel="${channel}"><div class="form-error" hidden role="alert"></div><div class="config-feedback" hidden role="status"></div>${body}<p class="config-check-note">“保存并检查”会测试连接与认证,不发送消息。</p>${service.source === 'account' ? `<button type="button" class="text-button config-clear" data-action="clear-service-config" data-channel="${channel}">清除本账号配置</button>` : ''}</form>`,`<button class="btn" data-action="close">取消</button><button class="btn" type="submit" form="notification-config-form" value="save">保存配置</button><button class="btn btn-primary" type="submit" form="notification-config-form" value="check">保存并检查</button>`,'config-modal');
|
||||
}
|
||||
function renderNotificationLog() {
|
||||
const labels = {pending:'等待发送',processing:'发送中',sent:'已发送',failed:'发送失败',cancelled:'已取消'};
|
||||
return `<section class="notification-log"><div class="section-header"><h2 class="section-title">发送记录<span class="count">${data.notifications.length}</span></h2><button class="text-button" data-action="refresh">${icon('refresh')}刷新记录</button></div>${data.notifications.length ? `<div class="card-list"><table><thead><tr><th>提醒渠道</th><th>安排时间</th><th>状态</th><th>尝试次数</th><th>说明</th></tr></thead><tbody>${data.notifications.map(log => `<tr><td>${log.channel === 'telegram' ? 'Telegram' : '邮箱'}</td><td>${e(new Intl.DateTimeFormat('zh-CN',{timeZone:data.settings.timezone,dateStyle:'short',timeStyle:'short'}).format(new Date(log.created_at*1000)))}</td><td><span class="status ${log.status === 'sent' ? 'success' : log.status === 'failed' ? 'danger' : 'neutral'}">${labels[log.status] || e(log.status)}</span></td><td>${log.attempts}</td><td>${e(log.error || (log.status === 'sent' ? '已提交到通知服务' : '—'))}</td></tr>`).join('')}</tbody></table></div>` : `<div class="notification-log-empty">${icon('bell')}还没有发送记录。续期提醒安排发送后,会出现在这里。</div>`}</section>`;
|
||||
}
|
||||
function emailCodeForm() {
|
||||
openOverlay('验证接收邮箱',`验证码已发送至 ${e(data.settings.emailAddress)}`,`<form id="email-code-form"><div class="form-error" hidden role="alert"></div>${field('email-code','6 位验证码',`<input id="email-code" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" minlength="6" maxlength="6" required placeholder="输入邮件里的验证码">`)}<p class="account-note">验证码 10 分钟内有效。没有收到时,请检查垃圾邮件。</p></form>`,`<button class="btn" data-action="close">稍后验证</button><button class="btn btn-primary" form="email-code-form" type="submit">验证邮箱</button>`,'small');
|
||||
}
|
||||
async function bindTelegram() {
|
||||
const binding = await mutation('POST','/api/notifications/telegram/binding');
|
||||
telegramBindingCode = binding.code;
|
||||
openOverlay('绑定 Telegram','打开 Bot,点击“开始”,然后回到这里检查绑定。',`<div class="binding-steps"><span class="channel-symbol">${icon('telegram')}</span><a class="btn btn-primary" href="${e(binding.url)}" target="_blank" rel="noopener noreferrer">打开 Telegram Bot ${icon('link')}</a><p>如果没有自动发送,手动向 Bot 发送:</p><div class="field"><input aria-label="Telegram 绑定指令" readonly value="/start ${e(binding.code)}"></div><p>绑定指令 10 分钟内有效,仅绑定你的私人聊天。</p></div>`,`<button class="btn" data-action="close">稍后绑定</button><button class="btn btn-primary" data-action="check-telegram">检查绑定</button>`,'small');
|
||||
}
|
||||
|
||||
function toast(message, error = false) {
|
||||
clearTimeout(toastTimer);
|
||||
el('#toast-root').innerHTML = `<div class="toast ${error ? 'error' : ''}" role="status">${icon(error ? 'help' : 'checkCircle')}${e(message)}</div>`;
|
||||
toastTimer = setTimeout(() => {el('#toast-root').innerHTML = '';},4200);
|
||||
}
|
||||
function closeOverlay(restoreFocus = true) {
|
||||
if (overlayCleanup) {overlayCleanup();overlayCleanup = null;}
|
||||
el('#overlay-root').innerHTML = '';
|
||||
document.body.classList.remove('body-overlay');
|
||||
el('#app').inert = false;
|
||||
if (restoreFocus && previousFocus?.isConnected) previousFocus.focus();
|
||||
}
|
||||
function openOverlay(title, subtitle, body, footerHtml = '', kind = '') {
|
||||
closeOverlay(false);
|
||||
previousFocus = document.activeElement;
|
||||
el('#overlay-root').innerHTML = `<div class="overlay-backdrop ${kind === 'drawer' ? 'drawer-backdrop' : ''}"><section class="${kind === 'drawer' ? 'drawer' : `modal ${kind}`}" role="dialog" aria-modal="true" aria-labelledby="dialog-title" tabindex="-1"><header class="modal-header"><div><h2 id="dialog-title">${title}</h2>${subtitle ? `<p>${subtitle}</p>` : ''}</div><button class="icon-button" data-action="close" aria-label="关闭对话框">${icon('close')}</button></header><div class="modal-body">${body}</div>${footerHtml ? `<div class="modal-footer">${footerHtml}</div>` : ''}</section></div>`;
|
||||
document.body.classList.add('body-overlay');
|
||||
el('#app').inert = true;
|
||||
const dialog = el('[role="dialog"]');
|
||||
const focusables = () => Array.from(dialog.querySelectorAll('button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex="0"]'));
|
||||
requestAnimationFrame(() => {(dialog.querySelector('input:not([type="hidden"]),select') || dialog.querySelector('button') || dialog).focus();});
|
||||
const onKey = event => {
|
||||
if (event.key === 'Escape') {event.preventDefault();closeOverlay();}
|
||||
if (event.key === 'Tab') {
|
||||
const elements = focusables(), first = elements[0], last = elements.at(-1);
|
||||
if (!first) {event.preventDefault();dialog.focus();}
|
||||
else if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) {event.preventDefault();last.focus();}
|
||||
else if (!event.shiftKey && document.activeElement === last) {event.preventDefault();first.focus();}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown',onKey);
|
||||
overlayCleanup = () => document.removeEventListener('keydown',onKey);
|
||||
}
|
||||
const formError = (form, message) => {const target = form.querySelector('.form-error');target.textContent = message;target.hidden = false;target.scrollIntoView({block:'nearest'});};
|
||||
const option = (value, label, selected) => `<option value="${e(value)}" ${value === selected ? 'selected' : ''}>${e(label)}</option>`;
|
||||
const field = (id, label, input, wide = false) => `<div class="field ${wide ? 'wide' : ''}"><label for="${id}">${label}</label>${input}</div>`;
|
||||
const currencyOptions = selected => Object.entries(CURRENCIES).map(([code,item])=>option(code,`${code} · ${item.label}`,selected)).join('');
|
||||
function amountField(id,name,label,value='') {
|
||||
return field(id,label,`<input id="${e(id)}" name="${e(name)}" inputmode="decimal" maxlength="30" pattern="[0-9]+([.][0-9]+)?" value="${e(value)}" placeholder="留空表示未填写">`);
|
||||
}
|
||||
function costFields(prefix,cost=null,currency='USD',label='预计续期金额') {
|
||||
return `${amountField(prefix+'-amount','costAmount',label,cost?.amount || '')}${field(prefix+'-currency','金额币种',`<select id="${prefix}-currency" name="costCurrency">${currencyOptions(cost?.currency || currency)}</select>`)}`;
|
||||
}
|
||||
function readCost(form) {
|
||||
const value = form.elements.costAmount.value.trim();
|
||||
if (!value) return null;
|
||||
const currency = form.elements.costCurrency.value;
|
||||
return {currency,amount:normalizeAmount(value,currency)};
|
||||
}
|
||||
function balanceRow(balance={currency:'USD',amount:''}) {
|
||||
const id = 'balance-'+uid();
|
||||
return `<div class="balance-row">${field(id+'-currency','余额币种',`<select id="${id}-currency" name="balanceCurrency">${currencyOptions(balance.currency)}</select>`)}${amountField(id+'-amount','balanceAmount','余额金额',balance.amount)}<button class="icon-button" type="button" data-action="remove-balance-row" aria-label="移除这条余额">${icon('trash')}</button></div>`;
|
||||
}
|
||||
function readBalances(form) {
|
||||
const balances = [...form.querySelectorAll('.balance-row')].flatMap(row => {
|
||||
const amount = row.querySelector('[name="balanceAmount"]').value.trim();
|
||||
if (!amount) return [];
|
||||
const currency = row.querySelector('[name="balanceCurrency"]').value;
|
||||
return [{currency,amount:normalizeAmount(amount,currency)}];
|
||||
});
|
||||
if (new Set(balances.map(item=>item.currency)).size !== balances.length) throw new Error('同一张卡的每个币种只能记录一条余额');
|
||||
return balances;
|
||||
}
|
||||
|
||||
function ruleFields(rule = {}, minDate = today(), currency = 'USD') {
|
||||
return `<div class="fields-grid">
|
||||
${field('rule-action','续期操作<span>*</span>',`<select id="rule-action" name="action">${Object.entries(ACTIONS).map(([key,item]) => option(key,item.label,rule.action || 'sms')).join('')}</select>`)}
|
||||
${field('rule-anchor','日期计算方式',`<select id="rule-anchor" name="anchor">${option('completion','按完成日期顺延',rule.anchor || 'completion')}${option('scheduled','保持固定周期',rule.anchor)}</select>`)}
|
||||
${field('rule-interval','续期周期<span>*</span>',`<input id="rule-interval" name="interval" type="number" min="1" max="3650" step="1" required value="${rule.interval || 90}">`)}
|
||||
${field('rule-unit','周期单位',`<select id="rule-unit" name="unit">${option('days','天',rule.unit || 'days')}${option('months','个月',rule.unit)}</select>`)}
|
||||
${field('rule-due','下次到期日期<span>*</span>',`<input id="rule-due" name="dueDate" type="date" min="${minDate}" required value="${rule.dueDate || addCycle(minDate,rule.interval || 90,rule.unit || 'days')}">`,true)}
|
||||
${costFields('rule-cost',rule.cost,currency)}
|
||||
${field('rule-instructions','操作步骤',`<textarea id="rule-instructions" name="instructions" maxlength="1000" placeholder="例如:发送一条收费短信,确认成功后记录完成。">${e(rule.instructions || '')}</textarea>`,true)}
|
||||
</div>`;
|
||||
}
|
||||
function cardForm(card = null) {
|
||||
const editing = Boolean(card);
|
||||
const c = card || {name:'',provider:'',phone:'',type:'eSIM',country:'HK',activatedAt:today(),notes:'',plan:''};
|
||||
openOverlay(editing ? '编辑卡片' : '添加一张新卡片',editing ? '修改卡片档案;续期规则可在卡片详情中单独调整。' : '记下这条连接,再为它安排第一次续期。',`<form id="card-form" data-card="${e(card?.id || '')}" data-version="${card?.version || ''}"><div class="form-error" hidden role="alert"></div><div class="fields-grid">
|
||||
${field('card-name','卡片名称<span>*</span>',`<input id="card-name" name="name" required maxlength="45" value="${e(c.name)}" placeholder="例如:英国常用号码">`,true)}
|
||||
${field('card-provider','运营商<span>*</span>',`<input id="card-provider" name="provider" required maxlength="45" value="${e(c.provider)}" placeholder="例如:giffgaff">`)}
|
||||
${field('card-type','卡片类型',`<select id="card-type" name="type">${option('eSIM','eSIM',c.type)}${option('SIM','实体 SIM',c.type)}</select>`)}
|
||||
${field('card-phone','手机号码',`<input id="card-phone" name="phone" type="tel" maxlength="35" value="${e(c.phone)}" placeholder="数据 eSIM 可留空">`)}
|
||||
${field('card-country','国家或地区',`<select id="card-country" name="country">${Object.entries(COUNTRIES).map(([key,label]) => option(key,label,c.country)).join('')}</select>`)}
|
||||
${field('card-activation','开通日期<span>*</span>',`<input id="card-activation" name="activatedAt" type="date" required max="${today()}" value="${c.activatedAt}">`)}
|
||||
${field('card-plan','套餐说明',`<input id="card-plan" name="plan" maxlength="60" value="${e(c.plan)}" placeholder="例如:10 GB / 30 天">`)}
|
||||
</div><hr class="form-divider"><div class="form-section-title"><h3>卡片余额</h3><button class="text-button" type="button" data-action="add-balance-row">${icon('plus')}添加币种</button></div><div id="balance-fields">${(c.balances?.length ? c.balances : [{currency:cardCurrency(c),amount:''}]).map(balanceRow).join('')}</div><p class="form-help">同一张卡可记录多个币种。留空表示未记录,填 0 表示余额为零。</p>${editing ? '' : `<hr class="form-divider"><h3 class="form-section-title">第一次续期<span class="muted" style="font-size:10px;font-weight:400">之后可添加更多规则</span></h3>${ruleFields({},today(),cardCurrency(c))}`}
|
||||
<hr class="form-divider">${field('card-notes','备注',`<textarea id="card-notes" name="notes" maxlength="1000" placeholder="卡片用途、保号条件或其他需要记住的事">${e(c.notes)}</textarea>`)}
|
||||
</form>`,`<button class="btn" data-action="close">取消</button><button class="btn btn-primary" type="submit" form="card-form">${icon('check')}${editing ? '保存修改' : '添加卡片'}</button>`);
|
||||
const form = el('#card-form');
|
||||
if (!editing) {
|
||||
form.addEventListener('input',event => {
|
||||
if (!['activatedAt','interval','unit'].includes(event.target.name)) return;
|
||||
try {form.elements.dueDate.min = form.elements.activatedAt.value;form.elements.dueDate.value = addCycle(form.elements.activatedAt.value,Number(form.elements.interval.value),form.elements.unit.value);} catch { /* Let form validation explain an unfinished value on submit. */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
function readRule(form, existing = null) {
|
||||
const f = form.elements;
|
||||
const interval = Number(f.interval.value), unit = f.unit.value;
|
||||
if (!Number.isInteger(interval) || interval < 1 || interval > (unit === 'months' ? 120 : 3650)) throw new Error(`周期必须为 1–${unit === 'months' ? 120 : 3650} 的整数`);
|
||||
const dueDate = f.dueDate.value;
|
||||
parseDate(dueDate);
|
||||
return {action:f.action.value,interval,unit,dueDate,anchor:f.anchor.value,instructions:f.instructions.value.trim(),cost:readCost(form),...(existing ? {version:existing.version} : {})};
|
||||
}
|
||||
function ruleForm(cardId, ruleId = '') {
|
||||
const card = findCard(cardId), rule = card.rules.find(item => item.id === ruleId);
|
||||
detailReturn = cardId;
|
||||
openOverlay(rule ? '编辑续期规则' : '添加续期规则',`${e(card.name)} · 每条规则独立计算下一次日期`, `<form id="rule-form" data-card="${e(cardId)}" data-rule="${e(ruleId)}" data-version="${rule?.version || ''}"><div class="form-error" hidden role="alert"></div>${ruleFields(rule,card.activatedAt,cardCurrency(card))}<div class="instructions-box" style="margin-top:18px;margin-bottom:0">按完成日期顺延适合保号;固定周期适合套餐账单。修改当前到期日期会建立新的周期基准。</div></form>`,`<button class="btn" data-action="detail" data-card="${e(cardId)}">返回详情</button><button class="btn btn-primary" type="submit" form="rule-form">保存规则</button>`);
|
||||
}
|
||||
function completionForm(cardId, ruleId) {
|
||||
const card = findCard(cardId), rule = card?.rules.find(item => item.id === ruleId);
|
||||
if (!rule || card.archived) return;
|
||||
const minDate = rule.lastCompletedAt && rule.lastCompletedAt > card.activatedAt ? rule.lastCompletedAt : card.activatedAt;
|
||||
const balanceAction = rule.action === 'topup' ? 'credit' : 'deduct';
|
||||
openOverlay('记录续期完成','确认已经完成操作,记录花费并自动安排下一次。',`<div class="completion-summary">${provider(card)}<div><h3>${e(card.name)}</h3><p>${ACTIONS[rule.action].label} · ${cycleText(rule)} · ${rule.anchor === 'scheduled' ? '固定周期' : '完成后顺延'}</p></div></div><div class="instructions-box">${e(rule.instructions || '请确认续期操作成功后,再记录完成。')}</div><form id="completion-form" data-card="${e(cardId)}" data-rule="${e(ruleId)}" data-version="${rule.version}" data-card-version="${card.version}" data-balance-action="${balanceAction}"><div class="form-error" hidden role="alert"></div><div class="fields-grid">${field('completed-date','实际完成日期<span>*</span>',`<input id="completed-date" name="completedAt" type="date" required min="${minDate}" max="${today()}" value="${today()}">`,true)}${costFields('completed-cost',rule.cost,cardCurrency(card),'本次实际金额')}${field('completed-note','操作备注',`<textarea id="completed-note" name="note" maxlength="1000" placeholder="例如:短信已发送并扣费成功"></textarea>`,true)}</div><label class="checkbox-line balance-action"><input type="checkbox" name="updateBalance" ${balanceAction === 'deduct' ? 'checked' : ''}>${balanceAction === 'credit' ? '将本次金额计入同币种余额' : '从卡片同币种余额扣除本次金额'}</label><p class="form-help">${balanceAction === 'credit' ? '未勾选时只记录费用,余额保持当前记录。' : '默认扣除本次金额。取消勾选可只记录费用。'}</p><div class="calculation-preview balance-preview"><span>${balanceAction === 'credit' ? '入账后余额' : '扣除后余额'}</span><strong id="balance-after-preview">只记录费用</strong></div><div class="calculation-preview"><span>完成后,下次续期日期</span><strong id="next-date-preview">—</strong></div></form>`,`<button class="btn" data-action="close">取消</button><button class="btn btn-primary" type="submit" form="completion-form">${icon('check')}确认已完成</button>`,'small');
|
||||
const form = el('#completion-form');
|
||||
const updateDate = () => {try {el('#next-date-preview').textContent = formatDate(completeRenewal(card,ruleId,form.elements.completedAt.value,today()).card.rules.find(item => item.id === ruleId).dueDate);} catch (error) {el('#next-date-preview').textContent = error.message;}};
|
||||
const updateMoney = () => {
|
||||
const target = el('#balance-after-preview');
|
||||
try {
|
||||
target.textContent = form.elements.updateBalance.checked ? formatMoney(balanceAfter(card.balances || [],readCost(form),balanceAction)) : '只记录费用';
|
||||
target.classList.remove('money-error');
|
||||
} catch (error) {target.textContent = error.message;target.classList.add('money-error');}
|
||||
};
|
||||
form.dataset.requestId = uid();form.elements.completedAt.addEventListener('input',updateDate);form.addEventListener('input',updateMoney);form.addEventListener('change',updateMoney);updateDate();updateMoney();
|
||||
}
|
||||
|
||||
function showDetail(cardId) {
|
||||
const card = findCard(cardId);
|
||||
if (!card) return;
|
||||
const history = data.events.filter(item => item.cardId === cardId).sort((a,b) => b.completedAt.localeCompare(a.completedAt));
|
||||
openOverlay('卡片详情','开通、平台与续期,一张卡的完整记录。',`<div class="detail-brand">${provider(card)}<div><h2>${e(card.name)}</h2><p>${e(card.provider)} ${typeTag(card)} ${card.archived ? '<span class="status neutral">已归档</span>' : ''}</p></div></div><dl class="detail-grid"><div><dt>号码</dt><dd>${e(card.phone)}</dd></div><div><dt>国家或地区</dt><dd>${flag(card.country)} ${e(COUNTRIES[card.country])}</dd></div><div><dt>开通日期</dt><dd>${formatDate(card.activatedAt)}</dd></div><div><dt>套餐说明</dt><dd>${e(card.plan || '未填写')}</dd></div><div class="detail-money"><dt>卡片余额</dt><dd class="money-tags">${moneyTags(card.balances || [])}</dd></div></dl><section class="detail-section"><div class="section-header"><h3>续期规则 <span class="muted">${card.rules.length}</span></h3><button class="text-button" data-action="add-rule" data-card="${e(cardId)}">${icon('plus')}添加规则</button></div>${card.rules.length ? [...card.rules].sort((a,b) => a.dueDate.localeCompare(b.dueDate)).map(rule => `<div class="detail-rule"><div class="detail-rule-head">${icon(ACTIONS[rule.action].icon)}<strong>${ACTIONS[rule.action].label}</strong><span>${cycleText(rule)}</span></div><div class="detail-rule-cost">预计续期金额 <strong>${e(formatMoney(rule.cost))}</strong></div><p>${e(rule.instructions || '尚未填写操作说明')}</p><div class="detail-rule-bottom"><span>下次 ${formatDate(rule.dueDate)}<br><span style="line-height:2">${rule.anchor === 'scheduled' ? '固定周期' : '按完成日顺延'}</span></span><div><button class="text-button" data-action="edit-rule" data-card="${e(cardId)}" data-rule="${e(rule.id)}">编辑</button>${!card.archived ? `<button class="btn btn-soft btn-small" data-action="complete" data-card="${e(cardId)}" data-rule="${e(rule.id)}">记录完成</button>` : ''}</div></div></div>`).join('') : '<p class="detail-note">添加一条规则,开始记录这张卡的续期计划。</p>'}</section><section class="detail-section"><div class="section-header"><h3>关联平台 <span class="muted">${card.platforms.length}</span></h3><button class="text-button" data-action="add-platform" data-card="${e(cardId)}">${icon('plus')}添加平台</button></div>${card.platforms.length ? card.platforms.map(platform => `<div class="detail-platform"><span class="platform-initial" style="--platform-color:${colors[platform.name] || '#8e9ab3'}">${e(platform.name[0])}</span><div><strong>${e(platform.name)}</strong><p>${e([platform.account,platform.purpose].filter(Boolean).join(' · ') || '未填写账号标识')}</p></div><button class="icon-button" data-action="remove-platform" data-card="${e(cardId)}" data-platform="${e(platform.id)}" aria-label="移除${e(platform.name)}关联">${icon('trash')}</button></div>`).join('') : '<p class="detail-note">记录这张卡注册的平台,下次找账号更方便。</p>'}</section><section class="detail-section"><div class="section-header"><h3>续期历史 <span class="muted">${history.length}</span></h3></div>${history.length ? history.map(item => `<div class="history-item"><strong>${formatDate(item.completedAt)} · ${ACTIONS[item.action]?.label || '续期操作'}</strong><p>下次续期 ${formatDate(item.newDueDate)}<br>本次金额 ${e(formatMoney(item.cost))}${item.balanceAfter ? `<br>${item.balanceAction === 'credit' ? '计入余额后' : '扣除后余额'} ${e(formatMoney(item.balanceAfter))}` : ''}${item.note ? `<br>${e(item.note)}` : ''}</p></div>`).join('') : '<p class="detail-note">还没有操作记录。完成一次续期后,历史会留在这里。</p>'}</section>${card.notes ? `<section class="detail-section"><h3 style="margin-bottom:10px">备注</h3><p class="detail-note">${e(card.notes)}</p></section>` : ''}`,`<button class="btn" data-action="archive-card" data-card="${e(cardId)}">${icon('archive')}${card.archived ? '恢复卡片' : '归档卡片'}</button><button class="btn btn-primary" data-action="edit-card" data-card="${e(cardId)}">${icon('edit')}编辑卡片</button>`,'drawer');
|
||||
}
|
||||
function platformForm(cardId = '') {
|
||||
const card = findCard(cardId);
|
||||
detailReturn = cardId || null;
|
||||
const available = data.cards.filter(item => !item.archived);
|
||||
if (!available.length && !card) {toast('请先添加一张卡片,再关联平台');return;}
|
||||
openOverlay('添加平台关联',card ? `${e(card.name)} · 记住这个号码注册过什么` : '选择卡片,记录平台和账号归属。',`<form id="platform-form" data-card="${e(cardId)}"><div class="form-error" hidden role="alert"></div><div class="fields-grid">${!card ? field('platform-card','关联卡片',`<select name="cardId" id="platform-card">${available.map(item => option(item.id,item.name,available[0].id)).join('')}</select>`,true) : ''}${field('platform-name','平台名称<span>*</span>',`<input name="name" id="platform-name" required maxlength="45" list="platform-options" placeholder="例如:Telegram、Google、PayPal"><datalist id="platform-options">${Object.keys(colors).map(name => `<option value="${name}">`).join('')}</datalist>`,true)}${field('platform-account','账号标识',`<input name="account" id="platform-account" maxlength="120" placeholder="邮箱、用户名或号码尾号">`,true)}${field('platform-purpose','用途',`<input name="purpose" id="platform-purpose" maxlength="80" placeholder="例如:账号验证、日常通讯、支付">`,true)}</div></form>`,`<button class="btn" ${card ? `data-action="detail" data-card="${e(cardId)}"` : 'data-action="close"'}>取消</button><button class="btn btn-primary" type="submit" form="platform-form">保存关联</button>`,'small');
|
||||
}
|
||||
function removePlatform(cardId, platformId) {
|
||||
const card = findCard(cardId), platform = card?.platforms.find(item => item.id === platformId);
|
||||
if (!platform) return;
|
||||
const wasDetail = Boolean(el('.drawer'));
|
||||
openOverlay('移除平台关联',`${e(card.name)} · ${e(platform.name)}`,`<p class="help-copy">移除这条平台记录?这只会更新续卡中的记录,不会注销该平台的实际账号。</p>`,`<button class="btn" ${wasDetail ? `data-action="detail" data-card="${e(cardId)}"` : 'data-action="close"'}>取消</button><button class="btn btn-danger" data-action="confirm-remove-platform" data-card="${e(cardId)}" data-platform="${e(platformId)}" data-return="${wasDetail ? cardId : ''}">移除关联</button>`,'small');
|
||||
}
|
||||
function showAccount() {
|
||||
openOverlay('我的账号','卡片、规则与通知设置保存在你的个人空间。',`<div class="account-option"><span class="avatar">${e(user().initial)}</span><div><strong>${e(user().name)}</strong><p>${e(user().email)}</p></div>${icon('checkCircle')}</div><p class="account-note">登录这个账号,可以在不同设备上继续管理你的卡片。导出的文件包含卡片和平台资料,请妥善保管。</p>`,`<button class="btn" data-action="logout">退出登录</button><button class="btn btn-primary" data-action="close">完成</button>`,'small');
|
||||
}
|
||||
function showHelp() {
|
||||
openOverlay('认识续卡','把号码与维护它的那些小事,放在同一个地方。',`<div class="help-copy"><h3>记录卡片</h3><p>添加运营商、号码、卡片类型和开通日期。数据 eSIM 可以不填写号码。</p><h3>安排续期</h3><p>一张卡可以有多条规则,分别管理短信保号、通话、充值、重置和购买套餐。实际完成后点击“记录完成”,自动更新所选规则的下次日期。</p><h3>找到平台注册记录</h3><p>打开卡片详情添加平台与账号标识;使用顶部搜索可以按平台找到关联卡片。</p><h3>接收自动提醒</h3><p>在通知设置绑定 Telegram 或验证邮箱,开启渠道并保存提醒时间。归档卡片会停止安排提醒,历史资料仍然保留。</p></div>`,`<button class="btn btn-primary" data-action="close">知道了</button>`);
|
||||
}
|
||||
|
||||
function showReminders() {
|
||||
const upcoming = tasks().filter(task => daysUntil(task.rule.dueDate,today()) <= 7);
|
||||
openOverlay('近期提醒',`${upcoming.length} 项操作需要在未来 7 天内完成。`,upcoming.length ? `<div class="task-list">${upcoming.map(({card,rule}) => `<div class="completion-summary" style="margin-bottom:0">${provider(card,true)}<div style="flex:1"><h3>${e(card.name)}</h3><p>${formatDate(rule.dueDate)} · ${ACTIONS[rule.action].label}</p></div><button class="text-button" data-action="complete" data-card="${e(card.id)}" data-rule="${e(rule.id)}">记录完成</button></div>`).join('')}</div><p class="account-note">开启通知渠道并完成绑定或验证后,续期提醒会按设置的时间发送。</p>` : empty('本周没有待完成的续期','有新的临近到期任务时,会出现在这里。'),`<button class="btn btn-primary" data-nav="renewals">查看全部计划</button>`);
|
||||
}
|
||||
function exportData() {
|
||||
const blob = new Blob([JSON.stringify({exportedAt:new Date().toISOString(),user:user(),...data},null,2)],{type:'application/json'});
|
||||
const url = URL.createObjectURL(blob), link = document.createElement('a');
|
||||
link.href = url;link.download = `simkeep-${serverUser.id}-${today()}.json`;link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url),1000);toast('已导出卡片、平台与续期记录');
|
||||
}
|
||||
function navigate(next) {
|
||||
closeOverlay(false);view = next;query = '';showArchived = false;filter = 'all';
|
||||
location.hash = next;
|
||||
renderApp();window.scrollTo({top:0,behavior:'instant'});
|
||||
}
|
||||
function showCardActions(cardId) {
|
||||
const card = findCard(cardId);
|
||||
openOverlay(e(card.name),'选择要进行的操作。',`<div class="task-list"><button class="btn" style="justify-content:flex-start" data-action="detail" data-card="${e(cardId)}">${icon('sim')}查看完整资料</button><button class="btn" style="justify-content:flex-start" data-action="edit-card" data-card="${e(cardId)}">${icon('edit')}编辑卡片</button><button class="btn" style="justify-content:flex-start" data-action="archive-card" data-card="${e(cardId)}">${icon('archive')}${card.archived ? '恢复到在用卡片' : '归档,不再安排提醒'}</button></div>`,'','small');
|
||||
}
|
||||
|
||||
document.addEventListener('click',async event => {
|
||||
if (event.target.classList.contains('overlay-backdrop')) {closeOverlay();return;}
|
||||
if (event.target.classList.contains('menu-scrim')) {event.target.remove();el('.sidebar').classList.remove('open');return;}
|
||||
const button = event.target.closest('[data-action],[data-nav],[data-filter],[data-layout],[data-month],[data-date],[data-task-filter]');
|
||||
if (!button || button.disabled) return;
|
||||
if (button.dataset.nav) {event.preventDefault();navigate(button.dataset.nav);return;}
|
||||
if (button.dataset.filter) {filter = button.dataset.filter;renderApp();return;}
|
||||
if (button.dataset.layout) {layout = button.dataset.layout;renderApp();return;}
|
||||
if (button.dataset.taskFilter) {taskFilter = button.dataset.taskFilter;renderApp();return;}
|
||||
if (button.dataset.month) {monthDate.setUTCMonth(monthDate.getUTCMonth()+Number(button.dataset.month));selectedDate = null;renderApp();return;}
|
||||
if (button.dataset.date) {selectedDate = selectedDate === button.dataset.date ? null : button.dataset.date;renderApp();return;}
|
||||
const {action,card:cardId,rule:ruleId,platform:platformId} = button.dataset;
|
||||
if (button.type !== 'submit') event.preventDefault();
|
||||
const busy = ['refresh','retry-bootstrap','confirm-remove-platform','confirm-archive','archive-card','logout','bind-telegram','check-telegram','verify-email','test-email','test-telegram','confirm-unbind-telegram','confirm-clear-service-config'].includes(action);
|
||||
if (busy) button.disabled = true;
|
||||
try {
|
||||
switch (action) {
|
||||
case 'auth-login':authMode = 'login';renderAuth();break;
|
||||
case 'auth-register':authMode = 'register';renderAuth();break;
|
||||
case 'retry-bootstrap':await bootstrap();break;
|
||||
case 'refresh':await refreshWorkspace();closeOverlay(false);renderApp();toast('资料已刷新');break;
|
||||
case 'close':closeOverlay();break;
|
||||
case 'add-card':cardForm();break;
|
||||
case 'add-balance-row': {
|
||||
const container = el('#balance-fields');
|
||||
const used = [...container.querySelectorAll('[name="balanceCurrency"]')].map(input=>input.value);
|
||||
const currency = Object.keys(CURRENCIES).find(code=>!used.includes(code));
|
||||
if (!currency) throw new Error('所有支持的币种均已添加');
|
||||
container.insertAdjacentHTML('beforeend',balanceRow({currency,amount:''}));container.lastElementChild.querySelector('select').focus();break;
|
||||
}
|
||||
case 'remove-balance-row':button.closest('.balance-row').remove();break;
|
||||
case 'edit-card':cardForm(findCard(cardId));break;
|
||||
case 'detail':showDetail(cardId);break;
|
||||
case 'card-actions':showCardActions(cardId);break;
|
||||
case 'complete':completionForm(cardId,ruleId);break;
|
||||
case 'add-rule':ruleForm(cardId);break;
|
||||
case 'edit-rule':ruleForm(cardId,ruleId);break;
|
||||
case 'add-platform':platformForm(cardId);break;
|
||||
case 'choose-platform':platformForm();break;
|
||||
case 'remove-platform':removePlatform(cardId,platformId);break;
|
||||
case 'confirm-remove-platform': {
|
||||
const card = findCard(cardId);
|
||||
await mutation('DELETE',`/api/cards/${cardId}/platforms/${platformId}?version=${card.version}`);
|
||||
closeOverlay(false);renderApp();savedMessage('已移除平台关联');if (button.dataset.return) showDetail(button.dataset.return);break;
|
||||
}
|
||||
case 'archive-card': {
|
||||
const card = findCard(cardId);
|
||||
if (card.archived) {
|
||||
await mutation('POST',`/api/cards/${cardId}/archive`,{archived:false,version:card.version});closeOverlay(false);renderApp();savedMessage('卡片已恢复,续期计划重新显示');
|
||||
} else openOverlay('归档这张卡片?',e(card.name),'<p class="help-copy">归档后保留卡片资料、平台关联和操作历史,停止安排续期提醒。你可以随时在“已归档”中恢复。</p>',`<button class="btn" data-action="detail" data-card="${e(cardId)}">取消</button><button class="btn btn-primary" data-action="confirm-archive" data-card="${e(cardId)}">确认归档</button>`,'small');
|
||||
break;
|
||||
}
|
||||
case 'confirm-archive':await mutation('POST',`/api/cards/${cardId}/archive`,{archived:true,version:findCard(cardId).version});closeOverlay(false);renderApp();savedMessage('卡片已归档');break;
|
||||
case 'toggle-archived':showArchived = !showArchived;renderApp();break;
|
||||
case 'calendar-today':monthDate = parseDate(`${today().slice(0,7)}-01`);selectedDate = null;renderApp();break;
|
||||
case 'account':showAccount();break;
|
||||
case 'logout': {
|
||||
await mutation('POST','/api/auth/logout');closeOverlay(false);serverUser = null;data = emptyWorkspace();api.clear();view = 'overview';query = '';filter = 'all';showArchived = false;selectedDate = null;location.hash = '';renderAuth();toast('已退出登录');break;
|
||||
}
|
||||
case 'bind-telegram':await bindTelegram();break;
|
||||
case 'configure-service':notificationConfigForm(button.dataset.channel);break;
|
||||
case 'clear-service-config': {
|
||||
const channel = button.dataset.channel;
|
||||
openOverlay(`清除${channel === 'telegram' ? ' Telegram' : '邮件'}配置?`,'只影响当前账号。',`<p class="help-copy">清除后将停止使用你保存的凭据。${channel === 'telegram' ? 'Telegram 绑定也会解除。' : '已验证的接收邮箱会保留。'}如果服务器提供公共通知服务,将恢复使用公共服务。</p>`,`<button class="btn" data-action="configure-service" data-channel="${channel}">返回配置</button><button class="btn btn-danger" data-action="confirm-clear-service-config" data-channel="${channel}">清除配置</button>`,'small');break;
|
||||
}
|
||||
case 'confirm-clear-service-config':await mutation('DELETE',`/api/notifications/config/${button.dataset.channel}`);closeOverlay(false);renderApp();toast('本账号的通知服务配置已清除');break;
|
||||
case 'check-telegram': {
|
||||
const checked = await mutation('POST','/api/notifications/telegram/binding/check',{code:telegramBindingCode});
|
||||
if (checked.bindingStatus === 'bound') {closeOverlay(false);renderApp();toast('Telegram 已绑定,请开启提醒并保存设置');}
|
||||
else if (checked.bindingStatus === 'expired') toast('绑定指令已失效,请关闭后重新绑定',true);
|
||||
else toast('还未收到绑定指令,请在 Bot 中点击“开始”后再检查',true);
|
||||
break;
|
||||
}
|
||||
case 'unbind-telegram':openOverlay('解除 Telegram 绑定?','解除后将停止向这个 Telegram 账号发送提醒。','<p class="help-copy">你的卡片和操作记录仍保留,可以随时重新绑定。</p>','<button class="btn" data-action="close">取消</button><button class="btn btn-danger" data-action="confirm-unbind-telegram">解除绑定</button>','small');break;
|
||||
case 'confirm-unbind-telegram':await mutation('DELETE','/api/notifications/telegram/binding');closeOverlay(false);renderApp();toast('Telegram 绑定已解除');break;
|
||||
case 'verify-email': {
|
||||
if (el('#notify-email').value.trim().toLowerCase() !== data.settings.emailAddress) throw new Error('请先保存新的接收邮箱,再发送验证码');
|
||||
await mutation('POST','/api/notifications/email/verify');emailCodeForm();break;
|
||||
}
|
||||
case 'test-email':case 'test-telegram':await mutation('POST',`/api/notifications/test/${action === 'test-email' ? 'email' : 'telegram'}`);toast('测试提醒已发送,请检查接收消息');break;
|
||||
case 'preview':openOverlay('续期提醒预览','这是通知内容预览。可在通知设置中发送测试提醒。',previewMessage(),'<button class="btn btn-primary" data-action="close">关闭预览</button>','small');break;
|
||||
case 'reminders':showReminders();break;
|
||||
case 'help':showHelp();break;
|
||||
case 'export':exportData();break;
|
||||
case 'menu':el('.sidebar').classList.add('open');el('#app').insertAdjacentHTML('beforeend','<div class="menu-scrim"></div>');break;
|
||||
}
|
||||
} catch (error) {reportError(error);}
|
||||
finally {if (busy && button.isConnected) button.disabled = false;}
|
||||
});
|
||||
document.addEventListener('input',event => {
|
||||
if (event.target.id !== 'global-search') return;
|
||||
query = event.target.value;
|
||||
if (view === 'settings') view = 'cards';
|
||||
renderApp(true);
|
||||
});
|
||||
document.addEventListener('keydown',event => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k' && !el('[role="dialog"]')) {event.preventDefault();el('#global-search')?.focus();}
|
||||
if (event.key === 'Escape' && el('.sidebar.open')) {el('.sidebar').classList.remove('open');el('.menu-scrim')?.remove();}
|
||||
});
|
||||
document.addEventListener('submit',async event => {
|
||||
const form = event.target;
|
||||
if (!['auth-form','card-form','rule-form','completion-form','platform-form','settings-form','email-code-form','notification-config-form'].includes(form.id)) return;
|
||||
event.preventDefault();
|
||||
if (form.dataset.pending === 'true') return;
|
||||
form.dataset.pending = 'true';
|
||||
const submits = [...form.querySelectorAll('button[type="submit"]'),...document.querySelectorAll(`button[type="submit"][form="${form.id}"]`)];
|
||||
for (const button of submits) button.disabled = true;
|
||||
const errorBox = form.querySelector('.form-error');if (errorBox) errorBox.hidden = true;
|
||||
try {
|
||||
const f = form.elements;
|
||||
if (form.id === 'auth-form') {
|
||||
const payload = {email:f.email.value.trim(),password:f.password.value};
|
||||
if (authMode === 'register') payload.name = f.name.value.trim();
|
||||
const result = await mutation('POST',`/api/auth/${authMode}`,payload);
|
||||
serverUser = result.user;data = result.workspace;query = '';filter = 'all';showArchived = false;monthDate = parseDate(`${today().slice(0,7)}-01`);renderApp();toast(authMode === 'register' ? '账号已创建,添加你的第一张卡片吧' : '已登录你的个人空间');
|
||||
} else if (form.id === 'card-form') {
|
||||
const existing = findCard(form.dataset.card);
|
||||
const activatedAt = f.activatedAt.value;
|
||||
validateActivationChange(existing,data.events,activatedAt,today());
|
||||
if (!f.name.value.trim() || !f.provider.value.trim()) throw new Error('请填写卡片名称和运营商');
|
||||
const card = {name:f.name.value.trim(),provider:f.provider.value.trim(),type:f.type.value,country:f.country.value,phone:f.phone.value.trim(),activatedAt,plan:f.plan.value.trim(),notes:f.notes.value.trim(),balances:readBalances(form)};
|
||||
if (!existing) {const rule = readRule(form);if (rule.dueDate < activatedAt) throw new Error('首次到期日期不能早于开通日期');card.rules = [rule];}
|
||||
else card.version = Number(form.dataset.version);
|
||||
await mutation(existing ? 'PUT' : 'POST',existing ? `/api/cards/${existing.id}` : '/api/cards',card);
|
||||
closeOverlay(false);renderApp();savedMessage(existing ? '卡片资料已更新' : '卡片已添加,第一次续期已安排');
|
||||
} else if (form.id === 'rule-form') {
|
||||
const card = findCard(form.dataset.card), existing = card.rules.find(item => item.id === form.dataset.rule), rule = readRule(form,existing);
|
||||
if (rule.dueDate < card.activatedAt) throw new Error('到期日期不能早于卡片开通日期');
|
||||
if (existing?.lastCompletedAt && rule.dueDate <= existing.lastCompletedAt) throw new Error('新的到期日期必须晚于上次完成日期');
|
||||
if (existing) rule.version = Number(form.dataset.version);
|
||||
await mutation(existing ? 'PUT' : 'POST',existing ? `/api/rules/${existing.id}` : `/api/cards/${card.id}/rules`,rule);
|
||||
renderApp();savedMessage('续期规则已保存');showDetail(card.id);
|
||||
} else if (form.id === 'completion-form') {
|
||||
const card = findCard(form.dataset.card);
|
||||
// The local calculation is a preview; the API owns the date and the atomic history write.
|
||||
completeRenewal(card,form.dataset.rule,f.completedAt.value,today());
|
||||
const cost = readCost(form), balanceAction = f.updateBalance.checked ? form.dataset.balanceAction : 'none';
|
||||
if (balanceAction !== 'none') balanceAfter(card.balances || [],cost,balanceAction);
|
||||
await mutation('POST',`/api/rules/${form.dataset.rule}/completions`,{completedAt:f.completedAt.value,note:f.note.value.trim(),requestId:form.dataset.requestId,version:Number(form.dataset.version),cost,balanceAction,cardVersion:Number(form.dataset.cardVersion)});
|
||||
const next = findCard(card.id).rules.find(rule => rule.id === form.dataset.rule).dueDate;
|
||||
closeOverlay(false);renderApp();savedMessage(`已记录完成,下次续期 ${formatDate(next)}`);
|
||||
} else if (form.id === 'platform-form') {
|
||||
const card = findCard(form.dataset.card || f.cardId.value);
|
||||
const name = f.name.value.trim();if (!name) throw new Error('请填写平台名称');
|
||||
await mutation('POST',`/api/cards/${card.id}/platforms`,{name,account:f.account.value.trim(),purpose:f.purpose.value.trim(),version:card.version});
|
||||
closeOverlay(false);renderApp();savedMessage('平台关联已保存');if (detailReturn) showDetail(card.id);
|
||||
} else if (form.id === 'settings-form') {
|
||||
const emailAddress = f.emailAddress.value.trim();
|
||||
if (f.email.checked && !emailAddress) throw new Error('启用邮箱提醒时,请填写接收邮箱');
|
||||
await mutation('PUT','/api/settings',{telegram:f.telegram.checked,email:f.email.checked,emailAddress,timezone:f.timezone.value,time:f.time.value,offsets:Array.from(form.querySelectorAll('input[name="offset"]:checked')).map(input => Number(input.value)),overdue:f.overdue.checked});
|
||||
selectedDate = null;monthDate = parseDate(`${today().slice(0,7)}-01`);renderApp();savedMessage('通知设置已保存');
|
||||
} else if (form.id === 'notification-config-form') {
|
||||
const channel = form.dataset.channel;
|
||||
const payload = channel === 'telegram' ? {token:f.token.value.trim()} : {host:f.host.value.trim(),port:Number(f.port.value),mode:f.mode.value,user:f.user.value.trim(),password:f.password.value,from:f.sender.value.trim()};
|
||||
const feedback = form.querySelector('.config-feedback');feedback.hidden = true;
|
||||
await mutation('PUT',`/api/notifications/config/${channel}`,payload);
|
||||
renderApp();
|
||||
const secret = channel === 'telegram' ? f.token : f.password;
|
||||
secret.value = '';secret.required = false;secret.placeholder = '已保存,留空保留原值';
|
||||
if (event.submitter?.value === 'check') {
|
||||
feedback.textContent = '配置已保存,正在检查连接…';feedback.hidden = false;
|
||||
try {
|
||||
const checked = await mutation('POST',`/api/notifications/config/${channel}/check`);
|
||||
feedback.textContent = channel === 'telegram' ? `配置已保存,已连接 @${checked.botUsername}。下一步绑定 Telegram。` : '配置已保存,SMTP 连接与认证通过。下一步验证接收邮箱。';
|
||||
feedback.scrollIntoView({block:'nearest'});
|
||||
} catch (error) {feedback.hidden = true;error.message = `配置已保存。${error.message}`;throw error;}
|
||||
} else {closeOverlay(false);toast(channel === 'telegram' ? 'Telegram 配置已保存,可以绑定账号了' : '邮件配置已保存,可以验证接收邮箱了');}
|
||||
} else if (form.id === 'email-code-form') {
|
||||
await mutation('POST','/api/notifications/email/confirm',{code:f.code.value});closeOverlay(false);renderApp();toast('邮箱已验证,请开启提醒并保存设置');
|
||||
}
|
||||
} catch (error) {reportError(error,form);}
|
||||
finally {form.dataset.pending = 'false';for (const button of submits) if (button.isConnected) button.disabled = false;}
|
||||
});
|
||||
|
||||
if (NAV.some(item => `#${item.id}` === location.hash)) view = location.hash.slice(1);
|
||||
window.addEventListener('hashchange',() => {const next = location.hash.slice(1);if (NAV.some(item => item.id === next) && next !== view) {view = next;closeOverlay(false);query = '';renderApp();}});
|
||||
bootstrap();
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
name: simkeep
|
||||
services:
|
||||
web:
|
||||
image: ${SIMKEEP_IMAGE:-simkeep:local}
|
||||
build: .
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
env_file: ${SIMKEEP_ENV_FILE:-.env}
|
||||
environment:
|
||||
SIMKEEP_DB: /app/data/simkeep.db
|
||||
SIMKEEP_STATIC_DIR: /app/dist
|
||||
ports:
|
||||
- "${SIMKEEP_BIND_ADDRESS:-0.0.0.0}:${SIMKEEP_PORT:-5180}:5180"
|
||||
volumes:
|
||||
- simkeep-data:/app/data
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=32m,mode=1777
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5180/api/health',timeout=5)"]
|
||||
interval: 10s
|
||||
timeout: 6s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
volumes:
|
||||
simkeep-data:
|
||||
@@ -0,0 +1,132 @@
|
||||
const DAY = 86_400_000;
|
||||
|
||||
export function createId(cryptoSource = globalThis.crypto) {
|
||||
if (typeof cryptoSource?.randomUUID === 'function') return cryptoSource.randomUUID();
|
||||
const bytes = cryptoSource.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2,'0')).join('');
|
||||
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
export function parseDate(value) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) throw new Error('请输入有效日期');
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
const date = new Date(0);
|
||||
date.setUTCFullYear(year, month - 1, day);
|
||||
date.setUTCHours(0, 0, 0, 0);
|
||||
if (year < 1900 || year > 9999 || date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) throw new Error('请输入有效日期');
|
||||
return date;
|
||||
}
|
||||
|
||||
const isoDate = date => date.toISOString().slice(0, 10);
|
||||
|
||||
export function todayString(timeZone = 'Asia/Shanghai', now = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {timeZone, year:'numeric', month:'2-digit', day:'2-digit'}).formatToParts(now);
|
||||
const get = type => parts.find(part => part.type === type).value;
|
||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||
}
|
||||
|
||||
export function addDays(value, count) {
|
||||
if (!Number.isInteger(count)) throw new Error('周期必须为整数');
|
||||
const date = parseDate(value);
|
||||
date.setUTCDate(date.getUTCDate() + count);
|
||||
const result = isoDate(date);
|
||||
parseDate(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function addCycle(value, interval, unit) {
|
||||
if (!Number.isInteger(interval) || interval < 1) throw new Error('周期必须为正整数');
|
||||
if (unit === 'days') return addDays(value, interval);
|
||||
if (unit !== 'months') throw new Error('请选择有效周期单位');
|
||||
const date = parseDate(value);
|
||||
const day = date.getUTCDate();
|
||||
date.setUTCDate(1);
|
||||
date.setUTCMonth(date.getUTCMonth() + interval);
|
||||
const end = new Date(date);
|
||||
end.setUTCMonth(end.getUTCMonth() + 1, 0);
|
||||
date.setUTCDate(Math.min(day, end.getUTCDate()));
|
||||
const result = isoDate(date);
|
||||
parseDate(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function daysUntil(value, today = todayString()) {
|
||||
return Math.round((parseDate(value) - parseDate(today)) / DAY);
|
||||
}
|
||||
|
||||
export function nextAfterCompletion(rule, completedAt) {
|
||||
parseDate(completedAt);
|
||||
parseDate(rule.dueDate);
|
||||
if (rule.anchor !== 'scheduled') return addCycle(completedAt, rule.interval, rule.unit);
|
||||
const origin = rule.anchorDate || rule.dueDate;
|
||||
const after = completedAt > rule.dueDate ? completedAt : rule.dueDate;
|
||||
// Calculate every occurrence from the original anchor, preserving month-end intent.
|
||||
for (let cycle = 1; cycle < 50_000; cycle++) {
|
||||
const candidate = addCycle(origin, rule.interval * cycle, rule.unit);
|
||||
if (candidate > after) return candidate;
|
||||
}
|
||||
throw new Error('周期跨度过大,请调整规则');
|
||||
}
|
||||
|
||||
export function completeRenewal(card, ruleId, completedAt, today, note = '') {
|
||||
parseDate(completedAt);
|
||||
parseDate(today);
|
||||
const rule = card.rules.find(item => item.id === ruleId);
|
||||
if (!rule) throw new Error('找不到这条续期规则');
|
||||
if (card.archived) throw new Error('请先恢复这张卡片,再记录续期');
|
||||
if (completedAt > today) throw new Error('不能记录未来的完成日期');
|
||||
if (completedAt < card.activatedAt) throw new Error('完成日期不能早于开通日期');
|
||||
if (rule.lastCompletedAt && completedAt < rule.lastCompletedAt) throw new Error('完成日期不能早于上次完成日期');
|
||||
if (rule.lastCompletedAt === completedAt) throw new Error('这一天已经记录过该操作');
|
||||
const nextDueDate = nextAfterCompletion(rule, completedAt);
|
||||
const updated = {...card, rules:card.rules.map(item => item.id === ruleId ? {...item, dueDate:nextDueDate, lastCompletedAt:completedAt} : {...item})};
|
||||
const event = {
|
||||
id:createId(),
|
||||
cardId:card.id, ruleId, action:rule.action, completedAt,
|
||||
oldDueDate:rule.dueDate, newDueDate:nextDueDate, note,
|
||||
};
|
||||
return {card:updated, event};
|
||||
}
|
||||
|
||||
export function getTasks(cards) {
|
||||
return cards.filter(card => !card.archived)
|
||||
.flatMap(card => card.rules.map(rule => ({card, rule})))
|
||||
.sort((a, b) => a.rule.dueDate.localeCompare(b.rule.dueDate) || a.card.name.localeCompare(b.card.name));
|
||||
}
|
||||
|
||||
export function validateActivationChange(card, events, activatedAt, today) {
|
||||
parseDate(activatedAt);
|
||||
parseDate(today);
|
||||
if (activatedAt > today) throw new Error('开通日期不能是未来的日期');
|
||||
if (!card) return;
|
||||
const conflictsWithRule = card.rules.some(rule => rule.dueDate < activatedAt || (rule.lastCompletedAt && rule.lastCompletedAt < activatedAt));
|
||||
const conflictsWithHistory = events.some(event => event.cardId === card.id && event.completedAt < activatedAt);
|
||||
if (conflictsWithRule || conflictsWithHistory) throw new Error('开通日期不能晚于已有的续期记录或计划日期');
|
||||
}
|
||||
|
||||
export const ACTIONS = {
|
||||
sms:{label:'发送短信', icon:'message'},
|
||||
call:{label:'拨打电话', icon:'phone'},
|
||||
purchase:{label:'购买套餐', icon:'bag'},
|
||||
reset:{label:'重置有效期', icon:'refresh'},
|
||||
topup:{label:'账户充值', icon:'wallet'},
|
||||
custom:{label:'自定义操作', icon:'check'},
|
||||
};
|
||||
|
||||
export function dueStatus(date, today = todayString()) {
|
||||
const days = daysUntil(date, today);
|
||||
if (days < 0) return {label:`逾期 ${-days} 天`, tone:'danger', days};
|
||||
if (days === 0) return {label:'今天到期', tone:'danger', days};
|
||||
if (days <= 7) return {label:`${days} 天后到期`, tone:'warning', days};
|
||||
return {label:`${days} 天后到期`, tone:'success', days};
|
||||
}
|
||||
|
||||
export const cycleText = rule => `每 ${rule.interval} ${rule.unit === 'months' ? '个月' : '天'}`;
|
||||
|
||||
export function formatDate(value, short = false) {
|
||||
if (!value) return '尚未设置';
|
||||
const date = parseDate(value);
|
||||
return short ? `${date.getUTCMonth() + 1} 月 ${date.getUTCDate()} 日` : value.replaceAll('-', '.');
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const COUNTRIES={GB:'英国',US:'美国',HK:'中国香港',JP:'日本',CN:'中国大陆',DE:'德国',SG:'新加坡',OTHER:'其他地区'};
|
||||
@@ -0,0 +1,76 @@
|
||||
# Docker Compose 部署
|
||||
|
||||
需要 Docker Engine 与 Docker Compose 插件。镜像构建会自动打包前端,不需要宿主机安装 Node.js、npm 或 Python。应用使用单个 Uvicorn worker,后台续期提醒与 Web 同时启动。
|
||||
|
||||
## 在新服务器部署
|
||||
|
||||
进入包含 Dockerfile 和 compose.yml 的 simkeep 目录,准备配置:
|
||||
|
||||
```bash
|
||||
test -f .env || cp .env.example .env
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
编辑 `.env`,将 `SIMKEEP_PUBLIC_URL` 改为实际访问地址,例如 `http://服务器IP:5180`。`SIMKEEP_PORT` 控制宿主机端口,默认 5180;改端口时同步修改公网地址。保持 HTTP 时使用 `SIMKEEP_SECURE_COOKIES=0`。每个用户登录网页的“通知设置”,点击“配置 Telegram / 配置邮件”保存自己的凭据;不需要修改 `.env` 或重启。`.env` 中的通知凭据仅作为可选公共默认服务。
|
||||
|
||||
```bash
|
||||
docker compose up -d --build --wait
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
访问 `http://服务器IP:5180/`,首次使用创建自己的账号。服务器防火墙需要允许选定的 TCP 端口。
|
||||
|
||||
默认 Compose 项目名为 `simkeep`,持久化卷为 `simkeep_simkeep-data`,数据库位于容器 `/app/data/simkeep.db`。保持项目名即可复用同一个卷。容器重启、镜像更新及普通 `docker compose down` 都会保留卷;`docker compose down -v` 会删除数据卷。
|
||||
|
||||
## 更新、配置与日志
|
||||
|
||||
更新代码并重新构建镜像:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build --wait
|
||||
```
|
||||
|
||||
修改 `.env` 后重建容器加载新配置:
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate --wait web
|
||||
```
|
||||
|
||||
仅执行 `docker compose restart` 不会加载修改后的环境配置。日常查看或重启:
|
||||
|
||||
```bash
|
||||
docker compose logs --tail=100 web
|
||||
docker compose restart web
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
容器设置 `unless-stopped` 自动重启,Docker 服务需要随服务器启动。镜像以 UID / GID 10001 运行,根文件系统只读,数据卷和临时目录可写。健康检查访问 `/api/health`,日志最多保留三份,每份 10 MB。`.env`、数据库、备份与测试文件均不进入镜像或公开静态目录。
|
||||
|
||||
## 备份
|
||||
|
||||
使用 SQLite backup API 获取一致的数据库备份:
|
||||
|
||||
```bash
|
||||
docker compose exec web python scripts/backup.py
|
||||
docker compose cp web:/app/data/backups ./docker-backups
|
||||
```
|
||||
|
||||
备份保存在卷中的 `/app/data/backups/`,第二条命令复制到宿主机。复制出的备份包含账号、卡片、余额、历史、渠道设置以及用户在网页保存的 Bot Token / SMTP 密码,应存放在受保护的位置。迁移到另一台服务器时同时保留 `.env`,其中的可选公共服务密钥不在数据库备份中。个人配置会随数据卷或数据库备份一起恢复;旧版备份没有配置表时,应用启动自动补建。
|
||||
|
||||
## 恢复或导入已有 SQLite
|
||||
|
||||
先创建当前库备份,再停止应用。将选定的一致备份命名为当前项目目录下的 `restore.db`。不要直接复制运行中的主数据库而遗漏 WAL。
|
||||
|
||||
```bash
|
||||
docker compose stop web
|
||||
docker compose run --rm --no-deps -T --entrypoint python web scripts/restore.py --replace < restore.db
|
||||
docker compose up -d --wait
|
||||
```
|
||||
|
||||
恢复容器以应用用户读取标准输入,不需要 root 或修改宿主机备份权限。它检查数据库完整性与 SIMKEEP 表结构,临时生成恢复库后替换;未传 --replace 时会拒绝覆盖现有库。恢复容器只执行数据库操作,不启动提醒进程。使用 `docker compose -p 其他项目名` 时,以上所有命令也须带相同项目名。
|
||||
|
||||
## 从其他运行方式迁移
|
||||
|
||||
先用 SQLite backup API 创建一致性备份,保留原运行配置,然后停止旧进程。使用上面的“恢复或导入已有 SQLite”步骤导入 Docker 命名卷,再启动 web 容器。确认数据与通知设置后停用旧服务的自动启动;Docker 启动后的新数据以容器数据卷为准。
|
||||
|
||||
同一份 Telegram Bot 配置只运行一个提醒进程。不要同时运行旧服务和 Docker 容器,以免重复消费 Telegram 更新或重复安排提醒。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 436 KiB |
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="280" viewBox="0 0 1200 280" role="img" aria-labelledby="title desc">
|
||||
<title id="title">SIMKEEP · 续卡</title>
|
||||
<desc id="desc">记住每张卡的下一次续期。SIM 与 eSIM 管理,余额、续期与提醒。</desc>
|
||||
<rect width="1200" height="280" rx="18" fill="#f5f7fc"/>
|
||||
<g transform="translate(60 68)" fill="none" stroke="#5360ea" stroke-width="4" stroke-linejoin="round">
|
||||
<path d="M12 0h78l30 30v104a10 10 0 0 1-10 10H12a10 10 0 0 1-10-10V10A10 10 0 0 1 12 0Z" fill="#fff"/>
|
||||
<rect x="30" y="64" width="64" height="44" rx="7" fill="#eff1ff"/>
|
||||
<path d="M62 64v44m-32-22h64" stroke-width="2"/>
|
||||
<path d="m83 19-11 18h14L77 53" stroke-width="3"/>
|
||||
</g>
|
||||
<g font-family="'Noto Sans CJK SC','PingFang SC','Microsoft YaHei',sans-serif">
|
||||
<text x="222" y="114" font-size="48" font-weight="700" fill="#20283e">SIMKEEP <tspan font-size="30" font-weight="500">续卡</tspan></text>
|
||||
<text x="225" y="159" font-size="23" fill="#20283e">记住每张卡的下一次续期。</text>
|
||||
<text x="225" y="204" font-size="16" fill="#77839a">SIM / eSIM 管理 · 多币种余额 · Telegram 与邮件提醒</text>
|
||||
</g>
|
||||
<g transform="translate(845 72)" font-family="'Noto Sans CJK SC','PingFang SC','Microsoft YaHei',sans-serif">
|
||||
<path d="M10 15v117" fill="none" stroke="#d6dcf0" stroke-width="2"/>
|
||||
<circle cx="10" cy="15" r="7" fill="#168576"/>
|
||||
<circle cx="10" cy="74" r="7" fill="#5360ea"/>
|
||||
<circle cx="10" cy="133" r="7" fill="#f5f7fc" stroke="#9ca7c3" stroke-width="2"/>
|
||||
<text x="35" y="21" font-size="17" fill="#20283e">记录开通</text>
|
||||
<text x="35" y="80" font-size="17" fill="#20283e">安排续期</text>
|
||||
<text x="35" y="139" font-size="17" fill="#20283e">准时提醒</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="13" fill="#5360EA"/><path d="M15 10h13l7 7v20H15a3 3 0 0 1-3-3V13a3 3 0 0 1 3-3Z" fill="none" stroke="#fff" stroke-width="2.5"/><rect x="18" y="22" width="11" height="9" rx="2" fill="none" stroke="#fff" stroke-width="2"/><path d="M23.5 22v9M18 26.5h11" stroke="#fff" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 387 B |
+18
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#5360EA">
|
||||
<meta name="description" content="续卡 SIMKEEP:管理 SIM 与 eSIM 卡片、关联平台、续期计划和自动提醒。">
|
||||
<title>续卡 SIMKEEP · 卡片管理</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="overlay-root"></div>
|
||||
<div id="toast-root" aria-live="polite" aria-atomic="true"></div>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
export const CURRENCIES={
|
||||
CNY:{label:'人民币',places:2},USD:{label:'美元',places:2},HKD:{label:'港币',places:2},
|
||||
GBP:{label:'英镑',places:2},EUR:{label:'欧元',places:2},JPY:{label:'日元',places:0},
|
||||
TWD:{label:'新台币',places:2},SGD:{label:'新加坡元',places:2},AUD:{label:'澳元',places:2},
|
||||
CAD:{label:'加元',places:2},CHF:{label:'瑞士法郎',places:2},NZD:{label:'新西兰元',places:2},
|
||||
KRW:{label:'韩元',places:0},THB:{label:'泰铢',places:2},MYR:{label:'马来西亚林吉特',places:2},
|
||||
AED:{label:'阿联酋迪拉姆',places:2},SAR:{label:'沙特里亚尔',places:2},IDR:{label:'印尼盾',places:2},
|
||||
INR:{label:'印度卢比',places:2},PHP:{label:'菲律宾比索',places:2},VND:{label:'越南盾',places:0},
|
||||
KWD:{label:'科威特第纳尔',places:3},BHD:{label:'巴林第纳尔',places:3},
|
||||
};
|
||||
export const COUNTRY_CURRENCY={GB:'GBP',US:'USD',HK:'HKD',JP:'JPY',CN:'CNY',DE:'EUR',SG:'SGD',OTHER:'USD'};
|
||||
|
||||
export function normalizeAmount(value,currency) {
|
||||
const places=CURRENCIES[currency]?.places;
|
||||
if(places===undefined) throw new Error('请选择支持的币种');
|
||||
if(typeof value!=='string'||value.length>30||!/^\d+(?:\.\d+)?$/.test(value)) throw new Error('金额须为不小于 0 的数字');
|
||||
const [rawInteger,fraction='']=value.split('.');
|
||||
const integer=rawInteger.replace(/^0+(?=\d)/,'');
|
||||
if(BigInt(integer)>999999999n||(BigInt(integer)===999999999n&&/[1-9]/.test(fraction))) throw new Error('金额不能超过 999999999');
|
||||
if(/[1-9]/.test(fraction.slice(places))) throw new Error(`${currency} 金额最多保留 ${places} 位小数`);
|
||||
return integer+(places ? '.'+fraction.slice(0,places).padEnd(places,'0') : '');
|
||||
}
|
||||
const units = money => BigInt(normalizeAmount(money.amount,money.currency).replace('.',''));
|
||||
function fromUnits(value,currency) {
|
||||
const places=CURRENCIES[currency].places;
|
||||
const digits=value.toString().padStart(places+1,'0');
|
||||
return places ? digits.slice(0,-places)+'.'+digits.slice(-places) : digits;
|
||||
}
|
||||
export function sumMoney(values) {
|
||||
const sums=new Map();
|
||||
for(const money of values.filter(Boolean)) sums.set(money.currency,(sums.get(money.currency)||0n)+units(money));
|
||||
return [...sums].sort(([a],[b])=>a.localeCompare(b)).map(([currency,value])=>({currency,amount:fromUnits(value,currency)}));
|
||||
}
|
||||
export function balanceAfter(balances,cost,operation) {
|
||||
if(!cost||!['deduct','credit'].includes(operation)) throw new Error('更新余额时须填写本次金额和币种');
|
||||
const current=balances.find(item=>item.currency===cost.currency);
|
||||
if(!current&&operation==='deduct') throw new Error(`尚未记录 ${cost.currency} 余额,请先在卡片资料中添加`);
|
||||
const before=current ? units(current) : 0n;
|
||||
const after=operation==='deduct' ? before-units(cost) : before+units(cost);
|
||||
if(after<0n) throw new Error(`${cost.currency} 余额不足,可取消更新余额后单独记录费用`);
|
||||
return {currency:cost.currency,amount:normalizeAmount(fromUnits(after,cost.currency),cost.currency)};
|
||||
}
|
||||
export function formatMoney(money) {
|
||||
if(!money) return '未填写';
|
||||
const [integer,fraction]=money.amount.split('.');
|
||||
return `${money.currency} ${integer.replace(/\B(?=(\d{3})+(?!\d))/g,',')}${fraction===undefined ? '' : '.'+fraction}`;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "simkeep",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "python3 -m uvicorn server.main:create_app --factory --host 0.0.0.0 --port 5180 --workers 1",
|
||||
"build": "python3 scripts/build.py",
|
||||
"test": "node tests/core.test.js && node tests/api.test.js && node tests/money.test.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.141.1
|
||||
uvicorn==0.53.0
|
||||
pydantic==2.12.4
|
||||
httpx==0.28.1
|
||||
@@ -0,0 +1,17 @@
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
root=Path(__file__).resolve().parent.parent
|
||||
source=Path(os.getenv('SIMKEEP_DB',str(root/'data'/'simkeep.db'))).resolve()
|
||||
if not source.is_file():
|
||||
raise SystemExit('数据库尚未创建')
|
||||
directory=source.parent/'backups'
|
||||
directory.mkdir(mode=0o700,exist_ok=True)
|
||||
target=directory/f"simkeep-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f')}.db"
|
||||
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||
os.close(fd)
|
||||
with sqlite3.connect(f'{source.as_uri()}?mode=ro',uri=True) as original,sqlite3.connect(target) as backup:
|
||||
original.backup(backup)
|
||||
print(f'备份完成:{target}')
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Publish only browser assets. Server files, databases and .env are never copied."""
|
||||
import argparse
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parent.parent
|
||||
ASSETS=('index.html','style.css','app.js','api.js','core.js','money.js','countries.js','favicon.svg')
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('--output',default='dist')
|
||||
args=parser.parse_args()
|
||||
target=(ROOT/args.output).resolve()
|
||||
if target.parent!=ROOT or target.name not in ('dist','preview'):
|
||||
parser.error('output must be dist or preview in this project')
|
||||
for asset in ASSETS:
|
||||
if not (ROOT/asset).is_file():
|
||||
raise SystemExit(f'Missing asset: {asset}')
|
||||
target.mkdir(exist_ok=True)
|
||||
for asset in target.iterdir():
|
||||
if asset.is_file() and asset.name not in ASSETS:
|
||||
asset.unlink()
|
||||
for asset in ASSETS:
|
||||
temp=target/(asset+'.next')
|
||||
shutil.copyfile(ROOT/asset,temp)
|
||||
temp.replace(target/asset)
|
||||
print(f'Published {len(ASSETS)} browser assets to {target}')
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Restore a consistent SIMKEEP backup from stdin while the application is stopped."""
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
TABLES={'users','sessions','cards','events','bindings','notification_jobs','system_state'}
|
||||
|
||||
|
||||
def restore_database(destination,stream,replace=False):
|
||||
destination=Path(destination).resolve()
|
||||
if destination.exists() and not replace:
|
||||
raise ValueError('数据库已存在;先备份并停止应用,确认替换后使用 --replace')
|
||||
staged=None
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='simkeep-import-') as directory:
|
||||
incoming=Path(directory)/'backup.db'
|
||||
with incoming.open('wb') as target:
|
||||
shutil.copyfileobj(stream,target)
|
||||
if incoming.stat().st_size==0:
|
||||
raise ValueError('备份为空,数据库未修改')
|
||||
with closing(sqlite3.connect(f'{incoming.as_uri()}?mode=ro',uri=True)) as source:
|
||||
if source.execute('PRAGMA integrity_check').fetchone()[0]!='ok':
|
||||
raise ValueError('备份完整性检查失败,数据库未修改')
|
||||
tables={row[0] for row in source.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if not TABLES.issubset(tables):
|
||||
raise ValueError('请选择完整的 SIMKEEP 数据库备份,数据库未修改')
|
||||
destination.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(prefix='.simkeep-restore-',suffix='.db',dir=destination.parent,delete=False) as target:
|
||||
staged=Path(target.name)
|
||||
with closing(sqlite3.connect(staged)) as target:
|
||||
source.backup(target)
|
||||
if target.execute('PRAGMA integrity_check').fetchone()[0]!='ok':
|
||||
raise ValueError('恢复文件完整性检查失败,数据库未修改')
|
||||
staged.chmod(0o600)
|
||||
for suffix in ('-wal','-shm'):
|
||||
Path(str(destination)+suffix).unlink(missing_ok=True)
|
||||
staged.replace(destination)
|
||||
except sqlite3.DatabaseError:
|
||||
raise ValueError('备份格式无效或已损坏,数据库未修改') from None
|
||||
finally:
|
||||
if staged is not None:
|
||||
staged.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--replace',action='store_true',help='明确替换现有数据库;须先备份并停止应用')
|
||||
args=parser.parse_args()
|
||||
root=Path(__file__).resolve().parent.parent
|
||||
destination=os.getenv('SIMKEEP_DB',str(root/'data'/'simkeep.db'))
|
||||
try:
|
||||
restore_database(destination,sys.stdin.buffer,args.replace)
|
||||
except (ValueError,OSError) as error:
|
||||
parser.exit(1,f'恢复失败:{error}\n')
|
||||
print('数据库恢复完成')
|
||||
@@ -0,0 +1 @@
|
||||
"""SIMKEEP application services."""
|
||||
@@ -0,0 +1,45 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
salt=secrets.token_bytes(16)
|
||||
key=hashlib.scrypt(password.encode(),salt=salt,n=16384,r=8,p=1,dklen=32)
|
||||
return f'scrypt${salt.hex()}${key.hex()}'
|
||||
|
||||
|
||||
def verify_password(password,encoded):
|
||||
try:
|
||||
algorithm,salt,key=encoded.split('$')
|
||||
if algorithm != 'scrypt':
|
||||
return False
|
||||
actual=hashlib.scrypt(password.encode(),salt=bytes.fromhex(salt),n=16384,r=8,p=1,dklen=32)
|
||||
return hmac.compare_digest(actual,bytes.fromhex(key))
|
||||
except (ValueError,TypeError):
|
||||
return False
|
||||
|
||||
|
||||
DUMMY_HASH=hash_password('never-a-real-account-password')
|
||||
|
||||
|
||||
class RateLimit:
|
||||
def __init__(self):
|
||||
self.entries={}
|
||||
self.lock=threading.Lock()
|
||||
|
||||
def check(self,key,limit=20,window=60):
|
||||
now=time.monotonic()
|
||||
with self.lock:
|
||||
self.entries={k:v for k,v in self.entries.items() if now < v[0]}
|
||||
expiry,count=self.entries.get(key,(now+window,0))
|
||||
if count >= limit:
|
||||
raise HTTPException(429,'操作过于频繁,请稍后重试')
|
||||
self.entries[key]=(expiry,count+1)
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA = '''
|
||||
CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY,email TEXT NOT NULL UNIQUE,name TEXT NOT NULL,password_hash TEXT NOT NULL,settings TEXT NOT NULL,created_at REAL NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,csrf TEXT NOT NULL,expires_at REAL NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS cards(id TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,document TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS cards_user ON cards(user_id);
|
||||
CREATE TABLE IF NOT EXISTS events(id TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,card_id TEXT NOT NULL REFERENCES cards(id),rule_id TEXT NOT NULL,request_id TEXT NOT NULL,document TEXT NOT NULL,UNIQUE(user_id,request_id));
|
||||
CREATE INDEX IF NOT EXISTS events_user ON events(user_id);
|
||||
CREATE TABLE IF NOT EXISTS bindings(token_hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id),kind TEXT NOT NULL,destination TEXT,expires_at REAL NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,created_at REAL NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS notification_jobs(id TEXT PRIMARY KEY,dedupe_key TEXT NOT NULL UNIQUE,user_id TEXT NOT NULL REFERENCES users(id),card_id TEXT NOT NULL REFERENCES cards(id),rule_id TEXT NOT NULL,rule_version INTEGER NOT NULL,channel TEXT NOT NULL,destination TEXT NOT NULL,payload TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'pending',attempts INTEGER NOT NULL DEFAULT 0,ready_at REAL NOT NULL,created_at REAL NOT NULL,sent_at REAL,error TEXT);
|
||||
CREATE INDEX IF NOT EXISTS notification_ready ON notification_jobs(status,ready_at);
|
||||
CREATE TABLE IF NOT EXISTS system_state(key TEXT PRIMARY KEY,value TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS notification_configs(user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,channel TEXT NOT NULL CHECK(channel IN ('telegram','email')),document TEXT NOT NULL,telegram_identity TEXT UNIQUE,PRIMARY KEY(user_id,channel));
|
||||
'''
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self,path):
|
||||
self.path=Path(path)
|
||||
self.path.parent.mkdir(parents=True,exist_ok=True)
|
||||
with self.connect() as conn:
|
||||
conn.execute('PRAGMA journal_mode=WAL')
|
||||
conn.executescript(SCHEMA)
|
||||
|
||||
@contextmanager
|
||||
def connect(self,write=False):
|
||||
conn=sqlite3.connect(str(self.path),timeout=15)
|
||||
conn.row_factory=sqlite3.Row
|
||||
conn.execute('PRAGMA foreign_keys=ON')
|
||||
conn.execute('PRAGMA busy_timeout=15000')
|
||||
try:
|
||||
if write:
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
yield conn
|
||||
conn.commit()
|
||||
except BaseException:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def encode(value):
|
||||
return json.dumps(value,ensure_ascii=False,separators=(',',':'))
|
||||
|
||||
|
||||
def decode(value):
|
||||
return json.loads(value)
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import re
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
ACTIONS = {'sms':'发送短信','call':'拨打电话','purchase':'购买套餐','reset':'重置有效期','topup':'账户充值','custom':'自定义操作'}
|
||||
|
||||
|
||||
def parse_date(value: str) -> date:
|
||||
if not isinstance(value,str) or not re.fullmatch(r'\d{4}-\d{2}-\d{2}',value):
|
||||
raise ValueError('请输入有效日期')
|
||||
try:
|
||||
result = date.fromisoformat(value)
|
||||
if result.year < 1900:
|
||||
raise ValueError()
|
||||
return result
|
||||
except ValueError:
|
||||
raise ValueError('请输入有效日期') from None
|
||||
|
||||
|
||||
def add_cycle(value: str, interval: int, unit: str) -> str:
|
||||
start = parse_date(value)
|
||||
if type(interval) is not int or interval < 1:
|
||||
raise ValueError('周期必须为正整数')
|
||||
try:
|
||||
if unit == 'days':
|
||||
return (start + timedelta(days=interval)).isoformat()
|
||||
if unit != 'months':
|
||||
raise ValueError('请选择有效周期单位')
|
||||
year, month = divmod(start.year * 12 + start.month - 1 + interval,12)
|
||||
month += 1
|
||||
return date(year,month,min(start.day,calendar.monthrange(year,month)[1])).isoformat()
|
||||
except (OverflowError,ValueError):
|
||||
raise ValueError('周期或日期超出允许范围') from None
|
||||
|
||||
|
||||
def next_due(rule: dict, completed: str) -> str:
|
||||
parse_date(completed)
|
||||
if rule['anchor'] != 'scheduled':
|
||||
return add_cycle(completed,rule['interval'],rule['unit'])
|
||||
origin = rule.get('anchorDate') or rule['dueDate']
|
||||
after = max(completed,rule['dueDate'])
|
||||
for cycle in range(1,50000):
|
||||
candidate = add_cycle(origin,rule['interval']*cycle,rule['unit'])
|
||||
if candidate > after:
|
||||
return candidate
|
||||
raise ValueError('周期跨度过大,请调整规则')
|
||||
|
||||
|
||||
def business_today(zone: str, now: datetime | None = None) -> str:
|
||||
return (now or datetime.now(timezone.utc)).astimezone(ZoneInfo(zone)).date().isoformat()
|
||||
|
||||
|
||||
def check_activation(card: dict | None, events: list[dict], activated: str, today: str):
|
||||
parse_date(activated)
|
||||
if activated > today:
|
||||
raise ValueError('开通日期不能是未来日期')
|
||||
if not card:
|
||||
return
|
||||
if any(r['dueDate'] < activated or (r.get('lastCompletedAt') and r['lastCompletedAt'] < activated) for r in card['rules']):
|
||||
raise ValueError('开通日期不能晚于已有的续期记录或计划日期')
|
||||
if any(ev['cardId'] == card['id'] and ev['completedAt'] < activated for ev in events):
|
||||
raise ValueError('开通日期不能晚于已有的续期记录或计划日期')
|
||||
|
||||
|
||||
def check_completion(card: dict, rule: dict, completed: str, today: str):
|
||||
parse_date(completed)
|
||||
if card['archived']:
|
||||
raise ValueError('请先恢复卡片,再记录续期')
|
||||
if completed > today:
|
||||
raise ValueError('不能记录未来的完成日期')
|
||||
if completed < card['activatedAt']:
|
||||
raise ValueError('完成日期不能早于开通日期')
|
||||
if rule.get('lastCompletedAt') and completed <= rule['lastCompletedAt']:
|
||||
raise ValueError('完成日期须晚于上次完成日期,这一天可能已经记录过')
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Depends,FastAPI,HTTPException,Request,Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from .auth import DUMMY_HASH,RateLimit,digest,hash_password,verify_password
|
||||
from .db import Database,decode,encode
|
||||
from .domain import business_today,check_activation,check_completion,next_due
|
||||
from .models import ArchiveInput,CardInput,Completion,PlatformInput,Register,Login,RuleInput,SettingsInput
|
||||
from .money import apply_balance
|
||||
|
||||
ROOT=Path(__file__).resolve().parent.parent
|
||||
COLORS={'giffgaff':'#242C40','MobiMatter':'#F38B4A','Ultra Mobile':'#8562CA','3HK':'#457DE7','Tello':'#30A593','Airalo':'#D65870'}
|
||||
|
||||
|
||||
def create_app(db_path=None,start_worker=True,config=None):
|
||||
db=Database(db_path or os.getenv('SIMKEEP_DB',str(ROOT/'data'/'simkeep.db')))
|
||||
rate=RateLimit()
|
||||
from .notifications import Config,Notifier
|
||||
configuration=config or Config.from_env()
|
||||
notifier=Notifier(db,configuration)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
worker=asyncio.create_task(notifier.run()) if start_worker else None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if worker:
|
||||
worker.cancel()
|
||||
try:
|
||||
await worker
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
app=FastAPI(title='续卡 SIMKEEP',lifespan=lifespan,docs_url=None,redoc_url=None)
|
||||
app.state.db=db
|
||||
app.state.notifier=notifier
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def invalid_request(request,exception):
|
||||
errors=[{key:error[key] for key in ('loc','msg','type')} for error in exception.errors()]
|
||||
return JSONResponse({'detail':errors},status_code=422)
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
async def invalid_value(request,exception):
|
||||
return JSONResponse({'detail':str(exception)},status_code=422)
|
||||
|
||||
@app.middleware('http')
|
||||
async def protect_response(request,call_next):
|
||||
if request.url.path.startswith('/api/'):
|
||||
try:
|
||||
length=int(request.headers.get('content-length','0') or 0)
|
||||
except ValueError:
|
||||
return JSONResponse({'detail':'请求长度无效'},status_code=400)
|
||||
if length < 0:
|
||||
return JSONResponse({'detail':'请求长度无效'},status_code=400)
|
||||
if length > 1024*1024:
|
||||
return JSONResponse({'detail':'请求内容过大'},status_code=413)
|
||||
if request.method not in ('GET','HEAD','OPTIONS'):
|
||||
origin=request.headers.get('origin')
|
||||
allowed={str(request.base_url).rstrip('/')}
|
||||
if configuration.public_url:
|
||||
allowed.add(configuration.public_url.rstrip('/'))
|
||||
if origin and origin.rstrip('/') not in allowed:
|
||||
return JSONResponse({'detail':'请求来源不匹配'},status_code=403)
|
||||
response=await call_next(request)
|
||||
response.headers['X-Content-Type-Options']='nosniff'
|
||||
response.headers['Referrer-Policy']='same-origin'
|
||||
response.headers['X-Frame-Options']='DENY'
|
||||
if request.url.path.startswith('/api/'):
|
||||
response.headers['Cache-Control']='no-store'
|
||||
return response
|
||||
|
||||
def session(request:Request):
|
||||
token=request.cookies.get('simkeep_session','')
|
||||
with db.connect() as conn:
|
||||
row=conn.execute('SELECT users.*,sessions.csrf,sessions.token_hash FROM sessions JOIN users ON users.id=sessions.user_id WHERE token_hash=? AND expires_at>?',(digest(token),time.time())).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(401,'请先登录')
|
||||
if request.method not in ('GET','HEAD','OPTIONS'):
|
||||
supplied=request.headers.get('x-csrf-token','')
|
||||
if not hmac.compare_digest(supplied,row['csrf']):
|
||||
raise HTTPException(403,'请求校验失败,请刷新页面后重试')
|
||||
return dict(row)
|
||||
|
||||
def public_user(row):
|
||||
return {'id':row['id'],'email':row['email'],'name':row['name'],'initial':row['name'][0]}
|
||||
|
||||
def workspace(user_id):
|
||||
with db.connect() as conn:
|
||||
person=conn.execute('SELECT * FROM users WHERE id=?',(user_id,)).fetchone()
|
||||
cards=[decode(row['document']) for row in conn.execute('SELECT document FROM cards WHERE user_id=? ORDER BY rowid',(user_id,))]
|
||||
events=[decode(row['document']) for row in conn.execute('SELECT document FROM events WHERE user_id=? ORDER BY rowid',(user_id,))]
|
||||
logs=[dict(row) for row in conn.execute('SELECT id,channel,status,attempts,created_at,sent_at,error FROM notification_jobs WHERE user_id=? ORDER BY created_at DESC LIMIT 30',(user_id,))]
|
||||
return {'version':2,'cards':cards,'events':events,'settings':decode(person['settings']),'services':notifier.configs.status(user_id,notifier.config),'notifications':logs}
|
||||
|
||||
def result(user_id,**extra):
|
||||
return {'workspace':workspace(user_id),**extra}
|
||||
|
||||
def card_for(conn,user_id,card_id):
|
||||
row=conn.execute('SELECT document FROM cards WHERE id=? AND user_id=?',(card_id,user_id)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404,'找不到这张卡片')
|
||||
return decode(row['document'])
|
||||
|
||||
def rule_for(conn,user_id,rule_id):
|
||||
for row in conn.execute('SELECT document FROM cards WHERE user_id=?',(user_id,)):
|
||||
card=decode(row['document'])
|
||||
for rule in card['rules']:
|
||||
if rule['id']==rule_id:
|
||||
return card,rule
|
||||
raise HTTPException(404,'找不到这条续期规则')
|
||||
|
||||
def expected(actual,supplied):
|
||||
if actual != supplied:
|
||||
raise HTTPException(409,'资料已更新,请刷新后再操作')
|
||||
|
||||
def save_card(conn,owner,card):
|
||||
card['version']+=1
|
||||
conn.execute('UPDATE cards SET document=? WHERE id=? AND user_id=?',(encode(card),card['id'],owner))
|
||||
|
||||
def cancel_jobs(conn,owner,card_id,rule_id=None):
|
||||
query="UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND card_id=? AND status IN ('pending','processing')"
|
||||
args=[owner,card_id]
|
||||
if rule_id:
|
||||
query+=' AND rule_id=?'
|
||||
args.append(rule_id)
|
||||
conn.execute(query,args)
|
||||
|
||||
def new_rule(payload,card,old=None):
|
||||
rule=payload.model_dump(exclude={'version'})
|
||||
if old and 'cost' not in payload.model_fields_set:
|
||||
rule['cost']=old.get('cost')
|
||||
if rule['unit']=='months' and rule['interval']>120:
|
||||
raise ValueError('月份周期须为 1–120')
|
||||
if rule['dueDate']<card['activatedAt']:
|
||||
raise ValueError('到期日期不能早于卡片开通日期')
|
||||
if old and old.get('lastCompletedAt') and rule['dueDate']<=old['lastCompletedAt']:
|
||||
raise ValueError('到期日期须晚于上次完成日期')
|
||||
changed=old and any(old[key]!=rule[key] for key in ('dueDate','interval','unit','anchor'))
|
||||
return {**(old or {}),**rule,'id':old['id'] if old else str(uuid4()),'anchorDate':old['anchorDate'] if old and not changed else rule['dueDate'],'version':old['version']+1 if old else 1}
|
||||
|
||||
def new_session(conn,user_id,response):
|
||||
token=secrets.token_urlsafe(32)
|
||||
csrf=secrets.token_urlsafe(24)
|
||||
conn.execute('DELETE FROM sessions WHERE expires_at<?',(time.time(),))
|
||||
conn.execute('INSERT INTO sessions VALUES(?,?,?,?)',(digest(token),user_id,csrf,time.time()+30*86400))
|
||||
response.set_cookie('simkeep_session',token,max_age=30*86400,httponly=True,samesite='lax',secure=configuration.secure_cookies)
|
||||
return csrf
|
||||
|
||||
@app.get('/api/health')
|
||||
def health():
|
||||
return {'status':'ok','app':'simkeep'}
|
||||
|
||||
@app.post('/api/auth/register',status_code=201)
|
||||
def register(payload:Register,request:Request,response:Response):
|
||||
rate.check(('register',request.client.host),10,3600)
|
||||
user_id=str(uuid4())
|
||||
settings={'timezone':'Asia/Shanghai','time':'09:00','offsets':[7,3,1],'overdue':True,'telegram':False,'email':False,'emailAddress':payload.email,'emailVerified':False,'chatId':'','telegramVerified':False}
|
||||
hashed=hash_password(payload.password)
|
||||
try:
|
||||
with db.connect(write=True) as conn:
|
||||
conn.execute('INSERT INTO users VALUES(?,?,?,?,?,?)',(user_id,payload.email,payload.name,hashed,encode(settings),time.time()))
|
||||
csrf=new_session(conn,user_id,response)
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(409,'这个邮箱已经注册,请直接登录') from None
|
||||
return {'user':{'id':user_id,'email':payload.email,'name':payload.name,'initial':payload.name[0]},'csrfToken':csrf,'workspace':workspace(user_id)}
|
||||
|
||||
@app.post('/api/auth/login')
|
||||
def login(payload:Login,request:Request,response:Response):
|
||||
rate.check(('login',request.client.host),20,60)
|
||||
with db.connect(write=True) as conn:
|
||||
row=conn.execute('SELECT * FROM users WHERE email=?',(payload.email,)).fetchone()
|
||||
if not verify_password(payload.password,row['password_hash'] if row else DUMMY_HASH) or not row:
|
||||
raise HTTPException(401,'邮箱或密码不正确')
|
||||
csrf=new_session(conn,row['id'],response)
|
||||
return {'user':public_user(row),'csrfToken':csrf,'workspace':workspace(row['id'])}
|
||||
|
||||
@app.get('/api/auth/me')
|
||||
def me(person=Depends(session)):
|
||||
return {'user':public_user(person),'csrfToken':person['csrf']}
|
||||
|
||||
@app.post('/api/auth/logout')
|
||||
def logout(response:Response,person=Depends(session)):
|
||||
with db.connect(write=True) as conn:
|
||||
conn.execute('DELETE FROM sessions WHERE token_hash=?',(person['token_hash'],))
|
||||
response.delete_cookie('simkeep_session')
|
||||
return {'ok':True}
|
||||
|
||||
@app.get('/api/workspace')
|
||||
def get_workspace(person=Depends(session)):
|
||||
return result(person['id'])
|
||||
|
||||
@app.get('/api/cards/{card_id}')
|
||||
def get_card(card_id:str,person=Depends(session)):
|
||||
with db.connect() as conn:
|
||||
return {'card':card_for(conn,person['id'],card_id)}
|
||||
|
||||
@app.post('/api/cards',status_code=201)
|
||||
def add_card(payload:CardInput,person=Depends(session)):
|
||||
owner=person['id']
|
||||
check_activation(None,[],payload.activatedAt,business_today(decode(person['settings'])['timezone']))
|
||||
card=payload.model_dump(exclude={'rules','version','balances'})
|
||||
card['balances']=[item.model_dump() for item in payload.balances or []]
|
||||
card.update(id=str(uuid4()),version=1,archived=False,platforms=[],rules=[],color=COLORS.get(payload.provider,'#5360EA'),mark='3' if payload.provider=='3HK' else payload.provider[0])
|
||||
card['phone']=card['phone'] or '数据卡 · 无号码'
|
||||
card['rules']=[new_rule(r,card) for r in payload.rules]
|
||||
with db.connect(write=True) as conn:
|
||||
if conn.execute('SELECT count(*) FROM cards WHERE user_id=?',(owner,)).fetchone()[0]>=1000:
|
||||
raise HTTPException(422,'每个账号最多保存 1000 张卡片')
|
||||
conn.execute('INSERT INTO cards VALUES(?,?,?)',(card['id'],owner,encode(card)))
|
||||
return result(owner)
|
||||
|
||||
@app.put('/api/cards/{card_id}')
|
||||
def update_card(card_id:str,payload:CardInput,person=Depends(session)):
|
||||
owner=person['id']
|
||||
with db.connect(write=True) as conn:
|
||||
card=card_for(conn,owner,card_id)
|
||||
expected(card['version'],payload.version)
|
||||
if payload.rules:
|
||||
raise HTTPException(422,'请通过续期规则入口修改规则')
|
||||
events=[decode(r['document']) for r in conn.execute('SELECT document FROM events WHERE user_id=? AND card_id=?',(owner,card_id))]
|
||||
check_activation(card,events,payload.activatedAt,business_today(decode(person['settings'])['timezone']))
|
||||
card.update(payload.model_dump(exclude={'rules','version','balances'}))
|
||||
if payload.balances is not None:
|
||||
card['balances']=[item.model_dump() for item in payload.balances]
|
||||
card.update(color=COLORS.get(payload.provider,'#5360EA'),mark='3' if payload.provider=='3HK' else payload.provider[0])
|
||||
save_card(conn,owner,card)
|
||||
return result(owner)
|
||||
|
||||
@app.post('/api/cards/{card_id}/archive')
|
||||
def archive(card_id:str,payload:ArchiveInput,person=Depends(session)):
|
||||
owner=person['id']
|
||||
with db.connect(write=True) as conn:
|
||||
card=card_for(conn,owner,card_id)
|
||||
expected(card['version'],payload.version)
|
||||
card['archived']=payload.archived
|
||||
save_card(conn,owner,card)
|
||||
cancel_jobs(conn,owner,card_id)
|
||||
return result(owner)
|
||||
|
||||
@app.post('/api/cards/{card_id}/rules',status_code=201)
|
||||
def add_rule(card_id:str,payload:RuleInput,person=Depends(session)):
|
||||
owner=person['id']
|
||||
with db.connect(write=True) as conn:
|
||||
card=card_for(conn,owner,card_id)
|
||||
if len(card['rules'])>=20:
|
||||
raise HTTPException(422,'每张卡片最多设置 20 条续期规则')
|
||||
card['rules'].append(new_rule(payload,card))
|
||||
save_card(conn,owner,card)
|
||||
return result(owner)
|
||||
|
||||
@app.put('/api/rules/{rule_id}')
|
||||
def update_rule(rule_id:str,payload:RuleInput,person=Depends(session)):
|
||||
owner=person['id']
|
||||
with db.connect(write=True) as conn:
|
||||
card,rule=rule_for(conn,owner,rule_id)
|
||||
expected(rule['version'],payload.version)
|
||||
replacement=new_rule(payload,card,rule)
|
||||
card['rules']=[replacement if r['id']==rule_id else r for r in card['rules']]
|
||||
save_card(conn,owner,card)
|
||||
cancel_jobs(conn,owner,card['id'],rule_id)
|
||||
return result(owner)
|
||||
|
||||
@app.post('/api/rules/{rule_id}/completions')
|
||||
def complete(rule_id:str,payload:Completion,person=Depends(session)):
|
||||
owner=person['id']
|
||||
with db.connect(write=True) as conn:
|
||||
card,rule=rule_for(conn,owner,rule_id)
|
||||
previous=conn.execute('SELECT rule_id,document FROM events WHERE user_id=? AND request_id=?',(owner,payload.requestId)).fetchone()
|
||||
if previous:
|
||||
if previous['rule_id']!=rule_id:
|
||||
raise HTTPException(409,'这次提交已用于另一条规则')
|
||||
recorded=decode(previous['document'])
|
||||
requested_cost=payload.cost.model_dump() if payload.cost else None
|
||||
changed=(payload.completedAt!=recorded['completedAt'] or payload.note!=recorded.get('note','') or payload.balanceAction!=recorded.get('balanceAction','none'))
|
||||
if 'cost' in payload.model_fields_set:
|
||||
changed=changed or requested_cost!=recorded.get('cost')
|
||||
if changed:
|
||||
raise HTTPException(409,'本次续期已保存,但重试内容有变化。请刷新卡片查看已保存的记录')
|
||||
else:
|
||||
expected(rule['version'],payload.version)
|
||||
check_completion(card,rule,payload.completedAt,business_today(decode(person['settings'])['timezone']))
|
||||
due=next_due(rule,payload.completedAt)
|
||||
cost=payload.cost.model_dump() if payload.cost else None
|
||||
if 'cost' not in payload.model_fields_set:
|
||||
cost=rule.get('cost')
|
||||
before=after=None
|
||||
if payload.balanceAction!='none':
|
||||
if payload.cardVersion is None:
|
||||
raise ValueError('更新余额时须提交当前卡片版本,请刷新后重试')
|
||||
expected(card['version'],payload.cardVersion)
|
||||
if payload.balanceAction=='credit' and rule['action']!='topup':
|
||||
raise ValueError('只有账户充值操作可以将金额计入余额')
|
||||
if payload.balanceAction=='deduct' and rule['action']=='topup':
|
||||
raise ValueError('账户充值请使用计入余额,或只记录费用')
|
||||
card['balances'],before,after=apply_balance(card.get('balances',[]),cost,payload.balanceAction)
|
||||
event={'id':str(uuid4()),'cardId':card['id'],'ruleId':rule_id,'action':rule['action'],'completedAt':payload.completedAt,'oldDueDate':rule['dueDate'],'newDueDate':due,'note':payload.note,'cost':cost,'balanceAction':payload.balanceAction,'balanceBefore':before,'balanceAfter':after}
|
||||
rule.update(dueDate=due,lastCompletedAt=payload.completedAt,version=rule['version']+1)
|
||||
save_card(conn,owner,card)
|
||||
conn.execute('INSERT INTO events VALUES(?,?,?,?,?,?)',(event['id'],owner,card['id'],rule_id,payload.requestId,encode(event)))
|
||||
cancel_jobs(conn,owner,card['id'],rule_id)
|
||||
return result(owner)
|
||||
|
||||
@app.post('/api/cards/{card_id}/platforms',status_code=201)
|
||||
def add_platform(card_id:str,payload:PlatformInput,person=Depends(session)):
|
||||
owner=person['id']
|
||||
with db.connect(write=True) as conn:
|
||||
card=card_for(conn,owner,card_id)
|
||||
expected(card['version'],payload.version)
|
||||
if len(card['platforms'])>=200:
|
||||
raise HTTPException(422,'每张卡片最多保存 200 条平台关联')
|
||||
if any(p['name'].casefold()==payload.name.casefold() and p['account'].casefold()==payload.account.casefold() for p in card['platforms']):
|
||||
raise HTTPException(409,'这条平台与账号关联已经存在')
|
||||
card['platforms'].append({'id':str(uuid4()),**payload.model_dump(exclude={'version'})})
|
||||
save_card(conn,owner,card)
|
||||
return result(owner)
|
||||
|
||||
@app.delete('/api/cards/{card_id}/platforms/{platform_id}')
|
||||
def delete_platform(card_id:str,platform_id:str,version:int,person=Depends(session)):
|
||||
owner=person['id']
|
||||
with db.connect(write=True) as conn:
|
||||
card=card_for(conn,owner,card_id)
|
||||
expected(card['version'],version)
|
||||
if not any(p['id']==platform_id for p in card['platforms']):
|
||||
raise HTTPException(404,'找不到这条平台关联')
|
||||
card['platforms']=[p for p in card['platforms'] if p['id']!=platform_id]
|
||||
save_card(conn,owner,card)
|
||||
return result(owner)
|
||||
|
||||
@app.put('/api/settings')
|
||||
def settings(payload:SettingsInput,person=Depends(session)):
|
||||
if payload.email and not payload.emailAddress:
|
||||
raise HTTPException(422,'启用邮箱提醒时须填写接收邮箱')
|
||||
owner=person['id']
|
||||
with db.connect(write=True) as conn:
|
||||
old=decode(conn.execute('SELECT settings FROM users WHERE id=?',(owner,)).fetchone()['settings'])
|
||||
updated={**old,**payload.model_dump()}
|
||||
if old['emailAddress']!=payload.emailAddress:
|
||||
updated['emailVerified']=False
|
||||
conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='email'",(owner,))
|
||||
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(updated),owner))
|
||||
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND status IN ('pending','processing')",(owner,))
|
||||
return result(owner)
|
||||
|
||||
notifier.install_routes(app,session,result,rate)
|
||||
static_path=Path(os.getenv('SIMKEEP_STATIC_DIR',str(ROOT/'dist')))
|
||||
if static_path.exists():
|
||||
app.mount('/',StaticFiles(directory=static_path,html=True),name='frontend')
|
||||
return app
|
||||
@@ -0,0 +1,161 @@
|
||||
import re
|
||||
from typing import Literal
|
||||
from zoneinfo import ZoneInfo,ZoneInfoNotFoundError
|
||||
from pydantic import BaseModel,ConfigDict,Field,StrictInt,field_validator,model_validator
|
||||
from .domain import parse_date
|
||||
from .money import CURRENCIES,normalize_amount
|
||||
|
||||
|
||||
class Model(BaseModel):
|
||||
model_config=ConfigDict(extra='forbid',str_strip_whitespace=True)
|
||||
|
||||
|
||||
class Money(Model):
|
||||
currency:str=Field(min_length=3,max_length=3)
|
||||
amount:str=Field(min_length=1,max_length=30)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def valid_money(self):
|
||||
self.currency=self.currency.upper()
|
||||
self.amount=normalize_amount(self.amount,self.currency)
|
||||
return self
|
||||
|
||||
|
||||
class Login(Model):
|
||||
model_config=ConfigDict(extra='forbid',str_strip_whitespace=False)
|
||||
email:str=Field(min_length=3,max_length=120)
|
||||
password:str=Field(min_length=1,max_length=128)
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
def email_format(cls,value):
|
||||
value=value.strip()
|
||||
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[^\s@]+',value):
|
||||
raise ValueError('请输入有效邮箱')
|
||||
return value.lower()
|
||||
|
||||
class Register(Login):
|
||||
name:str=Field(min_length=1,max_length=45)
|
||||
password:str=Field(min_length=10,max_length=128)
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def valid_name(cls,value):
|
||||
value=value.strip()
|
||||
if not value:
|
||||
raise ValueError('请填写名字')
|
||||
return value
|
||||
|
||||
|
||||
class RuleInput(Model):
|
||||
action:Literal['sms','call','purchase','reset','topup','custom']
|
||||
interval:StrictInt=Field(ge=1,le=3650)
|
||||
unit:Literal['days','months']
|
||||
anchor:Literal['completion','scheduled']
|
||||
dueDate:str
|
||||
instructions:str=Field(default='',max_length=1000)
|
||||
version:StrictInt|None=Field(default=None,ge=1)
|
||||
cost:Money|None=None
|
||||
|
||||
@field_validator('dueDate')
|
||||
@classmethod
|
||||
def valid_date(cls,value):
|
||||
parse_date(value)
|
||||
return value
|
||||
|
||||
|
||||
class CardInput(Model):
|
||||
name:str=Field(min_length=1,max_length=45)
|
||||
provider:str=Field(min_length=1,max_length=45)
|
||||
type:Literal['SIM','eSIM']
|
||||
country:Literal['GB','US','HK','JP','CN','DE','SG','OTHER']
|
||||
phone:str=Field(default='',max_length=35)
|
||||
activatedAt:str
|
||||
notes:str=Field(default='',max_length=1000)
|
||||
plan:str=Field(default='',max_length=60)
|
||||
rules:list[RuleInput]=Field(default_factory=list,max_length=20)
|
||||
version:StrictInt|None=Field(default=None,ge=1)
|
||||
balances:list[Money]|None=Field(default=None,max_length=len(CURRENCIES))
|
||||
|
||||
@field_validator('balances')
|
||||
@classmethod
|
||||
def unique_currencies(cls,value):
|
||||
if value is not None and len({item.currency for item in value})!=len(value):
|
||||
raise ValueError('同一张卡的每个币种只能记录一条余额')
|
||||
return value
|
||||
|
||||
@field_validator('activatedAt')
|
||||
@classmethod
|
||||
def valid_date(cls,value):
|
||||
parse_date(value)
|
||||
return value
|
||||
|
||||
|
||||
class ArchiveInput(Model):
|
||||
archived:bool
|
||||
version:StrictInt=Field(ge=1)
|
||||
|
||||
|
||||
class Completion(Model):
|
||||
completedAt:str
|
||||
note:str=Field(default='',max_length=1000)
|
||||
requestId:str=Field(min_length=8,max_length=100)
|
||||
version:StrictInt=Field(ge=1)
|
||||
cost:Money|None=None
|
||||
balanceAction:Literal['none','deduct','credit']='none'
|
||||
cardVersion:StrictInt|None=Field(default=None,ge=1)
|
||||
|
||||
|
||||
class PlatformInput(Model):
|
||||
name:str=Field(min_length=1,max_length=45)
|
||||
account:str=Field(default='',max_length=120)
|
||||
purpose:str=Field(default='',max_length=80)
|
||||
version:StrictInt=Field(ge=1)
|
||||
|
||||
|
||||
class SettingsInput(Model):
|
||||
timezone:str
|
||||
time:str
|
||||
offsets:list[StrictInt]=Field(max_length=12)
|
||||
overdue:bool
|
||||
telegram:bool
|
||||
email:bool
|
||||
emailAddress:str=Field(default='',max_length=120)
|
||||
|
||||
@field_validator('timezone')
|
||||
@classmethod
|
||||
def valid_zone(cls,value):
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except (ZoneInfoNotFoundError,ValueError):
|
||||
raise ValueError('请选择有效时区') from None
|
||||
return value
|
||||
|
||||
@field_validator('time')
|
||||
@classmethod
|
||||
def valid_time(cls,value):
|
||||
if not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d',value):
|
||||
raise ValueError('请输入有效提醒时间')
|
||||
return value
|
||||
|
||||
@field_validator('offsets')
|
||||
@classmethod
|
||||
def valid_offsets(cls,value):
|
||||
if any(v < 1 or v > 365 for v in value):
|
||||
raise ValueError('提前提醒天数须为 1–365')
|
||||
return sorted(set(value),reverse=True)
|
||||
|
||||
@field_validator('emailAddress')
|
||||
@classmethod
|
||||
def valid_email(cls,value):
|
||||
if value:
|
||||
return Login.email_format(value)
|
||||
return value
|
||||
|
||||
|
||||
class EmailCode(Model):
|
||||
code:str=Field(min_length=6,max_length=6)
|
||||
|
||||
|
||||
class TelegramCode(Model):
|
||||
code:str=Field(min_length=20,max_length=100)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Exact, nonnegative amounts in their original currency. No FX conversion."""
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
CURRENCIES={
|
||||
'CNY':2,'USD':2,'HKD':2,'GBP':2,'EUR':2,'JPY':0,'TWD':2,'SGD':2,
|
||||
'AUD':2,'CAD':2,'CHF':2,'NZD':2,'KRW':0,'THB':2,'MYR':2,'AED':2,
|
||||
'SAR':2,'IDR':2,'INR':2,'PHP':2,'VND':0,'KWD':3,'BHD':3,
|
||||
}
|
||||
MAX_AMOUNT=Decimal('999999999')
|
||||
|
||||
|
||||
def normalize_amount(value,currency):
|
||||
if currency not in CURRENCIES:
|
||||
raise ValueError('请选择支持的币种')
|
||||
if not isinstance(value,str) or len(value)>30 or not re.fullmatch(r'[0-9]+(?:\.[0-9]+)?',value):
|
||||
raise ValueError('金额须为不小于 0 的数字')
|
||||
try:
|
||||
amount=Decimal(value)
|
||||
if amount>MAX_AMOUNT:
|
||||
raise ValueError('金额不能超过 999999999')
|
||||
places=CURRENCIES[currency]
|
||||
normalized=amount.quantize(Decimal(1).scaleb(-places))
|
||||
if normalized!=amount:
|
||||
raise ValueError(f'{currency} 金额最多保留 {places} 位小数')
|
||||
return format(normalized,f'.{places}f')
|
||||
except InvalidOperation:
|
||||
raise ValueError('请输入有效金额') from None
|
||||
|
||||
|
||||
def format_money(money):
|
||||
if money is None:
|
||||
return '未填写'
|
||||
amount=normalize_amount(money['amount'],money['currency'])
|
||||
return f"{money['currency']} {Decimal(amount):,f}"
|
||||
|
||||
|
||||
def apply_balance(balances,cost,operation):
|
||||
if operation not in ('deduct','credit') or not cost:
|
||||
raise ValueError('更新余额时须填写本次金额和币种')
|
||||
currency=cost['currency']
|
||||
amount=Decimal(normalize_amount(cost['amount'],currency))
|
||||
current=next((item for item in balances if item['currency']==currency),None)
|
||||
if operation=='deduct' and current is None:
|
||||
raise ValueError(f'尚未记录 {currency} 余额,请先在卡片资料中添加')
|
||||
before={'currency':currency,'amount':normalize_amount(current['amount'] if current else '0',currency)}
|
||||
remaining=Decimal(before['amount'])-amount if operation=='deduct' else Decimal(before['amount'])+amount
|
||||
if remaining<0:
|
||||
raise ValueError(f'{currency} 余额不足,可取消更新余额后单独记录费用')
|
||||
after={'currency':currency,'amount':normalize_amount(format(remaining,'f'),currency)}
|
||||
updated=[dict(item) for item in balances if item['currency']!=currency]
|
||||
if current:
|
||||
updated=[dict(after) if item['currency']==currency else dict(item) for item in balances]
|
||||
else:
|
||||
updated.append(dict(after))
|
||||
return updated,before,after
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Owned notification credentials. Only status and non-secret fields leave this module."""
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import replace
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ConfigDict, Field, StrictInt, field_validator
|
||||
from typing import Literal
|
||||
|
||||
from .auth import digest
|
||||
from .db import decode, encode
|
||||
from .models import Login, Model
|
||||
|
||||
|
||||
class TelegramConfigInput(Model):
|
||||
token: str = Field(default='', max_length=160)
|
||||
|
||||
@field_validator('token')
|
||||
@classmethod
|
||||
def token_format(cls, value):
|
||||
if value and not re.fullmatch(r'[0-9]{1,20}:[A-Za-z0-9_-]{20,120}', value):
|
||||
raise ValueError('Bot Token 格式无效,请复制 BotFather 提供的完整 Token')
|
||||
if not value:
|
||||
return value
|
||||
bot_id, secret = value.split(':', 1)
|
||||
if int(bot_id) == 0:
|
||||
raise ValueError('Bot Token 格式无效,请复制 BotFather 提供的完整 Token')
|
||||
return f'{int(bot_id)}:{secret}'
|
||||
|
||||
|
||||
class EmailConfigInput(Model):
|
||||
# SMTP passwords may intentionally start or end with spaces.
|
||||
model_config = ConfigDict(extra='forbid', str_strip_whitespace=False)
|
||||
host: str = Field(min_length=1, max_length=253)
|
||||
port: StrictInt = Field(ge=1, le=65535)
|
||||
mode: Literal['starttls', 'ssl', 'plain']
|
||||
user: str = Field(default='', max_length=254)
|
||||
password: str = Field(default='', max_length=1024)
|
||||
sender: str = Field(alias='from', min_length=3, max_length=120)
|
||||
|
||||
@field_validator('host')
|
||||
@classmethod
|
||||
def host_format(cls, value):
|
||||
value = value.strip()
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.:\[\]-]+', value):
|
||||
raise ValueError('SMTP 服务器填写主机名或 IP,不包含网址、路径或端口')
|
||||
return value
|
||||
|
||||
@field_validator('user')
|
||||
@classmethod
|
||||
def user_format(cls, value):
|
||||
value = value.strip()
|
||||
if '\r' in value or '\n' in value:
|
||||
raise ValueError('SMTP 用户名不能包含换行')
|
||||
return value
|
||||
|
||||
@field_validator('sender')
|
||||
@classmethod
|
||||
def sender_format(cls, value):
|
||||
return Login.email_format(value)
|
||||
|
||||
|
||||
class NotificationConfigs:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
def read(self, conn, owner):
|
||||
return {row['channel']: decode(row['document']) for row in conn.execute('SELECT channel,document FROM notification_configs WHERE user_id=?', (owner,))}
|
||||
|
||||
def load(self, owner, defaults, conn=None):
|
||||
if conn is None:
|
||||
with self.db.connect() as connection:
|
||||
return self.load(owner, defaults, connection)
|
||||
personal = self.read(conn, owner)
|
||||
fields = {}
|
||||
if 'telegram' in personal:
|
||||
fields.update(telegram_token=personal['telegram']['token'], telegram_username='')
|
||||
if 'email' in personal:
|
||||
fields.update({f'smtp_{key}': value for key, value in personal['email'].items()})
|
||||
return replace(defaults, **fields)
|
||||
|
||||
def status(self, owner, defaults):
|
||||
with self.db.connect() as conn:
|
||||
personal = self.read(conn, owner)
|
||||
config = self.load(owner, defaults, conn)
|
||||
result = config.status()
|
||||
for channel in ('telegram', 'email'):
|
||||
result[channel]['source'] = 'account' if channel in personal else 'server' if config.configured(channel) else 'none'
|
||||
result['telegram']['tokenSaved'] = bool(personal.get('telegram', {}).get('token'))
|
||||
email = personal.get('email', {})
|
||||
result['email'].update({key: email.get(key, default) for key, default in [('host',''),('port',587),('mode','starttls'),('user',''),('from','')]})
|
||||
result['email']['passwordSaved'] = bool(email.get('password'))
|
||||
return result
|
||||
|
||||
def invalidate(self, conn, owner, channel, bot_changed=False):
|
||||
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND channel=? AND status IN ('pending','processing','failed') AND sent_at IS NULL", (owner, channel))
|
||||
if bot_changed:
|
||||
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?', (owner,)).fetchone()[0])
|
||||
settings.update(chatId='', telegramVerified=False, telegram=False)
|
||||
conn.execute('UPDATE users SET settings=? WHERE id=?', (encode(settings), owner))
|
||||
conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='telegram'", (owner,))
|
||||
|
||||
def save(self, owner, channel, payload, defaults):
|
||||
try:
|
||||
with self.db.connect(write=True) as conn:
|
||||
personal = self.read(conn, owner)
|
||||
old = personal.get(channel, {})
|
||||
before = self.load(owner, defaults, conn)
|
||||
document = payload.model_dump(by_alias=True)
|
||||
identity = None
|
||||
if channel == 'telegram':
|
||||
document['token'] = document['token'] or old.get('token', '')
|
||||
if not document['token']:
|
||||
raise HTTPException(422, '请先填写 Bot Token')
|
||||
# A guessed Bot ID with a fake secret must not reserve a real Bot.
|
||||
identity = digest(document['token'])
|
||||
if defaults.telegram_token and document['token'] == defaults.telegram_token:
|
||||
raise HTTPException(409, '这个 Bot 已用于公共服务,请为本账号使用独立的 Bot')
|
||||
else:
|
||||
document['password'] = (document['password'] or old.get('password', '')) if document['user'] else ''
|
||||
if document['user'] and not document['password']:
|
||||
raise HTTPException(422, '填写 SMTP 用户名后,请填写密码或授权码')
|
||||
if document == old:
|
||||
return
|
||||
conn.execute('INSERT INTO notification_configs(user_id,channel,document,telegram_identity) VALUES(?,?,?,?) ON CONFLICT(user_id,channel) DO UPDATE SET document=excluded.document,telegram_identity=excluded.telegram_identity', (owner, channel, encode(document), identity))
|
||||
self.invalidate(conn, owner, channel, channel == 'telegram' and before.telegram_token != document['token'])
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(409, '这个 Bot 已由其他账号使用,请使用自己的 Bot') from None
|
||||
|
||||
def clear(self, owner, channel, defaults):
|
||||
with self.db.connect(write=True) as conn:
|
||||
before = self.load(owner, defaults, conn)
|
||||
conn.execute('DELETE FROM notification_configs WHERE user_id=? AND channel=?', (owner, channel))
|
||||
after = self.load(owner, defaults, conn)
|
||||
self.invalidate(conn, owner, channel, channel == 'telegram' and before.telegram_token != after.telegram_token)
|
||||
@@ -0,0 +1,456 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import smtplib
|
||||
import ssl
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from email.message import EmailMessage
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, HTTPException
|
||||
|
||||
from .auth import digest
|
||||
from .db import decode, encode
|
||||
from .domain import ACTIONS, parse_date
|
||||
from .models import EmailCode, TelegramCode
|
||||
from .money import format_money
|
||||
from .notification_config import EmailConfigInput, NotificationConfigs, TelegramConfigInput
|
||||
|
||||
logger = logging.getLogger('simkeep.notifications')
|
||||
SCHEDULE_FIELDS = ('timezone','time','offsets','overdue')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
public_url: str = ''
|
||||
secure_cookies: bool = False
|
||||
telegram_token: str = ''
|
||||
telegram_username: str = ''
|
||||
smtp_host: str = ''
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ''
|
||||
smtp_password: str = ''
|
||||
smtp_from: str = ''
|
||||
smtp_mode: str = 'starttls'
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
mode = os.getenv('SIMKEEP_SMTP_MODE','starttls').lower()
|
||||
if mode not in ('starttls','ssl','plain'):
|
||||
raise ValueError('SIMKEEP_SMTP_MODE 须为 starttls、ssl 或 plain')
|
||||
return cls(
|
||||
public_url=os.getenv('SIMKEEP_PUBLIC_URL','').rstrip('/'),
|
||||
secure_cookies=os.getenv('SIMKEEP_SECURE_COOKIES','0')=='1',
|
||||
telegram_token=os.getenv('SIMKEEP_TELEGRAM_TOKEN',''),
|
||||
telegram_username=os.getenv('SIMKEEP_TELEGRAM_USERNAME','').lstrip('@'),
|
||||
smtp_host=os.getenv('SIMKEEP_SMTP_HOST',''),
|
||||
smtp_port=int(os.getenv('SIMKEEP_SMTP_PORT','465' if mode=='ssl' else '587')),
|
||||
smtp_user=os.getenv('SIMKEEP_SMTP_USER',''),
|
||||
smtp_password=os.getenv('SIMKEEP_SMTP_PASSWORD',''),
|
||||
smtp_from=os.getenv('SIMKEEP_SMTP_FROM',os.getenv('SIMKEEP_SMTP_USER','')),
|
||||
smtp_mode=mode,
|
||||
)
|
||||
|
||||
def configured(self, channel):
|
||||
if channel == 'telegram':
|
||||
return bool(self.telegram_token)
|
||||
return bool(self.smtp_host and self.smtp_from and (not self.smtp_user or self.smtp_password))
|
||||
|
||||
def status(self):
|
||||
# Only capability flags are public; destinations and secrets stay on the server.
|
||||
return {'telegram':{'configured':self.configured('telegram')},'email':{'configured':self.configured('email')}}
|
||||
|
||||
|
||||
class Transport:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.username = config.telegram_username
|
||||
|
||||
async def telegram(self, method, payload):
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.post(f'https://api.telegram.org/bot{self.config.telegram_token}/{method}',json=payload)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError('Telegram 暂时无法发送')
|
||||
result = response.json()
|
||||
if not result.get('ok'):
|
||||
raise RuntimeError('Telegram 暂时无法发送')
|
||||
return result['result']
|
||||
|
||||
async def bot_username(self):
|
||||
if not self.username:
|
||||
self.username = (await self.telegram('getMe',{}))['username']
|
||||
if not re.fullmatch(r'[A-Za-z0-9_]{5,32}', self.username):
|
||||
raise RuntimeError('Bot 用户名无效')
|
||||
return self.username
|
||||
|
||||
async def updates(self, offset):
|
||||
return await self.telegram('getUpdates',{'offset':offset,'timeout':0,'allowed_updates':['message']})
|
||||
|
||||
def smtp(self, destination=None, text=''):
|
||||
context = ssl.create_default_context()
|
||||
if self.config.smtp_mode == 'ssl':
|
||||
connection = smtplib.SMTP_SSL(self.config.smtp_host,self.config.smtp_port,timeout=20,context=context)
|
||||
else:
|
||||
connection = smtplib.SMTP(self.config.smtp_host,self.config.smtp_port,timeout=20)
|
||||
with connection:
|
||||
connection.ehlo_or_helo_if_needed()
|
||||
if self.config.smtp_mode == 'starttls':
|
||||
connection.starttls(context=context)
|
||||
connection.ehlo()
|
||||
if self.config.smtp_user:
|
||||
connection.login(self.config.smtp_user,self.config.smtp_password)
|
||||
if destination is None:
|
||||
return
|
||||
message = EmailMessage()
|
||||
message['Subject'] = '续卡 SIMKEEP · 续期提醒' if '验证码' not in text else '续卡 SIMKEEP · 验证邮箱'
|
||||
message['From'] = self.config.smtp_from
|
||||
message['To'] = destination
|
||||
message.set_content(text)
|
||||
refused = connection.send_message(message)
|
||||
if refused:
|
||||
raise RuntimeError('邮箱服务暂时无法发送')
|
||||
|
||||
async def send(self, channel, destination, text):
|
||||
if channel == 'telegram':
|
||||
await self.telegram('sendMessage',{'chat_id':destination,'text':text[:4000],'disable_web_page_preview':True})
|
||||
else:
|
||||
await asyncio.to_thread(self.smtp,destination,text)
|
||||
|
||||
|
||||
def destination(settings, channel):
|
||||
if channel == 'telegram':
|
||||
return settings.get('chatId','') if settings.get('telegramVerified') else ''
|
||||
return settings.get('emailAddress','') if settings.get('emailVerified') else ''
|
||||
|
||||
|
||||
class Notifier:
|
||||
def __init__(self, db, config, transport=None):
|
||||
self.db = db
|
||||
self.config = config
|
||||
self.transport = transport or Transport(config)
|
||||
self.configs = NotificationConfigs(db)
|
||||
|
||||
def transport_for(self, owner, config=None):
|
||||
config = config or self.configs.load(owner, self.config)
|
||||
return self.transport if config == self.config else Transport(config)
|
||||
|
||||
def recover(self):
|
||||
with self.db.connect(write=True) as conn:
|
||||
conn.execute("UPDATE notification_jobs SET status='pending',error='发送中断,等待重试' WHERE status='processing'")
|
||||
|
||||
def enqueue(self, now=None):
|
||||
now = now or datetime.now(timezone.utc)
|
||||
timestamp = now.timestamp()
|
||||
with self.db.connect(write=True) as conn:
|
||||
for person in conn.execute('SELECT id,settings FROM users').fetchall():
|
||||
config = self.configs.load(person['id'], self.config, conn)
|
||||
settings = decode(person['settings'])
|
||||
local = now.astimezone(ZoneInfo(settings['timezone']))
|
||||
if local.strftime('%H:%M') < settings['time']:
|
||||
continue
|
||||
day = local.date()
|
||||
for row in conn.execute('SELECT document FROM cards WHERE user_id=?',(person['id'],)).fetchall():
|
||||
card = decode(row['document'])
|
||||
if card['archived']:
|
||||
continue
|
||||
for rule in card['rules']:
|
||||
remaining = (parse_date(rule['dueDate'])-day).days
|
||||
if remaining not in [0,*settings['offsets']] and not (remaining < 0 and settings['overdue']):
|
||||
continue
|
||||
payload = {'dueDate':rule['dueDate'],'day':day.isoformat(),'remaining':remaining,'schedule':{k:settings[k] for k in SCHEDULE_FIELDS}}
|
||||
for channel in ('telegram','email'):
|
||||
target = destination(settings,channel)
|
||||
if not settings[channel] or not target or not config.configured(channel):
|
||||
continue
|
||||
key = ':'.join((person['id'],card['id'],rule['id'],str(rule['version']),channel,day.isoformat()))
|
||||
values = (str(uuid4()),key,person['id'],card['id'],rule['id'],rule['version'],channel,target,encode(payload),timestamp,timestamp)
|
||||
conn.execute('INSERT OR IGNORE INTO notification_jobs(id,dedupe_key,user_id,card_id,rule_id,rule_version,channel,destination,payload,ready_at,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)',values)
|
||||
# Restore only unsent, cancelled jobs. Sent keys remain final even after settings change.
|
||||
conn.execute("UPDATE notification_jobs SET status='pending',attempts=0,error=NULL,destination=?,payload=?,ready_at=? WHERE dedupe_key=? AND status='cancelled' AND sent_at IS NULL",(target,encode(payload),timestamp,key))
|
||||
|
||||
def valid(self, conn, job, now):
|
||||
person = conn.execute('SELECT settings FROM users WHERE id=?',(job['user_id'],)).fetchone()
|
||||
row = conn.execute('SELECT document FROM cards WHERE id=? AND user_id=?',(job['card_id'],job['user_id'])).fetchone()
|
||||
if not person or not row:
|
||||
return None
|
||||
settings, card, payload = decode(person[0]), decode(row[0]), decode(job['payload'])
|
||||
rule = next((r for r in card['rules'] if r['id']==job['rule_id']),None)
|
||||
if card['archived'] or not rule or rule['version']!=job['rule_version'] or rule['dueDate']!=payload['dueDate']:
|
||||
return None
|
||||
if not settings[job['channel']] or destination(settings,job['channel'])!=job['destination']:
|
||||
return None
|
||||
if {k:settings[k] for k in SCHEDULE_FIELDS}!=payload['schedule']:
|
||||
return None
|
||||
if now.astimezone(ZoneInfo(settings['timezone'])).date().isoformat()!=payload['day']:
|
||||
return None
|
||||
return card, rule, payload
|
||||
|
||||
def message(self, card, rule, payload):
|
||||
remaining = payload['remaining']
|
||||
timing = f'还有 {remaining} 天' if remaining > 0 else '今天到期' if remaining == 0 else f'已逾期 {-remaining} 天'
|
||||
action = ACTIONS[rule['action']]
|
||||
phone = f"号码尾号 {re.sub(r'\s','',card['phone'])[-4:]}" if card['phone'].startswith('+') else '数据卡 / 未填写号码'
|
||||
cost = rule.get('cost')
|
||||
balance = next((item for item in card.get('balances',[]) if cost and item['currency']==cost['currency']),None)
|
||||
money = f"续期金额:{format_money(cost)}\n"
|
||||
if balance is not None:
|
||||
money += f"同币种余额:{format_money(balance)}\n"
|
||||
return f"续卡 SIMKEEP · 续期提醒\n\n{card['name']} · {card['provider']}\n{phone}\n{action} · {timing}\n到期日期:{rule['dueDate']}\n{money}操作说明:{rule['instructions'] or '请按运营商的要求完成续期'}\n\n完成操作后,请在续卡记录完成,更新下次续期日期。\n{self.config.public_url}/#renewals"
|
||||
|
||||
async def deliver(self, now=None):
|
||||
now = now or datetime.now(timezone.utc)
|
||||
timestamp = now.timestamp()
|
||||
with self.db.connect(write=True) as conn:
|
||||
# Expire stale dates even when their channel is currently unavailable.
|
||||
for person in conn.execute('SELECT id,settings FROM users').fetchall():
|
||||
local_day = now.astimezone(ZoneInfo(decode(person['settings'])['timezone'])).date().isoformat()
|
||||
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND status='pending' AND json_extract(payload,'$.day')<>?",(person['id'],local_day))
|
||||
configurations = {}
|
||||
pending = []
|
||||
# Apply availability before the batch limit, including per-account services.
|
||||
for row in conn.execute("SELECT * FROM notification_jobs WHERE status='pending' AND ready_at<=? ORDER BY ready_at", (timestamp,)):
|
||||
owner = row['user_id']
|
||||
if owner not in configurations:
|
||||
configurations[owner] = self.configs.load(owner, self.config, conn)
|
||||
if configurations[owner].configured(row['channel']):
|
||||
pending.append(dict(row))
|
||||
if len(pending) == 50:
|
||||
break
|
||||
for job in pending:
|
||||
with self.db.connect(write=True) as conn:
|
||||
config = self.configs.load(job['user_id'], self.config, conn)
|
||||
if not config.configured(job['channel']):
|
||||
continue
|
||||
current = conn.execute("SELECT * FROM notification_jobs WHERE id=? AND status='pending'",(job['id'],)).fetchone()
|
||||
if not current:
|
||||
continue
|
||||
valid = self.valid(conn,current,now)
|
||||
if not valid:
|
||||
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE id=?",(job['id'],))
|
||||
continue
|
||||
attempts = current['attempts']+1
|
||||
conn.execute("UPDATE notification_jobs SET status='processing',attempts=? WHERE id=?",(attempts,job['id']))
|
||||
try:
|
||||
await self.transport_for(job['user_id'],config).send(job['channel'],job['destination'],self.message(*valid))
|
||||
except Exception:
|
||||
# Raw transport exceptions can contain credentials. Persist only a safe, actionable message.
|
||||
error = '发送失败,请检查服务配置或接收地址' if attempts>=5 else '发送失败,稍后自动重试'
|
||||
with self.db.connect(write=True) as conn:
|
||||
conn.execute("UPDATE notification_jobs SET status=?,error=?,ready_at=? WHERE id=? AND status='processing'",('failed' if attempts>=5 else 'pending',error,timestamp+60*2**(attempts-1),job['id']))
|
||||
else:
|
||||
with self.db.connect(write=True) as conn:
|
||||
# An edit during the network call cannot recall an accepted message; record the actual outcome.
|
||||
conn.execute("UPDATE notification_jobs SET status='sent',sent_at=?,error=NULL WHERE id=?",(timestamp,job['id']))
|
||||
|
||||
def process_update(self, update, user_id=None, token=None):
|
||||
message = update.get('message',{})
|
||||
chat = message.get('chat',{})
|
||||
match = re.fullmatch(r'/start(?:@[A-Za-z0-9_]+)?\s+([A-Za-z0-9_-]+)',message.get('text','').strip())
|
||||
if chat.get('type')!='private' or not isinstance(chat.get('id'),int) or not match:
|
||||
return False
|
||||
chat_id = str(chat['id'])
|
||||
with self.db.connect(write=True) as conn:
|
||||
binding = conn.execute("SELECT * FROM bindings WHERE token_hash=? AND kind='telegram' AND destination IS NULL AND expires_at>?",(digest(match[1]),time.time())).fetchone()
|
||||
if not binding:
|
||||
return False
|
||||
expected = token if token is not None else self.configs.load(user_id, self.config, conn).telegram_token if user_id else self.config.telegram_token
|
||||
current = self.configs.load(binding['user_id'], self.config, conn)
|
||||
if (user_id is not None and binding['user_id'] != user_id) or current.telegram_token != expected:
|
||||
return False
|
||||
for row in conn.execute('SELECT id,settings FROM users'):
|
||||
existing = decode(row['settings'])
|
||||
same_bot = self.configs.load(row['id'],self.config,conn).telegram_token == current.telegram_token
|
||||
if same_bot and row['id']!=binding['user_id'] and existing.get('telegramVerified') and existing.get('chatId')==chat_id:
|
||||
return False
|
||||
row = conn.execute('SELECT settings FROM users WHERE id=?',(binding['user_id'],)).fetchone()
|
||||
settings = decode(row[0])
|
||||
settings.update(chatId=chat_id,telegramVerified=True)
|
||||
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),binding['user_id']))
|
||||
conn.execute('UPDATE bindings SET destination=? WHERE token_hash=?',(chat_id,binding['token_hash']))
|
||||
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND channel='telegram' AND status IN ('pending','processing')",(binding['user_id'],))
|
||||
return True
|
||||
|
||||
async def poll(self):
|
||||
with self.db.connect() as conn:
|
||||
people = [row['id'] for row in conn.execute('SELECT id FROM users')]
|
||||
personal = [(owner,self.configs.load(owner,self.config,conn)) for owner in people]
|
||||
targets = [(owner,config) for owner,config in personal if config.telegram_token and config.telegram_token != self.config.telegram_token]
|
||||
if self.config.configured('telegram'):
|
||||
targets.append((None,self.config))
|
||||
semaphore = asyncio.Semaphore(4)
|
||||
|
||||
async def guarded(owner, config):
|
||||
async with semaphore:
|
||||
try:
|
||||
await self.poll_bot(owner, config)
|
||||
except Exception:
|
||||
logger.warning('Telegram 绑定暂时不可用,请检查本账号的 Bot 配置与网络')
|
||||
await asyncio.gather(*(guarded(owner,config) for owner,config in targets))
|
||||
|
||||
async def poll_bot(self, owner, config):
|
||||
key = f'telegram_offset:{owner}:{digest(config.telegram_token)}' if owner else 'telegram_offset'
|
||||
with self.db.connect() as conn:
|
||||
row = conn.execute('SELECT value FROM system_state WHERE key=?', (key,)).fetchone()
|
||||
offset = int(row[0]) if row else 0
|
||||
updates = await self.transport_for(owner,config).updates(offset)
|
||||
for update in updates:
|
||||
self.process_update(update,user_id=owner,token=config.telegram_token)
|
||||
with self.db.connect(write=True) as conn:
|
||||
conn.execute('INSERT OR REPLACE INTO system_state VALUES(?,?)',(key,str(update['update_id']+1)))
|
||||
|
||||
async def run(self):
|
||||
self.recover()
|
||||
while True:
|
||||
try:
|
||||
await self.poll()
|
||||
except Exception:
|
||||
logger.warning('Telegram 绑定暂时不可用,请检查 Bot 配置与网络')
|
||||
try:
|
||||
self.enqueue()
|
||||
await self.deliver()
|
||||
with self.db.connect(write=True) as conn:
|
||||
conn.execute('DELETE FROM bindings WHERE expires_at<?',(time.time(),))
|
||||
except Exception:
|
||||
logger.warning('续期调度暂时失败,将在下一轮重试')
|
||||
await asyncio.sleep(5)
|
||||
|
||||
def install_routes(self, app, session, result, rate):
|
||||
def require_config(channel, owner):
|
||||
config = self.configs.load(owner,self.config)
|
||||
if not config.configured(channel):
|
||||
raise HTTPException(503,'请先配置本账号的 Telegram Bot' if channel=='telegram' else '请先配置本账号的邮件服务')
|
||||
return self.transport_for(owner,config)
|
||||
|
||||
@app.get('/api/notifications/config')
|
||||
def read_config(person=Depends(session)):
|
||||
return {'services':self.configs.status(person['id'],self.config)}
|
||||
|
||||
@app.put('/api/notifications/config/telegram')
|
||||
def save_telegram_config(payload:TelegramConfigInput,person=Depends(session)):
|
||||
self.configs.save(person['id'],'telegram',payload,self.config)
|
||||
return result(person['id'])
|
||||
|
||||
@app.put('/api/notifications/config/email')
|
||||
def save_email_config(payload:EmailConfigInput,person=Depends(session)):
|
||||
self.configs.save(person['id'],'email',payload,self.config)
|
||||
return result(person['id'])
|
||||
|
||||
@app.delete('/api/notifications/config/{channel}')
|
||||
def clear_config(channel:Literal['email','telegram'],person=Depends(session)):
|
||||
self.configs.clear(person['id'],channel,self.config)
|
||||
return result(person['id'])
|
||||
|
||||
@app.post('/api/notifications/config/{channel}/check')
|
||||
async def check_config(channel:Literal['email','telegram'],person=Depends(session)):
|
||||
transport = require_config(channel,person['id'])
|
||||
rate.check(('config_check',channel,person['id']),5,600)
|
||||
try:
|
||||
if channel == 'telegram':
|
||||
username = await transport.bot_username()
|
||||
return {'checked':True,'botUsername':username}
|
||||
await asyncio.to_thread(transport.smtp)
|
||||
except Exception:
|
||||
message = '无法连接 Telegram Bot,请检查 Token 和服务器网络' if channel=='telegram' else 'SMTP 连接或认证失败,请检查服务器、端口、安全方式及密码 / 授权码'
|
||||
raise HTTPException(503,message) from None
|
||||
return {'checked':True}
|
||||
|
||||
@app.post('/api/notifications/telegram/binding')
|
||||
async def telegram_binding(person=Depends(session)):
|
||||
transport = require_config('telegram',person['id'])
|
||||
token = getattr(transport,'config',self.config).telegram_token
|
||||
rate.check(('telegram_binding',person['id']),3,60)
|
||||
try:
|
||||
username = await transport.bot_username()
|
||||
except Exception:
|
||||
raise HTTPException(503,'无法连接 Telegram Bot,请检查本账号的配置') from None
|
||||
code = secrets.token_urlsafe(24)
|
||||
with self.db.connect(write=True) as conn:
|
||||
if self.configs.load(person['id'],self.config,conn).telegram_token != token:
|
||||
raise HTTPException(409,'Bot 配置已更改,请重新开始绑定')
|
||||
conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='telegram'",(person['id'],))
|
||||
conn.execute('INSERT INTO bindings(token_hash,user_id,kind,expires_at,created_at) VALUES(?,?,?,?,?)',(digest(code),person['id'],'telegram',time.time()+600,time.time()))
|
||||
return {'code':code,'url':f'https://t.me/{username}?start={code}','expiresIn':600}
|
||||
|
||||
@app.post('/api/notifications/telegram/binding/check')
|
||||
def check_telegram_binding(payload:TelegramCode,person=Depends(session)):
|
||||
with self.db.connect() as conn:
|
||||
binding = conn.execute("SELECT destination FROM bindings WHERE token_hash=? AND user_id=? AND kind='telegram' AND expires_at>?",(digest(payload.code),person['id'],time.time())).fetchone()
|
||||
status = 'expired' if not binding else 'bound' if binding['destination'] else 'waiting'
|
||||
return result(person['id'],bindingStatus=status)
|
||||
|
||||
@app.delete('/api/notifications/telegram/binding')
|
||||
def unbind_telegram(person=Depends(session)):
|
||||
with self.db.connect(write=True) as conn:
|
||||
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0])
|
||||
settings.update(chatId='',telegramVerified=False,telegram=False)
|
||||
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),person['id']))
|
||||
conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='telegram'",(person['id'],))
|
||||
conn.execute("UPDATE notification_jobs SET status='cancelled' WHERE user_id=? AND channel='telegram' AND status IN ('pending','processing')",(person['id'],))
|
||||
return result(person['id'])
|
||||
|
||||
@app.post('/api/notifications/email/verify')
|
||||
async def verify_email(person=Depends(session)):
|
||||
transport = require_config('email',person['id'])
|
||||
rate.check(('email_verify',person['id']),3,600)
|
||||
code = f'{secrets.randbelow(1000000):06d}'
|
||||
nonce = secrets.token_urlsafe(24)
|
||||
token_hash = digest(nonce+code)
|
||||
with self.db.connect(write=True) as conn:
|
||||
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0])
|
||||
address = settings['emailAddress']
|
||||
if not address:
|
||||
raise HTTPException(422,'请先保存接收邮箱')
|
||||
conn.execute("DELETE FROM bindings WHERE user_id=? AND kind='email'",(person['id'],))
|
||||
conn.execute('INSERT INTO bindings(token_hash,user_id,kind,destination,expires_at,created_at) VALUES(?,?,?,?,?,?)',(token_hash,person['id'],'email',encode({'address':address,'nonce':nonce}),time.time()+600,time.time()))
|
||||
try:
|
||||
await transport.send('email',address,f'续卡 SIMKEEP · 验证邮箱\n\n验证码:{code}\n10 分钟内有效。请在通知设置输入验证码,确认这是你的接收邮箱。')
|
||||
except Exception:
|
||||
with self.db.connect(write=True) as conn:
|
||||
conn.execute('DELETE FROM bindings WHERE token_hash=?',(token_hash,))
|
||||
raise HTTPException(503,'验证码发送失败,请检查邮件服务配置') from None
|
||||
return {'sent':True,'expiresIn':600}
|
||||
|
||||
@app.post('/api/notifications/email/confirm')
|
||||
def confirm_email(payload:EmailCode,person=Depends(session)):
|
||||
rate.check(('email_confirm',person['id']),20,600)
|
||||
valid = False
|
||||
with self.db.connect(write=True) as conn:
|
||||
binding = conn.execute("SELECT * FROM bindings WHERE user_id=? AND kind='email' ORDER BY created_at DESC LIMIT 1",(person['id'],)).fetchone()
|
||||
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0])
|
||||
if binding and binding['expires_at']>time.time() and binding['attempts']<5:
|
||||
stored = decode(binding['destination'])
|
||||
valid = stored['address']==settings['emailAddress'] and hmac.compare_digest(binding['token_hash'],digest(stored['nonce']+payload.code))
|
||||
if valid:
|
||||
settings['emailVerified'] = True
|
||||
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),person['id']))
|
||||
conn.execute('DELETE FROM bindings WHERE token_hash=?',(binding['token_hash'],))
|
||||
else:
|
||||
conn.execute('UPDATE bindings SET attempts=attempts+1 WHERE token_hash=?',(binding['token_hash'],))
|
||||
if not valid:
|
||||
raise HTTPException(422,'验证码不正确或已失效,请重新发送')
|
||||
return result(person['id'])
|
||||
|
||||
@app.post('/api/notifications/test/{channel}')
|
||||
async def test_notification(channel:Literal['email','telegram'],person=Depends(session)):
|
||||
transport = require_config(channel,person['id'])
|
||||
rate.check(('notification_test',person['id']),5,600)
|
||||
with self.db.connect() as conn:
|
||||
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(person['id'],)).fetchone()[0])
|
||||
target = destination(settings,channel)
|
||||
if not target:
|
||||
raise HTTPException(422,'请先验证邮箱或绑定 Telegram')
|
||||
try:
|
||||
await transport.send(channel,target,'续卡 SIMKEEP · 测试提醒\n\n通知渠道已连接。开启提醒并保存设置后,续期提醒将按你选择的时间发送。')
|
||||
except Exception:
|
||||
raise HTTPException(503,'测试提醒发送失败,请检查服务配置或接收地址') from None
|
||||
return {'sent':True}
|
||||
@@ -0,0 +1,667 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
:root {
|
||||
--canvas:#f5f7fc; --surface:#fff; --ink:#20283e; --muted:#77839a;
|
||||
--accent:#5360ea; --accent-dark:#414dd1; --accent-soft:#eff1ff;
|
||||
--border:#e9edf5; --success:#168576; --warning:#a96616; --danger:#c4495a;
|
||||
font-family:"Noto Sans CJK SC","PingFang SC","Microsoft YaHei",Arial,sans-serif;
|
||||
font-size:14px; font-synthesis:none; color:var(--ink); background:var(--canvas);
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
* {box-sizing:border-box}
|
||||
body {margin:0}
|
||||
.bootstrap {min-height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:22px;color:var(--muted)}
|
||||
.auth-shell {min-height:100vh;display:grid;grid-template-columns:minmax(0,1.15fr) minmax(420px,1fr);background:white}
|
||||
.auth-story {padding:48px 58px 30px;background:#f0f3fc;display:flex;flex-direction:column;border-right:1px solid #e2e7f4}
|
||||
.auth-story .brand {margin:0;align-self:flex-start}
|
||||
.auth-story-content {max-width:560px;margin:auto 0;padding:60px 0}
|
||||
.auth-story h1 {font-size:43px;line-height:1.4;letter-spacing:1px;font-weight:700;margin-bottom:23px}
|
||||
.auth-story-content>p {font-size:14px;line-height:2.1;color:#6f7e9c}
|
||||
.connection-illustration {display:flex;align-items:center;gap:40px;margin-top:48px;padding:28px 0}
|
||||
.illustration-sim {width:146px;height:178px;border:1px solid #c6ceee;flex:none;background:white;clip-path:polygon(0 0,76% 0,100% 20%,100% 100%,0 100%);border-radius:9px;position:relative;display:flex;flex-direction:column;padding:21px;transform:rotate(-6deg)}
|
||||
.illustration-sim>.icon {color:var(--accent);width:27px;height:27px;margin-bottom:14px}
|
||||
.illustration-sim>span {font-size:11px;color:#6e7daa}
|
||||
.sim-chip {display:grid;grid-template-columns:repeat(3,1fr);gap:2px;width:62px;height:47px;margin-top:20px;background:#e2e6ff;border:1px solid #9faaf2;border-radius:6px;overflow:hidden}
|
||||
.sim-chip i {background:#eff1ff;border:1px solid #c7cefa}
|
||||
.illustration-timeline {display:grid;gap:27px}
|
||||
.illustration-timeline>div {position:relative;padding-left:23px}
|
||||
.illustration-timeline>div:not(:last-child)::after {content:"";position:absolute;top:15px;bottom:-30px;left:4px;width:1px;background:#c7d0ee}
|
||||
.timeline-dot {position:absolute;left:0;top:5px;width:9px;height:9px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 5px #e2e6ff;z-index:1}
|
||||
.timeline-dot.done {background:#39a390;box-shadow:none}
|
||||
.timeline-dot.hollow {background:#f0f3fc;box-shadow:none;border:1px solid #99a8c8}
|
||||
.illustration-timeline strong {font-size:12px;font-weight:550;display:block}
|
||||
.illustration-timeline small {font-size:10px;display:block;color:#8c98b2;margin-top:6px;line-height:1.8}
|
||||
.auth-story-foot {font-size:11px;color:#8d9ab5}
|
||||
.auth-panel {position:relative;display:flex;align-items:center;justify-content:center;padding:60px 42px 90px}
|
||||
.auth-form-wrap {width:100%;max-width:350px}
|
||||
.auth-tabs {display:flex;gap:26px;border-bottom:1px solid var(--border);margin-bottom:34px}
|
||||
.auth-tabs button {padding:11px 0;font-size:13px;color:var(--muted);position:relative}
|
||||
.auth-tabs button.active {color:var(--accent);font-weight:650}
|
||||
.auth-tabs button.active::after {content:"";position:absolute;bottom:-1px;left:0;right:0;height:2px;background:var(--accent)}
|
||||
.auth-form-wrap h2 {font-size:23px;font-weight:650;margin-bottom:10px}
|
||||
.auth-intro {font-size:12px;line-height:1.8;color:var(--muted);margin-bottom:28px}
|
||||
#auth-form {display:grid;gap:21px}
|
||||
#auth-form .field input {padding:12px 14px;font-size:13px}
|
||||
.auth-submit {width:100%;padding:13px;margin-top:6px;justify-content:space-between}
|
||||
.auth-account-note {display:flex;align-items:center;justify-content:center;gap:6px;margin-top:25px;color:#95a0b5;font-size:11px}
|
||||
.auth-account-note>.icon {width:14px;height:14px}
|
||||
.auth-footer {position:absolute;bottom:30px;font-size:10px;color:#a1aabc}
|
||||
.channel-actions {display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin-top:17px}
|
||||
.channel-help {font-size:10px;color:var(--muted);line-height:1.8;margin-top:12px}
|
||||
.service-status {display:grid;gap:10px;border-top:1px solid var(--border);padding-top:17px;margin-top:17px;font-size:10px;color:var(--muted)}
|
||||
.service-status>span {display:flex;justify-content:space-between}
|
||||
.service-status strong {font-weight:500;color:var(--ink)}
|
||||
.binding-steps {display:grid;gap:19px}
|
||||
.binding-steps a {text-decoration:none}
|
||||
.binding-steps>p {font-size:12px;line-height:1.9;color:var(--muted)}
|
||||
.binding-steps .field input {font-size:10px}
|
||||
.notification-log {margin-top:35px}
|
||||
.notification-log-empty {display:flex;align-items:center;gap:12px;background:white;border:1px dashed #dce2f0;border-radius:9px;padding:27px;font-size:11px;color:var(--muted);line-height:1.9}
|
||||
.money-summary {display:grid;grid-template-columns:1fr 1fr;margin:17px 0 22px;padding:19px 24px;background:white;border:1px solid var(--border);border-radius:10px;gap:24px}
|
||||
.money-summary>div {min-width:0}
|
||||
.money-summary>div+div {border-left:1px solid var(--border);padding-left:24px}
|
||||
.money-summary h2 {display:flex;align-items:center;gap:7px;font-size:11px;font-weight:500;color:var(--muted);margin-bottom:12px}
|
||||
.money-summary h2 .icon {width:15px;height:15px}
|
||||
.money-summary p {font-size:10px;color:var(--muted);line-height:1.8;margin-top:9px}
|
||||
.money-tags {display:flex;flex-wrap:wrap;gap:5px 8px;align-items:center;min-width:0}
|
||||
.money-tag {display:inline-block;background:#f3f5fc;color:#576482;border:1px solid #e8edf7;border-radius:5px;padding:4px 7px;font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.money-unknown {color:var(--muted);font-size:10px}
|
||||
.card-balance {display:flex;align-items:flex-start;gap:8px;margin:0 0 14px;padding-top:4px;min-width:0}
|
||||
.card-balance>span {font-size:10px;color:var(--muted);line-height:25px;flex:none}
|
||||
.card-balance .money-tag {font-size:10px;padding:3px 6px}
|
||||
.card-rule-name {min-width:0}
|
||||
.card-rule-name>span {white-space:normal;line-height:1.8}
|
||||
.task-info p {flex-wrap:wrap}
|
||||
.task-info .renewal-cost {font-variant-numeric:tabular-nums;color:var(--accent);font-weight:550}
|
||||
.detail-money {grid-column:1/-1}
|
||||
.detail-rule-cost {font-size:11px;display:flex;gap:12px;align-items:center;color:var(--muted);margin-top:12px}
|
||||
.detail-rule-cost strong {font-weight:500;color:var(--ink);font-variant-numeric:tabular-nums}
|
||||
#balance-fields {display:grid;gap:12px}
|
||||
.balance-row {display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 30px;gap:12px;align-items:end}
|
||||
.balance-row>.icon-button {height:39px;padding:5px}
|
||||
.form-help {font-size:10px;color:var(--muted);line-height:1.8;margin-top:11px}
|
||||
.form-section-title>h3 {font-size:12px;font-weight:600}
|
||||
.balance-action {margin-top:19px;align-items:flex-start}
|
||||
.balance-action input {margin-top:3px;flex:none}
|
||||
.calculation-preview.balance-preview strong {font-size:12px;text-align:right;line-height:1.7;max-width:65%;white-space:normal;overflow-wrap:anywhere}
|
||||
.balance-preview .money-error {color:var(--danger);font-size:10px}
|
||||
@media(max-width:600px) {
|
||||
.money-summary {grid-template-columns:1fr;padding:18px 20px;gap:15px}
|
||||
.money-summary>div+div {border-left:0;border-top:1px solid var(--border);padding:15px 0 0}
|
||||
.balance-row {gap:8px;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 26px}
|
||||
.balance-row .field select,.balance-row .field input {padding:10px 7px;font-size:11px}
|
||||
}
|
||||
.status.success {background:#eaf8f3;color:#168576}
|
||||
.status.danger {background:#fff0f1;color:#c4495a}
|
||||
.user-text {min-width:0}
|
||||
.user-text strong {overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
@media(max-width:1000px) {
|
||||
.auth-story {padding:38px 35px 25px}
|
||||
.auth-story h1 {font-size:33px}
|
||||
.connection-illustration {gap:25px}
|
||||
.illustration-sim {width:116px;height:157px;padding:17px}
|
||||
}
|
||||
@media(max-width:760px) {
|
||||
.auth-shell {grid-template-columns:1fr}
|
||||
.auth-story {padding:26px;border-right:0;border-bottom:1px solid #e2e7f4}
|
||||
.auth-story .brand {gap:9px}
|
||||
.auth-story .brand-symbol {width:33px;height:36px}
|
||||
.auth-story .brand-name {font-size:18px}
|
||||
.auth-story .brand-name {display:block}
|
||||
.auth-story-content {padding:29px 0 0;max-width:none}
|
||||
.auth-story h1 {font-size:27px;line-height:1.5;margin-bottom:12px}
|
||||
.auth-story h1 br {display:initial}
|
||||
.auth-story-content>p {font-size:12px;line-height:1.9}
|
||||
.connection-illustration,.auth-story-foot {display:none}
|
||||
.auth-panel {padding:30px 26px 76px;align-items:flex-start}
|
||||
.auth-form-wrap {max-width:420px}
|
||||
.auth-tabs {margin-bottom:24px}
|
||||
.auth-form-wrap h2 {font-size:21px}
|
||||
.auth-intro {margin-bottom:24px}
|
||||
.auth-footer {bottom:22px}
|
||||
}
|
||||
button,input,select,textarea {font:inherit}
|
||||
button {cursor:pointer}
|
||||
button,a,input,select,textarea { -webkit-tap-highlight-color:transparent }
|
||||
button {color:inherit}
|
||||
button:disabled {cursor:not-allowed;opacity:.5}
|
||||
button:focus-visible,a:focus-visible {outline:3px solid #8b94ff;outline-offset:3px}
|
||||
input:focus,select:focus,textarea:focus {outline:2px solid #9aa3ff;outline-offset:1px;border-color:var(--accent)}
|
||||
button {border:0;background:none}
|
||||
h1,h2,h3,h4,p {margin:0}
|
||||
svg.icon {width:20px;height:20px;flex:none;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle}
|
||||
.small-icon svg.icon {width:16px;height:16px}
|
||||
.app-shell {display:grid;grid-template-columns:222px minmax(0,1fr);min-height:100vh}
|
||||
.sidebar {position:fixed;inset:0 auto 0 0;width:222px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:30;padding:33px 17px 18px}
|
||||
.brand {display:flex;align-items:center;gap:11px;margin:0 15px 45px;text-decoration:none;color:var(--ink)}
|
||||
.brand-symbol {width:39px;height:42px;background:var(--accent);border-radius:11px;display:grid;place-items:center;color:white;box-shadow:0 4px 9px #5360ea21}
|
||||
.brand-symbol svg {width:26px;height:30px}
|
||||
.brand-name {font-size:21px;font-weight:750;letter-spacing:1.2px;line-height:1.3}
|
||||
.brand-name small {display:block;font-size:10px;letter-spacing:1.8px;font-weight:550;color:#97a0b4;margin-top:4px}
|
||||
.nav-caption {font-size:11px;color:#939daf;padding-left:20px;margin-bottom:13px}
|
||||
.nav-list {display:grid;gap:7px}
|
||||
.nav-button {position:relative;display:flex;align-items:center;gap:13px;text-align:left;width:100%;padding:12px 18px;border-radius:8px;color:#7b8498;font-size:13px;font-weight:500;transition:background .15s}
|
||||
.nav-button:hover {background:#f7f8fc;color:var(--ink)}
|
||||
.nav-button.active {background:var(--accent-soft);color:var(--accent);font-weight:650}
|
||||
.nav-button.active::before {content:"";position:absolute;left:0;top:16px;bottom:16px;width:3px;border-radius:4px;background:var(--accent)}
|
||||
.nav-count {margin-left:auto;font-size:10px;line-height:19px;min-width:20px;border-radius:5px;text-align:center;background:#e0e4ff;color:var(--accent);font-weight:700}
|
||||
.sidebar-spacer {flex:1;min-height:32px}
|
||||
.sidebar-note {margin:14px 9px 23px;padding:16px 14px 14px;background:#f8f9ff;border:1px solid #eceefb;border-radius:10px;position:relative;overflow:hidden}
|
||||
.sidebar-note::after {content:"";position:absolute;width:65px;height:65px;border:1px solid #e2e6ff;border-radius:50%;right:-28px;top:-24px}
|
||||
.note-icon {color:var(--accent);margin-bottom:9px}
|
||||
.sidebar-note h4 {font-size:12px;font-weight:600;margin-bottom:6px}
|
||||
.sidebar-note p {font-size:10px;line-height:1.9;color:#8a93a8}
|
||||
.sidebar-note button {font-size:11px;color:var(--accent);display:flex;align-items:center;gap:8px;margin-top:11px;padding:0}
|
||||
.sidebar-user {border-top:1px solid var(--border);padding:18px 9px 1px;display:flex;align-items:center;gap:10px;width:100%;text-align:left}
|
||||
.avatar {width:34px;height:34px;border-radius:11px;background:#eee7e0;color:#8b6c55;display:grid;place-items:center;flex:none;font-size:12px;font-weight:600}
|
||||
.user-text {flex:1}
|
||||
.user-text strong {font-size:12px;font-weight:600;display:block}
|
||||
.user-text small {font-size:10px;color:#99a1b2;display:block;margin-top:3px}
|
||||
.main-shell {grid-column:2;min-width:0}
|
||||
.topbar {height:77px;display:flex;align-items:center;justify-content:space-between;padding:0 34px;background:#fff;border-bottom:1px solid var(--border);gap:20px}
|
||||
.breadcrumbs {display:flex;align-items:center;gap:13px;font-size:12px;color:#99a1b3;white-space:nowrap}
|
||||
.breadcrumbs strong {font-weight:500;color:#657087}
|
||||
.breadcrumbs svg.icon {width:13px;height:13px}
|
||||
.topbar-right {display:flex;align-items:center;gap:19px}
|
||||
.global-search {display:flex;align-items:center;gap:9px;padding:9px 11px;width:245px;border:1px solid var(--border);border-radius:7px;background:#f9fafc;color:#a3acc0}
|
||||
.global-search svg {width:16px;height:16px}
|
||||
.global-search input {border:0;outline:none;background:none;width:100%;font-size:11px;min-width:0;color:var(--ink)}
|
||||
.global-search input:focus {outline:none}
|
||||
.global-search:focus-within {outline:2px solid #abb2ff}
|
||||
.global-search input::placeholder {color:#99a3b5}
|
||||
kbd {font:10px Arial;color:#a5adbc;border:1px solid #e2e6ef;padding:1px 4px;border-radius:3px;white-space:nowrap}
|
||||
.icon-button {display:inline-flex;align-items:center;justify-content:center;padding:7px;color:#818b9e;border-radius:7px;gap:4px}
|
||||
.icon-button:hover {background:var(--accent-soft);color:var(--accent)}
|
||||
.notification-button {position:relative}
|
||||
.notification-button::after {content:"";position:absolute;width:5px;height:5px;background:var(--accent);border:2px solid white;border-radius:50%;top:6px;right:8px;box-sizing:content-box}
|
||||
.topbar-divider {height:23px;width:1px;background:var(--border);margin:0 -4px}
|
||||
.topbar-avatar {display:flex;align-items:center;gap:7px;padding:0}
|
||||
.topbar-avatar .avatar {width:32px;height:32px;border-radius:50%}
|
||||
.mobile-menu {display:none}
|
||||
.content {padding:29px 34px 30px;max-width:1550px;margin:auto}
|
||||
.page-heading {display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:27px}
|
||||
.heading-title {display:flex;align-items:center;gap:12px;margin-bottom:8px}
|
||||
h1 {font-size:27px;font-weight:650;letter-spacing:-.5px}
|
||||
.demo-badge {font-size:9px;border:1px solid #dfe4f1;color:#8893a9;padding:3px 6px;border-radius:4px;background:#fff;white-space:nowrap;letter-spacing:.3px}
|
||||
.page-heading p {font-size:12px;color:var(--muted);line-height:1.8}
|
||||
.heading-actions {display:flex;gap:10px;flex:none}
|
||||
.btn {display:inline-flex;align-items:center;justify-content:center;gap:7px;border-radius:7px;padding:10px 15px;font-size:12px;line-height:1.4;font-weight:500;white-space:nowrap;border:1px solid var(--border);background:white}
|
||||
.btn svg.icon {width:16px;height:16px}
|
||||
.btn:hover {border-color:#c5ccdd;background:#fafbfe}
|
||||
.btn-primary {color:#fff;background:var(--accent);border-color:var(--accent);box-shadow:0 3px 7px #5360ea16}
|
||||
.btn-primary:hover {background:var(--accent-dark);border-color:var(--accent-dark)}
|
||||
.btn-soft {background:var(--accent-soft);color:var(--accent);border-color:#e3e7ff}
|
||||
.btn-danger {color:var(--danger);border-color:#f0d9de;background:#fff6f8}
|
||||
.btn-small {font-size:11px;padding:7px 11px;border-radius:6px}
|
||||
.text-button {padding:0;color:var(--accent);display:inline-flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}
|
||||
.text-button svg.icon {width:14px;height:14px}
|
||||
.stat-strip {display:grid;grid-template-columns:repeat(4,1fr);background:#fff;border:1px solid var(--border);border-radius:12px;padding:23px 0;margin-bottom:23px}
|
||||
.stat {padding:0 24px;position:relative;border-right:1px solid var(--border)}
|
||||
.stat:last-child {border-right:0}
|
||||
.stat-label {font-size:11px;color:#78849a;display:flex;align-items:center;gap:7px}
|
||||
.stat-label svg.icon {width:15px;height:15px;color:#949eb3}
|
||||
.stat-line {display:flex;align-items:baseline;gap:8px;margin-top:8px}
|
||||
.stat-value {font-size:31px;line-height:1.2;font-weight:650;letter-spacing:-1px;font-variant-numeric:tabular-nums}
|
||||
.stat-unit {font-size:11px;color:#8e98ac}
|
||||
.stat-foot {font-size:10px;color:#98a1b2;line-height:1.7;margin-top:8px}
|
||||
.stat-foot .stat-colored {color:var(--accent);font-weight:600}
|
||||
.stat:nth-child(2) .stat-value {color:#d59a3e}
|
||||
.stat:nth-child(2) .stat-foot {color:#b68e54}
|
||||
.renewal-strip {border-radius:10px;background:#edf0ff;border:1px solid #e4e8fc;padding:17px 21px;margin-bottom:27px;display:flex;align-items:center;gap:19px;position:relative}
|
||||
.renewal-strip-icon {width:39px;height:39px;border-radius:10px;background:#fff;display:grid;place-items:center;color:var(--accent);flex:none}
|
||||
.renewal-intro {flex:none;min-width:125px}
|
||||
.renewal-intro h3 {font-size:12px;font-weight:650;margin-bottom:5px}
|
||||
.renewal-intro p {font-size:10px;color:#8690b0}
|
||||
.renewal-pills {display:flex;flex:1;gap:18px;justify-content:space-around;min-width:0}
|
||||
.renewal-pill {display:flex;align-items:center;gap:9px;min-width:0;text-align:left;padding:0}
|
||||
.renewal-pill .provider-mini {width:29px;height:29px;font-size:15px;border-radius:8px}
|
||||
.renewal-pill strong {font-size:10px;display:block;font-weight:550;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100px}
|
||||
.renewal-pill small {font-size:9px;color:#8992af;display:block;margin-top:4px;white-space:nowrap}
|
||||
.renewal-strip>.text-button {flex:none;margin-left:5px;font-size:10px}
|
||||
.workspace-grid {display:grid;grid-template-columns:minmax(0,1fr) 274px;gap:24px;align-items:start}
|
||||
.section-header {display:flex;align-items:center;justify-content:space-between;margin-bottom:18px;gap:14px}
|
||||
.section-title {font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}
|
||||
.section-title .count {font-size:10px;font-weight:500;color:#929bb0;background:#e9edf5;padding:2px 6px;border-radius:4px}
|
||||
.section-controls {display:flex;gap:8px;align-items:center}
|
||||
.segmented {display:flex;gap:2px;background:#e9edf5;padding:3px;border-radius:7px;align-items:center}
|
||||
.segmented button {font-size:10px;color:#8c95a9;padding:5px 11px;border-radius:5px;white-space:nowrap}
|
||||
.segmented button.active {color:var(--ink);background:white;box-shadow:0 1px 3px #1720390b;font-weight:550}
|
||||
.segmented button span {font-size:9px;margin-left:4px;color:#a3acbc}
|
||||
.view-toggle {display:flex;gap:0;border:1px solid #e2e6ee;border-radius:6px;overflow:hidden;background:white}
|
||||
.view-toggle button {padding:5px 7px;color:#a2abbd;display:flex}
|
||||
.view-toggle button svg {width:15px;height:15px}
|
||||
.view-toggle button.active {background:#eef0ff;color:var(--accent)}
|
||||
.card-grid {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:17px}
|
||||
.sim-card {border:1px solid #e4e9f2;background:white;border-radius:12px;padding:19px 19px 0;min-width:0;position:relative;transition:border-color .16s,box-shadow .16s}
|
||||
.sim-card:hover {border-color:#c9cff3;box-shadow:0 5px 19px #30395007}
|
||||
.card-top {display:flex;align-items:center;gap:11px;margin-bottom:17px}
|
||||
.provider-logo,.provider-mini {display:grid;place-items:center;background:var(--brand-color);color:white;font-size:22px;font-weight:750;border-radius:10px;width:39px;height:40px;font-family:Arial,sans-serif;flex:none;line-height:1}
|
||||
.provider-logo[data-provider="giffgaff"] {background:#f8fafc;color:#263044;border:1px solid #e4e9f2;font-size:31px;font-weight:800}
|
||||
.provider-logo[data-provider="3HK"] {font-size:29px;font-weight:800}
|
||||
.provider-mini {width:31px;height:31px;font-size:16px;border-radius:8px}
|
||||
.card-heading {flex:1;min-width:0}
|
||||
.card-heading button {display:block;padding:0;max-width:100%;text-align:left}
|
||||
.card-heading h3 {font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.card-heading p {font-size:11px;color:#7c889e;margin-top:5px}
|
||||
.type-tag {font-size:9px;padding:3px 6px;border-radius:4px;background:#eef0ff;color:#5863c5;letter-spacing:.2px;font-weight:650;display:inline-flex;align-items:center;gap:4px;white-space:nowrap}
|
||||
.type-tag.physical {background:#eef2f6;color:#8b96a7}
|
||||
.type-tag svg {width:11px;height:11px}
|
||||
.card-top>.icon-button {padding:3px;margin-right:-6px;margin-left:-2px}
|
||||
.card-top>.icon-button svg {width:16px;height:16px}
|
||||
.card-number {display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:17px;letter-spacing:.3px;font-variant-numeric:tabular-nums;font-weight:500;min-height:24px}
|
||||
.card-number.data-number {font-size:13px;letter-spacing:0;color:#525e75}
|
||||
.flag {width:18px;height:13px;flex:none;display:inline-block;border-radius:2px;overflow:hidden;box-shadow:0 0 0 1px #00000007;position:relative}
|
||||
.flag svg {display:block;width:100%;height:100%}
|
||||
.card-meta {display:flex;gap:13px;font-size:10px;color:#7c889e;margin-bottom:17px}
|
||||
.card-meta span {display:flex;align-items:center;gap:4px}
|
||||
.card-meta svg {width:12px;height:12px}
|
||||
.card-rule {padding:11px 12px;background:#f7f8fc;border-radius:7px;display:flex;align-items:center;gap:8px;margin-bottom:14px;position:relative}
|
||||
.card-rule>.icon {width:15px;height:15px;color:#7f8aaa}
|
||||
.card-rule-name {font-size:11px;font-weight:500;flex:1;display:flex;align-items:center;gap:7px}
|
||||
.card-rule-name span {font-size:10px;color:#7e8ba3;font-weight:400}
|
||||
.card-rule-date {font-size:11px;font-weight:600;color:#536482;font-variant-numeric:tabular-nums}
|
||||
.platform-label {font-size:10px;color:#7e8ba3;margin-bottom:9px;display:flex;align-items:center;gap:5px}
|
||||
.platform-label svg {width:12px;height:12px}
|
||||
.platform-tags {display:flex;gap:5px;flex-wrap:wrap;min-height:21px;margin-bottom:16px}
|
||||
.platform-tag {font-size:9px;color:#68758e;border:1px solid #e4e9f2;background:white;border-radius:4px;padding:4px 6px;display:inline-flex;align-items:center;gap:4px;line-height:1.25}
|
||||
.platform-tag .platform-dot {width:9px;height:9px;border-radius:3px;background:var(--platform-color,#7885a7);display:grid;place-items:center;color:#fff;font:bold 7px Arial}
|
||||
.card-footer {border-top:1px solid #edf0f6;display:flex;align-items:center;justify-content:space-between;margin:0 -19px;padding:12px 19px;gap:8px;font-size:9px}
|
||||
.status {display:inline-flex;align-items:center;gap:5px;font-size:10px;color:var(--success);white-space:nowrap}
|
||||
.status::before {content:"";width:5px;height:5px;background:currentColor;border-radius:50%;flex:none}
|
||||
.status.warning {color:var(--warning)}
|
||||
.status.danger {color:var(--danger)}
|
||||
.status.neutral {color:#939daf}
|
||||
.card-footer .text-button {font-size:10px;color:#78869f}
|
||||
.card-footer .text-button:hover {color:var(--accent)}
|
||||
.card-footer .text-button svg {width:12px;height:12px}
|
||||
.collection-foot {display:flex;justify-content:space-between;align-items:center;margin-top:18px;color:#a0a8b9;font-size:10px;gap:12px}
|
||||
.collection-foot button {font-size:10px}
|
||||
.right-rail {display:grid;gap:20px;padding-top:1px}
|
||||
.panel {border:1px solid var(--border);background:white;border-radius:11px;overflow:hidden}
|
||||
.panel-heading {display:flex;align-items:center;justify-content:space-between;padding:18px 18px 14px}
|
||||
.panel-heading h2 {font-size:13px;font-weight:600;display:flex;align-items:center;gap:7px}
|
||||
.panel-heading svg {width:15px;height:15px;color:#8b95b0}
|
||||
.calendar-toolbar {display:flex;justify-content:space-between;align-items:center;padding:0 18px 14px}
|
||||
.calendar-toolbar strong {font-size:12px;font-weight:550}
|
||||
.calendar-controls {display:flex;gap:3px}
|
||||
.calendar-controls .icon-button {padding:4px;border:1px solid #edf0f6;border-radius:5px}
|
||||
.calendar-controls svg {width:12px;height:12px}
|
||||
.calendar-grid {display:grid;grid-template-columns:repeat(7,1fr);padding:0 12px 16px;row-gap:4px}
|
||||
.weekday {text-align:center;font-size:9px;color:#a2aabc;line-height:25px;margin-bottom:3px}
|
||||
.calendar-day {height:30px;font-size:10px;color:#66738d;position:relative;margin:0 2px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums}
|
||||
.calendar-day.other {color:#c4cada}
|
||||
.calendar-day:hover {background:#f2f4ff}
|
||||
.calendar-day.today {background:var(--accent);color:white;box-shadow:0 2px 4px #5360ea20}
|
||||
.calendar-day.selected:not(.today) {background:var(--accent-soft);box-shadow:inset 0 0 0 1px #cfd5ff;color:var(--accent)}
|
||||
.calendar-day.has-task::after {content:"";width:3px;height:3px;border-radius:50%;background:#e7a047;position:absolute;bottom:3px;left:calc(50% - 1.5px)}
|
||||
.calendar-day.today.has-task::after {background:white}
|
||||
.calendar-legend {font-size:9px;color:#a0a9ba;border-top:1px solid #eff1f6;padding:11px 18px;display:flex;gap:16px}
|
||||
.calendar-legend span {display:flex;align-items:center;gap:5px}
|
||||
.legend-dot {width:5px;height:5px;background:var(--accent);border-radius:50%}
|
||||
.legend-dot.orange {background:#e7a047}
|
||||
.calendar-events {border-top:1px solid #eff1f6;padding:16px 18px}
|
||||
.calendar-events h4 {font-size:10px;color:#8995ab;font-weight:500;margin-bottom:13px}
|
||||
.calendar-event {display:flex;align-items:center;gap:10px;width:100%;text-align:left;padding:0;margin:12px 0}
|
||||
.calendar-event:last-child {margin-bottom:0}
|
||||
.calendar-event-day {background:#f5f6fc;border:1px solid #eceef6;border-radius:6px;width:32px;height:35px;display:flex;flex-direction:column;align-items:center;justify-content:center;flex:none;color:#717d97;line-height:1.3;font-size:12px;font-weight:650}
|
||||
.calendar-event-day small {font-size:7px;font-weight:400;color:#9ea7ba}
|
||||
.calendar-event-text {flex:1}
|
||||
.calendar-event-text strong {display:block;font-size:11px;font-weight:500}
|
||||
.calendar-event-text small {display:block;font-size:10px;color:#7e8ba3;margin-top:4px}
|
||||
.calendar-event .icon {width:13px;height:13px;color:#b1b9c9}
|
||||
.empty-calendar {font-size:10px;color:#a2aabb;line-height:1.8;padding:5px 0}
|
||||
.reminder-panel {background:#fff}
|
||||
.reminder-head {padding:18px 18px 12px}
|
||||
.reminder-head .reminder-icon {width:32px;height:32px;display:grid;place-items:center;border-radius:9px;background:#f0f2ff;color:var(--accent);margin-bottom:12px}
|
||||
.reminder-head h3 {font-size:12px;font-weight:600;line-height:1.7;margin-bottom:6px}
|
||||
.reminder-head p {font-size:11px;color:#7e8ba3;line-height:1.9}
|
||||
.channel-line {display:flex;gap:9px;align-items:center;margin:0 18px;padding:11px 0;border-bottom:1px solid #f0f2f7}
|
||||
.channel-line:last-of-type {border-bottom:0}
|
||||
.channel-symbol {width:24px;height:24px;border-radius:7px;display:grid;place-items:center;background:#eaf5fe;color:#4b9acd}
|
||||
.channel-symbol.email {background:#f0effe;color:#8a7aca}
|
||||
.channel-symbol svg {width:13px;height:13px}
|
||||
.channel-line strong {font-size:11px;font-weight:500;flex:1}
|
||||
.channel-label {font-size:8px;color:#92a0b6;display:flex;align-items:center;gap:3px}
|
||||
.channel-label svg {width:10px;height:10px;color:var(--success)}
|
||||
.reminder-panel .panel-actions {display:flex;justify-content:space-between;align-items:center;padding:13px 18px 17px}
|
||||
.reminder-panel .text-button {font-size:9px}
|
||||
.rail-tip {font-size:9px;color:#a0a9bb;text-align:center;display:flex;justify-content:center;align-items:center;gap:5px;line-height:1.7}
|
||||
.rail-tip svg {width:11px;height:11px}
|
||||
.page-footer {display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:9px;color:#a5aebf;margin-top:34px;padding-top:17px;border-top:1px solid #e9edf5}
|
||||
.page-footer span {display:flex;align-items:center;gap:5px}
|
||||
.page-footer svg {width:12px;height:12px}
|
||||
.card-list {background:white;border:1px solid var(--border);border-radius:11px;overflow-x:auto}
|
||||
.card-list table {width:100%;border-collapse:collapse;min-width:610px}
|
||||
th {font-size:10px;font-weight:500;color:#8e99ae;text-align:left;background:#fafbfe;padding:14px 16px;border-bottom:1px solid var(--border)}
|
||||
td {font-size:11px;padding:17px 16px;border-bottom:1px solid #eef1f7;white-space:nowrap}
|
||||
tr:last-child td {border-bottom:0}
|
||||
.list-card-name {display:flex;align-items:center;gap:9px;text-align:left;padding:0}
|
||||
.list-card-name strong {font-size:11px;font-weight:550;display:block}
|
||||
.list-card-name small {font-size:9px;color:#a0a8bb;display:block;margin-top:4px}
|
||||
.empty-state {border:1px dashed #dce2f0;border-radius:12px;padding:55px 20px;background:#ffffff90;text-align:center;grid-column:1/-1}
|
||||
.empty-state>.icon {width:36px;height:36px;color:#acb4cd;margin-bottom:15px}
|
||||
.empty-state h3 {font-size:14px;font-weight:550;margin-bottom:8px}
|
||||
.empty-state p {font-size:11px;color:#98a2b7;line-height:1.9;margin-bottom:20px}
|
||||
.section-full {margin-top:8px}
|
||||
.task-list {display:grid;gap:12px}
|
||||
.task-row {background:white;border:1px solid var(--border);border-radius:10px;padding:19px 21px;display:flex;align-items:center;gap:17px}
|
||||
.task-date {flex:none;width:62px;border-right:1px solid var(--border);padding-right:16px;text-align:center;font-size:21px;font-weight:650;color:#5360a5;line-height:1.2}
|
||||
.task-date small {display:block;font-size:9px;font-weight:400;color:#9aa3b7;margin-bottom:5px}
|
||||
.task-row .provider-mini {width:36px;height:36px;border-radius:9px}
|
||||
.task-info {flex:1;min-width:0}
|
||||
.task-info h3 {font-size:12px;font-weight:550;margin-bottom:7px}
|
||||
.task-info p {font-size:10px;color:#939db2;display:flex;gap:15px;align-items:center;line-height:1.8}
|
||||
.task-info p span {display:flex;gap:5px;align-items:center}
|
||||
.task-info p svg {width:12px;height:12px}
|
||||
.task-row>.status {margin-right:10px}
|
||||
.platform-table td {white-space:normal}
|
||||
.platform-name-cell {display:flex;gap:10px;align-items:center}
|
||||
.platform-initial {width:29px;height:29px;border-radius:8px;background:var(--platform-color);color:white;display:grid;place-items:center;font-weight:650;font-family:Arial;font-size:14px}
|
||||
.muted {color:var(--muted)}
|
||||
.settings-layout {display:grid;grid-template-columns:minmax(0,1fr) 305px;gap:23px;align-items:start}
|
||||
.settings-main {display:grid;gap:19px}
|
||||
.settings-panel {padding:25px}
|
||||
.settings-panel h2 {font-size:14px;font-weight:600;margin-bottom:7px;display:flex;align-items:center;gap:9px}
|
||||
.settings-panel h2 svg {width:17px;height:17px;color:#7e8bb2}
|
||||
.settings-panel>p {font-size:11px;color:#98a1b5;line-height:1.8;margin-bottom:23px}
|
||||
.settings-channel {border:1px solid var(--border);border-radius:9px;margin-top:13px;padding:17px}
|
||||
.settings-channel-head {display:flex;align-items:center;gap:10px}
|
||||
.settings-channel-head>div:nth-child(2) {flex:1}
|
||||
.settings-channel-head strong {display:block;font-size:12px;font-weight:550}
|
||||
.settings-channel-head p {font-size:10px;color:#98a2b6;margin-top:5px}
|
||||
.switch {appearance:none;width:31px;height:18px;border-radius:12px;background:#cfd5e3;cursor:pointer;position:relative;flex:none;transition:background .15s;border:0}
|
||||
.switch::after {content:"";position:absolute;top:3px;left:3px;width:12px;height:12px;background:white;border-radius:50%;transition:transform .15s;box-shadow:0 1px 3px #0001}
|
||||
.switch:checked {background:var(--accent)}
|
||||
.switch:checked::after {transform:translateX(13px)}
|
||||
.settings-channel .field {margin-top:16px}
|
||||
.channel-setup {display:flex;align-items:center;justify-content:space-between;gap:12px;margin:16px 0 12px;padding:12px 0;border-block:1px solid var(--border);flex-wrap:wrap}
|
||||
.channel-setup p {display:flex;align-items:center;gap:7px;line-height:1.7;font-size:11px;color:var(--muted);margin:0;flex:1;min-width:140px}
|
||||
.channel-setup p svg {width:14px;height:14px;flex:none}
|
||||
.service-config-button {color:var(--accent);border-color:#d4dafb;background:var(--accent-soft);flex:none}
|
||||
.config-intro {display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.8;color:#637087;margin-bottom:20px}
|
||||
.config-intro svg {width:18px;height:18px;color:var(--accent);flex:none;margin-top:3px}
|
||||
.config-check-note {font-size:11px;color:var(--muted);line-height:1.8;margin:18px 0 0}
|
||||
.config-clear {color:var(--danger);margin-top:18px}
|
||||
.config-feedback {font-size:12px;color:var(--success);padding:12px 14px;border:1px solid #cce5df;background:#f2faf7;border-radius:7px;line-height:1.8;margin-bottom:18px}
|
||||
.config-feedback[hidden] {display:none}
|
||||
.config-modal .modal-footer {flex-wrap:wrap}
|
||||
.fields-grid {display:grid;grid-template-columns:1fr 1fr;gap:18px}
|
||||
.field {display:flex;flex-direction:column;gap:8px;min-width:0}
|
||||
.field>label,.field-label {font-size:11px;color:#637087;font-weight:500;display:block}
|
||||
.field label span {color:#cb6573;margin-left:3px}
|
||||
.field input,.field select,.field textarea {width:100%;border:1px solid #e0e5ef;border-radius:7px;background:white;padding:10px 11px;font-size:12px;line-height:1.5;color:var(--ink);min-width:0}
|
||||
.field input::placeholder,.field textarea::placeholder {color:#aab2c1}
|
||||
.field textarea {resize:vertical;min-height:75px;line-height:1.8}
|
||||
.field small {font-size:10px;color:#99a3b7;line-height:1.7}
|
||||
.field.wide {grid-column:1/-1}
|
||||
.form-divider {border:0;border-top:1px solid var(--border);margin:22px 0}
|
||||
.form-section-title {font-size:12px;font-weight:600;margin-bottom:15px;display:flex;justify-content:space-between}
|
||||
.offsets {display:flex;flex-wrap:wrap;gap:9px;margin:10px 0 17px}
|
||||
.offset-check {display:flex;align-items:center;gap:7px;background:#f6f7fc;border:1px solid #e8ecf5;border-radius:7px;padding:9px 13px;font-size:11px;color:#78869f;cursor:pointer}
|
||||
.offset-check:has(input:checked) {background:#eff1ff;border-color:#cfd5ff;color:var(--accent)}
|
||||
input[type=checkbox]:not(.switch) {accent-color:var(--accent);width:13px;height:13px;margin:0}
|
||||
.checkbox-line {display:flex;gap:9px;align-items:center;font-size:11px;color:#7a879e;line-height:1.7}
|
||||
.settings-actions {display:flex;justify-content:flex-end;gap:11px;padding-top:5px}
|
||||
.notification-preview {padding:22px;background:#eef1ff;border-color:#e0e5fc}
|
||||
.notification-preview h3 {font-size:12px;font-weight:600;margin-bottom:16px;display:flex;align-items:center;gap:7px;color:#616fb7}
|
||||
.preview-message {background:white;border-radius:9px;padding:18px;box-shadow:0 3px 10px #5963a108;font-size:11px;line-height:2.1;color:#66748e}
|
||||
.preview-message strong {display:flex;align-items:center;gap:6px;color:var(--ink);font-size:12px;margin-bottom:8px}
|
||||
.preview-message strong .icon {width:15px;height:15px;color:var(--accent)}
|
||||
.preview-message .preview-label {color:#99a4b8;display:inline-block;width:58px}
|
||||
.preview-message .preview-instructions {border-top:1px solid #eef0f6;margin-top:9px;padding-top:10px;font-size:10px;line-height:1.9}
|
||||
.preview-message .preview-link {display:block;text-align:center;color:var(--accent);background:#f2f4ff;border:1px solid #e5e9fc;padding:7px 10px;border-radius:6px;margin-top:15px;font-size:10px}
|
||||
.preview-note {font-size:10px;color:#8995b7;line-height:1.9;margin-top:15px}
|
||||
.overlay-backdrop {position:fixed;inset:0;background:#222c4657;backdrop-filter:blur(3px);z-index:100;display:flex;justify-content:center;align-items:center;padding:24px;animation:backdrop-in .15s ease}
|
||||
.modal {background:white;border-radius:15px;width:570px;max-width:100%;max-height:calc(100dvh - 48px);display:flex;flex-direction:column;box-shadow:0 16px 80px #1d29402b;overflow:hidden;animation:modal-in .18s ease}
|
||||
.modal.small {width:440px}
|
||||
.modal-header {padding:23px 25px 18px;display:flex;align-items:flex-start;justify-content:space-between;gap:15px;border-bottom:1px solid #edf0f6}
|
||||
.modal-header h2 {font-size:17px;font-weight:600;margin-bottom:6px}
|
||||
.modal-header p {font-size:10px;color:#98a2b6;line-height:1.8}
|
||||
.modal-header .icon-button {padding:3px}
|
||||
.modal-body {padding:24px 25px;overflow-y:auto}
|
||||
.modal-footer {border-top:1px solid var(--border);padding:17px 25px;display:flex;align-items:center;justify-content:flex-end;gap:9px;background:#fcfcfe}
|
||||
.form-error {font-size:11px;color:var(--danger);padding:10px 12px;border:1px solid #f0d2d8;background:#fff5f7;border-radius:6px;margin-bottom:16px;line-height:1.8}
|
||||
.form-error[hidden] {display:none}
|
||||
.calculation-preview {background:#f2f4ff;border:1px solid #e3e7ff;border-radius:8px;padding:13px 15px;margin-top:18px;font-size:11px;color:#7c88ac;display:flex;justify-content:space-between;gap:12px}
|
||||
.calculation-preview strong {color:var(--accent);font-size:12px;font-weight:600;white-space:nowrap}
|
||||
.completion-summary {display:flex;align-items:center;gap:12px;border-radius:9px;padding:15px;background:#f7f8fc;margin-bottom:20px}
|
||||
.completion-summary h3 {font-size:12px;font-weight:600;margin-bottom:5px}
|
||||
.completion-summary p {font-size:10px;color:#92a0b7}
|
||||
.instructions-box {font-size:11px;color:#7b889f;line-height:1.9;padding:13px 15px;background:#f9fafc;border-left:2px solid #d7def3;margin-bottom:20px;border-radius:0 6px 6px 0}
|
||||
.drawer-backdrop {justify-content:flex-end;padding:0}
|
||||
.drawer {width:525px;max-width:100%;height:100dvh;background:white;display:flex;flex-direction:column;box-shadow:-10px 0 50px #1c28421a;animation:drawer-in .2s ease;overflow:hidden}
|
||||
.drawer .modal-header {padding:22px 27px;align-items:center}
|
||||
.drawer .modal-body {padding:26px 27px}
|
||||
.detail-brand {display:flex;align-items:center;gap:13px;margin-bottom:24px}
|
||||
.detail-brand .provider-logo {width:51px;height:53px;font-size:30px;border-radius:13px}
|
||||
.detail-brand h2 {font-size:18px;font-weight:600;margin-bottom:8px}
|
||||
.detail-brand p {font-size:11px;color:#8f9ab1;display:flex;align-items:center;gap:8px}
|
||||
.detail-grid {display:grid;grid-template-columns:1fr 1fr;gap:19px;background:#f8f9fc;border-radius:10px;padding:18px;margin-bottom:24px}
|
||||
.detail-grid dt {font-size:10px;color:#98a2b7;margin-bottom:6px}
|
||||
.detail-grid dd {margin:0;font-size:12px;color:#57667e;font-variant-numeric:tabular-nums;line-height:1.8}
|
||||
.detail-section {border-top:1px solid var(--border);padding-top:23px;margin-top:23px}
|
||||
.detail-section .section-header {margin-bottom:14px}
|
||||
.detail-section h3 {font-size:13px;font-weight:550}
|
||||
.detail-rule {border:1px solid var(--border);border-radius:9px;padding:15px;margin:10px 0}
|
||||
.detail-rule-head {display:flex;align-items:center;gap:8px;margin-bottom:10px}
|
||||
.detail-rule-head>.icon {width:16px;height:16px;color:var(--accent)}
|
||||
.detail-rule-head strong {font-size:11px;font-weight:550;flex:1}
|
||||
.detail-rule-head span {font-size:10px;color:#99a4b8}
|
||||
.detail-rule>p {font-size:10px;color:#94a0b6;line-height:1.9;margin-bottom:12px}
|
||||
.detail-rule-bottom {display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:10px;color:#8592ad}
|
||||
.detail-rule-bottom>div:last-child {display:flex;gap:10px}
|
||||
.detail-platform {display:flex;align-items:center;gap:9px;border-bottom:1px solid #f0f2f7;padding:12px 0}
|
||||
.detail-platform:last-child {border:0}
|
||||
.detail-platform>div:nth-child(2) {flex:1;min-width:0}
|
||||
.detail-platform strong {display:block;font-size:11px;font-weight:500}
|
||||
.detail-platform p {font-size:9px;color:#99a4b8;line-height:1.7;margin-top:3px;overflow-wrap:anywhere}
|
||||
.detail-platform .icon-button svg {width:14px;height:14px}
|
||||
.history-item {border-left:2px solid #e4e8fc;padding:0 0 18px 14px;position:relative}
|
||||
.history-item::before {content:"";position:absolute;width:6px;height:6px;border:2px solid white;background:#adb7ec;border-radius:50%;left:-5px;top:0;box-sizing:content-box}
|
||||
.history-item strong {font-size:10px;font-weight:500}
|
||||
.history-item p {font-size:9px;color:#95a1b6;line-height:1.8;margin-top:5px}
|
||||
.detail-note {font-size:11px;color:#8793aa;line-height:1.9;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.help-copy {font-size:12px;color:#7d8ba3;line-height:2}
|
||||
.help-copy h3 {font-size:13px;font-weight:600;color:#475570;margin:17px 0 5px}
|
||||
.help-copy h3:first-child {margin-top:0}
|
||||
.help-copy ul {padding-left:19px;margin:6px 0 15px}
|
||||
.account-option {border:1px solid var(--border);border-radius:9px;display:flex;align-items:center;gap:12px;width:100%;padding:14px;text-align:left;margin:10px 0}
|
||||
.account-option.active {background:var(--accent-soft);border-color:#d5dbff}
|
||||
.account-option>div:nth-child(2) {flex:1}
|
||||
.account-option strong {font-size:12px;font-weight:550}
|
||||
.account-option p {font-size:10px;color:#97a2b7;margin-top:5px}
|
||||
.account-option>.icon {width:17px;height:17px;color:var(--accent)}
|
||||
.account-note {font-size:10px;color:#99a3b7;line-height:1.9;margin-top:20px}
|
||||
#toast-root {position:fixed;left:calc(50% + 111px);bottom:30px;transform:translateX(-50%);z-index:200;max-width:calc(100% - 32px)}
|
||||
.toast {display:flex;align-items:center;gap:9px;background:#28334a;color:white;padding:13px 20px;border-radius:9px;box-shadow:0 8px 30px #1d27402b;font-size:12px;line-height:1.7;animation:modal-in .18s ease}
|
||||
.toast svg {width:16px;height:16px;color:#9ad7c6}
|
||||
.toast.error svg {color:#ffb6c4}
|
||||
.body-overlay {overflow:hidden}
|
||||
@keyframes backdrop-in {from{opacity:0}to{opacity:1}}
|
||||
@keyframes modal-in {from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:translateY(0)}}
|
||||
@keyframes drawer-in {from{transform:translateX(30px);opacity:.5}to{transform:translateX(0);opacity:1}}
|
||||
@media (min-width:1650px) {
|
||||
.card-grid {grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.workspace-grid {grid-template-columns:minmax(0,1fr) 292px}
|
||||
}
|
||||
@media (max-width:1250px) {
|
||||
.app-shell {grid-template-columns:195px minmax(0,1fr)}
|
||||
.sidebar {width:195px;padding-inline:12px}
|
||||
.brand {margin-inline:13px}
|
||||
.content {padding:26px 25px}
|
||||
.topbar {padding-inline:25px}
|
||||
.workspace-grid {grid-template-columns:minmax(0,1fr) 250px;gap:19px}
|
||||
.card-grid {gap:13px}
|
||||
.sim-card {padding-inline:15px}
|
||||
.card-footer {margin-inline:-15px;padding-inline:15px}
|
||||
.card-top {gap:8px}
|
||||
.card-heading h3 {font-size:11px}
|
||||
.card-number {font-size:13px}
|
||||
.provider-logo {width:33px;height:35px;border-radius:8px;font-size:20px}
|
||||
.card-rule {padding-inline:9px}
|
||||
.card-rule-name {gap:4px;font-size:9px}
|
||||
.card-rule-name span {font-size:8px}
|
||||
.card-rule-date {font-size:9px}
|
||||
.renewal-pills {gap:9px}
|
||||
.renewal-pill:nth-child(3) {display:none}
|
||||
.stat {padding-inline:20px}
|
||||
.section-controls {gap:6px}
|
||||
.segmented button {padding-inline:8px}
|
||||
#toast-root {left:calc(50% + 97px)}
|
||||
}
|
||||
@media (max-width:1060px) {
|
||||
.app-shell {grid-template-columns:80px minmax(0,1fr)}
|
||||
.sidebar {width:80px;padding:28px 10px 19px;align-items:center}
|
||||
.brand {margin:0 0 35px}
|
||||
.brand-name,.nav-caption,.nav-button>span,.sidebar-note,.user-text,.sidebar-user>.icon {display:none}
|
||||
.nav-list {width:100%;gap:12px}
|
||||
.nav-button {padding:13px;justify-content:center}
|
||||
.nav-button .icon {width:21px;height:21px}
|
||||
.nav-button.active::before {top:15px;bottom:15px}
|
||||
.sidebar-user {justify-content:center;padding-top:20px}
|
||||
.topbar {height:70px}
|
||||
.content {padding:25px}
|
||||
.workspace-grid {grid-template-columns:minmax(0,1fr) 245px;gap:19px}
|
||||
.settings-layout {grid-template-columns:minmax(0,1fr) 265px}
|
||||
.settings-panel {padding:20px}
|
||||
#toast-root {left:calc(50% + 40px)}
|
||||
}
|
||||
@media (max-width:890px) {
|
||||
.workspace-grid {grid-template-columns:minmax(0,1fr)}
|
||||
.right-rail {grid-template-columns:1fr 1fr;align-items:start;margin-top:10px}
|
||||
.rail-tip {grid-column:1/-1}
|
||||
.card-grid {gap:17px}
|
||||
.sim-card {padding:19px 19px 0}
|
||||
.card-footer {margin-inline:-19px;padding-inline:19px}
|
||||
.card-heading h3 {font-size:12px}
|
||||
.card-number {font-size:15px}
|
||||
.card-rule-name {font-size:10px}
|
||||
.card-rule-name span {font-size:9px}
|
||||
.card-rule-date {font-size:10px}
|
||||
.provider-logo {width:39px;height:40px;font-size:22px}
|
||||
.renewal-pill:nth-child(3) {display:flex}
|
||||
.renewal-intro {min-width:105px}
|
||||
.renewal-strip {gap:13px;padding-inline:17px}
|
||||
.renewal-strip>.text-button {font-size:0;gap:0}
|
||||
.renewal-strip>.text-button svg {width:15px;height:15px}
|
||||
.global-search {width:220px}
|
||||
.stat {padding-inline:18px}
|
||||
.stat-foot {font-size:9px}
|
||||
.settings-layout {grid-template-columns:1fr}
|
||||
.notification-preview {max-width:460px}
|
||||
.task-row {gap:13px;padding:17px}
|
||||
.task-row>.status {margin-right:0}
|
||||
}
|
||||
@media (max-width:680px) {
|
||||
.app-shell {display:block}
|
||||
.sidebar {transform:translateX(-100%);visibility:hidden;width:222px;padding:28px 17px 18px;align-items:stretch;transition:transform .2s;z-index:70;box-shadow:none}
|
||||
.sidebar.open {transform:translateX(0);visibility:visible;box-shadow:8px 0 45px #20305010}
|
||||
.sidebar.open .brand {margin:0 15px 35px}
|
||||
.sidebar.open .brand-name,.sidebar.open .nav-caption,.sidebar.open .nav-button>span,.sidebar.open .sidebar-note,.sidebar.open .user-text {display:block}
|
||||
.sidebar.open .nav-button {justify-content:flex-start;padding:12px 18px}
|
||||
.sidebar.open .nav-list {gap:7px}
|
||||
.sidebar.open .sidebar-user {justify-content:flex-start}
|
||||
.menu-scrim {position:fixed;inset:0;background:#25304a4a;z-index:60}
|
||||
.topbar {height:62px;padding:0 18px;gap:8px}
|
||||
.mobile-menu {display:flex;padding:5px;margin-left:-5px}
|
||||
.breadcrumbs {font-size:11px;gap:8px;flex:1}
|
||||
.breadcrumbs>span:first-child,.breadcrumbs>.icon {display:none}
|
||||
.topbar-right {gap:11px}
|
||||
.global-search {width:155px;padding:7px 9px;gap:5px}
|
||||
.global-search input {font-size:10px}
|
||||
.global-search kbd,.topbar-divider,.topbar-avatar>.icon {display:none}
|
||||
.notification-button {padding:4px}
|
||||
.topbar-avatar .avatar {width:28px;height:28px;font-size:10px}
|
||||
.content {padding:25px 19px 24px}
|
||||
.page-heading {align-items:flex-start;gap:10px;margin-bottom:21px;flex-wrap:wrap}
|
||||
.heading-title {gap:8px;margin-bottom:7px}
|
||||
h1 {font-size:24px}
|
||||
.page-heading p {font-size:11px;max-width:320px}
|
||||
.heading-actions {gap:7px;margin-top:7px}
|
||||
.heading-actions .btn {font-size:11px;padding:9px 12px}
|
||||
.stat-strip {grid-template-columns:1fr 1fr;padding:0;margin-bottom:18px;overflow:hidden}
|
||||
.stat {padding:17px 19px;border-right:0}
|
||||
.stat:nth-child(odd) {border-right:1px solid var(--border)}
|
||||
.stat:nth-child(-n+2) {border-bottom:1px solid var(--border)}
|
||||
.stat-value {font-size:28px}
|
||||
.stat-foot {margin-top:6px;font-size:9px}
|
||||
.renewal-strip {padding:15px;gap:11px;flex-wrap:wrap;margin-bottom:24px}
|
||||
.renewal-strip-icon {width:32px;height:32px;border-radius:8px}
|
||||
.renewal-strip-icon .icon {width:17px;height:17px}
|
||||
.renewal-intro {flex:1}
|
||||
.renewal-strip>.text-button {position:absolute;top:20px;right:15px;font-size:10px;gap:4px}
|
||||
.renewal-pills {flex-basis:100%;justify-content:flex-start;border-top:1px solid #dfe4fb;padding-top:13px;margin-top:1px;gap:16px}
|
||||
.renewal-pill {flex:1;gap:7px}
|
||||
.renewal-pill .provider-mini {width:26px;height:26px;font-size:14px;flex:none}
|
||||
.renewal-pill strong {font-size:9px;max-width:80px}
|
||||
.renewal-pill small {font-size:8px;margin-top:3px}
|
||||
.renewal-pill:nth-child(3) {display:none}
|
||||
.section-header {gap:8px;margin-bottom:15px;flex-wrap:wrap}
|
||||
.section-title {font-size:14px;gap:7px}
|
||||
.section-controls {margin-left:auto;gap:6px}
|
||||
.segmented button {font-size:9px;padding:5px 8px}
|
||||
.segmented button span {display:none}
|
||||
.view-toggle button {padding:5px}
|
||||
.card-grid {grid-template-columns:1fr;gap:14px}
|
||||
.card-top {gap:11px;margin-bottom:16px}
|
||||
.card-heading h3 {font-size:13px}
|
||||
.card-heading p {font-size:10px}
|
||||
.card-number {font-size:17px;margin-bottom:9px}
|
||||
.card-meta {font-size:10px;margin-bottom:17px}
|
||||
.card-rule {padding:13px 12px;margin-bottom:16px}
|
||||
.card-rule-name {font-size:11px}
|
||||
.card-rule-name span {font-size:10px;margin-left:2px}
|
||||
.card-rule-date {font-size:11px}
|
||||
.platform-label {font-size:10px}
|
||||
.platform-tag {font-size:9px;padding:4px 7px}
|
||||
.card-footer {padding-block:14px}
|
||||
.card-footer .status,.card-footer .text-button {font-size:10px}
|
||||
.right-rail {grid-template-columns:1fr;margin-top:15px;gap:17px}
|
||||
.calendar-grid {row-gap:5px;padding-inline:17px}
|
||||
.calendar-day {height:35px;font-size:12px;margin-inline:3px}
|
||||
.weekday {font-size:10px}
|
||||
.calendar-events {padding-bottom:20px}
|
||||
.panel-heading h2 {font-size:14px}
|
||||
.calendar-toolbar strong {font-size:13px}
|
||||
.page-footer {font-size:8px;align-items:flex-start;line-height:1.8;margin-top:26px}
|
||||
.page-footer>span:last-child {max-width:145px;text-align:right}
|
||||
.task-row {flex-wrap:wrap;padding:16px;gap:12px}
|
||||
.task-date {width:45px;padding-right:11px;font-size:18px}
|
||||
.task-row .provider-mini {width:30px;height:30px}
|
||||
.task-info {flex-basis:calc(100% - 104px)}
|
||||
.task-info h3 {font-size:11px}
|
||||
.task-info p {font-size:9px;gap:8px;flex-wrap:wrap}
|
||||
.task-row>.status {margin-left:57px;flex:1}
|
||||
.task-row>.btn {font-size:10px;padding:7px 10px}
|
||||
.fields-grid {gap:16px}
|
||||
.settings-panel {padding:20px 17px}
|
||||
.settings-panel>.fields-grid {grid-template-columns:1fr}
|
||||
.offset-check {padding:8px 10px}
|
||||
.overlay-backdrop {padding:14px}
|
||||
.modal {max-height:calc(100dvh - 28px);border-radius:12px}
|
||||
.modal-header {padding:20px 20px 16px}
|
||||
.modal-header h2 {font-size:16px}
|
||||
.modal-body {padding:21px 20px}
|
||||
.modal-footer {padding:15px 20px}
|
||||
.modal .fields-grid {gap:14px}
|
||||
.field input,.field select,.field textarea {font-size:13px}
|
||||
.drawer-backdrop {padding:0}
|
||||
.drawer .modal-header {padding:20px}
|
||||
.drawer .modal-body {padding:23px 20px}
|
||||
#toast-root {left:50%;bottom:22px;width:max-content}
|
||||
.toast {font-size:11px;padding:12px 16px}
|
||||
}
|
||||
@media (max-width:370px) {
|
||||
.content {padding-inline:15px}
|
||||
.topbar {padding-inline:15px}
|
||||
.global-search {width:110px}
|
||||
.topbar-right {gap:8px}
|
||||
.breadcrumbs strong {font-size:10px}
|
||||
.segmented button {padding-inline:6px}
|
||||
.modal .fields-grid {grid-template-columns:1fr}
|
||||
.field.wide {grid-column:auto}
|
||||
.stat {padding-inline:16px}
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce) {
|
||||
*,*::before,*::after {animation:none!important;transition:none!important;scroll-behavior:auto!important}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
let API;
|
||||
try { ({API}=await import('../api.js')); } catch {}
|
||||
assert.ok(API,'Same-origin authenticated API client must exist');
|
||||
|
||||
const requests=[];
|
||||
const transport=async (path,options) => {
|
||||
requests.push({path,options});
|
||||
if (path==='/api/auth/login') return {ok:true,status:200,json:async()=>({csrfToken:'session-csrf',user:{name:'Alice'}})};
|
||||
if (path==='/api/failure') return {ok:false,status:409,json:async()=>({detail:'资料已更新'})};
|
||||
if (path==='/api/validation') return {ok:false,status:422,json:async()=>({detail:[{msg:'Value error, 请输入有效日期'}]})};
|
||||
return {ok:true,status:200,json:async()=>({workspace:{cards:[{name:'Persisted SIM'}]}})};
|
||||
};
|
||||
const client=new API(transport);
|
||||
await client.request('POST','/api/auth/login',{email:'alice@example.com',password:' password '});
|
||||
const result=await client.request('POST','/api/cards',{name:'Persisted SIM'});
|
||||
assert.equal(requests[0].options.credentials,'same-origin');
|
||||
assert.equal(JSON.parse(requests[0].options.body).password,' password ');
|
||||
assert.equal(requests[1].options.headers['X-CSRF-Token'],'session-csrf');
|
||||
assert.equal(result.workspace.cards[0].name,'Persisted SIM');
|
||||
await assert.rejects(client.request('GET','/api/failure'),error=>error.status===409 && error.message==='资料已更新');
|
||||
await assert.rejects(client.request('GET','/api/validation'),error=>error.message==='请输入有效日期');
|
||||
const offline=new API(async()=>{throw new TypeError('Failed to fetch')});
|
||||
await assert.rejects(offline.request('GET','/api/workspace'),/无法连接服务器/);
|
||||
client.clear();
|
||||
await client.request('GET','/api/workspace');
|
||||
assert.equal(requests.at(-1).options.headers['X-CSRF-Token'],undefined);
|
||||
let finishOldRead, finishOldError;
|
||||
const deferred = new API((path) => {
|
||||
if(path==='/api/old-workspace') return new Promise(resolve=>{finishOldRead=resolve;});
|
||||
if(path==='/api/old-error') return new Promise(resolve=>{finishOldError=resolve;});
|
||||
return Promise.resolve({ok:true,status:200,json:async()=>({csrfToken:'bob-csrf',user:{id:'bob'},workspace:{cards:[]}})});
|
||||
});
|
||||
const oldRead=deferred.request('GET','/api/old-workspace');
|
||||
const oldError=deferred.request('GET','/api/old-error');
|
||||
deferred.clear();
|
||||
const bob=await deferred.request('POST','/api/auth/login',{email:'bob@example.com',password:'long-password'});
|
||||
const rejectOld=assert.rejects(oldRead,error=>error.name==='StaleResponse');
|
||||
const rejectError=assert.rejects(oldError,error=>error.name==='StaleResponse');
|
||||
finishOldRead({ok:true,status:200,json:async()=>({csrfToken:'alice-csrf',workspace:{cards:[{name:'Alice private SIM'}]}})});
|
||||
finishOldError({ok:false,status:401,json:async()=>({detail:'请先登录'})});
|
||||
await rejectOld;await rejectError;
|
||||
assert.equal(bob.user.id,'bob');
|
||||
assert.equal(deferred.csrf,'bob-csrf');
|
||||
deferred.clear();
|
||||
assert.throws(()=>deferred.assertCurrent(bob),error=>error.name==='StaleResponse');
|
||||
console.log('API client: 12 checks passed');
|
||||
@@ -0,0 +1,115 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const core = await import('../core.js').catch(error => {
|
||||
if (error.code === 'ERR_MODULE_NOT_FOUND') return {};
|
||||
throw error;
|
||||
});
|
||||
|
||||
function invoke(name, ...args) {
|
||||
assert.equal(typeof core[name], 'function', `${name} is required for renewal calculations`);
|
||||
return core[name](...args);
|
||||
}
|
||||
|
||||
test('days advance across a year boundary', () => {
|
||||
assert.equal(invoke('addDays', '2026-12-31', 2), '2027-01-02');
|
||||
});
|
||||
|
||||
test('calendar months clamp January 31 to the last day of February', () => {
|
||||
assert.equal(invoke('addCycle', '2026-01-31', 1, 'months'), '2026-02-28');
|
||||
});
|
||||
|
||||
test('calendar months respect leap years', () => {
|
||||
assert.equal(invoke('addCycle', '2028-01-31', 1, 'months'), '2028-02-29');
|
||||
});
|
||||
|
||||
test('days use whole business dates independent of daylight saving time', () => {
|
||||
assert.equal(invoke('daysUntil', '2026-03-09', '2026-03-07'), 2);
|
||||
assert.equal(invoke('daysUntil', '2026-10-03', '2026-10-04'), -1);
|
||||
});
|
||||
|
||||
test('completion-based renewal starts from the actual completion date', () => {
|
||||
const rule = {dueDate:'2026-10-03', interval:90, unit:'days', anchor:'completion', anchorDate:'2026-07-05'};
|
||||
assert.equal(invoke('nextAfterCompletion', rule, '2026-10-04'), '2027-01-02');
|
||||
});
|
||||
|
||||
test('early fixed renewal advances one scheduled cycle rather than retaining the completed deadline', () => {
|
||||
const rule = {dueDate:'2026-10-10', interval:1, unit:'months', anchor:'scheduled', anchorDate:'2026-10-10'};
|
||||
assert.equal(invoke('nextAfterCompletion', rule, '2026-10-04'), '2026-11-10');
|
||||
});
|
||||
|
||||
test('overdue fixed renewal skips elapsed cycles', () => {
|
||||
const rule = {dueDate:'2026-01-31', interval:1, unit:'months', anchor:'scheduled', anchorDate:'2026-01-31'};
|
||||
assert.equal(invoke('nextAfterCompletion', rule, '2026-04-01'), '2026-04-30');
|
||||
});
|
||||
|
||||
test('fixed monthly schedules restore the original day after a short month', () => {
|
||||
const rule = {dueDate:'2026-02-28', interval:1, unit:'months', anchor:'scheduled', anchorDate:'2026-01-31'};
|
||||
assert.equal(invoke('nextAfterCompletion', rule, '2026-02-28'), '2026-03-31');
|
||||
});
|
||||
|
||||
test('invalid dates and cycles are rejected instead of silently overflowing', () => {
|
||||
assert.throws(() => invoke('addCycle', '2026-02-30', 1, 'months'), /日期/);
|
||||
assert.throws(() => invoke('addCycle', '2026-10-04', 0, 'days'), /周期/);
|
||||
assert.throws(() => invoke('addCycle', '2026-10-04', -1, 'days'), /周期/);
|
||||
assert.throws(() => invoke('addCycle', '2026-10-04', 1.5, 'days'), /周期/);
|
||||
});
|
||||
|
||||
test('recording one rule leaves another renewal rule untouched', () => {
|
||||
const card = {id:'c1', activatedAt:'2026-01-01', rules:[
|
||||
{id:'sms', dueDate:'2026-10-03', interval:90, unit:'days', anchor:'completion', anchorDate:'2026-10-03'},
|
||||
{id:'plan', dueDate:'2026-10-20', interval:1, unit:'months', anchor:'scheduled', anchorDate:'2026-10-20'}
|
||||
]};
|
||||
const result = invoke('completeRenewal', card, 'sms', '2026-10-04', '2026-10-04', '发了一条短信');
|
||||
assert.equal(result.card.rules[0].dueDate, '2027-01-02');
|
||||
assert.equal(result.card.rules[1].dueDate, '2026-10-20');
|
||||
assert.equal(card.rules[0].dueDate, '2026-10-03');
|
||||
assert.equal(result.event.oldDueDate, '2026-10-03');
|
||||
assert.equal(result.event.newDueDate, '2027-01-02');
|
||||
});
|
||||
|
||||
test('completion cannot be in the future, before activation or before the last completion', () => {
|
||||
const card = {activatedAt:'2026-02-01', rules:[{id:'r1', dueDate:'2026-10-20', lastCompletedAt:'2026-09-20', interval:30, unit:'days', anchor:'completion'}]};
|
||||
assert.throws(() => invoke('completeRenewal', card, 'r1', '2026-10-05', '2026-10-04'), /未来/);
|
||||
assert.throws(() => invoke('completeRenewal', card, 'r1', '2026-01-01', '2026-10-04'), /开通/);
|
||||
assert.throws(() => invoke('completeRenewal', card, 'r1', '2026-09-01', '2026-10-04'), /上次/);
|
||||
});
|
||||
|
||||
test('archived cards are excluded and remaining tasks sort by their actual deadline', () => {
|
||||
const cards = [
|
||||
{id:'a', archived:false, rules:[{id:'late',dueDate:'2026-10-20'},{id:'soon',dueDate:'2026-10-03'}]},
|
||||
{id:'b', archived:true, rules:[{id:'archived',dueDate:'2026-09-01'}]},
|
||||
{id:'c', archived:false, rules:[{id:'middle',dueDate:'2026-10-10'}]}
|
||||
];
|
||||
assert.deepEqual(invoke('getTasks', cards).map(task => task.rule.id), ['soon','middle','late']);
|
||||
});
|
||||
|
||||
test('user timezone determines today around the UTC midnight boundary', () => {
|
||||
const instant = new Date('2026-10-03T17:00:00Z');
|
||||
assert.equal(invoke('todayString', 'Asia/Shanghai', instant), '2026-10-04');
|
||||
assert.equal(invoke('todayString', 'America/New_York', instant), '2026-10-03');
|
||||
});
|
||||
|
||||
test('editing activation cannot invalidate an older renewal event even when the latest completion is later', () => {
|
||||
const card = {id:'c1', activatedAt:'2026-01-01', rules:[{dueDate:'2026-12-01',lastCompletedAt:'2026-10-04'}]};
|
||||
const events = [
|
||||
{cardId:'c1',completedAt:'2026-06-01'},
|
||||
{cardId:'c1',completedAt:'2026-10-04'},
|
||||
];
|
||||
assert.throws(() => invoke('validateActivationChange', card, events, '2026-08-01', '2026-10-04'), /续期记录/);
|
||||
});
|
||||
|
||||
test('editing activation ignores another card history and permits a correction before its own history', () => {
|
||||
const card = {id:'c1',activatedAt:'2026-01-01',rules:[{dueDate:'2026-12-01'}]};
|
||||
const events = [{cardId:'c2',completedAt:'2026-02-01'},{cardId:'c1',completedAt:'2026-06-01'}];
|
||||
assert.doesNotThrow(() => invoke('validateActivationChange', card, events, '2026-05-01', '2026-10-04'));
|
||||
});
|
||||
|
||||
test('HTTP browsers without randomUUID can still generate distinct valid card identifiers', () => {
|
||||
const httpCrypto = {getRandomValues:globalThis.crypto.getRandomValues.bind(globalThis.crypto)};
|
||||
const first = invoke('createId', httpCrypto);
|
||||
const second = invoke('createId', httpCrypto);
|
||||
assert.match(first, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
assert.match(second, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
assert.notEqual(first,second);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
let money;
|
||||
try {money=await import('../money.js');} catch {}
|
||||
assert.ok(money,'Exact currency helpers must exist');
|
||||
const {normalizeAmount,sumMoney,balanceAfter,formatMoney}=money;
|
||||
assert.equal(normalizeAmount('00019.9','USD'),'19.90');
|
||||
assert.equal(normalizeAmount('1000.00','JPY'),'1000');
|
||||
assert.equal(normalizeAmount('1.234','KWD'),'1.234');
|
||||
for(const [amount,currency] of [['1.001','USD'],['1.1','JPY'],['-1','USD'],['1e3','USD'],['NaN','USD'],['1000000000','USD'],['1','ZZZ']]) {
|
||||
assert.throws(()=>normalizeAmount(amount,currency));
|
||||
}
|
||||
assert.deepEqual(sumMoney([{currency:'USD',amount:'0.10'},{currency:'USD',amount:'0.20'},{currency:'JPY',amount:'1000'}]),[{currency:'JPY',amount:'1000'},{currency:'USD',amount:'0.30'}]);
|
||||
assert.equal(balanceAfter([{currency:'USD',amount:'0.30'}],{currency:'USD',amount:'0.10'},'deduct').amount,'0.20');
|
||||
assert.throws(()=>balanceAfter([],{currency:'USD',amount:'0.10'},'deduct'));
|
||||
assert.throws(()=>balanceAfter([{currency:'USD',amount:'0.30'}],{currency:'USD',amount:'0.31'},'deduct'));
|
||||
assert.equal(balanceAfter([],{currency:'HKD',amount:'5.50'},'credit').amount,'5.50');
|
||||
assert.equal(formatMoney({currency:'USD',amount:'1250.50'}),'USD 1,250.50');
|
||||
assert.equal(formatMoney({currency:'JPY',amount:'1000'}),'JPY 1,000');
|
||||
assert.equal(formatMoney(null),'未填写');
|
||||
console.log('Money: 18 checks passed');
|
||||
@@ -0,0 +1,141 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date,timedelta
|
||||
from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
try:
|
||||
from server.main import create_app
|
||||
except ImportError:
|
||||
create_app = None
|
||||
|
||||
|
||||
class ApiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertIsNotNone(create_app,'Authenticated card API must be implemented')
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.db = Path(self.temp.name)/'test.db'
|
||||
self.app = create_app(self.db, start_worker=False)
|
||||
self.a = TestClient(self.app)
|
||||
self.b = TestClient(self.app)
|
||||
self.addCleanup(self.a.close)
|
||||
self.addCleanup(self.b.close)
|
||||
for client,name in [(self.a,'Alice'),(self.b,'Bob')]:
|
||||
response = client.post('/api/auth/register',json={'name':name,'email':f'{name.lower()}@example.com','password':'safe-password-123'})
|
||||
self.assertEqual(response.status_code,201,response.text)
|
||||
|
||||
def headers(self,client):
|
||||
return {'X-CSRF-Token':client.get('/api/auth/me').json()['csrfToken']}
|
||||
|
||||
def card_payload(self):
|
||||
today = date.today().isoformat()
|
||||
return dict(name='My SIM',provider='Example',type='eSIM',country='US',phone='+1 202 555 0100',activatedAt='2026-01-01',notes='',plan='',rules=[dict(action='sms',interval=90,unit='days',anchor='completion',dueDate=today,instructions='发送短信')])
|
||||
|
||||
def create_card(self,client=None):
|
||||
client = client or self.a
|
||||
response = client.post('/api/cards',headers=self.headers(client),json=self.card_payload())
|
||||
self.assertEqual(response.status_code,201,response.text)
|
||||
return response.json()['workspace']['cards'][0]
|
||||
|
||||
def test_login_cookie_csrf_logout(self):
|
||||
cookie = self.a.cookies.get('simkeep_session')
|
||||
self.assertTrue(cookie)
|
||||
self.assertEqual(self.a.post('/api/cards',json=self.card_payload()).status_code,403)
|
||||
self.assertEqual(self.a.post('/api/auth/logout',headers=self.headers(self.a)).status_code,200)
|
||||
self.assertEqual(self.a.get('/api/workspace').status_code,401)
|
||||
self.assertEqual(self.a.post('/api/auth/login',json={'email':'alice@example.com','password':'wrong-password'}).status_code,401)
|
||||
self.assertEqual(self.a.post('/api/auth/login',json={'email':'alice@example.com','password':'safe-password-123'}).status_code,200)
|
||||
self.assertEqual(self.a.get('/api/workspace').status_code,200)
|
||||
|
||||
def test_card_rule_platform_and_history_are_private(self):
|
||||
card = self.create_card()
|
||||
self.assertEqual(self.b.get('/api/workspace').json()['workspace']['cards'],[])
|
||||
self.assertEqual(self.b.get(f"/api/cards/{card['id']}").status_code,404)
|
||||
self.assertEqual(self.b.post(f"/api/cards/{card['id']}/archive",headers=self.headers(self.b),json={'archived':True,'version':card['version']}).status_code,404)
|
||||
self.assertEqual(self.b.post(f"/api/cards/{card['id']}/platforms",headers=self.headers(self.b),json={'name':'Google','account':'a','purpose':'auth','version':card['version']}).status_code,404)
|
||||
rule = card['rules'][0]
|
||||
self.assertEqual(self.b.post(f"/api/rules/{rule['id']}/completions",headers=self.headers(self.b),json={'completedAt':date.today().isoformat(),'note':'','requestId':'other-user-attempt','version':rule['version']}).status_code,404)
|
||||
self.assertEqual(len(self.a.get('/api/workspace').json()['workspace']['cards']),1)
|
||||
|
||||
def test_completion_is_server_calculated_and_idempotent(self):
|
||||
card = self.create_card()
|
||||
rule = card['rules'][0]
|
||||
payload = {'completedAt':date.today().isoformat(),'note':'done','requestId':'one-logical-operation','version':rule['version']}
|
||||
url=f"/api/rules/{rule['id']}/completions"
|
||||
first = self.a.post(url,headers=self.headers(self.a),json=payload)
|
||||
self.assertEqual(first.status_code,200,first.text)
|
||||
second = self.a.post(url,headers=self.headers(self.a),json=payload)
|
||||
self.assertEqual(second.status_code,200,second.text)
|
||||
workspace=second.json()['workspace']
|
||||
self.assertEqual(len(workspace['events']),1)
|
||||
self.assertEqual(workspace['cards'][0]['rules'][0]['dueDate'],(date.today()+timedelta(days=90)).isoformat())
|
||||
payload['requestId']='another-operation'
|
||||
self.assertEqual(self.a.post(url,headers=self.headers(self.a),json=payload).status_code,409)
|
||||
|
||||
def test_persisted_card_survives_application_recreation(self):
|
||||
card=self.create_card()
|
||||
other=TestClient(create_app(self.db,start_worker=False))
|
||||
self.addCleanup(other.close)
|
||||
other.post('/api/auth/login',json={'email':'alice@example.com','password':'safe-password-123'})
|
||||
self.assertEqual(other.get('/api/workspace').json()['workspace']['cards'][0]['id'],card['id'])
|
||||
|
||||
def test_settings_validation_and_credentials_not_exposed(self):
|
||||
payload={'timezone':'Asia/Shanghai','time':'09:00','offsets':[7,3,1],'overdue':True,'telegram':True,'email':True,'emailAddress':'alice@example.com'}
|
||||
response=self.a.put('/api/settings',headers=self.headers(self.a),json=payload)
|
||||
self.assertEqual(response.status_code,200,response.text)
|
||||
self.assertFalse(response.json()['workspace']['settings']['emailVerified'])
|
||||
self.assertNotIn('smtpPassword',response.text)
|
||||
payload['timezone']='Invalid/Zone'
|
||||
self.assertEqual(self.a.put('/api/settings',headers=self.headers(self.a),json=payload).status_code,422)
|
||||
|
||||
def test_card_update_rejects_old_version_and_history_conflict(self):
|
||||
card=self.create_card()
|
||||
payload=self.card_payload()
|
||||
payload.pop('rules')
|
||||
payload['version']=card['version']
|
||||
payload['name']='Renamed'
|
||||
first=self.a.put(f"/api/cards/{card['id']}",headers=self.headers(self.a),json=payload)
|
||||
self.assertEqual(first.status_code,200,first.text)
|
||||
self.assertEqual(self.a.put(f"/api/cards/{card['id']}",headers=self.headers(self.a),json=payload).status_code,409)
|
||||
|
||||
def test_concurrent_completions_only_advance_once(self):
|
||||
card=self.create_card()
|
||||
rule=card['rules'][0]
|
||||
headers=self.headers(self.a)
|
||||
def submit(request_id):
|
||||
return self.a.post(f"/api/rules/{rule['id']}/completions",headers=headers,json={'completedAt':date.today().isoformat(),'note':'','requestId':request_id,'version':rule['version']}).status_code
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
statuses=list(executor.map(submit,['concurrent-one','concurrent-two']))
|
||||
self.assertEqual(sorted(statuses),[200,409])
|
||||
self.assertEqual(len(self.a.get('/api/workspace').json()['workspace']['events']),1)
|
||||
|
||||
def test_activation_edit_checks_older_history_after_later_completion(self):
|
||||
card=self.create_card()
|
||||
rule=card['rules'][0]
|
||||
for completed,request_id in [('2026-02-01','older-history'),('2026-09-01','later-history')]:
|
||||
response=self.a.post(f"/api/rules/{rule['id']}/completions",headers=self.headers(self.a),json={'completedAt':completed,'note':'','requestId':request_id,'version':rule['version']})
|
||||
self.assertEqual(response.status_code,200,response.text)
|
||||
card=response.json()['workspace']['cards'][0]
|
||||
rule=card['rules'][0]
|
||||
payload=self.card_payload()
|
||||
payload.pop('rules')
|
||||
payload.update(activatedAt='2026-05-01',version=card['version'])
|
||||
self.assertEqual(self.a.put(f"/api/cards/{card['id']}",headers=self.headers(self.a),json=payload).status_code,422)
|
||||
|
||||
def test_bad_length_and_validation_never_echo_password(self):
|
||||
response=self.a.get('/api/health',headers={'Content-Length':'invalid'})
|
||||
self.assertEqual(response.status_code,400)
|
||||
response=self.a.post('/api/auth/register',json={'name':'Test','email':'test@example.com','password':'secret12'})
|
||||
self.assertEqual(response.status_code,422)
|
||||
self.assertNotIn('secret12',response.text)
|
||||
|
||||
def test_foreign_origin_cannot_create_a_card(self):
|
||||
headers={**self.headers(self.a),'Origin':'http://another-site.example'}
|
||||
self.assertEqual(self.a.post('/api/cards',headers=headers,json=self.card_payload()).status_code,403)
|
||||
self.assertEqual(self.a.get('/api/workspace').json()['workspace']['cards'],[])
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,23 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from fastapi import HTTPException
|
||||
from server.auth import RateLimit, hash_password, verify_password
|
||||
|
||||
|
||||
class AuthTests(unittest.TestCase):
|
||||
def test_password_hash_is_salted_and_preserves_spaces(self):
|
||||
a=hash_password(' password with spaces ')
|
||||
b=hash_password(' password with spaces ')
|
||||
self.assertNotEqual(a,b)
|
||||
self.assertTrue(verify_password(' password with spaces ',a))
|
||||
self.assertFalse(verify_password('password with spaces',a))
|
||||
|
||||
def test_other_routes_do_not_clear_longer_rate_limit(self):
|
||||
rate=RateLimit()
|
||||
with patch('server.auth.time.monotonic',return_value=100):
|
||||
rate.check('registration',1,3600)
|
||||
with patch('server.auth.time.monotonic',return_value=200):
|
||||
rate.check('login',10,60)
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
rate.check('registration',1,3600)
|
||||
self.assertEqual(error.exception.status_code,429)
|
||||
@@ -0,0 +1,42 @@
|
||||
import unittest
|
||||
|
||||
try:
|
||||
from server import domain
|
||||
except ImportError:
|
||||
domain = None
|
||||
|
||||
|
||||
class DomainTests(unittest.TestCase):
|
||||
def run_function(self, name, *args):
|
||||
self.assertIsNotNone(domain, 'Service date rules must be implemented')
|
||||
return getattr(domain, name)(*args)
|
||||
|
||||
def test_calendar_months_and_leap_year(self):
|
||||
self.assertEqual(self.run_function('add_cycle','2026-01-31',1,'months'),'2026-02-28')
|
||||
self.assertEqual(self.run_function('add_cycle','2028-01-31',1,'months'),'2028-02-29')
|
||||
|
||||
def test_fixed_months_restore_original_anchor(self):
|
||||
rule = dict(dueDate='2026-02-28',anchorDate='2026-01-31',interval=1,unit='months',anchor='scheduled')
|
||||
self.assertEqual(self.run_function('next_due',rule,'2026-02-28'),'2026-03-31')
|
||||
|
||||
def test_early_fixed_completion_advances_current_occurrence(self):
|
||||
rule = dict(dueDate='2026-10-10',anchorDate='2026-10-10',interval=1,unit='months',anchor='scheduled')
|
||||
self.assertEqual(self.run_function('next_due',rule,'2026-10-04'),'2026-11-10')
|
||||
|
||||
def test_overdue_fixed_completion_skips_elapsed_occurrences(self):
|
||||
rule = dict(dueDate='2026-01-31',anchorDate='2026-01-31',interval=1,unit='months',anchor='scheduled')
|
||||
self.assertEqual(self.run_function('next_due',rule,'2026-04-01'),'2026-04-30')
|
||||
|
||||
def test_completion_based_dates_start_from_actual_date(self):
|
||||
rule = dict(dueDate='2026-10-03',interval=90,unit='days',anchor='completion')
|
||||
self.assertEqual(self.run_function('next_due',rule,'2026-10-04'),'2027-01-02')
|
||||
|
||||
def test_bad_date_and_cycle_rejected(self):
|
||||
self.assertIsNotNone(domain)
|
||||
for value, interval, unit in [('2026-02-30',1,'months'),('2026-10-04',0,'days'),('2026-10-04',True,'days')]:
|
||||
with self.assertRaises(ValueError):
|
||||
domain.add_cycle(value,interval,unit)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,42 @@
|
||||
import unittest
|
||||
|
||||
try:
|
||||
from server.money import normalize_amount, apply_balance
|
||||
except ImportError:
|
||||
normalize_amount = apply_balance = None
|
||||
|
||||
|
||||
class MoneyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertIsNotNone(normalize_amount,'Exact multi-currency amounts must be implemented')
|
||||
|
||||
def test_normalizes_currency_precision_without_rounding(self):
|
||||
self.assertEqual(normalize_amount('00019.9','USD'),'19.90')
|
||||
self.assertEqual(normalize_amount('1000.00','JPY'),'1000')
|
||||
self.assertEqual(normalize_amount('1.234','KWD'),'1.234')
|
||||
self.assertEqual(normalize_amount('0','GBP'),'0.00')
|
||||
|
||||
def test_invalid_amounts_are_rejected(self):
|
||||
for amount,currency in [('1.001','USD'),('0.1','JPY'),('-1','USD'),('NaN','USD'),('1e3','USD'),('1000000000','USD'),('1','ZZZ')]:
|
||||
with self.subTest(amount=amount,currency=currency),self.assertRaises(ValueError):
|
||||
normalize_amount(amount,currency)
|
||||
|
||||
def test_exact_subtraction_only_touches_matching_currency(self):
|
||||
balances=[{'currency':'USD','amount':'0.30'},{'currency':'GBP','amount':'8.00'}]
|
||||
updated,before,after=apply_balance(balances,{'currency':'USD','amount':'0.10'},'deduct')
|
||||
self.assertEqual(updated,[{'currency':'USD','amount':'0.20'},{'currency':'GBP','amount':'8.00'}])
|
||||
self.assertEqual(before,{'currency':'USD','amount':'0.30'})
|
||||
self.assertEqual(after,{'currency':'USD','amount':'0.20'})
|
||||
self.assertEqual(balances[0]['amount'],'0.30')
|
||||
|
||||
def test_insufficient_or_missing_balance_cannot_be_deducted(self):
|
||||
for balances,cost in [([],{'currency':'USD','amount':'1.00'}),([{'currency':'USD','amount':'1.00'}],{'currency':'USD','amount':'1.01'})]:
|
||||
with self.assertRaises(ValueError):
|
||||
apply_balance(balances,cost,'deduct')
|
||||
|
||||
def test_topup_can_create_currency_and_credit_exactly(self):
|
||||
balances=[{'currency':'JPY','amount':'100'}]
|
||||
updated,before,after=apply_balance(balances,{'currency':'HKD','amount':'10.50'},'credit')
|
||||
self.assertEqual(updated,[{'currency':'JPY','amount':'100'},{'currency':'HKD','amount':'10.50'}])
|
||||
self.assertEqual(before,{'currency':'HKD','amount':'0.00'})
|
||||
self.assertEqual(after,{'currency':'HKD','amount':'10.50'})
|
||||
@@ -0,0 +1,161 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
from server.main import create_app
|
||||
|
||||
|
||||
class MoneyApiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp=tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.path=Path(self.temp.name)/'test.db'
|
||||
self.app=create_app(self.path,start_worker=False)
|
||||
self.client=TestClient(self.app)
|
||||
self.addCleanup(self.client.close)
|
||||
response=self.client.post('/api/auth/register',json={'name':'Money Test','email':'money@example.com','password':'long-password-123'})
|
||||
self.headers={'X-CSRF-Token':response.json()['csrfToken']}
|
||||
|
||||
def payload(self):
|
||||
return {'name':'Money SIM','provider':'Example','type':'SIM','country':'US','activatedAt':'2026-01-01','balances':[{'currency':'USD','amount':'0.30'},{'currency':'JPY','amount':'1000'}],'rules':[{'action':'sms','interval':90,'unit':'days','anchor':'completion','dueDate':date.today().isoformat(),'cost':{'currency':'USD','amount':'0.10'}}]}
|
||||
|
||||
def create_card(self):
|
||||
response=self.client.post('/api/cards',headers=self.headers,json=self.payload())
|
||||
self.assertEqual(response.status_code,201,response.text)
|
||||
return response.json()['workspace']['cards'][0]
|
||||
|
||||
def complete(self,card,request_id='money-operation',**extra):
|
||||
rule=card['rules'][0]
|
||||
payload={'completedAt':date.today().isoformat(),'note':'','requestId':request_id,'version':rule['version'],**extra}
|
||||
return self.client.post(f"/api/rules/{rule['id']}/completions",headers=self.headers,json=payload)
|
||||
|
||||
def test_multiple_balances_and_rule_cost_persist_after_restart(self):
|
||||
card=self.create_card()
|
||||
with TestClient(create_app(self.path,start_worker=False)) as client:
|
||||
client.post('/api/auth/login',json={'email':'money@example.com','password':'long-password-123'})
|
||||
persisted=client.get('/api/workspace').json()['workspace']['cards'][0]
|
||||
self.assertEqual(persisted['balances'],card['balances'])
|
||||
self.assertEqual(persisted['rules'][0]['cost'],{'currency':'USD','amount':'0.10'})
|
||||
|
||||
def test_explicit_deduction_and_duplicate_submit_are_atomic(self):
|
||||
card=self.create_card()
|
||||
payload={'balanceAction':'deduct','cardVersion':card['version']}
|
||||
first=self.complete(card,**payload)
|
||||
self.assertEqual(first.status_code,200,first.text)
|
||||
retry=self.complete(card,**payload)
|
||||
self.assertEqual(retry.status_code,200,retry.text)
|
||||
workspace=retry.json()['workspace']
|
||||
self.assertEqual(workspace['cards'][0]['balances'],[{'currency':'USD','amount':'0.20'},{'currency':'JPY','amount':'1000'}])
|
||||
self.assertEqual(len(workspace['events']),1)
|
||||
event=workspace['events'][0]
|
||||
self.assertEqual(event['cost'],{'currency':'USD','amount':'0.10'})
|
||||
self.assertEqual(event['balanceBefore']['amount'],'0.30')
|
||||
self.assertEqual(event['balanceAfter']['amount'],'0.20')
|
||||
|
||||
def test_record_only_keeps_balance_and_historical_actual_cost(self):
|
||||
card=self.create_card()
|
||||
response=self.complete(card,cost={'currency':'GBP','amount':'2.5'})
|
||||
self.assertEqual(response.status_code,200,response.text)
|
||||
workspace=response.json()['workspace']
|
||||
self.assertEqual(workspace['cards'][0]['balances'],card['balances'])
|
||||
self.assertEqual(workspace['events'][0]['cost'],{'currency':'GBP','amount':'2.50'})
|
||||
rule=workspace['cards'][0]['rules'][0]
|
||||
edited={k:rule[k] for k in ['action','interval','unit','anchor','dueDate','instructions','version']}
|
||||
edited['cost']={'currency':'USD','amount':'5.00'}
|
||||
updated=self.client.put(f"/api/rules/{rule['id']}",headers=self.headers,json=edited)
|
||||
self.assertEqual(updated.status_code,200,updated.text)
|
||||
self.assertEqual(updated.json()['workspace']['events'][0]['cost'],{'currency':'GBP','amount':'2.50'})
|
||||
|
||||
def test_changed_retry_cannot_silently_discard_cost_or_balance_action(self):
|
||||
card=self.create_card()
|
||||
first=self.complete(card,cost={'currency':'USD','amount':'0.10'})
|
||||
self.assertEqual(first.status_code,200,first.text)
|
||||
for changed in [
|
||||
{'cost':{'currency':'USD','amount':'0.20'}},
|
||||
{'cost':{'currency':'USD','amount':'0.10'},'balanceAction':'deduct','cardVersion':card['version']},
|
||||
{'cost':None},
|
||||
{'note':'changed after a lost response'},
|
||||
]:
|
||||
with self.subTest(changed=changed):
|
||||
retry=self.complete(card,**changed)
|
||||
self.assertEqual(retry.status_code,409,retry.text)
|
||||
retry=self.complete(card,cost={'currency':'USD','amount':'0.100'})
|
||||
self.assertEqual(retry.status_code,200,retry.text)
|
||||
workspace=retry.json()['workspace']
|
||||
self.assertEqual(workspace['cards'][0]['balances'],card['balances'])
|
||||
self.assertEqual(len(workspace['events']),1)
|
||||
self.assertEqual(workspace['events'][0]['cost'],{'currency':'USD','amount':'0.10'})
|
||||
|
||||
def test_insufficient_balance_rejects_entire_completion(self):
|
||||
card=self.create_card()
|
||||
response=self.complete(card,cost={'currency':'USD','amount':'0.31'},balanceAction='deduct',cardVersion=card['version'])
|
||||
self.assertEqual(response.status_code,422,response.text)
|
||||
workspace=self.client.get('/api/workspace').json()['workspace']
|
||||
self.assertEqual(workspace['cards'][0],card)
|
||||
self.assertEqual(workspace['events'],[])
|
||||
|
||||
def test_stale_card_balance_version_rejects_deduction(self):
|
||||
card=self.create_card()
|
||||
changed={**self.payload(),'rules':[],'version':card['version'],'balances':[{'currency':'USD','amount':'8.00'}]}
|
||||
response=self.client.put(f"/api/cards/{card['id']}",headers=self.headers,json=changed)
|
||||
self.assertEqual(response.status_code,200,response.text)
|
||||
self.assertEqual(self.complete(card,balanceAction='deduct',cardVersion=card['version']).status_code,409)
|
||||
|
||||
def test_duplicate_currency_and_invalid_precision_are_rejected(self):
|
||||
payload=self.payload()
|
||||
payload['balances'].append({'currency':'USD','amount':'9.00'})
|
||||
self.assertEqual(self.client.post('/api/cards',headers=self.headers,json=payload).status_code,422)
|
||||
payload=self.payload()
|
||||
payload['rules'][0]['cost']['amount']='0.001'
|
||||
self.assertEqual(self.client.post('/api/cards',headers=self.headers,json=payload).status_code,422)
|
||||
|
||||
def test_legacy_card_edits_preserve_existing_money(self):
|
||||
card=self.create_card()
|
||||
changed={k:v for k,v in self.payload().items() if k not in ('balances','rules')}
|
||||
changed.update(name='Renamed',version=card['version'])
|
||||
response=self.client.put(f"/api/cards/{card['id']}",headers=self.headers,json=changed)
|
||||
self.assertEqual(response.status_code,200,response.text)
|
||||
updated=response.json()['workspace']['cards'][0]
|
||||
self.assertEqual(updated['balances'],card['balances'])
|
||||
rule=updated['rules'][0]
|
||||
fields={k:rule[k] for k in ['action','interval','unit','anchor','dueDate','instructions','version']}
|
||||
response=self.client.put(f"/api/rules/{rule['id']}",headers=self.headers,json=fields)
|
||||
self.assertEqual(response.status_code,200,response.text)
|
||||
self.assertEqual(response.json()['workspace']['cards'][0]['rules'][0]['cost'],card['rules'][0]['cost'])
|
||||
|
||||
def test_topup_creates_new_currency_and_saves_balance_snapshot(self):
|
||||
payload=self.payload()
|
||||
payload['rules'][0].update(action='topup',cost={'currency':'HKD','amount':'10.50'})
|
||||
response=self.client.post('/api/cards',headers=self.headers,json=payload)
|
||||
self.assertEqual(response.status_code,201,response.text)
|
||||
card=response.json()['workspace']['cards'][0]
|
||||
response=self.complete(card,balanceAction='credit',cardVersion=card['version'])
|
||||
self.assertEqual(response.status_code,200,response.text)
|
||||
workspace=response.json()['workspace']
|
||||
self.assertEqual(workspace['cards'][0]['balances'][-1],{'currency':'HKD','amount':'10.50'})
|
||||
self.assertEqual(workspace['events'][0]['balanceBefore'],{'currency':'HKD','amount':'0.00'})
|
||||
|
||||
def test_concurrent_requests_cannot_deduct_twice(self):
|
||||
card=self.create_card()
|
||||
def submit(request_id):
|
||||
return self.complete(card,request_id,balanceAction='deduct',cardVersion=card['version']).status_code
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
statuses=list(pool.map(submit,['money-concurrent-one','money-concurrent-two']))
|
||||
self.assertEqual(sorted(statuses),[200,409])
|
||||
workspace=self.client.get('/api/workspace').json()['workspace']
|
||||
self.assertEqual(workspace['cards'][0]['balances'][0]['amount'],'0.20')
|
||||
self.assertEqual(len(workspace['events']),1)
|
||||
|
||||
def test_other_account_cannot_change_or_deduct_balance(self):
|
||||
card=self.create_card()
|
||||
with TestClient(self.app) as other:
|
||||
response=other.post('/api/auth/register',json={'name':'Other','email':'other-money@example.com','password':'long-password-123'})
|
||||
headers={'X-CSRF-Token':response.json()['csrfToken']}
|
||||
payload={**self.payload(),'rules':[],'version':card['version']}
|
||||
self.assertEqual(other.put(f"/api/cards/{card['id']}",headers=headers,json=payload).status_code,404)
|
||||
rule=card['rules'][0]
|
||||
response=other.post(f"/api/rules/{rule['id']}/completions",headers=headers,json={'completedAt':date.today().isoformat(),'requestId':'foreign-deduction','version':rule['version'],'balanceAction':'deduct','cardVersion':card['version']})
|
||||
self.assertEqual(response.status_code,404)
|
||||
self.assertEqual(self.client.get('/api/workspace').json()['workspace']['cards'][0],card)
|
||||
@@ -0,0 +1,282 @@
|
||||
import asyncio
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from server.db import decode, encode
|
||||
from server.main import create_app
|
||||
from server.notifications import Config, Transport
|
||||
from server.notification_config import TelegramConfigInput
|
||||
|
||||
|
||||
TOKEN_A = '123456789:' + 'A' * 35
|
||||
TOKEN_B = '987654321:' + 'B' * 35
|
||||
SMTP_A = {'host':'smtp.example.com','port':587,'mode':'starttls','user':'alice@example.com','password':' password $ with spaces ','from':'alice@example.com'}
|
||||
|
||||
|
||||
class NotificationConfigTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.path = Path(self.temp.name)/'test.db'
|
||||
self.app = create_app(self.path, start_worker=False, config=Config())
|
||||
self.db = self.app.state.db
|
||||
self.notifier = self.app.state.notifier
|
||||
self.a, self.b = TestClient(self.app), TestClient(self.app)
|
||||
self.addCleanup(self.a.close)
|
||||
self.addCleanup(self.b.close)
|
||||
self.people, self.headers = [], []
|
||||
for client, name in [(self.a, 'Alice'), (self.b, 'Bob')]:
|
||||
reply = client.post('/api/auth/register', json={'name':name,'email':f'{name.lower()}@example.com','password':'long-password-123'})
|
||||
self.assertEqual(reply.status_code, 201, reply.text)
|
||||
self.people.append(reply.json()['user']['id'])
|
||||
self.headers.append({'X-CSRF-Token':reply.json()['csrfToken']})
|
||||
|
||||
def save(self, channel, payload, client=None, headers=None):
|
||||
reply = (client or self.a).put(f'/api/notifications/config/{channel}', headers=headers or self.headers[0], json=payload)
|
||||
self.assertEqual(reply.status_code, 200, reply.text)
|
||||
return reply
|
||||
|
||||
def services(self, client=None):
|
||||
return (client or self.a).get('/api/workspace').json()['workspace']['services']
|
||||
|
||||
def test_saved_configuration_is_owned_persistent_and_never_returns_secrets(self):
|
||||
for channel, payload in [('telegram',{'token':TOKEN_A}), ('email',SMTP_A)]:
|
||||
reply = self.save(channel, payload)
|
||||
self.assertNotIn(TOKEN_A, reply.text)
|
||||
self.assertNotIn(SMTP_A['password'], reply.text)
|
||||
self.assertTrue(self.services()[channel]['configured'])
|
||||
self.assertFalse(self.services(self.b)[channel]['configured'])
|
||||
response = self.a.get('/api/notifications/config')
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertNotIn(TOKEN_A, response.text)
|
||||
self.assertNotIn(SMTP_A['password'], response.text)
|
||||
self.assertEqual(response.json()['services']['email']['host'], 'smtp.example.com')
|
||||
self.assertTrue(response.json()['services']['telegram']['tokenSaved'])
|
||||
restarted = create_app(self.path, start_worker=False, config=Config())
|
||||
with TestClient(restarted) as client:
|
||||
client.cookies.update(self.a.cookies)
|
||||
self.assertTrue(self.services(client)['email']['configured'])
|
||||
self.assertTrue(self.services(client)['telegram']['configured'])
|
||||
|
||||
def test_blank_secret_preserves_saved_credentials_and_password_whitespace(self):
|
||||
self.save('telegram', {'token':TOKEN_A})
|
||||
self.save('telegram', {'token':''})
|
||||
self.save('email', SMTP_A)
|
||||
self.save('email', {**SMTP_A, 'password':'', 'port':465, 'mode':'ssl'})
|
||||
received = []
|
||||
|
||||
class SMTP:
|
||||
def __init__(self, host, port, **kwargs):
|
||||
received.append((host, port))
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): pass
|
||||
def ehlo_or_helo_if_needed(self): pass
|
||||
def ehlo(self): return 250, b'ok'
|
||||
def login(self, user, password): received.append((user, password))
|
||||
def send_message(self, message):
|
||||
received.append((message['From'], message['To']))
|
||||
return {}
|
||||
|
||||
with patch('server.notifications.smtplib.SMTP_SSL', SMTP):
|
||||
reply = self.a.post('/api/notifications/email/verify', headers=self.headers[0])
|
||||
self.assertEqual(reply.status_code, 200, reply.text)
|
||||
self.assertEqual(received[:2], [('smtp.example.com',465), ('alice@example.com',' password $ with spaces ')])
|
||||
self.assertEqual(received[2], ('alice@example.com','alice@example.com'))
|
||||
|
||||
def test_save_requires_session_csrf_and_does_not_accept_another_owner(self):
|
||||
route = '/api/notifications/config/telegram'
|
||||
with TestClient(self.app) as visitor:
|
||||
self.assertEqual(visitor.put(route,json={'token':TOKEN_A}).status_code,401)
|
||||
self.assertEqual(self.a.put(route,json={'token':TOKEN_A}).status_code,403)
|
||||
reply = self.a.put(route,headers=self.headers[0],json={'token':TOKEN_A,'userId':self.people[1]})
|
||||
self.assertEqual(reply.status_code,422)
|
||||
self.assertFalse(self.services(self.b)['telegram']['configured'])
|
||||
|
||||
def test_invalid_configuration_never_echoes_credentials_and_keeps_previous_value(self):
|
||||
self.save('telegram', {'token':TOKEN_A})
|
||||
for secret in ['https://secret.example/token','1234:secret/malicious','secret\npassword']:
|
||||
reply = self.a.put('/api/notifications/config/telegram',headers=self.headers[0],json={'token':secret})
|
||||
self.assertEqual(reply.status_code,422,reply.text)
|
||||
self.assertNotIn(secret,reply.text)
|
||||
for change in [{'port':0},{'mode':'unsafe'},{'host':'https://smtp.example.com'},{'from':'alice@example.com\r\nBcc:secret@example.com'}]:
|
||||
reply = self.a.put('/api/notifications/config/email',headers=self.headers[0],json={**SMTP_A,**change})
|
||||
self.assertEqual(reply.status_code,422,reply.text)
|
||||
self.assertNotIn(SMTP_A['password'],reply.text)
|
||||
self.assertTrue(self.services()['telegram']['configured'])
|
||||
self.assertFalse(self.services()['email']['configured'])
|
||||
|
||||
def test_telegram_bot_cannot_be_claimed_by_two_accounts(self):
|
||||
self.save('telegram', {'token':TOKEN_A})
|
||||
reply = self.b.put('/api/notifications/config/telegram',headers=self.headers[1],json={'token':TOKEN_A})
|
||||
self.assertEqual(reply.status_code,409,reply.text)
|
||||
self.assertNotIn(TOKEN_A,reply.text)
|
||||
self.assertFalse(self.services(self.b)['telegram']['configured'])
|
||||
|
||||
def test_changing_bot_invalidates_old_binding_and_requires_rebinding(self):
|
||||
self.save('telegram', {'token':TOKEN_A})
|
||||
async def telegram(transport, method, payload):
|
||||
return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'}
|
||||
with patch.object(Transport,'telegram',telegram):
|
||||
code = self.a.post('/api/notifications/telegram/binding',headers=self.headers[0]).json()['code']
|
||||
message = {'message':{'chat':{'id':12345,'type':'private'},'text':f'/start {code}'}}
|
||||
self.assertTrue(self.notifier.process_update(message,user_id=self.people[0]))
|
||||
self.save('telegram', {'token':TOKEN_B})
|
||||
settings = self.a.get('/api/workspace').json()['workspace']['settings']
|
||||
self.assertFalse(settings['telegramVerified'])
|
||||
self.assertFalse(settings['telegram'])
|
||||
self.assertEqual(settings['chatId'],'')
|
||||
self.assertFalse(self.notifier.process_update(message,user_id=self.people[0]))
|
||||
|
||||
def test_personal_bot_cannot_consume_another_accounts_binding_code(self):
|
||||
self.save('telegram', {'token':TOKEN_A})
|
||||
self.save('telegram', {'token':TOKEN_B},self.b,self.headers[1])
|
||||
async def telegram(transport, method, payload):
|
||||
return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'}
|
||||
with patch.object(Transport,'telegram',telegram):
|
||||
code = self.a.post('/api/notifications/telegram/binding',headers=self.headers[0]).json()['code']
|
||||
message = {'message':{'chat':{'id':12345,'type':'private'},'text':f'/start {code}'}}
|
||||
self.assertFalse(self.notifier.process_update(message,user_id=self.people[1]))
|
||||
self.assertFalse(self.notifier.process_update(message))
|
||||
self.assertTrue(self.notifier.process_update(message,user_id=self.people[0]))
|
||||
|
||||
def test_background_reminders_use_each_accounts_saved_token_immediately(self):
|
||||
messages = []
|
||||
for client, headers, token, chat in [(self.a,self.headers[0],TOKEN_A,'100'),(self.b,self.headers[1],TOKEN_B,'200')]:
|
||||
self.save('telegram',{'token':token},client,headers)
|
||||
reply = client.post('/api/cards',headers=headers,json={'name':'Personal SIM','provider':'Example','type':'SIM','country':'US','activatedAt':'2026-01-01','rules':[{'action':'sms','interval':90,'unit':'days','anchor':'completion','dueDate':'2026-10-07'}]})
|
||||
self.assertEqual(reply.status_code,201,reply.text)
|
||||
owner = client.get('/api/auth/me').json()['user']['id']
|
||||
with self.db.connect(write=True) as conn:
|
||||
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(owner,)).fetchone()[0])
|
||||
settings.update(telegram=True,telegramVerified=True,chatId=chat)
|
||||
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),owner))
|
||||
async def telegram(transport, method, payload):
|
||||
messages.append((transport.config.telegram_token,method,payload['chat_id']))
|
||||
return {'message_id':1}
|
||||
now = datetime(2026,10,4,1,0,tzinfo=timezone.utc)
|
||||
self.notifier.enqueue(now)
|
||||
with patch.object(Transport,'telegram',telegram):
|
||||
asyncio.run(self.notifier.deliver(now))
|
||||
self.assertCountEqual(messages,[(TOKEN_A,'sendMessage','100'),(TOKEN_B,'sendMessage','200')])
|
||||
self.assertEqual(self.a.get('/api/workspace').json()['workspace']['notifications'][0]['status'],'sent')
|
||||
|
||||
def test_connection_check_never_sends_a_message_and_redacts_network_errors(self):
|
||||
self.save('telegram', {'token':TOKEN_A})
|
||||
methods = []
|
||||
async def telegram(transport, method, payload):
|
||||
methods.append(method)
|
||||
return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'}
|
||||
with patch.object(Transport,'telegram',telegram):
|
||||
reply = self.a.post('/api/notifications/config/telegram/check',headers=self.headers[0])
|
||||
self.assertEqual(reply.status_code,200,reply.text)
|
||||
self.assertTrue(reply.json()['checked'])
|
||||
self.assertEqual(reply.json()['botUsername'],'AliceTestBot')
|
||||
self.assertEqual(methods,['getMe'])
|
||||
async def fail(transport, method, payload): raise RuntimeError(TOKEN_A)
|
||||
with patch.object(Transport,'telegram',fail):
|
||||
reply = self.a.post('/api/notifications/config/telegram/check',headers=self.headers[0])
|
||||
self.assertEqual(reply.status_code,503,reply.text)
|
||||
self.assertNotIn(TOKEN_A,reply.text)
|
||||
self.assertTrue(self.services()['telegram']['configured'])
|
||||
|
||||
def test_clearing_configuration_only_affects_its_owner(self):
|
||||
self.save('email',SMTP_A)
|
||||
self.save('email',{**SMTP_A,'user':'bob@example.com','from':'bob@example.com'},self.b,self.headers[1])
|
||||
reply = self.a.delete('/api/notifications/config/email',headers=self.headers[0])
|
||||
self.assertEqual(reply.status_code,200,reply.text)
|
||||
self.assertFalse(self.services()['email']['configured'])
|
||||
self.assertTrue(self.services(self.b)['email']['configured'])
|
||||
|
||||
def test_bot_change_during_connection_cannot_create_a_stale_binding(self):
|
||||
self.save('telegram', {'token':TOKEN_A})
|
||||
async def telegram(transport, method, payload):
|
||||
self.notifier.configs.save(self.people[0],'telegram',TelegramConfigInput(token=TOKEN_B),Config())
|
||||
return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'}
|
||||
with patch.object(Transport,'telegram',telegram):
|
||||
reply = self.a.post('/api/notifications/telegram/binding',headers=self.headers[0])
|
||||
self.assertEqual(reply.status_code,409,reply.text)
|
||||
with self.db.connect() as conn:
|
||||
self.assertEqual(conn.execute('SELECT count(*) FROM bindings WHERE user_id=?',(self.people[0],)).fetchone()[0],0)
|
||||
|
||||
def test_personal_polling_keeps_separate_offsets_and_failed_bot_does_not_block_others(self):
|
||||
self.save('telegram', {'token':TOKEN_A})
|
||||
self.save('telegram', {'token':TOKEN_B},self.b,self.headers[1])
|
||||
async def get_me(transport, method, payload):
|
||||
return {'id':123456789,'is_bot':True,'first_name':'Test','username':'AliceTestBot'}
|
||||
with patch.object(Transport,'telegram',get_me):
|
||||
code = self.a.post('/api/notifications/telegram/binding',headers=self.headers[0]).json()['code']
|
||||
calls = []
|
||||
async def updates(transport, method, payload):
|
||||
calls.append((transport.config.telegram_token,payload['offset']))
|
||||
if transport.config.telegram_token == TOKEN_B:
|
||||
raise RuntimeError(TOKEN_B)
|
||||
return [] if payload['offset'] else [{'update_id':21,'message':{'chat':{'id':12345,'type':'private'},'text':f'/start {code}'}}]
|
||||
with patch.object(Transport,'telegram',updates), self.assertLogs('simkeep.notifications',level='WARNING') as logs:
|
||||
asyncio.run(self.notifier.poll())
|
||||
asyncio.run(self.notifier.poll())
|
||||
self.assertCountEqual(calls,[(TOKEN_A,0),(TOKEN_B,0),(TOKEN_A,22),(TOKEN_B,0)])
|
||||
self.assertTrue(self.a.get('/api/workspace').json()['workspace']['settings']['telegramVerified'])
|
||||
self.assertNotIn(TOKEN_B,' '.join(logs.output))
|
||||
|
||||
def test_smtp_connection_check_authenticates_without_sending_email(self):
|
||||
self.save('email',SMTP_A)
|
||||
commands = []
|
||||
class SMTP:
|
||||
def __init__(self, host, port, **kwargs): commands.append(('connect',host,port))
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): commands.append(('quit',))
|
||||
def ehlo_or_helo_if_needed(self): commands.append(('hello',))
|
||||
def starttls(self, **kwargs): commands.append(('starttls',))
|
||||
def ehlo(self): commands.append(('hello-tls',))
|
||||
def login(self, user, password): commands.append(('login',user,password))
|
||||
def send_message(self, message): raise AssertionError('Connection checking must not send email')
|
||||
with patch('server.notifications.smtplib.SMTP',SMTP):
|
||||
reply = self.a.post('/api/notifications/config/email/check',headers=self.headers[0])
|
||||
self.assertEqual(reply.status_code,200,reply.text)
|
||||
self.assertTrue(reply.json()['checked'])
|
||||
self.assertEqual(commands,[('connect','smtp.example.com',587),('hello',),('starttls',),('hello-tls',),('login','alice@example.com',' password $ with spaces '),('quit',)])
|
||||
|
||||
def test_fake_token_with_same_bot_prefix_cannot_reserve_another_persons_credentials(self):
|
||||
self.save('telegram',{'token':'123456789:'+'X'*35})
|
||||
self.save('telegram',{'token':TOKEN_A},self.b,self.headers[1])
|
||||
self.assertTrue(self.services(self.b)['telegram']['configured'])
|
||||
# The actual full credential remains exclusive, including canonical ID spelling.
|
||||
reply = self.a.put('/api/notifications/config/telegram',headers=self.headers[0],json={'token':'000'+TOKEN_A})
|
||||
self.assertEqual(reply.status_code,409,reply.text)
|
||||
|
||||
def test_repaired_smtp_retries_failed_reminder_without_resending_a_sent_job(self):
|
||||
self.save('email',SMTP_A)
|
||||
reply = self.a.post('/api/cards',headers=self.headers[0],json={'name':'Personal SIM','provider':'Example','type':'SIM','country':'US','activatedAt':'2026-01-01','rules':[{'action':'sms','interval':90,'unit':'days','anchor':'completion','dueDate':'2026-10-07'}]})
|
||||
self.assertEqual(reply.status_code,201,reply.text)
|
||||
with self.db.connect(write=True) as conn:
|
||||
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?',(self.people[0],)).fetchone()[0])
|
||||
settings.update(email=True,emailVerified=True)
|
||||
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),self.people[0]))
|
||||
now = datetime(2026,10,4,1,0,tzinfo=timezone.utc)
|
||||
self.notifier.enqueue(now)
|
||||
with patch.object(Transport,'smtp',side_effect=RuntimeError('old-password')):
|
||||
for attempt in range(5):
|
||||
asyncio.run(self.notifier.deliver(now+timedelta(minutes=10*attempt)))
|
||||
self.assertEqual(self.a.get('/api/workspace').json()['workspace']['notifications'][0]['status'],'failed')
|
||||
self.save('email',{**SMTP_A,'password':'corrected-password'})
|
||||
delivered = []
|
||||
def smtp(transport, destination, text): delivered.append(destination)
|
||||
with patch.object(Transport,'smtp',smtp):
|
||||
self.notifier.enqueue(now+timedelta(minutes=50))
|
||||
asyncio.run(self.notifier.deliver(now+timedelta(minutes=50)))
|
||||
self.save('email',{**SMTP_A,'password':'another-password'})
|
||||
self.notifier.enqueue(now+timedelta(minutes=60))
|
||||
asyncio.run(self.notifier.deliver(now+timedelta(minutes=60)))
|
||||
self.assertEqual(delivered,['alice@example.com'])
|
||||
jobs = self.a.get('/api/workspace').json()['workspace']['notifications']
|
||||
self.assertEqual(len(jobs),1)
|
||||
self.assertEqual(jobs[0]['status'],'sent')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,261 @@
|
||||
import asyncio
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from server.db import decode, encode
|
||||
from server.main import create_app
|
||||
|
||||
try:
|
||||
from server.notifications import Config, Notifier
|
||||
except ImportError:
|
||||
Config = Notifier = None
|
||||
|
||||
|
||||
class RecordingTransport:
|
||||
"""Only the external delivery boundary is replaced; queue and DB stay real."""
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
self.failures = {}
|
||||
|
||||
async def send(self, channel, destination, text):
|
||||
if self.failures.get(channel, 0):
|
||||
self.failures[channel] -= 1
|
||||
raise RuntimeError('secret-token-must-never-reach-browser')
|
||||
self.messages.append((channel, destination, text))
|
||||
|
||||
async def bot_username(self):
|
||||
return 'SimkeepTestBot'
|
||||
|
||||
async def updates(self, offset):
|
||||
return []
|
||||
|
||||
|
||||
class NotificationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertIsNotNone(Notifier, 'Real notification service must be implemented')
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.path = Path(self.temp.name)/'test.db'
|
||||
self.config = Config(telegram_token='server-secret-token', smtp_host='smtp.example.com', smtp_from='bot@example.com', public_url='http://example.com')
|
||||
self.app = create_app(self.path, start_worker=False, config=self.config)
|
||||
self.a = TestClient(self.app)
|
||||
self.b = TestClient(self.app)
|
||||
self.addCleanup(self.a.close)
|
||||
self.addCleanup(self.b.close)
|
||||
for client, name in [(self.a, 'Alice'), (self.b, 'Bob')]:
|
||||
response = client.post('/api/auth/register', json={'name':name,'email':f'{name.lower()}@example.com','password':'long-password-123'})
|
||||
self.assertEqual(response.status_code, 201, response.text)
|
||||
self.db = self.app.state.db
|
||||
self.notifier = self.app.state.notifier
|
||||
self.transport = RecordingTransport()
|
||||
self.notifier.transport = self.transport
|
||||
self.owner = self.a.get('/api/auth/me').json()['user']['id']
|
||||
self.headers = {'X-CSRF-Token': self.a.get('/api/auth/me').json()['csrfToken']}
|
||||
self.now = datetime(2026, 10, 4, 1, 0, tzinfo=timezone.utc)
|
||||
self.card = self.a.post('/api/cards', headers=self.headers, json={'name':'Private SIM','provider':'Example','type':'SIM','country':'US','activatedAt':'2026-01-01','rules':[{'action':'sms','interval':90,'unit':'days','anchor':'completion','dueDate':'2026-10-07','instructions':'发送一条短信'}]}).json()['workspace']['cards'][0]
|
||||
|
||||
def enable(self, verified=True):
|
||||
with self.db.connect(write=True) as conn:
|
||||
settings = decode(conn.execute('SELECT settings FROM users WHERE id=?', (self.owner,)).fetchone()[0])
|
||||
settings.update(telegram=True, email=True, chatId='123456', telegramVerified=verified, emailVerified=verified)
|
||||
conn.execute('UPDATE users SET settings=? WHERE id=?', (encode(settings), self.owner))
|
||||
|
||||
def tick(self, now=None):
|
||||
self.notifier.enqueue(now or self.now)
|
||||
asyncio.run(self.notifier.deliver(now or self.now))
|
||||
|
||||
def test_local_time_deduplication_and_sent_jobs_survive_restart(self):
|
||||
self.enable()
|
||||
self.tick(datetime(2026, 10, 4, 0, 59, tzinfo=timezone.utc))
|
||||
self.assertEqual(self.transport.messages, [])
|
||||
self.tick()
|
||||
self.assertEqual(len(self.transport.messages), 2)
|
||||
self.assertIn('Private SIM', self.transport.messages[0][2])
|
||||
self.assertIn('2026-10-07', self.transport.messages[0][2])
|
||||
self.tick()
|
||||
restarted = Notifier(self.db, self.config, self.transport)
|
||||
restarted.recover()
|
||||
restarted.enqueue(self.now)
|
||||
asyncio.run(restarted.deliver(self.now))
|
||||
self.assertEqual(len(self.transport.messages), 2)
|
||||
self.assertNotIn('server-secret-token', self.a.get('/api/workspace').text)
|
||||
|
||||
def test_unverified_and_archived_cards_never_send(self):
|
||||
self.enable(verified=False)
|
||||
self.tick()
|
||||
self.assertEqual(self.transport.messages, [])
|
||||
self.enable()
|
||||
self.notifier.enqueue(self.now)
|
||||
self.a.post(f"/api/cards/{self.card['id']}/archive", headers=self.headers, json={'archived':True,'version':self.card['version']})
|
||||
asyncio.run(self.notifier.deliver(self.now))
|
||||
self.assertEqual(self.transport.messages, [])
|
||||
|
||||
def test_old_rule_jobs_cancel_after_completion(self):
|
||||
self.enable()
|
||||
self.notifier.enqueue(self.now)
|
||||
rule = self.card['rules'][0]
|
||||
response = self.a.post(f"/api/rules/{rule['id']}/completions", headers=self.headers, json={'completedAt':'2026-10-04','note':'','requestId':'record-once-only','version':rule['version']})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
asyncio.run(self.notifier.deliver(self.now))
|
||||
self.assertEqual(self.transport.messages, [])
|
||||
self.assertTrue(all(x['status']=='cancelled' for x in self.a.get('/api/workspace').json()['workspace']['notifications']))
|
||||
|
||||
def test_unconfigured_channel_does_not_starve_email_and_stale_jobs_cancel(self):
|
||||
self.enable()
|
||||
with self.db.connect(write=True) as conn:
|
||||
original=decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0])
|
||||
settings=decode(conn.execute('SELECT settings FROM users WHERE id=?',(self.owner,)).fetchone()[0])
|
||||
settings['email']=False
|
||||
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),self.owner))
|
||||
# Arrange 50 real active rules so the previous batch was fully occupied.
|
||||
for index in range(49):
|
||||
card={**original,'id':f'card-{index}','rules':[{**original['rules'][0],'id':f'rule-{index}'}]}
|
||||
conn.execute('INSERT INTO cards VALUES(?,?,?)',(card['id'],self.owner,encode(card)))
|
||||
self.notifier.enqueue(self.now)
|
||||
self.notifier.config=Config(smtp_host='smtp.example.com',smtp_from='bot@example.com')
|
||||
with self.db.connect(write=True) as conn:
|
||||
settings['email']=True
|
||||
conn.execute('UPDATE users SET settings=? WHERE id=?',(encode(settings),self.owner))
|
||||
self.tick()
|
||||
self.assertEqual(len(self.transport.messages),50)
|
||||
self.assertTrue(all(channel=='email' for channel,_,_ in self.transport.messages))
|
||||
with self.db.connect(write=True) as conn:
|
||||
conn.execute("UPDATE notification_jobs SET ready_at=ready_at-86400 WHERE channel='telegram'")
|
||||
self.tick(datetime(2026,10,5,1,0,tzinfo=timezone.utc))
|
||||
with self.db.connect() as conn:
|
||||
self.assertEqual(conn.execute("SELECT count(*) FROM notification_jobs WHERE channel='telegram' AND status='cancelled'").fetchone()[0],50)
|
||||
|
||||
def test_rebind_check_waits_for_this_binding(self):
|
||||
self.enable()
|
||||
code=self.a.post('/api/notifications/telegram/binding',headers=self.headers).json()['code']
|
||||
check=lambda:self.a.post('/api/notifications/telegram/binding/check',headers=self.headers,json={'code':code})
|
||||
response=check()
|
||||
self.assertEqual(response.status_code,200,response.text)
|
||||
self.assertEqual(response.json()['bindingStatus'],'waiting')
|
||||
self.assertTrue(self.notifier.process_update({'message':{'chat':{'id':654321,'type':'private'},'text':f'/start {code}'}}))
|
||||
self.assertEqual(check().json()['bindingStatus'],'bound')
|
||||
bheaders={'X-CSRF-Token':self.b.get('/api/auth/me').json()['csrfToken']}
|
||||
other=self.b.post('/api/notifications/telegram/binding/check',headers=bheaders,json={'code':code})
|
||||
self.assertEqual(other.json()['bindingStatus'],'expired')
|
||||
|
||||
def test_real_message_matches_suffix_preview(self):
|
||||
self.enable()
|
||||
with self.db.connect(write=True) as conn:
|
||||
card=decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0])
|
||||
card['phone']='+44 7700 900123'
|
||||
conn.execute('UPDATE cards SET document=? WHERE id=?',(encode(card),card['id']))
|
||||
self.tick()
|
||||
for _,_,text in self.transport.messages:
|
||||
self.assertIn('尾号 0123',text)
|
||||
self.assertNotIn('+44 7700 900123',text)
|
||||
|
||||
def test_reminder_includes_estimated_cost_and_matching_balance(self):
|
||||
self.enable()
|
||||
with self.db.connect(write=True) as conn:
|
||||
card=decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0])
|
||||
card['balances']=[{'currency':'USD','amount':'10.50'}]
|
||||
card['rules'][0]['cost']={'currency':'USD','amount':'2.50'}
|
||||
conn.execute('UPDATE cards SET document=? WHERE id=?',(encode(card),card['id']))
|
||||
self.tick()
|
||||
for _,_,text in self.transport.messages:
|
||||
self.assertIn('续期金额:USD 2.50',text)
|
||||
self.assertIn('同币种余额:USD 10.50',text)
|
||||
|
||||
def test_transport_failures_retry_without_blocking_other_channel(self):
|
||||
self.enable()
|
||||
self.transport.failures['telegram'] = 1
|
||||
self.tick()
|
||||
self.assertEqual([x[0] for x in self.transport.messages], ['email'])
|
||||
self.assertNotIn('secret-token-must', self.a.get('/api/workspace').text)
|
||||
self.tick(datetime(2026, 10, 4, 1, 3, tzinfo=timezone.utc))
|
||||
self.assertEqual(sorted(x[0] for x in self.transport.messages), ['email','telegram'])
|
||||
self.tick(datetime(2026, 10, 4, 1, 6, tzinfo=timezone.utc))
|
||||
self.assertEqual(len(self.transport.messages), 2)
|
||||
|
||||
def test_cancelled_pending_job_can_resume_without_resending_sent_job(self):
|
||||
self.enable()
|
||||
self.notifier.enqueue(self.now)
|
||||
settings = self.a.get('/api/workspace').json()['workspace']['settings']
|
||||
fields = ['timezone','time','offsets','overdue','telegram','email','emailAddress']
|
||||
payload = {k:settings[k] for k in fields}
|
||||
payload['telegram'] = payload['email'] = False
|
||||
self.a.put('/api/settings', headers=self.headers, json=payload)
|
||||
payload['telegram'] = payload['email'] = True
|
||||
self.a.put('/api/settings', headers=self.headers, json=payload)
|
||||
self.tick()
|
||||
self.assertEqual(len(self.transport.messages), 2)
|
||||
self.a.put('/api/settings', headers=self.headers, json=payload)
|
||||
self.tick()
|
||||
self.assertEqual(len(self.transport.messages), 2)
|
||||
|
||||
def test_telegram_binding_is_private_expiring_and_single_use(self):
|
||||
response = self.a.post('/api/notifications/telegram/binding', headers=self.headers)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
code = response.json()['code']
|
||||
group = {'message':{'chat':{'id':-123,'type':'group'},'text':f'/start {code}'}}
|
||||
self.assertFalse(self.notifier.process_update(group))
|
||||
private = {'message':{'chat':{'id':123456,'type':'private'},'text':f'/start {code}'}}
|
||||
self.assertTrue(self.notifier.process_update(private))
|
||||
self.assertFalse(self.notifier.process_update(private))
|
||||
settings = self.a.get('/api/workspace').json()['workspace']['settings']
|
||||
self.assertTrue(settings['telegramVerified'])
|
||||
self.assertEqual(settings['chatId'], '123456')
|
||||
bheaders = {'X-CSRF-Token':self.b.get('/api/auth/me').json()['csrfToken']}
|
||||
other = self.b.post('/api/notifications/telegram/binding', headers=bheaders).json()['code']
|
||||
private['message']['text'] = f'/start {other}'
|
||||
self.assertFalse(self.notifier.process_update(private))
|
||||
self.assertFalse(self.b.get('/api/workspace').json()['workspace']['settings']['telegramVerified'])
|
||||
expired = self.a.post('/api/notifications/telegram/binding', headers=self.headers).json()['code']
|
||||
with self.db.connect(write=True) as conn:
|
||||
conn.execute("UPDATE bindings SET expires_at=0 WHERE kind='telegram'")
|
||||
private['message']['text'] = f'/start {expired}'
|
||||
self.assertFalse(self.notifier.process_update(private))
|
||||
|
||||
def test_email_confirmation_is_owner_scoped_and_destination_bound(self):
|
||||
response = self.a.post('/api/notifications/email/verify', headers=self.headers)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
code = re.search(r'\b\d{6}\b', self.transport.messages[-1][2]).group()
|
||||
bheaders = {'X-CSRF-Token':self.b.get('/api/auth/me').json()['csrfToken']}
|
||||
self.assertEqual(self.b.post('/api/notifications/email/confirm', headers=bheaders, json={'code':code}).status_code, 422)
|
||||
self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':code}).status_code, 200)
|
||||
self.assertTrue(self.a.get('/api/workspace').json()['workspace']['settings']['emailVerified'])
|
||||
self.assertEqual(self.a.post('/api/notifications/test/email', headers=self.headers).status_code, 200)
|
||||
self.assertEqual(self.b.post('/api/notifications/test/email', headers=bheaders).status_code, 422)
|
||||
self.a.post('/api/notifications/email/verify', headers=self.headers)
|
||||
code = re.search(r'\b\d{6}\b', self.transport.messages[-1][2]).group()
|
||||
self.a.put('/api/settings', headers=self.headers, json={'timezone':'Asia/Shanghai','time':'09:00','offsets':[3],'overdue':True,'telegram':False,'email':True,'emailAddress':'new@example.com'})
|
||||
self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':code}).status_code, 422)
|
||||
|
||||
def test_email_attempt_limit_and_unconfigured_service(self):
|
||||
self.a.post('/api/notifications/email/verify', headers=self.headers)
|
||||
code = re.search(r'\b\d{6}\b', self.transport.messages[-1][2]).group()
|
||||
wrong = '000000' if code != '000000' else '111111'
|
||||
for _ in range(5):
|
||||
self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':wrong}).status_code, 422)
|
||||
self.assertEqual(self.a.post('/api/notifications/email/confirm', headers=self.headers, json={'code':code}).status_code, 422)
|
||||
app = create_app(Path(self.temp.name)/'no-config.db', start_worker=False, config=Config())
|
||||
with TestClient(app) as client:
|
||||
client.post('/api/auth/register', json={'name':'Test','email':'test@example.com','password':'long-password-123'})
|
||||
headers = {'X-CSRF-Token':client.get('/api/auth/me').json()['csrfToken']}
|
||||
self.assertEqual(client.post('/api/notifications/email/verify', headers=headers).status_code, 503)
|
||||
self.assertEqual(client.post('/api/notifications/telegram/binding', headers=headers).status_code, 503)
|
||||
|
||||
def test_overdue_notification_is_once_per_local_day(self):
|
||||
self.enable()
|
||||
with self.db.connect(write=True) as conn:
|
||||
card = decode(conn.execute('SELECT document FROM cards WHERE id=?',(self.card['id'],)).fetchone()[0])
|
||||
card['rules'][0]['dueDate']='2026-10-03'
|
||||
conn.execute('UPDATE cards SET document=? WHERE id=?',(encode(card),card['id']))
|
||||
self.tick()
|
||||
self.tick()
|
||||
self.assertEqual(len(self.transport.messages),2)
|
||||
self.tick(datetime(2026,10,5,1,0,tzinfo=timezone.utc))
|
||||
self.assertEqual(len(self.transport.messages),4)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,63 @@
|
||||
import io
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from server.db import Database
|
||||
|
||||
try:
|
||||
from scripts.restore import restore_database
|
||||
except ImportError:
|
||||
restore_database=None
|
||||
|
||||
|
||||
class RestoreTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertIsNotNone(restore_database,'Container restore must work as the application user')
|
||||
self.temp=tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root=Path(self.temp.name)
|
||||
original=self.root/'backup.db'
|
||||
db=Database(original)
|
||||
with db.connect(write=True) as conn:
|
||||
conn.execute('INSERT INTO system_state VALUES(?,?)',('restore-fixture','preserved'))
|
||||
self.incoming=original.read_bytes()
|
||||
self.destination=self.root/'data'/'simkeep.db'
|
||||
|
||||
def test_stream_import_preserves_database_contents_and_private_mode(self):
|
||||
restore_database(self.destination,io.BytesIO(self.incoming))
|
||||
with sqlite3.connect(self.destination) as conn:
|
||||
self.assertEqual(conn.execute('SELECT value FROM system_state WHERE key=?',('restore-fixture',)).fetchone()[0],'preserved')
|
||||
self.assertEqual(conn.execute('PRAGMA integrity_check').fetchone()[0],'ok')
|
||||
self.assertEqual(self.destination.stat().st_mode&0o777,0o600)
|
||||
|
||||
def test_existing_database_requires_explicit_replace(self):
|
||||
restore_database(self.destination,io.BytesIO(self.incoming))
|
||||
before=self.destination.read_bytes()
|
||||
with self.assertRaises(ValueError):
|
||||
restore_database(self.destination,io.BytesIO(self.incoming))
|
||||
self.assertEqual(self.destination.read_bytes(),before)
|
||||
|
||||
def test_invalid_or_unrelated_backup_preserves_existing_database(self):
|
||||
restore_database(self.destination,io.BytesIO(self.incoming))
|
||||
before=self.destination.read_bytes()
|
||||
other=self.root/'other.db'
|
||||
with sqlite3.connect(other) as conn:
|
||||
conn.execute('CREATE TABLE unrelated(value TEXT)')
|
||||
for incoming in (b'not a database',b'',other.read_bytes()):
|
||||
with self.subTest(incoming_size=len(incoming)),self.assertRaises(ValueError):
|
||||
restore_database(self.destination,io.BytesIO(incoming),replace=True)
|
||||
self.assertEqual(self.destination.read_bytes(),before)
|
||||
|
||||
def test_explicit_restore_removes_old_sidecars_and_replaces_contents(self):
|
||||
restore_database(self.destination,io.BytesIO(self.incoming))
|
||||
with sqlite3.connect(self.destination) as conn:
|
||||
conn.execute('DELETE FROM system_state')
|
||||
for suffix in ('-wal','-shm'):
|
||||
Path(str(self.destination)+suffix).write_bytes(b'old stopped database sidecar')
|
||||
restore_database(self.destination,io.BytesIO(self.incoming),replace=True)
|
||||
for suffix in ('-wal','-shm'):
|
||||
self.assertFalse(Path(str(self.destination)+suffix).exists())
|
||||
with sqlite3.connect(self.destination) as conn:
|
||||
self.assertEqual(conn.execute('SELECT value FROM system_state').fetchone()[0],'preserved')
|
||||
Reference in new issue
Block a user