init
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
*.db
|
||||
dist/
|
||||
.env
|
||||
369
README.md
Normal file
369
README.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# SMS Monitor
|
||||
|
||||
一个基于 Android + Node.js 的个人短信监控系统。Android App 读取手机短信并上传至服务器,Web Dashboard 提供网页端查看与管理。
|
||||
|
||||
## 项目架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ HTTP / JSON ┌─────────────────┐ SQL ┌──────────┐
|
||||
│ Android App │ ────────────────────→ │ Node.js Server │ ──────────→ │ SQLite │
|
||||
│ (Flutter) │ │ (Express) │ │ │
|
||||
└─────────────────┘ └────────┬────────┘ └──────────┘
|
||||
│
|
||||
│ REST API
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Web Dashboard │
|
||||
│ (React + Vite) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
- **App 端**:Flutter 编写,仅支持 Android。读取系统短信数据库 + 实时监听新短信,支持自动/手动上传。
|
||||
- **服务端**:Node.js + Express 提供 REST API,JWT 鉴权,SQLite 存储。
|
||||
- **Web 端**:React + Vite SPA,Material You 风格,登录后查看/筛选/删除短信。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| Android App | Flutter 3.2+ | `dynamic_color` Material You 主题 |
|
||||
| 短信读取 | `sms_maintained` | 查询系统短信数据库 + 接收新短信广播 |
|
||||
| 权限管理 | `permission_handler` | Android SMS 权限 |
|
||||
| 后端框架 | Express 4.x | TypeScript 编写 |
|
||||
| 数据库 | better-sqlite3 | WAL 模式,同步 API |
|
||||
| 鉴权 | JWT (jsonwebtoken) | 30 天有效期 |
|
||||
| 密码加密 | bcryptjs | 10 轮哈希 |
|
||||
| 前端框架 | React 18 + Vite 6 | TypeScript |
|
||||
| HTTP 客户端 | fetch (内置) | 无额外依赖 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
transfer_sms/
|
||||
├── server/
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── src/
|
||||
│ ├── index.ts # Express 入口,挂载路由
|
||||
│ ├── db.ts # SQLite 初始化 + 自动建表
|
||||
│ ├── types.ts # TypeScript 类型定义
|
||||
│ ├── middleware/
|
||||
│ │ └── auth.ts # JWT Bearer Token 鉴权中间件
|
||||
│ └── routes/
|
||||
│ ├── auth.ts # POST /api/auth/register, /api/auth/login
|
||||
│ ├── sms.ts # POST /api/sms/upload, GET /api/sms, DELETE /api/sms/:id
|
||||
│ └── devices.ts # POST /api/devices/register
|
||||
├── web/
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.ts # 开发代理 /api → localhost:3000
|
||||
│ └── src/
|
||||
│ ├── main.tsx # 入口
|
||||
│ ├── App.tsx # 路由:未登录 → Login,已登录 → SmsList
|
||||
│ ├── api.ts # 封装 fetch 调用
|
||||
│ ├── index.css # Material You CSS 变量 + 基础样式
|
||||
│ └── pages/
|
||||
│ ├── Login.tsx / .css # 登录/注册页
|
||||
│ └── SmsList.tsx / .css # 短信列表 + 详情面板 + 筛选
|
||||
└── app/
|
||||
├── pubspec.yaml
|
||||
└── lib/
|
||||
├── main.dart # 入口,DynamicColorBuilder 主题
|
||||
├── models/
|
||||
│ └── sms_message.dart # SmsMessage 数据类 + JSON 序列化
|
||||
├── services/
|
||||
│ ├── api_service.dart # HTTP 客户端
|
||||
│ ├── sms_reader_service.dart # 查询/监听系统短信
|
||||
│ └── settings_service.dart # SharedPreferences 读写
|
||||
└── screens/
|
||||
├── login_screen.dart # 服务器地址 + 用户名密码登录
|
||||
├── home_screen.dart # NavigationBar Tab 容器
|
||||
├── sms_list_screen.dart # 短信列表 + 选择上传 + 自动模式
|
||||
└── settings_screen.dart # 服务器信息 + 上传模式 + 登出
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 启动服务端
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm run dev
|
||||
# 服务运行在 http://localhost:3000
|
||||
```
|
||||
|
||||
### 2. 启动 Web Dashboard
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
# 访问 http://localhost:5173
|
||||
```
|
||||
|
||||
Vite 开发服务器已配置 API 代理,`/api/*` 请求会自动转发到 `localhost:3000`。
|
||||
|
||||
### 3. 运行 Flutter App
|
||||
|
||||
```bash
|
||||
cd app
|
||||
flutter pub get
|
||||
flutter run
|
||||
```
|
||||
|
||||
需要 Android 设备或模拟器,且需要安装 Flutter SDK。
|
||||
|
||||
## API 文档
|
||||
|
||||
所有 API 均返回 JSON。需要鉴权的接口在 Header 中携带 `Authorization: Bearer <token>`。
|
||||
|
||||
### 认证
|
||||
|
||||
#### POST /api/auth/register
|
||||
|
||||
注册新用户,成功后直接返回 JWT token。
|
||||
|
||||
```
|
||||
Request:
|
||||
{
|
||||
"username": "alice", // 3-50 字符
|
||||
"password": "123456" // 6-100 字符
|
||||
}
|
||||
|
||||
Response 201:
|
||||
{
|
||||
"token": "eyJhbG...",
|
||||
"userId": 1
|
||||
}
|
||||
|
||||
Error 409: { "error": "Username already taken" }
|
||||
Error 400: { "error": [...] } // 参数校验失败
|
||||
```
|
||||
|
||||
#### POST /api/auth/login
|
||||
|
||||
登录已有用户。
|
||||
|
||||
```
|
||||
Request:
|
||||
{
|
||||
"username": "alice",
|
||||
"password": "123456"
|
||||
}
|
||||
|
||||
Response 200:
|
||||
{
|
||||
"token": "eyJhbG...",
|
||||
"userId": 1
|
||||
}
|
||||
|
||||
Error 401: { "error": "Invalid credentials" }
|
||||
```
|
||||
|
||||
### 短信
|
||||
|
||||
#### POST /api/sms/upload
|
||||
|
||||
批量上传短信,单次不超过 5MB。
|
||||
|
||||
```
|
||||
Headers: Authorization: Bearer <token>
|
||||
|
||||
Request:
|
||||
[
|
||||
{
|
||||
"phone_number": "+8613800138000",
|
||||
"contact_name": "张三", // 可选
|
||||
"content": "你好,明天见面聊",
|
||||
"type": "received", // "received" | "sent"
|
||||
"sms_date": "2026-04-28T10:30:00.000Z" // ISO 8601
|
||||
}
|
||||
]
|
||||
|
||||
Response 200:
|
||||
{
|
||||
"uploaded": 1 // 成功插入条数
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /api/sms
|
||||
|
||||
分页查询当前用户的短信。
|
||||
|
||||
```
|
||||
Headers: Authorization: Bearer <token>
|
||||
|
||||
Query:
|
||||
page - 页码,默认 1
|
||||
limit - 每页条数,默认 20,最大 100
|
||||
phone - 按手机号模糊搜索(可选)
|
||||
type - 按类型筛选,"received" | "sent"(可选)
|
||||
|
||||
Response 200:
|
||||
{
|
||||
"messages": [ ... ],
|
||||
"total": 42,
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"totalPages": 3
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /api/sms/:id
|
||||
|
||||
查看单条短信详情。
|
||||
|
||||
```
|
||||
Response 200:
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 1,
|
||||
"phone_number": "+8613800138000",
|
||||
"contact_name": "张三",
|
||||
"content": "你好,明天见面聊",
|
||||
"type": "received",
|
||||
"sms_date": "2026-04-28T10:30:00.000Z",
|
||||
"created_at": "2026-04-28 12:00:00"
|
||||
}
|
||||
|
||||
Error 404: { "error": "Not found" }
|
||||
```
|
||||
|
||||
#### DELETE /api/sms/:id
|
||||
|
||||
删除一条短信(仅限自己的)。
|
||||
|
||||
```
|
||||
Response 200: { "deleted": true }
|
||||
|
||||
Error 404: { "error": "Not found" }
|
||||
```
|
||||
|
||||
### 设备
|
||||
|
||||
#### POST /api/devices/register
|
||||
|
||||
注册设备,重复注册同名设备会更新同步时间。
|
||||
|
||||
```
|
||||
Request:
|
||||
{
|
||||
"device_name": "Pixel 8 Pro"
|
||||
}
|
||||
|
||||
Response 201: { "deviceId": 1 }
|
||||
```
|
||||
|
||||
## 数据库
|
||||
|
||||
使用 SQLite,数据库文件自动创建在 `server/data.db`。
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE sms_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
phone_number TEXT NOT NULL,
|
||||
contact_name TEXT,
|
||||
content TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('received', 'sent')),
|
||||
sms_date DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_sms_user ON sms_messages(user_id, sms_date);
|
||||
|
||||
CREATE TABLE devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
device_name TEXT NOT NULL,
|
||||
last_sync_at DATETIME
|
||||
);
|
||||
```
|
||||
|
||||
## App 功能说明
|
||||
|
||||
### 登录
|
||||
- 首次使用需填写服务器地址、用户名和密码
|
||||
- 支持注册新用户或登录已有账号
|
||||
- 配置自动保存至 SharedPreferences
|
||||
|
||||
### 短信列表
|
||||
- 读取手机所有短信(收件箱 + 已发送)
|
||||
- 每条短信显示联系人、内容摘要、类型箭头图标
|
||||
- 多选模式:点击短信或圆圈图标选中,选中的显示对号
|
||||
- 点击云上传按钮上传已选中的短信
|
||||
- 点击右下角悬浮按钮一键上传全部短信
|
||||
|
||||
### 上传模式
|
||||
- **手动模式**(默认):用户选择短信后点击上传
|
||||
- **自动模式**:开启后实时监听新短信,自动上传到服务器
|
||||
- 自动模式下新的入站短信会立即出现在列表中
|
||||
|
||||
### 设置
|
||||
- 查看当前服务器地址和 Token 状态
|
||||
- 切换自动/手动上传模式
|
||||
- 登出按钮清除 Token 并返回登录页
|
||||
|
||||
## Web Dashboard 功能说明
|
||||
|
||||
### 登录页
|
||||
- 用户名 + 密码登录或注册
|
||||
- Material You 风格卡片布局
|
||||
|
||||
### 短信列表页
|
||||
- 表格展示所有上传的短信
|
||||
- 按手机号码模糊搜索
|
||||
- 按类型(发送/接收)下拉筛选
|
||||
- 分页浏览,支持 Prev/Next
|
||||
- 点击行查看详情面板(右侧)
|
||||
- 每条短信可单独删除
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 密码经 bcrypt(10轮)哈希存储,不保存明文
|
||||
- JWT token 有效期 30 天
|
||||
- 所有短信接口均需认证,用户只能访问自己的数据
|
||||
- 生产环境请修改 `JWT_SECRET` 环境变量
|
||||
|
||||
```bash
|
||||
JWT_SECRET=your-secret-key npm run dev
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `PORT` | 3000 | 服务端口 |
|
||||
| `JWT_SECRET` | 内置默认值 | JWT 签名密钥,生产环境必须修改 |
|
||||
|
||||
## 验证测试
|
||||
|
||||
```bash
|
||||
# 注册用户
|
||||
curl -s -X POST http://localhost:3000/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"test","password":"123456"}'
|
||||
|
||||
# 登录获取 token
|
||||
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"test","password":"123456"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
# 上传短信
|
||||
curl -s -X POST http://localhost:3000/api/sms/upload \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '[{"phone_number":"+8613800138000","contact_name":"张三","content":"你好","type":"received","sms_date":"2026-04-28T10:30:00.000Z"}]'
|
||||
|
||||
# 查询短信列表
|
||||
curl -s http://localhost:3000/api/sms -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# 删除
|
||||
curl -s -X DELETE http://localhost:3000/api/sms/1 -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
52
app/lib/main.dart
Normal file
52
app/lib/main.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'services/settings_service.dart';
|
||||
import 'services/api_service.dart';
|
||||
import 'services/sms_reader_service.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
|
||||
final apiService = ApiService();
|
||||
final smsReaderService = SmsReaderService();
|
||||
late SettingsService settings;
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
settings = await SettingsService.init();
|
||||
|
||||
if (settings.isConfigured) {
|
||||
apiService.configure(settings.serverUrl!, settings.token!);
|
||||
}
|
||||
|
||||
runApp(const SmsMonitorApp());
|
||||
}
|
||||
|
||||
class SmsMonitorApp extends StatelessWidget {
|
||||
const SmsMonitorApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DynamicColorBuilder(
|
||||
builder: (ColorScheme? dynamicScheme) {
|
||||
final scheme = dynamicScheme?.harmonized() ??
|
||||
ColorScheme.fromSeed(seedColor: Colors.indigo, brightness: Brightness.light);
|
||||
return MaterialApp(
|
||||
title: 'SMS Monitor',
|
||||
theme: ThemeData(
|
||||
colorScheme: scheme,
|
||||
useMaterial3: true,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: dynamicScheme?.harmonized() ??
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: Colors.indigo,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: settings.isConfigured ? const HomeScreen() : const LoginScreen(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
34
app/lib/models/sms_message.dart
Normal file
34
app/lib/models/sms_message.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
class SmsMessage {
|
||||
final int? id;
|
||||
final String phoneNumber;
|
||||
final String? contactName;
|
||||
final String content;
|
||||
final String type; // 'received' | 'sent'
|
||||
final String smsDate;
|
||||
|
||||
SmsMessage({
|
||||
this.id,
|
||||
required this.phoneNumber,
|
||||
this.contactName,
|
||||
required this.content,
|
||||
required this.type,
|
||||
required this.smsDate,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'phone_number': phoneNumber,
|
||||
'contact_name': contactName,
|
||||
'content': content,
|
||||
'type': type,
|
||||
'sms_date': smsDate,
|
||||
};
|
||||
|
||||
factory SmsMessage.fromJson(Map<String, dynamic> json) => SmsMessage(
|
||||
id: json['id'],
|
||||
phoneNumber: json['phone_number'],
|
||||
contactName: json['contact_name'],
|
||||
content: json['content'],
|
||||
type: json['type'],
|
||||
smsDate: json['sms_date'],
|
||||
);
|
||||
}
|
||||
32
app/lib/screens/home_screen.dart
Normal file
32
app/lib/screens/home_screen.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'sms_list_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
int _index = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _index,
|
||||
children: const [SmsListScreen(), SettingsScreen()],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: (i) => setState(() => _index = i),
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.sms_outlined), selectedIcon: Icon(Icons.sms), label: 'Messages'),
|
||||
NavigationDestination(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: 'Settings'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
121
app/lib/screens/login_screen.dart
Normal file
121
app/lib/screens/login_screen.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../main.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _serverCtrl = TextEditingController(text: 'http://192.168.1.100:3000');
|
||||
final _userCtrl = TextEditingController();
|
||||
final _passCtrl = TextEditingController();
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _submit(bool register) async {
|
||||
setState(() { _loading = true; _error = null; });
|
||||
|
||||
final url = _serverCtrl.text.trim();
|
||||
final user = _userCtrl.text.trim();
|
||||
final pass = _passCtrl.text.trim();
|
||||
|
||||
try {
|
||||
apiService.configure(url, '');
|
||||
final token = register
|
||||
? await apiService.register(user, pass)
|
||||
: await apiService.login(user, pass);
|
||||
apiService.configure(url, token);
|
||||
await settings.setServerUrl(url);
|
||||
await settings.setToken(token);
|
||||
await apiService.registerDevice('Android');
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const HomeScreen()),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
|
||||
}
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.sms_outlined, size: 64, color: colors.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text('SMS Monitor', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: _serverCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
hintText: 'http://your-server:3000',
|
||||
prefixIcon: Icon(Icons.dns_outlined),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _userCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Username',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passCtrl,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _submit(false),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!, style: TextStyle(color: colors.error)),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _loading ? null : () => _submit(true),
|
||||
child: const Text('Register'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _loading ? null : () => _submit(false),
|
||||
child: _loading
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Sign In'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
app/lib/screens/settings_screen.dart
Normal file
76
app/lib/screens/settings_screen.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../main.dart';
|
||||
import 'login_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Server', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text('URL: ${settings.serverUrl ?? "Not set"}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant)),
|
||||
Text('Token: ${settings.token != null ? "Configured" : "Missing"}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Upload Mode', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
title: const Text('Auto Upload'),
|
||||
subtitle: const Text('Automatically upload new incoming SMS'),
|
||||
value: settings.autoUpload,
|
||||
onChanged: (v) => settings.setAutoUpload(v),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
settings.setToken('');
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
(_) => false,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.logout),
|
||||
label: const Text('Logout'),
|
||||
style: OutlinedButton.styleFrom(foregroundColor: colors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
229
app/lib/screens/sms_list_screen.dart
Normal file
229
app/lib/screens/sms_list_screen.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../models/sms_message.dart';
|
||||
import '../main.dart';
|
||||
|
||||
class SmsListScreen extends StatefulWidget {
|
||||
const SmsListScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SmsListScreen> createState() => _SmsListScreenState();
|
||||
}
|
||||
|
||||
class _SmsListScreenState extends State<SmsListScreen> {
|
||||
List<SmsMessage> _messages = [];
|
||||
final _selected = <int>{};
|
||||
Set<String> _uploadedKeys = {};
|
||||
bool _loading = false;
|
||||
bool _hasSmsPermission = false;
|
||||
StreamSubscription<SmsMessage>? _smsSub;
|
||||
|
||||
static String _dedupKey(SmsMessage m) => '${m.phoneNumber}|${m.content}|${m.smsDate}';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_uploadedKeys = settings.getUploadedKeys();
|
||||
_requestPermission();
|
||||
}
|
||||
|
||||
Future<void> _requestPermission() async {
|
||||
final status = await Permission.sms.request();
|
||||
if (mounted) {
|
||||
setState(() => _hasSmsPermission = status.isGranted);
|
||||
if (status.isGranted) _loadSms();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSms() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
_messages = await smsReaderService.queryAllSms();
|
||||
} catch (e) { /* ignore */ }
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
|
||||
bool _isUploaded(SmsMessage m) => _uploadedKeys.contains(_dedupKey(m));
|
||||
|
||||
Future<void> _uploadSelected() async {
|
||||
if (_selected.isEmpty) return;
|
||||
final toUpload = _messages
|
||||
.asMap()
|
||||
.entries
|
||||
.where((e) => _selected.contains(e.key) && !_isUploaded(e.value))
|
||||
.map((e) => e.value)
|
||||
.toList();
|
||||
if (toUpload.isEmpty) return;
|
||||
try {
|
||||
await apiService.uploadSms(toUpload);
|
||||
final newKeys = toUpload.map(_dedupKey).toList();
|
||||
settings.addUploadedKeys(newKeys);
|
||||
_uploadedKeys = settings.getUploadedKeys();
|
||||
_selected.clear();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Uploaded ${toUpload.length} messages')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Upload failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uploadAll() async {
|
||||
final toUpload = _messages.where((m) => !_isUploaded(m)).toList();
|
||||
if (toUpload.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No new messages to upload')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final count = await apiService.uploadSms(toUpload);
|
||||
final newKeys = toUpload.map(_dedupKey).toList();
|
||||
settings.addUploadedKeys(newKeys);
|
||||
_uploadedKeys = settings.getUploadedKeys();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Uploaded $count messages')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Upload failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleAutoUpload() {
|
||||
final newVal = !settings.autoUpload;
|
||||
settings.setAutoUpload(newVal);
|
||||
if (newVal) {
|
||||
_startAutoUpload();
|
||||
} else {
|
||||
_smsSub?.cancel();
|
||||
_smsSub = null;
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _startAutoUpload() {
|
||||
_smsSub = smsReaderService.listenToIncoming().listen((sms) async {
|
||||
try {
|
||||
await apiService.uploadSms([sms]);
|
||||
settings.addUploadedKey(_dedupKey(sms));
|
||||
_uploadedKeys = settings.getUploadedKeys();
|
||||
_messages.insert(0, sms);
|
||||
if (mounted) setState(() {});
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_smsSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Messages'),
|
||||
actions: [
|
||||
if (_selected.isNotEmpty)
|
||||
IconButton(icon: const Icon(Icons.cloud_upload), onPressed: _uploadSelected, tooltip: 'Upload selected'),
|
||||
IconButton(icon: const Icon(Icons.refresh), onPressed: _hasSmsPermission ? _loadSms : _requestPermission),
|
||||
],
|
||||
),
|
||||
body: _hasSmsPermission == false
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('SMS permission required'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(onPressed: _requestPermission, child: const Text('Grant')),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _messages.isEmpty
|
||||
? const Center(child: Text('No messages'))
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadSms,
|
||||
child: ListView.builder(
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (_, i) {
|
||||
final m = _messages[i];
|
||||
final sel = _selected.contains(i);
|
||||
final uploaded = _isUploaded(m);
|
||||
return ListTile(
|
||||
selected: sel,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: sel ? colors.primaryContainer : colors.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
uploaded ? Icons.cloud_done : (m.type == 'received' ? Icons.arrow_downward : Icons.arrow_upward),
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
m.contactName ?? m.phoneNumber,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(m.content, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: uploaded
|
||||
? Icon(Icons.cloud_done, color: colors.primary, size: 20)
|
||||
: IconButton(
|
||||
icon: Icon(sel ? Icons.check_circle : Icons.circle_outlined),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
sel ? _selected.remove(i) : _selected.add(i);
|
||||
});
|
||||
},
|
||||
),
|
||||
onTap: uploaded
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
sel ? _selected.remove(i) : _selected.add(i);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButton: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'auto',
|
||||
onPressed: _toggleAutoUpload,
|
||||
backgroundColor: settings.autoUpload ? colors.errorContainer : colors.surfaceContainerHighest,
|
||||
child: Icon(settings.autoUpload ? Icons.sync_disabled : Icons.sync, color: colors.onPrimaryContainer),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FloatingActionButton(
|
||||
heroTag: 'upload',
|
||||
onPressed: _uploadAll,
|
||||
tooltip: 'Upload all',
|
||||
child: const Icon(Icons.cloud_upload),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
64
app/lib/services/api_service.dart
Normal file
64
app/lib/services/api_service.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/sms_message.dart';
|
||||
|
||||
class ApiService {
|
||||
String _baseUrl = '';
|
||||
String? _token;
|
||||
|
||||
void configure(String baseUrl, String token) {
|
||||
_baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
|
||||
_token = token;
|
||||
}
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Content-Type': 'application/json',
|
||||
if (_token != null) 'Authorization': 'Bearer $_token',
|
||||
};
|
||||
|
||||
Future<String> login(String username, String password) async {
|
||||
final res = await http.post(
|
||||
Uri.parse('$_baseUrl/api/auth/login'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'username': username, 'password': password}),
|
||||
);
|
||||
if (res.statusCode != 200) throw Exception(_error(res));
|
||||
return jsonDecode(res.body)['token'];
|
||||
}
|
||||
|
||||
Future<String> register(String username, String password) async {
|
||||
final res = await http.post(
|
||||
Uri.parse('$_baseUrl/api/auth/register'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'username': username, 'password': password}),
|
||||
);
|
||||
if (res.statusCode != 201) throw Exception(_error(res));
|
||||
return jsonDecode(res.body)['token'];
|
||||
}
|
||||
|
||||
Future<int> uploadSms(List<SmsMessage> messages) async {
|
||||
final res = await http.post(
|
||||
Uri.parse('$_baseUrl/api/sms/upload'),
|
||||
headers: _headers,
|
||||
body: jsonEncode(messages.map((m) => m.toJson()).toList()),
|
||||
);
|
||||
if (res.statusCode != 200) throw Exception(_error(res));
|
||||
return jsonDecode(res.body)['uploaded'];
|
||||
}
|
||||
|
||||
Future<void> registerDevice(String deviceName) async {
|
||||
await http.post(
|
||||
Uri.parse('$_baseUrl/api/devices/register'),
|
||||
headers: _headers,
|
||||
body: jsonEncode({'device_name': deviceName}),
|
||||
);
|
||||
}
|
||||
|
||||
String _error(http.Response res) {
|
||||
try {
|
||||
return jsonDecode(res.body)['error'] ?? 'Request failed';
|
||||
} catch (_) {
|
||||
return 'Request failed (${res.statusCode})';
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/lib/services/settings_service.dart
Normal file
43
app/lib/services/settings_service.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class SettingsService {
|
||||
static const _keyServerUrl = 'server_url';
|
||||
static const _keyToken = 'token';
|
||||
static const _keyAutoUpload = 'auto_upload';
|
||||
|
||||
static const _keyUploaded = 'uploaded_keys';
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
SettingsService(this._prefs);
|
||||
|
||||
String? get serverUrl => _prefs.getString(_keyServerUrl);
|
||||
Future<bool> setServerUrl(String v) => _prefs.setString(_keyServerUrl, v);
|
||||
|
||||
String? get token => _prefs.getString(_keyToken);
|
||||
Future<bool> setToken(String v) => _prefs.setString(_keyToken, v);
|
||||
|
||||
bool get autoUpload => _prefs.getBool(_keyAutoUpload) ?? false;
|
||||
Future<bool> setAutoUpload(bool v) => _prefs.setBool(_keyAutoUpload, v);
|
||||
|
||||
bool get isConfigured => serverUrl != null && token != null;
|
||||
|
||||
Set<String> getUploadedKeys() => _prefs.getStringList(_keyUploaded)?.toSet() ?? <String>{};
|
||||
|
||||
Future<bool> addUploadedKey(String key) {
|
||||
final keys = _prefs.getStringList(_keyUploaded) ?? [];
|
||||
keys.add(key);
|
||||
return _prefs.setStringList(_keyUploaded, keys);
|
||||
}
|
||||
|
||||
Future<bool> addUploadedKeys(List<String> newKeys) {
|
||||
final keys = _prefs.getStringList(_keyUploaded) ?? [];
|
||||
keys.addAll(newKeys);
|
||||
return _prefs.setStringList(_keyUploaded, keys);
|
||||
}
|
||||
|
||||
static Future<SettingsService> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return SettingsService(prefs);
|
||||
}
|
||||
}
|
||||
36
app/lib/services/sms_reader_service.dart
Normal file
36
app/lib/services/sms_reader_service.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'dart:async';
|
||||
import 'package:sms_maintained/sms_maintained.dart';
|
||||
import '../models/sms_message.dart';
|
||||
|
||||
class SmsReaderService {
|
||||
Future<List<SmsMessage>> queryAllSms() async {
|
||||
final messages = <SmsMessage>[];
|
||||
final all = await SmsQuery().querySms(
|
||||
kinds: [SmsQueryKind.Inbox, SmsQueryKind.Sent],
|
||||
);
|
||||
for (final s in all) {
|
||||
messages.add(SmsMessage(
|
||||
phoneNumber: s.address ?? '',
|
||||
contactName: s.sender ?? s.address,
|
||||
content: s.body ?? '',
|
||||
type: s.kind == SmsQueryKind.Sent ? 'sent' : 'received',
|
||||
smsDate: DateTime.fromMillisecondsSinceEpoch(s.date!).toUtc().toIso8601String(),
|
||||
));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
Stream<SmsMessage> listenToIncoming() {
|
||||
final controller = StreamController<SmsMessage>.broadcast();
|
||||
SmsReceiver().onSmsReceived!.listen((SmsMessage sms) {
|
||||
controller.add(SmsMessage(
|
||||
phoneNumber: sms.address ?? '',
|
||||
contactName: sms.sender ?? sms.address,
|
||||
content: sms.body ?? '',
|
||||
type: 'received',
|
||||
smsDate: DateTime.now().toUtc().toIso8601String(),
|
||||
));
|
||||
});
|
||||
return controller.stream;
|
||||
}
|
||||
}
|
||||
25
app/pubspec.yaml
Normal file
25
app/pubspec.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: sms_monitor
|
||||
description: SMS monitoring app with Material You design
|
||||
publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.2.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
dynamic_color: ^1.7.0
|
||||
http: ^1.2.0
|
||||
shared_preferences: ^2.3.0
|
||||
sms_maintained: ^0.3.10
|
||||
permission_handler: ^11.3.0
|
||||
intl: ^0.19.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^4.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "transfer_sms",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
119
plan.md
Normal file
119
plan.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# SMS Monitor - Implementation Plan
|
||||
## Context
|
||||
A personal SMS monitoring system: an Android app monitors incoming/outgoing SMS and sends them to a server. The server stores them in SQLite with multi-user support. A web dashboard allows viewing/managing SMS. The user can configure auto-upload or manual-select modes.
|
||||
## Tech Stack Decisions
|
||||
| Layer | Choice | Rationale |
|
||||
|-------|--------|-----------|
|
||||
| Android App | **Flutter** | Best Material You support via `dynamic_color`, simple platform channels for SMS |
|
||||
| Backend | **Node.js + Express** | Simplest, most documented |
|
||||
| Database | **SQLite (better-sqlite3)** | Zero-config, fast sync API, perfect for single-server personal use |
|
||||
| Web Dashboard | **React + Vite** | Lightweight SPA |
|
||||
| Auth | **JWT** | Stateless, simple to implement |
|
||||
## Project Structure
|
||||
```
|
||||
transfer_sms/
|
||||
├── server/ # Node.js backend
|
||||
│ ├── package.json
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Entry point
|
||||
│ │ ├── db.ts # SQLite setup & migrations
|
||||
│ │ ├── middleware/
|
||||
│ │ │ └── auth.ts # JWT auth middleware
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── auth.ts # Register/Login
|
||||
│ │ │ ├── sms.ts # SMS CRUD endpoints
|
||||
│ │ │ └── devices.ts # Device registration
|
||||
│ │ └── types.ts # TypeScript types
|
||||
│ └── tsconfig.json
|
||||
├── web/ # React web dashboard
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.ts
|
||||
│ └── src/
|
||||
│ ├── App.tsx
|
||||
│ ├── api.ts # API client
|
||||
│ ├── pages/
|
||||
│ │ ├── Login.tsx
|
||||
│ │ └── SmsList.tsx
|
||||
│ └── components/
|
||||
└── app/ # Flutter Android app
|
||||
└── (flutter project)
|
||||
```
|
||||
## Database Schema
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE sms_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
phone_number TEXT NOT NULL,
|
||||
contact_name TEXT,
|
||||
content TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('received', 'sent')),
|
||||
sms_date DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_sms_user ON sms_messages(user_id, sms_date);
|
||||
CREATE TABLE devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
device_name TEXT NOT NULL,
|
||||
last_sync_at DATETIME
|
||||
);
|
||||
```
|
||||
## API Endpoints
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | /api/auth/register | No | Create account |
|
||||
| POST | /api/auth/login | No | Get JWT token |
|
||||
| POST | /api/sms/upload | Yes | Batch upload SMS (array) |
|
||||
| GET | /api/sms | Yes | Paginated list, filter by phone/date/type |
|
||||
| GET | /api/sms/:id | Yes | Single SMS detail |
|
||||
| DELETE | /api/sms/:id | Yes | Delete one SMS |
|
||||
| POST | /api/devices/register | Yes | Register a device |
|
||||
## Flutter App Design
|
||||
### SMS Access
|
||||
- Use `flutter_sms_listener` or platform channel to native `ContentResolver`
|
||||
- On Android: query `content://sms` ContentProvider, use `Telephony.Sms` broadcast receiver for real-time
|
||||
### Material You Theming
|
||||
- `dynamic_color` package extracts system color scheme
|
||||
- `MaterialApp(theme: ThemeData(useMaterial3: true))`
|
||||
### Upload Modes
|
||||
- **Auto mode**: Background listener catches new SMS → immediately POSTs to server
|
||||
- **Manual mode**: UI lists unsent SMS → user selects → taps "Upload"
|
||||
- Mode toggle in settings screen
|
||||
### Screens
|
||||
1. **SMS List** - All SMS, filterable, with sync status indicators
|
||||
2. **Settings** - Server URL, auth token, upload mode toggle
|
||||
3. **Login** - Server credentials
|
||||
## Web Dashboard Design
|
||||
### Pages
|
||||
1. **Login** - Username + password
|
||||
2. **SMS List** - Table with search/filter, pagination
|
||||
3. **SMS Detail** - Single message view
|
||||
### Styling
|
||||
- CSS custom properties for Material You color tokens
|
||||
- Responsive design
|
||||
## Implementation Order
|
||||
1. **Server** (backend first, everything depends on it)
|
||||
- Database setup + migrations
|
||||
- Auth routes (register/login)
|
||||
- SMS routes (upload/list/get/delete)
|
||||
2. **Web Dashboard** (easiest to test against live server)
|
||||
- Login page
|
||||
- SMS list with pagination & filters
|
||||
3. **Flutter App** (needs real device for SMS testing)
|
||||
- Project scaffold + Material You theme
|
||||
- SMS native access layer
|
||||
- Upload logic (auto + manual)
|
||||
- UI screens
|
||||
## Verification
|
||||
1. Start server: `cd server && npm run dev`
|
||||
2. Register user via curl: `curl -X POST localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"username":"test","password":"123456"}'`
|
||||
3. Login to get token
|
||||
4. Upload test SMS via curl with token
|
||||
5. Open web dashboard, login, verify SMS appear
|
||||
6. Build & run Flutter app on Android device, verify SMS sync
|
||||
4
server/.gitignore
vendored
Normal file
4
server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
*.db
|
||||
dist/
|
||||
.env
|
||||
BIN
server/data.db-shm
Normal file
BIN
server/data.db-shm
Normal file
Binary file not shown.
BIN
server/data.db-wal
Normal file
BIN
server/data.db-wal
Normal file
Binary file not shown.
2133
server/package-lock.json
generated
Normal file
2133
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
server/package.json
Normal file
28
server/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "sms-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
41
server/src/db.ts
Normal file
41
server/src/db.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DB_PATH = path.join(__dirname, '..', 'data.db');
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sms_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
phone_number TEXT NOT NULL,
|
||||
contact_name TEXT,
|
||||
content TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('received', 'sent')),
|
||||
sms_date DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sms_user ON sms_messages(user_id, sms_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
device_name TEXT NOT NULL,
|
||||
last_sync_at DATETIME
|
||||
);
|
||||
`);
|
||||
|
||||
export default db;
|
||||
30
server/src/index.ts
Normal file
30
server/src/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import smsRoutes from './routes/sms.js';
|
||||
import deviceRoutes from './routes/devices.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
|
||||
// Ensure UTF-8 charset on all JSON responses
|
||||
app.use((_req, res, next) => {
|
||||
const orig = res.json.bind(res);
|
||||
res.json = (body: unknown) => {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
return orig(body);
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/sms', authMiddleware, smsRoutes);
|
||||
app.use('/api/devices', authMiddleware, deviceRoutes);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
27
server/src/middleware/auth.ts
Normal file
27
server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'sms-monitor-secret-change-in-production';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Missing token' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = header.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as { userId: number };
|
||||
req.userId = payload.userId;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
export { JWT_SECRET };
|
||||
63
server/src/routes/auth.ts
Normal file
63
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Router, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { z } from 'zod';
|
||||
import db from '../db.js';
|
||||
import { JWT_SECRET, AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const registerSchema = z.object({
|
||||
username: z.string().min(3, '用户名至少3个字符').max(50, '用户名最多50个字符'),
|
||||
password: z.string().min(6, '密码至少6个字符').max(100, '密码最多100个字符'),
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
});
|
||||
|
||||
function formatZodErrors(errors: z.ZodIssue[]) {
|
||||
return errors.map(e => e.message).join('; ');
|
||||
}
|
||||
|
||||
router.post('/register', (req: AuthRequest, res: Response) => {
|
||||
const parsed = registerSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: formatZodErrors(parsed.error.errors) });
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password } = parsed.data;
|
||||
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||
if (existing) {
|
||||
res.status(409).json({ error: '用户名已存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
const password_hash = bcrypt.hashSync(password, 10);
|
||||
const result = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(username, password_hash);
|
||||
|
||||
const token = jwt.sign({ userId: result.lastInsertRowid }, JWT_SECRET, { expiresIn: '30d' });
|
||||
res.status(201).json({ token, userId: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
router.post('/login', (req: AuthRequest, res: Response) => {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: formatZodErrors(parsed.error.errors) });
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password } = parsed.data;
|
||||
const user = db.prepare('SELECT id, password_hash FROM users WHERE username = ?').get(username) as any;
|
||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
res.status(401).json({ error: '用户名或密码错误' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '30d' });
|
||||
res.json({ token, userId: user.id });
|
||||
});
|
||||
|
||||
export default router;
|
||||
38
server/src/routes/devices.ts
Normal file
38
server/src/routes/devices.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db.js';
|
||||
import { AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const registerSchema = z.object({
|
||||
device_name: z.string().min(1),
|
||||
});
|
||||
|
||||
router.post('/register', (req: AuthRequest, res: Response) => {
|
||||
const parsed = registerSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.errors });
|
||||
return;
|
||||
}
|
||||
|
||||
const { device_name } = parsed.data;
|
||||
|
||||
const existing = db
|
||||
.prepare('SELECT id FROM devices WHERE user_id = ? AND device_name = ?')
|
||||
.get(req.userId!, device_name);
|
||||
|
||||
if (existing) {
|
||||
db.prepare('UPDATE devices SET last_sync_at = CURRENT_TIMESTAMP WHERE id = ?').run((existing as any).id);
|
||||
res.json({ deviceId: (existing as any).id });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = db
|
||||
.prepare('INSERT INTO devices (user_id, device_name, last_sync_at) VALUES (?, ?, CURRENT_TIMESTAMP)')
|
||||
.run(req.userId!, device_name);
|
||||
|
||||
res.status(201).json({ deviceId: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
export default router;
|
||||
85
server/src/routes/sms.ts
Normal file
85
server/src/routes/sms.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db.js';
|
||||
import { AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const uploadSchema = z.array(
|
||||
z.object({
|
||||
phone_number: z.string().min(1),
|
||||
contact_name: z.string().optional(),
|
||||
content: z.string(),
|
||||
type: z.enum(['received', 'sent']),
|
||||
sms_date: z.string().datetime(),
|
||||
})
|
||||
);
|
||||
|
||||
router.post('/upload', (req: AuthRequest, res: Response) => {
|
||||
const parsed = uploadSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.errors });
|
||||
return;
|
||||
}
|
||||
|
||||
const insert = db.prepare(
|
||||
'INSERT INTO sms_messages (user_id, phone_number, contact_name, content, type, sms_date) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
|
||||
const txn = db.transaction((items: z.infer<typeof uploadSchema>) => {
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
insert.run(req.userId!, item.phone_number, item.contact_name || null, item.content, item.type, item.sms_date);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
|
||||
const count = txn(parsed.data);
|
||||
res.json({ uploaded: count });
|
||||
});
|
||||
|
||||
router.get('/', (req: AuthRequest, res: Response) => {
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 20));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let where = 'WHERE user_id = ?';
|
||||
const params: any[] = [req.userId!];
|
||||
|
||||
if (req.query.phone) {
|
||||
where += ' AND phone_number LIKE ?';
|
||||
params.push(`%${req.query.phone}%`);
|
||||
}
|
||||
if (req.query.type) {
|
||||
where += ' AND type = ?';
|
||||
params.push(req.query.type);
|
||||
}
|
||||
|
||||
const total = (db.prepare(`SELECT COUNT(*) as count FROM sms_messages ${where}`).get(...params) as any).count;
|
||||
const messages = db
|
||||
.prepare(`SELECT * FROM sms_messages ${where} ORDER BY sms_date DESC LIMIT ? OFFSET ?`)
|
||||
.all(...params, limit, offset);
|
||||
|
||||
res.json({ messages, total, page, limit, totalPages: Math.ceil(total / limit) });
|
||||
});
|
||||
|
||||
router.get('/:id', (req: AuthRequest, res: Response) => {
|
||||
const msg = db.prepare('SELECT * FROM sms_messages WHERE id = ? AND user_id = ?').get(req.params.id, req.userId!);
|
||||
if (!msg) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
res.json(msg);
|
||||
});
|
||||
|
||||
router.delete('/:id', (req: AuthRequest, res: Response) => {
|
||||
const result = db.prepare('DELETE FROM sms_messages WHERE id = ? AND user_id = ?').run(req.params.id, req.userId!);
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ deleted: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
32
server/src/types.ts
Normal file
32
server/src/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SmsMessage {
|
||||
id: number;
|
||||
user_id: number;
|
||||
phone_number: string;
|
||||
contact_name: string | null;
|
||||
content: string;
|
||||
type: 'received' | 'sent';
|
||||
sms_date: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: number;
|
||||
user_id: number;
|
||||
device_name: string;
|
||||
last_sync_at: string | null;
|
||||
}
|
||||
|
||||
export interface SmsUploadItem {
|
||||
phone_number: string;
|
||||
contact_name?: string;
|
||||
content: string;
|
||||
type: 'received' | 'sent';
|
||||
sms_date: string;
|
||||
}
|
||||
16
server/tsconfig.json
Normal file
16
server/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
2
web/.gitignore
vendored
Normal file
2
web/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
15
web/Dockerfile
Normal file
15
web/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
16
web/index.html
Normal file
16
web/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SMS Monitor</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
17
web/nginx.conf
Normal file
17
web/nginx.conf
Normal file
@@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://host.docker.internal:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
1846
web/package-lock.json
generated
Normal file
1846
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
web/package.json
Normal file
22
web/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "sms-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
19
web/src/App.tsx
Normal file
19
web/src/App.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Login from './pages/Login';
|
||||
import SmsList from './pages/SmsList';
|
||||
|
||||
export default function App() {
|
||||
const [authed, setAuthed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem('token')) setAuthed(true);
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
localStorage.removeItem('token');
|
||||
setAuthed(false);
|
||||
}, []);
|
||||
|
||||
if (!authed) return <Login onLogin={() => setAuthed(true)} />;
|
||||
return <SmsList onLogout={handleLogout} />;
|
||||
}
|
||||
64
web/src/api.ts
Normal file
64
web/src/api.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
const BASE = '/api';
|
||||
|
||||
function token() { return localStorage.getItem('token') || ''; }
|
||||
function authHeaders() { return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json; charset=utf-8' }; }
|
||||
|
||||
export interface SmsMessage {
|
||||
id: number;
|
||||
user_id: number;
|
||||
phone_number: string;
|
||||
contact_name: string | null;
|
||||
content: string;
|
||||
type: 'received' | 'sent';
|
||||
sms_date: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SmsListResponse {
|
||||
messages: SmsMessage[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
async function parseError(res: Response): Promise<string> {
|
||||
try {
|
||||
const data = await res.json();
|
||||
return data.error || `${res.status} ${res.statusText}`;
|
||||
} catch {
|
||||
return `${res.status} ${res.statusText}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const res = await fetch(`${BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await parseError(res));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function register(username: string, password: string) {
|
||||
const res = await fetch(`${BASE}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await parseError(res));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchSms(params: Record<string, string> = {}): Promise<SmsListResponse> {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
const res = await fetch(`${BASE}/sms?${qs}`, { headers: authHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to fetch');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteSms(id: number) {
|
||||
const res = await fetch(`${BASE}/sms/${id}`, { method: 'DELETE', headers: authHeaders() });
|
||||
if (!res.ok) throw new Error('Delete failed');
|
||||
}
|
||||
108
web/src/index.css
Normal file
108
web/src/index.css
Normal file
@@ -0,0 +1,108 @@
|
||||
:root {
|
||||
--bg-primary: #0f0f13;
|
||||
--bg-secondary: #1a1a24;
|
||||
--bg-card: #1e1e2a;
|
||||
--bg-hover: #252533;
|
||||
--bg-input: #16161f;
|
||||
--border: #2a2a3a;
|
||||
--border-focus: #6c5ce7;
|
||||
--text-primary: #f0f0f5;
|
||||
--text-secondary: #9d9db5;
|
||||
--text-muted: #606078;
|
||||
--accent: #7c6ff7;
|
||||
--accent-hover: #9080ff;
|
||||
--accent-subtle: rgba(124, 111, 247, 0.12);
|
||||
--green: #4ade80;
|
||||
--green-bg: rgba(74, 222, 128, 0.1);
|
||||
--blue: #60a5fa;
|
||||
--blue-bg: rgba(96, 165, 250, 0.1);
|
||||
--red: #f87171;
|
||||
--red-hover: #fa8a8a;
|
||||
--red-bg: rgba(248, 113, 113, 0.1);
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
--radius-lg: 16px;
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
|
||||
--shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
|
||||
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html { font-size: 15px; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
input, select, button { font-family: inherit; }
|
||||
|
||||
input, select {
|
||||
font-size: 0.9rem;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
input::placeholder { color: var(--text-muted); }
|
||||
input:focus, select:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.2);
|
||||
}
|
||||
|
||||
button {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(124,111,247,0.35); }
|
||||
.btn-primary:active { transform: translateY(0); }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1.5px solid var(--border);
|
||||
}
|
||||
.btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--text-muted); }
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
color: var(--red);
|
||||
border: 1.5px solid transparent;
|
||||
padding: 6px 14px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-danger:hover { background: var(--red-bg); border-color: rgba(248,113,113,0.3); }
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.btn-icon:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
57
web/src/pages/Login.css
Normal file
57
web/src/pages/Login.css
Normal file
@@ -0,0 +1,57 @@
|
||||
.login-page {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 100vh; position: relative; overflow: hidden;
|
||||
}
|
||||
.login-bg-decor {
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% 20%, rgba(124,111,247,0.08) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 50% 50% at 80% 80%, rgba(124,111,247,0.05) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.login-card {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 44px 40px 36px;
|
||||
width: 420px; max-width: 90vw;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 20px;
|
||||
}
|
||||
.login-icon {
|
||||
width: 56px; height: 56px;
|
||||
background: var(--accent-subtle);
|
||||
border-radius: var(--radius);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.login-icon .material-symbols-outlined {
|
||||
font-size: 28px; color: var(--accent);
|
||||
}
|
||||
.login-card h1 {
|
||||
font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
}
|
||||
.login-card .subtitle {
|
||||
color: var(--text-muted); font-size: 0.9rem; margin-top: -12px;
|
||||
}
|
||||
.login-fields { width: 100%; display: flex; flex-direction: column; gap: 12px; }
|
||||
.field { position: relative; display: flex; align-items: center; }
|
||||
.field-icon {
|
||||
position: absolute; left: 14px; font-size: 20px; color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.field input { width: 100%; padding-left: 42px; }
|
||||
|
||||
.error-msg {
|
||||
width: 100%;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--red-bg); border: 1px solid rgba(248,113,113,0.25);
|
||||
border-radius: var(--radius-sm); padding: 10px 14px;
|
||||
color: var(--red); font-size: 0.85rem;
|
||||
}
|
||||
.error-msg .material-symbols-outlined { font-size: 18px; }
|
||||
|
||||
.login-buttons {
|
||||
width: 100%; display: flex; flex-direction: column; gap: 10px; margin-top: 4px;
|
||||
}
|
||||
.login-buttons button { width: 100%; justify-content: center; padding: 12px; font-size: 0.9rem; }
|
||||
82
web/src/pages/Login.tsx
Normal file
82
web/src/pages/Login.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { login, register } from '../api';
|
||||
import './Login.css';
|
||||
|
||||
interface Props { onLogin: (token: string) => void }
|
||||
|
||||
export default function Login({ onLogin }: Props) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function doLogin() {
|
||||
setError(''); setLoading(true);
|
||||
try {
|
||||
const data = await login(username, password);
|
||||
localStorage.setItem('token', data.token);
|
||||
onLogin(data.token);
|
||||
} catch (e: any) { setError(e.message); }
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function doRegister() {
|
||||
setError(''); setLoading(true);
|
||||
try {
|
||||
const data = await register(username, password);
|
||||
localStorage.setItem('token', data.token);
|
||||
onLogin(data.token);
|
||||
} catch (e: any) { setError(e.message); }
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-bg-decor" />
|
||||
<div className="login-card">
|
||||
<div className="login-icon">
|
||||
<span className="material-symbols-outlined">sms</span>
|
||||
</div>
|
||||
<h1>SMS Monitor</h1>
|
||||
<p className="subtitle">短信管理与监控平台</p>
|
||||
|
||||
<div className="login-fields">
|
||||
<div className="field">
|
||||
<span className="material-symbols-outlined field-icon">person</span>
|
||||
<input
|
||||
type="text" placeholder="用户名" value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && doLogin()}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="material-symbols-outlined field-icon">lock</span>
|
||||
<input
|
||||
type="password" placeholder="密码" value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && doLogin()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-msg">
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="login-buttons">
|
||||
<button className="btn-primary" onClick={doLogin} disabled={loading}>
|
||||
<span className="material-symbols-outlined">login</span>
|
||||
登录
|
||||
</button>
|
||||
<button className="btn-ghost" onClick={doRegister} disabled={loading}>
|
||||
<span className="material-symbols-outlined">person_add</span>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
web/src/pages/SmsList.css
Normal file
155
web/src/pages/SmsList.css
Normal file
@@ -0,0 +1,155 @@
|
||||
.sms-layout { display: flex; flex-direction: column; height: 100vh; }
|
||||
|
||||
/* Top Bar */
|
||||
.topbar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 14px 24px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar-left { display: flex; align-items: center; gap: 12px; }
|
||||
.topbar-logo {
|
||||
width: 36px; height: 36px; background: var(--accent-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.topbar-logo .material-symbols-outlined { font-size: 20px; color: var(--accent); }
|
||||
.topbar h2 { font-size: 1.1rem; font-weight: 600; letter-spacing: -0.01em; }
|
||||
.topbar-right { display: flex; align-items: center; gap: 10px; }
|
||||
.topbar .badge-count {
|
||||
background: var(--bg-hover); color: var(--text-secondary);
|
||||
padding: 5px 12px; border-radius: 20px; font-size: 0.8rem; font-weight: 500;
|
||||
}
|
||||
|
||||
/* Filters Bar */
|
||||
.filters-bar {
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
padding: 12px 24px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filters-bar input { width: 240px; }
|
||||
.filters-bar select { width: 140px; }
|
||||
.filters-bar .spacer { flex: 1; }
|
||||
|
||||
/* Main Content */
|
||||
.sms-main { display: flex; flex: 1; overflow: hidden; }
|
||||
|
||||
/* Table */
|
||||
.sms-table-wrap { flex: 1; overflow: auto; }
|
||||
|
||||
.sms-table { width: 100%; border-collapse: collapse; }
|
||||
.sms-table thead { position: sticky; top: 0; z-index: 1; }
|
||||
.sms-table th {
|
||||
text-align: left; font-weight: 500; font-size: 0.75rem;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1.5px solid var(--border);
|
||||
}
|
||||
.sms-table td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.sms-table tbody tr {
|
||||
transition: background var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
.sms-table tbody tr:hover { background: var(--bg-hover); }
|
||||
.sms-table tbody tr.selected {
|
||||
background: var(--accent-subtle);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.cell-contact { font-weight: 500; }
|
||||
.cell-phone { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; color: var(--text-secondary); }
|
||||
.cell-content {
|
||||
max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.cell-date { white-space: nowrap; font-size: 0.8rem; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
.badge {
|
||||
font-size: 0.7rem; font-weight: 600; padding: 3px 10px; border-radius: 20px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.badge.received { background: var(--green-bg); color: var(--green); }
|
||||
.badge.sent { background: var(--blue-bg); color: var(--blue); }
|
||||
|
||||
.table-empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
||||
.table-empty .material-symbols-outlined { font-size: 48px; display: block; margin-bottom: 12px; opacity: 0.4; }
|
||||
.table-empty p { font-size: 0.9rem; }
|
||||
|
||||
.table-loading { text-align: center; padding: 60px 20px; }
|
||||
.table-loading .spinner {
|
||||
width: 32px; height: 32px;
|
||||
margin: 0 auto 12px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Detail Panel */
|
||||
.detail-panel {
|
||||
width: 360px; padding: 28px 24px;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.detail-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.detail-header h3 { font-size: 1rem; font-weight: 600; }
|
||||
.detail-meta { display: flex; flex-direction: column; gap: 10px; }
|
||||
.detail-row { display: flex; gap: 8px; align-items: baseline; }
|
||||
.detail-label {
|
||||
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: var(--text-muted); width: 52px; flex-shrink: 0;
|
||||
}
|
||||
.detail-value { font-size: 0.875rem; color: var(--text-primary); }
|
||||
.detail-value.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; }
|
||||
|
||||
.detail-content-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 16px;
|
||||
font-size: 0.9rem; line-height: 1.7;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 12px 24px;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.85rem; color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pagination-info { font-weight: 500; }
|
||||
.pagination-ctrl { display: flex; align-items: center; gap: 10px; }
|
||||
.pagination-ctrl .btn-icon { padding: 6px 12px; }
|
||||
.pagination-ctrl .page-num {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Slide-in transition for detail panel */
|
||||
.detail-panel {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
@keyframes slideIn { from { transform: translateX(20px); opacity: 0.5; } }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.topbar { flex-direction: column; align-items: flex-start; }
|
||||
.filters-bar { flex-wrap: wrap; }
|
||||
.filters-bar input { width: 100%; }
|
||||
.sms-main { flex-direction: column; }
|
||||
.detail-panel { width: 100%; border-left: none; border-top: 1px solid var(--border); }
|
||||
}
|
||||
177
web/src/pages/SmsList.tsx
Normal file
177
web/src/pages/SmsList.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchSms, deleteSms, SmsMessage } from '../api';
|
||||
import './SmsList.css';
|
||||
|
||||
interface Props { onLogout: () => void }
|
||||
|
||||
export default function SmsList({ onLogout }: Props) {
|
||||
const [messages, setMessages] = useState<SmsMessage[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [phone, setPhone] = useState('');
|
||||
const [type, setType] = useState('');
|
||||
const [detail, setDetail] = useState<SmsMessage | null>(null);
|
||||
|
||||
async function load(p: number) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = { page: String(p), limit: '20' };
|
||||
if (phone) params.phone = phone;
|
||||
if (type) params.type = type;
|
||||
const data = await fetchSms(params);
|
||||
setMessages(data.messages);
|
||||
setTotalPages(data.totalPages);
|
||||
setTotal(data.total);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load(1);
|
||||
setPage(1);
|
||||
}, [phone, type]);
|
||||
|
||||
useEffect(() => { load(page); }, [page]);
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await deleteSms(id);
|
||||
if (detail?.id === id) setDetail(null);
|
||||
load(page);
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
const dt = new Date(d);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sms-layout">
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
<div className="topbar-logo">
|
||||
<span className="material-symbols-outlined">sms</span>
|
||||
</div>
|
||||
<h2>SMS Monitor</h2>
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<span className="badge-count">{total} 条消息</span>
|
||||
<button className="btn-ghost" onClick={onLogout}>
|
||||
<span className="material-symbols-outlined">logout</span>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="filters-bar">
|
||||
<input
|
||||
placeholder="按手机号筛选..." value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && (setPage(1), load(1))}
|
||||
/>
|
||||
<select value={type} onChange={e => setType(e.target.value)}>
|
||||
<option value="">全部类型</option>
|
||||
<option value="received">接收</option>
|
||||
<option value="sent">发送</option>
|
||||
</select>
|
||||
<button className="btn-primary" onClick={() => { setPage(1); load(1); }}>
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main className="sms-main">
|
||||
<div className="sms-table-wrap">
|
||||
<table className="sms-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>联系人</th>
|
||||
<th>手机号</th>
|
||||
<th>类型</th>
|
||||
<th>内容</th>
|
||||
<th>时间</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6}>
|
||||
<div className="table-loading"><div className="spinner" /></div>
|
||||
</td></tr>
|
||||
) : messages.length === 0 ? (
|
||||
<tr><td colSpan={6}>
|
||||
<div className="table-empty">
|
||||
<span className="material-symbols-outlined">inbox</span>
|
||||
<p>暂无消息</p>
|
||||
</div>
|
||||
</td></tr>
|
||||
) : messages.map(m => (
|
||||
<tr key={m.id} className={detail?.id === m.id ? 'selected' : ''} onClick={() => setDetail(m)}>
|
||||
<td className="cell-contact">{m.contact_name || '—'}</td>
|
||||
<td className="cell-phone">{m.phone_number}</td>
|
||||
<td><span className={`badge ${m.type}`}>{m.type === 'received' ? '接收' : '发送'}</span></td>
|
||||
<td className="cell-content">{m.content}</td>
|
||||
<td className="cell-date">{formatDate(m.sms_date)}</td>
|
||||
<td>
|
||||
<button className="btn-danger" onClick={e => { e.stopPropagation(); handleDelete(m.id); }}>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{detail && (
|
||||
<aside className="detail-panel">
|
||||
<div className="detail-header">
|
||||
<h3>消息详情</h3>
|
||||
<button className="btn-icon" onClick={() => setDetail(null)}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="detail-meta">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">联系人</span>
|
||||
<span className="detail-value">{detail.contact_name || '—'}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">手机号</span>
|
||||
<span className="detail-value mono">{detail.phone_number}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">类型</span>
|
||||
<span className={`badge ${detail.type}`}>{detail.type === 'received' ? '接收' : '发送'}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">时间</span>
|
||||
<span className="detail-value">{formatDate(detail.sms_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-content-card">{detail.content}</div>
|
||||
<button className="btn-danger" style={{ justifyContent: 'center', padding: '10px' }} onClick={() => handleDelete(detail.id)}>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
删除此消息
|
||||
</button>
|
||||
</aside>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="pagination">
|
||||
<span className="pagination-info">共 {total} 条</span>
|
||||
<div className="pagination-ctrl">
|
||||
<button className="btn-icon" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
|
||||
<span className="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
<span className="page-num">{page} / {totalPages || 1}</span>
|
||||
<button className="btn-icon" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
web/tsconfig.json
Normal file
21
web/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
12
web/vite.config.ts
Normal file
12
web/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user