This commit is contained in:
wuxu
2026-04-28 17:34:03 +08:00
commit 80ee99e564
43 changed files with 6330 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
*.db
dist/
.env

369
README.md Normal file
View 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 APIJWT 鉴权SQLite 存储。
- **Web 端**React + Vite SPAMaterial 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
- 点击行查看详情面板(右侧)
- 每条短信可单独删除
## 安全说明
- 密码经 bcrypt10轮哈希存储不保存明文
- 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
View 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(),
);
},
);
}
}

View 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'],
);
}

View 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'),
],
),
);
}
}

View 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'),
),
),
],
),
],
),
),
),
),
);
}
}

View 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),
),
),
],
),
),
);
}
}

View 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),
),
],
),
);
}
}

View 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})';
}
}
}

View 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);
}
}

View 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
View 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
View File

@@ -0,0 +1,6 @@
{
"name": "transfer_sms",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

119
plan.md Normal file
View 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
View File

@@ -0,0 +1,4 @@
node_modules/
*.db
dist/
.env

BIN
server/data.db-shm Normal file

Binary file not shown.

BIN
server/data.db-wal Normal file

Binary file not shown.

2133
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
server/package.json Normal file
View 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
View 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
View 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}`);
});

View 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
View 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;

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

15
web/Dockerfile Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

22
web/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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',
},
},
});