清理
This commit is contained in:
37
CHANGELOG.md
37
CHANGELOG.md
@@ -4,6 +4,43 @@
|
||||
|
||||
***
|
||||
|
||||
## \[4.2.2] - 2026-05-12
|
||||
|
||||
### 🔧 重构 — TransferChatPage 拆分为多文件(1320行→3文件)
|
||||
|
||||
> 问题: `transfer_chat_page.dart` 达到 1320 行,超出 800 行限制,需拆分
|
||||
|
||||
1. **新增 transfer_chat_bubbles.dart** (~530行):
|
||||
- `ChatTransferCallbacks` — 传输控制回调封装类
|
||||
- `ChatMessageBubble` — 消息气泡容器+内容分发
|
||||
- `ChatTextContent` — 文本消息内容
|
||||
- `ChatFileContent` — 文件传输气泡(进度+控制按钮)
|
||||
- `ChatImageContent` — 图片气泡(含文件回退)
|
||||
- `ChatVideoContent` — 视频气泡(缩略图+播放按钮+回退)
|
||||
- `ChatProgressContent` — 进度条气泡
|
||||
- `ChatPairingBubble` — 配对消息气泡
|
||||
- `ChatSystemMessage` — 系统消息药丸
|
||||
- `ChatStatusIcon` — 传输状态图标
|
||||
- `ChatTransferControls` — 暂停/继续/重试/取消按钮组
|
||||
- `ChatControlButton` — 单个控制按钮
|
||||
|
||||
2. **新增 transfer_chat_input.dart** (~290行):
|
||||
- `ChatInputBar` — 输入栏(文本框+麦克风+发送+附件)
|
||||
- `ChatEmptyState` — 消息列表空状态
|
||||
- `ChatNetworkStatusBar` — 网络状态栏(信号/延迟/健康度)
|
||||
- `ChatSignalBars` — 信号强度条
|
||||
- `ChatLatencyChip` — 延迟显示芯片
|
||||
- `ChatHealthBadge` — 健康度徽章
|
||||
- `ChatStatusDot` — 在线状态闪烁点
|
||||
|
||||
3. **重构 transfer_chat_page.dart** (1320行→537行):
|
||||
- 保留: 状态管理、Ping逻辑、网络状态计算、业务方法
|
||||
- 保留: _sendMessage、语音录制、文件选择、云端暂存对话框、设备信息
|
||||
- build方法改用 `ChatMessageBubble`/`ChatInputBar`/`ChatNetworkStatusBar` 等组件
|
||||
- 传输控制通过 `ChatTransferCallbacks` 回调注入,不再直接访问 ref
|
||||
|
||||
***
|
||||
|
||||
## \[4.2.1] - 2026-05-12
|
||||
|
||||
### 🔧 重构 — TransferNotifier 拆分为多 Handler(文件行数优化)
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import paramiko
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('123.207.67.197', 22, 'root', '520Kiss123')
|
||||
|
||||
print('=== Create test file ===')
|
||||
stdin, stdout, stderr = ssh.exec_command('echo "test content 12345" > /tmp/test_upload.txt && cat /tmp/test_upload.txt')
|
||||
print(stdout.read().decode())
|
||||
|
||||
print('=== Test upload with verbose ===')
|
||||
stdin, stdout, stderr = ssh.exec_command(
|
||||
'curl -sv -X POST "https://tools.wktyl.com/api/cloud_cache/upload" '
|
||||
'-F "file=@/tmp/test_upload.txt" '
|
||||
'-F "fromId=device_test" '
|
||||
'-F "toId=device_test2" '
|
||||
'-F "fileName=test.txt" '
|
||||
'-F "fileSize=18" '
|
||||
'-F "mimeType=text/plain" 2>&1'
|
||||
)
|
||||
print(stdout.read().decode())
|
||||
|
||||
print('=== Check PHP error logs ===')
|
||||
stdin, stdout, stderr = ssh.exec_command(
|
||||
'find /www/wwwroot/tools.wktyl.com/runtime -name "*.log" -mmin -5 -exec tail -20 {} \\; 2>/dev/null'
|
||||
)
|
||||
print(stdout.read().decode())
|
||||
|
||||
print('=== Check nginx error log ===')
|
||||
stdin, stdout, stderr = ssh.exec_command(
|
||||
'tail -10 /www/server/nginx/logs/tools.wktyl.com.log 2>/dev/null || tail -10 /var/log/nginx/error.log 2>/dev/null || echo "no nginx log"'
|
||||
)
|
||||
print(stdout.read().decode())
|
||||
|
||||
ssh.close()
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
============================================================
|
||||
闲言工具箱 — IP归属地修复部署脚本
|
||||
创建时间: 2026-05-12
|
||||
作用: 上传修复ip_city为空的UserSecurity.php到服务器
|
||||
============================================================
|
||||
"""
|
||||
|
||||
import paramiko
|
||||
import time
|
||||
|
||||
HOST = '123.207.67.197'
|
||||
PORT = 22
|
||||
USER = 'root'
|
||||
PASS = '520Kiss123'
|
||||
|
||||
REMOTE_APP_PATH = '/www/wwwroot/tools.wktyl.com/application'
|
||||
|
||||
LOCAL_FILE = r'e:\project\flutter\f\xianyan\docs\toolsapi\application\api\controller\UserSecurity.php'
|
||||
REMOTE_FILE = f'{REMOTE_APP_PATH}/api/controller/UserSecurity.php'
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(HOST, PORT, USER, PASS)
|
||||
|
||||
sftp = ssh.open_sftp()
|
||||
|
||||
# 备份
|
||||
timestamp = time.strftime('%Y%m%d_%H%M%S')
|
||||
backup = f"{REMOTE_FILE}.bak.{timestamp}"
|
||||
try:
|
||||
sftp.rename(REMOTE_FILE, backup)
|
||||
print(f"✅ 备份: {backup}")
|
||||
except:
|
||||
print("⚠️ 无需备份")
|
||||
|
||||
# 上传
|
||||
sftp.put(LOCAL_FILE, REMOTE_FILE)
|
||||
sftp.chmod(REMOTE_FILE, 0o644)
|
||||
print("✅ 上传: UserSecurity.php (含queryIpLocation修复)")
|
||||
|
||||
# 清理缓存
|
||||
ssh.exec_command(f'rm -rf {REMOTE_APP_PATH}/../runtime/cache/* {REMOTE_APP_PATH}/../runtime/temp/*')
|
||||
print("✅ 缓存已清理")
|
||||
|
||||
# 验证文件
|
||||
stat = sftp.stat(REMOTE_FILE)
|
||||
print(f"✅ 文件大小: {stat.st_size} bytes")
|
||||
|
||||
sftp.close()
|
||||
|
||||
# 测试: 登录后检查ip_city
|
||||
print("\n--- 测试: 登录后ip_city是否自动填充 ---")
|
||||
import requests as req
|
||||
|
||||
session = req.Session()
|
||||
session.headers.update({
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
|
||||
resp = session.post('https://tools.wktyl.com/api/user_security/login', data={
|
||||
'account': 'apitest_user',
|
||||
'password': '123456',
|
||||
}, timeout=15)
|
||||
login_data = resp.json()
|
||||
if login_data.get('code') == 1:
|
||||
token = login_data['data']['userinfo']['token']
|
||||
user_id = login_data['data']['userinfo']['id']
|
||||
print(f"✅ 登录成功 (user_id={user_id})")
|
||||
|
||||
# 查询设备列表
|
||||
resp2 = session.get(
|
||||
'https://tools.wktyl.com/api/user_center/myDevices',
|
||||
headers={'token': token},
|
||||
timeout=15,
|
||||
)
|
||||
devices_data = resp2.json()
|
||||
if devices_data.get('code') == 1:
|
||||
devices = devices_data.get('data', {}).get('devices', [])
|
||||
for d in devices:
|
||||
ip_city = d.get('ip_city', '')
|
||||
ip_range = d.get('ip_range', '')
|
||||
device_name = d.get('device_name', '')
|
||||
print(f" 设备: {device_name} | ip_city={ip_city} | ip_range={ip_range}")
|
||||
if ip_city:
|
||||
print(" ✅ ip_city已自动填充!")
|
||||
else:
|
||||
print(" ⚠️ ip_city仍为空")
|
||||
else:
|
||||
print(f" ❌ 设备列表查询失败: {devices_data.get('msg')}")
|
||||
else:
|
||||
print(f"❌ 登录失败: {login_data.get('msg')}")
|
||||
|
||||
ssh.close()
|
||||
print("\n部署完成!")
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
============================================================
|
||||
闲言工具箱 — 注销功能部署脚本
|
||||
创建时间: 2026-05-12
|
||||
作用: 上传注销接口相关PHP文件到服务器,并执行SQL迁移
|
||||
上次更新: v9.2.0 新建
|
||||
============================================================
|
||||
"""
|
||||
|
||||
import paramiko
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = '123.207.67.197'
|
||||
PORT = 22
|
||||
USER = 'root'
|
||||
PASS = '520Kiss123'
|
||||
|
||||
REMOTE_APP_PATH = '/www/wwwroot/tools.wktyl.com/application'
|
||||
|
||||
FILES_TO_UPLOAD = [
|
||||
{
|
||||
'local': r'e:\project\flutter\f\xianyan\docs\toolsapi\application\api\controller\UserSecurity.php',
|
||||
'remote': f'{REMOTE_APP_PATH}/api/controller/UserSecurity.php',
|
||||
'desc': 'UserSecurity.php (新增requestDeletion/deletionStatus/cancelDeletion)',
|
||||
},
|
||||
{
|
||||
'local': r'e:\project\flutter\f\xianyan\docs\toolsapi\application\api\controller\User.php',
|
||||
'remote': f'{REMOTE_APP_PATH}/api/controller/User.php',
|
||||
'desc': 'User.php (新增注销方法转发)',
|
||||
},
|
||||
{
|
||||
'local': r'e:\project\flutter\f\xianyan\docs\toolsapi\application\route.php',
|
||||
'remote': f'{REMOTE_APP_PATH}/route.php',
|
||||
'desc': 'route.php (新增注销路由)',
|
||||
},
|
||||
]
|
||||
|
||||
SQL_MIGRATION = r'e:\project\flutter\f\xianyan\docs\toolsapi\application\admin\command\Install\migrate_v9_2.sql'
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("闲言工具箱 — 注销功能部署")
|
||||
print("=" * 60)
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(HOST, PORT, USER, PASS)
|
||||
sftp = ssh.open_sftp()
|
||||
|
||||
# 1. 上传PHP文件
|
||||
print("\n--- 上传PHP文件 ---")
|
||||
for item in FILES_TO_UPLOAD:
|
||||
local = item['local']
|
||||
remote = item['remote']
|
||||
desc = item['desc']
|
||||
|
||||
if not os.path.exists(local):
|
||||
print(f"❌ 本地文件不存在: {local}")
|
||||
continue
|
||||
|
||||
timestamp = time.strftime('%Y%m%d_%H%M%S')
|
||||
backup = f"{remote}.bak.{timestamp}"
|
||||
try:
|
||||
sftp.rename(remote, backup)
|
||||
print(f" 备份: {remote} -> {backup}")
|
||||
except:
|
||||
print(f" 无需备份(文件不存在): {remote}")
|
||||
|
||||
sftp.put(local, remote)
|
||||
sftp.chmod(remote, 0o644)
|
||||
print(f" ✅ 上传: {desc}")
|
||||
|
||||
# 2. 上传SQL迁移文件
|
||||
print("\n--- 上传SQL迁移文件 ---")
|
||||
if os.path.exists(SQL_MIGRATION):
|
||||
remote_sql = f'{REMOTE_APP_PATH}/admin/command/Install/migrate_v9_2.sql'
|
||||
sftp.put(SQL_MIGRATION, remote_sql)
|
||||
sftp.chmod(remote_sql, 0o644)
|
||||
print(f" ✅ 上传: migrate_v9_2.sql")
|
||||
else:
|
||||
print(f" ❌ SQL文件不存在: {SQL_MIGRATION}")
|
||||
|
||||
# 3. 执行SQL迁移
|
||||
print("\n--- 执行SQL迁移(创建user_deletion表) ---")
|
||||
sql_content = open(SQL_MIGRATION, 'r', encoding='utf-8').read()
|
||||
sql_statements = [s.strip() for s in sql_content.split(';') if s.strip() and not s.strip().startswith('--')]
|
||||
|
||||
for sql in sql_statements:
|
||||
if 'CREATE TABLE' in sql:
|
||||
check_cmd = "mysql -u tools -ptools tools -e \"SHOW TABLES LIKE 'fa_user_deletion'\" 2>/dev/null"
|
||||
stdin, stdout, stderr = ssh.exec_command(check_cmd)
|
||||
result = stdout.read().decode().strip()
|
||||
if result:
|
||||
print(" ⚠️ fa_user_deletion表已存在,跳过创建")
|
||||
else:
|
||||
full_sql = sql + ';'
|
||||
mysql_cmd = f"mysql -u tools -ptools tools -e \"{full_sql.replace('\"', '\\\"')}\" 2>/dev/null"
|
||||
stdin, stdout, stderr = ssh.exec_command(mysql_cmd)
|
||||
err = stderr.read().decode().strip()
|
||||
if err:
|
||||
print(f" ❌ SQL执行错误: {err}")
|
||||
else:
|
||||
print(" ✅ fa_user_deletion表创建成功")
|
||||
|
||||
# 4. 清理PHP缓存
|
||||
print("\n--- 清理PHP缓存 ---")
|
||||
stdin, stdout, stderr = ssh.exec_command(f'rm -rf {REMOTE_APP_PATH}/../runtime/cache/* {REMOTE_APP_PATH}/../runtime/temp/*')
|
||||
print(" ✅ 缓存已清理")
|
||||
|
||||
# 5. 验证文件
|
||||
print("\n--- 验证上传文件 ---")
|
||||
for item in FILES_TO_UPLOAD:
|
||||
remote = item['remote']
|
||||
try:
|
||||
stat = sftp.stat(remote)
|
||||
print(f" ✅ {item['desc']} ({stat.st_size} bytes)")
|
||||
except:
|
||||
print(f" ❌ 文件不存在: {remote}")
|
||||
|
||||
sftp.close()
|
||||
|
||||
# 6. 快速接口检测
|
||||
print("\n--- 接口可用性检测 ---")
|
||||
import requests as req
|
||||
test_urls = [
|
||||
('POST', '/api/user_security/requestDeletion', '注销申请接口'),
|
||||
('GET', '/api/user_security/deletionStatus', '注销状态接口'),
|
||||
('POST', '/api/user_security/cancelDeletion', '取消注销接口'),
|
||||
]
|
||||
for method, path, desc in test_urls:
|
||||
url = f'https://tools.wktyl.com{path}'
|
||||
try:
|
||||
if method == 'GET':
|
||||
r = req.get(url, timeout=10)
|
||||
else:
|
||||
r = req.post(url, timeout=10)
|
||||
if r.status_code == 404:
|
||||
print(f" ❌ {desc}: 404 未找到")
|
||||
elif r.status_code == 200:
|
||||
data = r.json()
|
||||
if data.get('code') == -1:
|
||||
print(f" ✅ {desc}: 接口存在(需登录)")
|
||||
else:
|
||||
print(f" ✅ {desc}: 接口正常(code={data.get('code')})")
|
||||
else:
|
||||
print(f" ⚠️ {desc}: HTTP {r.status_code}")
|
||||
except Exception as e:
|
||||
print(f" ❌ {desc}: 请求失败 - {e}")
|
||||
|
||||
ssh.close()
|
||||
print("\n" + "=" * 60)
|
||||
print("部署完成!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,34 +0,0 @@
|
||||
import paramiko
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect('123.207.67.197', 22, 'root', '520Kiss123')
|
||||
|
||||
print('=== Fix directory permissions ===')
|
||||
stdin, stdout, stderr = ssh.exec_command(
|
||||
'chown -R www:www /www/wwwroot/tools.wktyl.com/runtime/cloud_cache && '
|
||||
'chmod -R 755 /www/wwwroot/tools.wktyl.com/runtime/cloud_cache && '
|
||||
'ls -la /www/wwwroot/tools.wktyl.com/runtime/ | grep cloud'
|
||||
)
|
||||
print(stdout.read().decode())
|
||||
print(stderr.read().decode())
|
||||
|
||||
print('=== Check web server user ===')
|
||||
stdin, stdout, stderr = ssh.exec_command(
|
||||
'ps aux | grep -E "nginx|php-fpm" | head -5'
|
||||
)
|
||||
print(stdout.read().decode())
|
||||
|
||||
print('=== Test upload again ===')
|
||||
stdin, stdout, stderr = ssh.exec_command(
|
||||
'curl -s -X POST "https://tools.wktyl.com/api/cloud_cache/upload" '
|
||||
'-F "file=@/tmp/test_upload.txt" '
|
||||
'-F "fromId=device_test" '
|
||||
'-F "toId=device_test2" '
|
||||
'-F "fileName=test.txt" '
|
||||
'-F "fileSize=18" '
|
||||
'-F "mimeType=text/plain"'
|
||||
)
|
||||
print(stdout.read().decode())
|
||||
|
||||
ssh.close()
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import paramiko
|
||||
import time
|
||||
|
||||
HOST = '123.207.67.197'
|
||||
PORT = 22
|
||||
USER = 'root'
|
||||
PASS = '520Kiss123'
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(HOST, PORT, USER, PASS)
|
||||
|
||||
print("--- 上传SQL文件 ---")
|
||||
sftp = ssh.open_sftp()
|
||||
local_sql = r'e:\project\flutter\f\xianyan\docs\toolsapi\application\admin\command\Install\migrate_v9_2.sql'
|
||||
remote_sql = '/tmp/migrate_v9_2.sql'
|
||||
sftp.put(local_sql, remote_sql)
|
||||
sftp.chmod(remote_sql, 0o644)
|
||||
sftp.close()
|
||||
print("✅ SQL文件已上传到 /tmp/migrate_v9_2.sql")
|
||||
|
||||
print("\n--- 执行SQL迁移 ---")
|
||||
stdin, stdout, stderr = ssh.exec_command('mysql -u tools -ptools tools < /tmp/migrate_v9_2.sql 2>&1')
|
||||
out = stdout.read().decode().strip()
|
||||
err = stderr.read().decode().strip()
|
||||
if out:
|
||||
print(f"输出: {out}")
|
||||
if err:
|
||||
print(f"错误: {err}")
|
||||
if not out and not err:
|
||||
print("✅ SQL执行成功(无输出=成功)")
|
||||
|
||||
print("\n--- 验证表结构 ---")
|
||||
stdin, stdout, stderr = ssh.exec_command("mysql -u tools -ptools tools -e 'DESC fa_user_deletion' 2>/dev/null")
|
||||
result = stdout.read().decode().strip()
|
||||
if result:
|
||||
print(result)
|
||||
print("✅ fa_user_deletion表创建成功")
|
||||
else:
|
||||
print("❌ 表不存在")
|
||||
|
||||
print("\n--- 清理临时文件 ---")
|
||||
ssh.exec_command('rm -f /tmp/migrate_v9_2.sql')
|
||||
print("✅ 临时文件已清理")
|
||||
|
||||
ssh.close()
|
||||
print("\n完成!")
|
||||
@@ -1,218 +0,0 @@
|
||||
import paramiko
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
|
||||
HOST = '123.207.67.197'
|
||||
PORT = 22
|
||||
USER = 'root'
|
||||
PASS = '520Kiss123'
|
||||
BASE_URL = 'https://tools.wktyl.com/api/cloud_cache'
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(HOST, PORT, USER, PASS)
|
||||
|
||||
def run_curl(cmd, label=''):
|
||||
print(f'\n{"="*60}')
|
||||
print(f'TEST: {label}')
|
||||
print(f'CMD: {cmd[:120]}...')
|
||||
stdin, stdout, stderr = ssh.exec_command(cmd)
|
||||
out = stdout.read().decode()
|
||||
err = stderr.read().decode()
|
||||
print(f'RESPONSE: {out[:500]}')
|
||||
if err:
|
||||
print(f'ERROR: {err[:200]}')
|
||||
return out
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def check(label, condition):
|
||||
global passed, failed
|
||||
if condition:
|
||||
passed += 1
|
||||
print(f' ✅ PASS: {label}')
|
||||
else:
|
||||
failed += 1
|
||||
print(f' ❌ FAIL: {label}')
|
||||
|
||||
# ─── Test 1: Install table ───────────────────────────────
|
||||
result = run_curl(f'curl -s -X POST "{BASE_URL}/install"', 'Install database table')
|
||||
try:
|
||||
data = json.loads(result)
|
||||
check('Install returns success', data.get('code') == 1)
|
||||
except:
|
||||
check('Install returns valid JSON', False)
|
||||
|
||||
# ─── Test 2: Upload file (anonymous) ─────────────────────
|
||||
test_file = '/tmp/test_cloud_cache_upload.txt'
|
||||
ssh.exec_command(f'echo "This is a test file for cloud cache - {int(time.time())}" > {test_file}')
|
||||
time.sleep(0.5)
|
||||
|
||||
result = run_curl(
|
||||
f'curl -s -X POST "{BASE_URL}/upload" '
|
||||
f'-F "file=@{test_file}" '
|
||||
f'-F "fromId=device_test_sender" '
|
||||
f'-F "toId=device_test_receiver" '
|
||||
f'-F "fileName=test_upload.txt" '
|
||||
f'-F "fileSize=100" '
|
||||
f'-F "mimeType=text/plain" '
|
||||
f'-F "expireHours=24"',
|
||||
'Upload file (anonymous)'
|
||||
)
|
||||
|
||||
cache_id = None
|
||||
try:
|
||||
data = json.loads(result)
|
||||
check('Upload returns success', data.get('code') == 1)
|
||||
cache_id = data.get('data', {}).get('cacheId', '')
|
||||
check('Upload returns cacheId', len(cache_id) > 0)
|
||||
check('Upload returns expiresAt', data.get('data', {}).get('expiresAt') is not None)
|
||||
except Exception as e:
|
||||
check('Upload returns valid JSON', False)
|
||||
print(f' Exception: {e}')
|
||||
|
||||
# ─── Test 3: Upload dangerous file type ──────────────────
|
||||
danger_file = '/tmp/test_malicious.php'
|
||||
ssh.exec_command(f'echo "<?php echo \\"hack\\"; ?>" > {danger_file}')
|
||||
time.sleep(0.5)
|
||||
|
||||
result = run_curl(
|
||||
f'curl -s -X POST "{BASE_URL}/upload" '
|
||||
f'-F "file=@{danger_file}" '
|
||||
f'-F "fromId=device_test" '
|
||||
f'-F "toId=device_test2" '
|
||||
f'-F "fileName=malicious.php" '
|
||||
f'-F "mimeType=application/x-php"',
|
||||
'Upload dangerous file type (should be rejected)'
|
||||
)
|
||||
try:
|
||||
data = json.loads(result)
|
||||
check('Dangerous file rejected', data.get('code') != 1)
|
||||
check('Error mentions unsupported type', '不支持' in data.get('msg', '') or '类型' in data.get('msg', ''))
|
||||
except:
|
||||
check('Dangerous file rejection returns valid JSON', False)
|
||||
|
||||
# ─── Test 4: Upload oversized file (anonymous > 10MB) ────
|
||||
big_file = '/tmp/test_big_file.bin'
|
||||
ssh.exec_command(f'dd if=/dev/zero of={big_file} bs=1M count=11 2>/dev/null')
|
||||
time.sleep(1)
|
||||
|
||||
result = run_curl(
|
||||
f'curl -s -X POST "{BASE_URL}/upload" '
|
||||
f'-F "file=@{big_file}" '
|
||||
f'-F "fromId=device_test" '
|
||||
f'-F "toId=device_test2" '
|
||||
f'-F "fileName=big_file.bin"',
|
||||
'Upload oversized file >10MB anonymous (should be rejected)'
|
||||
)
|
||||
try:
|
||||
data = json.loads(result)
|
||||
check('Oversized file rejected', data.get('code') != 1)
|
||||
check('Error mentions size limit', '大小限制' in data.get('msg', '') or '10' in data.get('msg', ''))
|
||||
except:
|
||||
check('Oversized rejection returns valid JSON', False)
|
||||
|
||||
# ─── Test 5: List files ─────────────────────────────────
|
||||
result = run_curl(
|
||||
f'curl -s -X GET "{BASE_URL}/list?userId=device_test_receiver"',
|
||||
'List files for receiver'
|
||||
)
|
||||
try:
|
||||
data = json.loads(result)
|
||||
check('List returns success', data.get('code') == 1)
|
||||
items = data.get('data', [])
|
||||
check('List returns array', isinstance(items, list))
|
||||
if cache_id and isinstance(items, list):
|
||||
found = any(item.get('cacheId') == cache_id for item in items)
|
||||
check('List contains uploaded file', found)
|
||||
except:
|
||||
check('List returns valid JSON', False)
|
||||
|
||||
# ─── Test 6: Get file info ──────────────────────────────
|
||||
if cache_id:
|
||||
result = run_curl(
|
||||
f'curl -s -X GET "{BASE_URL}/info?cacheId={cache_id}"',
|
||||
'Get file info'
|
||||
)
|
||||
try:
|
||||
data = json.loads(result)
|
||||
check('Info returns success', data.get('code') == 1)
|
||||
check('Info returns correct cacheId', data.get('data', {}).get('cacheId') == cache_id)
|
||||
check('Info returns fileName', data.get('data', {}).get('fileName') is not None)
|
||||
except:
|
||||
check('Info returns valid JSON', False)
|
||||
|
||||
# ─── Test 7: Download file ──────────────────────────────
|
||||
if cache_id:
|
||||
result = run_curl(
|
||||
f'curl -s -o /tmp/test_download.txt -w "%{{http_code}}" '
|
||||
f'"{BASE_URL}/download?cacheId={cache_id}&deviceId=device_test_receiver"',
|
||||
'Download file'
|
||||
)
|
||||
check('Download returns HTTP 200', '200' in result)
|
||||
|
||||
stdin, stdout, stderr = ssh.exec_command('cat /tmp/test_download.txt')
|
||||
dl_content = stdout.read().decode()
|
||||
check('Downloaded file has content', len(dl_content) > 0)
|
||||
|
||||
# ─── Test 8: Notify ─────────────────────────────────────
|
||||
result = run_curl(
|
||||
f'curl -s -X POST "{BASE_URL}/notify" '
|
||||
f'-H "Content-Type: application/json" '
|
||||
f'-d \'{{"cacheId":"test_notify_id","toId":"device_test_receiver","fromId":"device_test_sender","fileName":"test.txt"}}\'',
|
||||
'Send notification'
|
||||
)
|
||||
try:
|
||||
data = json.loads(result)
|
||||
check('Notify returns success', data.get('code') == 1)
|
||||
except:
|
||||
check('Notify returns valid JSON', False)
|
||||
|
||||
# ─── Test 9: Delete file ────────────────────────────────
|
||||
if cache_id:
|
||||
result = run_curl(
|
||||
f'curl -s -X DELETE "{BASE_URL}/delete" '
|
||||
f'-H "Content-Type: application/json" '
|
||||
f'-d \'{{"cacheId":"{cache_id}","deviceId":"device_test_sender"}}\'',
|
||||
'Delete file'
|
||||
)
|
||||
try:
|
||||
data = json.loads(result)
|
||||
check('Delete returns success', data.get('code') == 1)
|
||||
except:
|
||||
check('Delete returns valid JSON', False)
|
||||
|
||||
result = run_curl(
|
||||
f'curl -s -X GET "{BASE_URL}/info?cacheId={cache_id}"',
|
||||
'Verify file deleted'
|
||||
)
|
||||
try:
|
||||
data = json.loads(result)
|
||||
check('Deleted file returns error', data.get('code') != 1)
|
||||
except:
|
||||
check('Deleted file check returns valid JSON', False)
|
||||
|
||||
# ─── Test 10: Clean expired ─────────────────────────────
|
||||
result = run_curl(
|
||||
f'curl -s -X POST "{BASE_URL}/clean"',
|
||||
'Clean expired files'
|
||||
)
|
||||
try:
|
||||
data = json.loads(result)
|
||||
check('Clean returns success', data.get('code') == 1)
|
||||
except:
|
||||
check('Clean returns valid JSON', False)
|
||||
|
||||
# ─── Cleanup ────────────────────────────────────────────
|
||||
ssh.exec_command(f'rm -f {test_file} {danger_file} {big_file} /tmp/test_download.txt')
|
||||
|
||||
ssh.close()
|
||||
|
||||
print(f'\n{"="*60}')
|
||||
print(f'TEST RESULTS: {passed} passed, {failed} failed, {passed + failed} total')
|
||||
if failed == 0:
|
||||
print('🎉 ALL TESTS PASSED!')
|
||||
else:
|
||||
print(f'⚠️ {failed} test(s) failed')
|
||||
@@ -1,222 +0,0 @@
|
||||
import paramiko
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
import sys
|
||||
|
||||
HOST = '123.207.67.197'
|
||||
PORT = 22
|
||||
USER = 'root'
|
||||
PASS = '520Kiss123'
|
||||
WS_PORT = 3001
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(HOST, PORT, USER, PASS)
|
||||
|
||||
print('=== 接口测试: 信令服务器 v11.0.0 ===\n')
|
||||
|
||||
test_results = []
|
||||
|
||||
def run_test(name, command):
|
||||
print(f'\n--- 测试: {name} ---')
|
||||
stdin, stdout, stderr = ssh.exec_command(command)
|
||||
out = stdout.read().decode().strip()
|
||||
err = stderr.read().decode().strip()
|
||||
print(f'输出: {out[:500]}')
|
||||
if err:
|
||||
print(f'错误: {err[:300]}')
|
||||
success = 'error' not in out.lower() and 'fail' not in out.lower() and not err
|
||||
test_results.append((name, success))
|
||||
return out, err
|
||||
|
||||
print('1. 检查服务运行状态')
|
||||
out, err = run_test('PM2 Status', 'pm2 describe signaling | grep status')
|
||||
if 'online' in out:
|
||||
print(' ✅ 信令服务在线')
|
||||
else:
|
||||
print(' ❌ 信令服务离线')
|
||||
|
||||
print('\n2. 检查WebSocket端口监听')
|
||||
out, err = run_test('Port Check', f'ss -tlnp | grep {WS_PORT}')
|
||||
if str(WS_PORT) in out:
|
||||
print(f' ✅ 端口 {WS_PORT} 正在监听')
|
||||
else:
|
||||
print(f' ❌ 端口 {WS_PORT} 未监听')
|
||||
|
||||
print('\n3. 测试WebSocket连接 + register')
|
||||
node_test = f'''
|
||||
const WebSocket = require("ws");
|
||||
const ws = new WebSocket("ws://127.0.0.1:{WS_PORT}");
|
||||
let results = [];
|
||||
|
||||
ws.on("open", () => {{
|
||||
ws.send(JSON.stringify({{
|
||||
type: "register",
|
||||
payload: {{ userId: "test_user_001", alias: "TestDevice", platform: "test" }}
|
||||
}}));
|
||||
}});
|
||||
|
||||
ws.on("message", (data) => {{
|
||||
const msg = JSON.parse(data);
|
||||
results.push(msg);
|
||||
if (msg.type === "registered") {{
|
||||
console.log("REGISTER OK: " + JSON.stringify(msg));
|
||||
ws.send(JSON.stringify({{
|
||||
type: "discoverMyDevices",
|
||||
payload: {{ userId: "test_user_001" }}
|
||||
}}));
|
||||
}}
|
||||
if (msg.type === "myDevicesResponse") {{
|
||||
console.log("DISCOVER OK: " + JSON.stringify(msg));
|
||||
ws.send(JSON.stringify({{
|
||||
type: "pair-request",
|
||||
to: "nonexistent_peer",
|
||||
fingerprint: "test_fp_123"
|
||||
}}));
|
||||
}}
|
||||
if (msg.type === "pair-response") {{
|
||||
console.log("PAIR-REQUEST ROUTED: " + JSON.stringify(msg));
|
||||
ws.send(JSON.stringify({{
|
||||
type: "heartbeat"
|
||||
}}));
|
||||
console.log("HEARTBEAT OK");
|
||||
ws.close();
|
||||
}}
|
||||
if (msg.type === "pong") {{
|
||||
console.log("PONG OK");
|
||||
}}
|
||||
}});
|
||||
|
||||
ws.on("error", (err) => {{
|
||||
console.log("WS ERROR: " + err.message);
|
||||
}});
|
||||
|
||||
setTimeout(() => {{
|
||||
console.log("TIMEOUT - closing");
|
||||
ws.close();
|
||||
process.exit(0);
|
||||
}}, 5000);
|
||||
'''
|
||||
|
||||
out, err = run_test('WebSocket + Register + Discover + Pair', f'cd /www/wwwroot/tools.wktyl.com/signaling && node -e \'{node_test.replace(chr(10), " ").replace(chr(13), "")}\'')
|
||||
|
||||
print('\n4. 测试通用消息转发 (delivery-ack)')
|
||||
node_test_relay = f'''
|
||||
const WebSocket = require("ws");
|
||||
const ws1 = new WebSocket("ws://127.0.0.1:{WS_PORT}");
|
||||
const ws2 = new WebSocket("ws://127.0.0.1:{WS_PORT}");
|
||||
let peer1Id, peer2Id;
|
||||
|
||||
ws1.on("open", () => {{
|
||||
ws1.send(JSON.stringify({{ type: "register", payload: {{ userId: "test_relay_user" }} }}));
|
||||
}});
|
||||
|
||||
ws1.on("message", (data) => {{
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === "registered") peer1Id = msg.id;
|
||||
}});
|
||||
|
||||
ws2.on("open", () => {{
|
||||
ws2.send(JSON.stringify({{ type: "register", payload: {{ userId: "test_relay_user" }} }}));
|
||||
}});
|
||||
|
||||
ws2.on("message", (data) => {{
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === "registered") {{
|
||||
peer2Id = msg.id;
|
||||
setTimeout(() => {{
|
||||
ws1.send(JSON.stringify({{
|
||||
type: "delivery-ack",
|
||||
to: peer2Id,
|
||||
messageId: "test_msg_001",
|
||||
status: "delivered"
|
||||
}}));
|
||||
}}, 500);
|
||||
}}
|
||||
if (msg.type === "delivery-ack") {{
|
||||
console.log("DELIVERY-ACK FORWARDED: " + JSON.stringify(msg));
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
process.exit(0);
|
||||
}}
|
||||
}});
|
||||
|
||||
setTimeout(() => {{
|
||||
console.log("TIMEOUT");
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
process.exit(0);
|
||||
}}, 8000);
|
||||
'''
|
||||
|
||||
out, err = run_test('Delivery-Ack Forward', f'cd /www/wwwroot/tools.wktyl.com/signaling && node -e \'{node_test_relay.replace(chr(10), " ").replace(chr(13), "")}\'')
|
||||
|
||||
print('\n5. 测试 chunk-ack / resume-request 通用转发')
|
||||
node_test_chunk = f'''
|
||||
const WebSocket = require("ws");
|
||||
const ws1 = new WebSocket("ws://127.0.0.1:{WS_PORT}");
|
||||
const ws2 = new WebSocket("ws://127.0.0.1:{WS_PORT}");
|
||||
let peer1Id, peer2Id;
|
||||
|
||||
ws1.on("open", () => {{
|
||||
ws1.send(JSON.stringify({{ type: "register", payload: {{ userId: "test_chunk_user" }} }}));
|
||||
}});
|
||||
|
||||
ws1.on("message", (data) => {{
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === "registered") peer1Id = msg.id;
|
||||
}});
|
||||
|
||||
ws2.on("open", () => {{
|
||||
ws2.send(JSON.stringify({{ type: "register", payload: {{ userId: "test_chunk_user" }} }}));
|
||||
}});
|
||||
|
||||
ws2.on("message", (data) => {{
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === "registered") {{
|
||||
peer2Id = msg.id;
|
||||
setTimeout(() => {{
|
||||
ws1.send(JSON.stringify({{
|
||||
type: "chunk-ack",
|
||||
to: peer2Id,
|
||||
taskId: "test_task_001",
|
||||
chunkIndex: 5
|
||||
}}));
|
||||
}}, 500);
|
||||
}}
|
||||
if (msg.type === "chunk-ack") {{
|
||||
console.log("CHUNK-ACK FORWARDED: " + JSON.stringify(msg));
|
||||
ws1.send(JSON.stringify({{
|
||||
type: "resume-request",
|
||||
to: peer2Id,
|
||||
taskId: "test_task_001",
|
||||
missingChunks: [3, 7, 12]
|
||||
}}));
|
||||
}}
|
||||
if (msg.type === "resume-request") {{
|
||||
console.log("RESUME-REQUEST FORWARDED: " + JSON.stringify(msg));
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
process.exit(0);
|
||||
}}
|
||||
}});
|
||||
|
||||
setTimeout(() => {{
|
||||
console.log("TIMEOUT");
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
process.exit(0);
|
||||
}}, 8000);
|
||||
'''
|
||||
|
||||
out, err = run_test('Chunk-Ack + Resume-Request Forward', f'cd /www/wwwroot/tools.wktyl.com/signaling && node -e \'{node_test_chunk.replace(chr(10), " ").replace(chr(13), "")}\'')
|
||||
|
||||
print('\n\n========================================')
|
||||
print('测试结果汇总:')
|
||||
print('========================================')
|
||||
for name, success in test_results:
|
||||
status = '✅ PASS' if success else '❌ FAIL'
|
||||
print(f' {status} - {name}')
|
||||
|
||||
ssh.close()
|
||||
@@ -1,234 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
============================================================
|
||||
闲言APP — 账号注销API测试脚本
|
||||
创建时间: 2026-05-12
|
||||
作用: 测试新版注销接口(requestDeletion/deletionStatus/cancelDeletion)
|
||||
上次更新: v9.2.0 新建
|
||||
============================================================
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
BASE_URL = "https://tools.wktyl.com"
|
||||
RECEIPT_SECRET = "Xy7kP9mL2qR4wS8v"
|
||||
TIMEOUT = 15
|
||||
|
||||
TEST_USER = "apitest_user"
|
||||
TEST_PASS = "123456"
|
||||
|
||||
session = requests.Session()
|
||||
session.headers.update({
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
|
||||
|
||||
def generate_receipt(action: str, payload: str) -> dict:
|
||||
ts = int(time.time())
|
||||
nonce = os.urandom(4).hex()
|
||||
payload_digest = hashlib.sha256(payload.encode()).hexdigest()[:16]
|
||||
data = {
|
||||
"action": action,
|
||||
"payload": payload_digest,
|
||||
"ts": ts,
|
||||
"nonce": nonce,
|
||||
}
|
||||
receipt = base64.b64encode(json.dumps(data).encode()).decode()
|
||||
sig = hmac.new(
|
||||
RECEIPT_SECRET.encode(),
|
||||
receipt.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return {"receipt": receipt, "sig": sig}
|
||||
|
||||
|
||||
def log_test(name: str, success: bool, detail: str = ""):
|
||||
status = "✅ PASS" if success else "❌ FAIL"
|
||||
msg = f"{status} | {name}"
|
||||
if detail:
|
||||
msg += f" — {detail}"
|
||||
print(msg)
|
||||
|
||||
|
||||
def api_post(path: str, data: dict = None, token: str = None) -> dict:
|
||||
headers = {}
|
||||
if token:
|
||||
headers["token"] = token
|
||||
resp = session.post(f"{BASE_URL}{path}", data=data, headers=headers, timeout=TIMEOUT)
|
||||
return resp.json()
|
||||
|
||||
|
||||
def api_get(path: str, params: dict = None, token: str = None) -> dict:
|
||||
headers = {}
|
||||
if token:
|
||||
headers["token"] = token
|
||||
resp = session.get(f"{BASE_URL}{path}", params=params, headers=headers, timeout=TIMEOUT)
|
||||
return resp.json()
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("闲言APP — 账号注销API测试")
|
||||
print("=" * 60)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
# Step 1: 登录获取Token
|
||||
print("\n--- Step 1: 登录测试账号 ---")
|
||||
resp = api_post("/api/user_security/login", {
|
||||
"account": TEST_USER,
|
||||
"password": TEST_PASS,
|
||||
})
|
||||
login_ok = resp.get("code") == 1
|
||||
log_test("登录", login_ok, resp.get("msg", ""))
|
||||
if not login_ok:
|
||||
print("登录失败,终止测试")
|
||||
return
|
||||
token = resp["data"]["userinfo"]["token"]
|
||||
user_id = resp["data"]["userinfo"]["id"]
|
||||
print(f" Token: {token[:20]}...")
|
||||
print(f" User ID: {user_id}")
|
||||
if login_ok:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Step 2: 查询注销状态(应无申请)
|
||||
print("\n--- Step 2: 查询注销状态(应无申请) ---")
|
||||
resp = api_get("/api/user_security/deletionStatus", token=token)
|
||||
status_ok = resp.get("code") == 1 and resp.get("data", {}).get("has_pending") == False
|
||||
log_test("查询注销状态(无申请)", status_ok, f"has_pending={resp.get('data', {}).get('has_pending')}")
|
||||
if status_ok:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Step 3: 申请注销(无回执,应失败)
|
||||
print("\n--- Step 3: 申请注销(无回执,应失败) ---")
|
||||
resp = api_post("/api/user_security/requestDeletion", {
|
||||
"reason": "测试注销",
|
||||
}, token=token)
|
||||
no_receipt_ok = resp.get("code") == 0
|
||||
log_test("无回执申请注销(应失败)", no_receipt_ok, resp.get("msg", ""))
|
||||
if no_receipt_ok:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Step 4: 申请注销(带回执)
|
||||
print("\n--- Step 4: 申请注销(带回执) ---")
|
||||
receipt_data = generate_receipt("delete_account", str(user_id))
|
||||
resp = api_post("/api/user_security/requestDeletion", {
|
||||
"reason": "测试注销-自动化测试",
|
||||
"receipt": receipt_data["receipt"],
|
||||
"sig": receipt_data["sig"],
|
||||
}, token=token)
|
||||
apply_ok = resp.get("code") == 1
|
||||
log_test("申请注销(带回执)", apply_ok, resp.get("msg", ""))
|
||||
if apply_ok:
|
||||
passed += 1
|
||||
data = resp.get("data", {})
|
||||
print(f" 注销记录ID: {data.get('id')}")
|
||||
print(f" 状态: {data.get('status_text')}")
|
||||
print(f" 自动注销时间: {data.get('auto_delete_time_text')}")
|
||||
print(f" 倒计时: {data.get('countdown')}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" 响应: {json.dumps(resp, ensure_ascii=False)}")
|
||||
|
||||
# Step 5: 重复申请注销(应失败)
|
||||
print("\n--- Step 5: 重复申请注销(应失败) ---")
|
||||
receipt_data2 = generate_receipt("delete_account", str(user_id))
|
||||
resp = api_post("/api/user_security/requestDeletion", {
|
||||
"reason": "重复申请",
|
||||
"receipt": receipt_data2["receipt"],
|
||||
"sig": receipt_data2["sig"],
|
||||
}, token=token)
|
||||
dup_ok = resp.get("code") == 0
|
||||
log_test("重复申请注销(应失败)", dup_ok, resp.get("msg", ""))
|
||||
if dup_ok:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Step 6: 查询注销状态(应有申请)
|
||||
print("\n--- Step 6: 查询注销状态(应有申请) ---")
|
||||
resp = api_get("/api/user_security/deletionStatus", token=token)
|
||||
status_ok2 = resp.get("code") == 1 and resp.get("data", {}).get("has_pending") == True
|
||||
log_test("查询注销状态(有申请)", status_ok2, f"has_pending={resp.get('data', {}).get('has_pending')}, status_text={resp.get('data', {}).get('status_text')}")
|
||||
if status_ok2:
|
||||
passed += 1
|
||||
data = resp.get("data", {})
|
||||
print(f" 倒计时: {data.get('countdown')}")
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Step 7: 取消注销申请
|
||||
print("\n--- Step 7: 取消注销申请 ---")
|
||||
resp = api_post("/api/user_security/cancelDeletion", token=token)
|
||||
cancel_ok = resp.get("code") == 1
|
||||
log_test("取消注销申请", cancel_ok, resp.get("msg", ""))
|
||||
if cancel_ok:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Step 8: 再次查询注销状态(应无待审核)
|
||||
print("\n--- Step 8: 查询注销状态(取消后) ---")
|
||||
resp = api_get("/api/user_security/deletionStatus", token=token)
|
||||
status_ok3 = resp.get("code") == 1 and resp.get("data", {}).get("has_pending") == False
|
||||
log_test("查询注销状态(已取消)", status_ok3, f"has_pending={resp.get('data', {}).get('has_pending')}")
|
||||
if status_ok3:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Step 9: 取消注销(无申请时,应失败)
|
||||
print("\n--- Step 9: 取消注销(无申请时,应失败) ---")
|
||||
resp = api_post("/api/user_security/cancelDeletion", token=token)
|
||||
no_cancel_ok = resp.get("code") == 0
|
||||
log_test("无申请时取消(应失败)", no_cancel_ok, resp.get("msg", ""))
|
||||
if no_cancel_ok:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Step 10: 旧路径兼容测试
|
||||
print("\n--- Step 10: 旧路径兼容测试(/api/user/requestDeletion) ---")
|
||||
receipt_data3 = generate_receipt("delete_account", str(user_id))
|
||||
resp = api_post("/api/user/requestDeletion", {
|
||||
"reason": "旧路径测试",
|
||||
"receipt": receipt_data3["receipt"],
|
||||
"sig": receipt_data3["sig"],
|
||||
}, token=token)
|
||||
old_path_ok = resp.get("code") == 1
|
||||
log_test("旧路径申请注销", old_path_ok, resp.get("msg", ""))
|
||||
if old_path_ok:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# 清理: 取消刚才的申请
|
||||
if old_path_ok:
|
||||
print("\n--- 清理: 取消测试申请 ---")
|
||||
resp = api_post("/api/user_security/cancelDeletion", token=token)
|
||||
log_test("清理-取消申请", resp.get("code") == 1, resp.get("msg", ""))
|
||||
|
||||
# 结果汇总
|
||||
print("\n" + "=" * 60)
|
||||
total = passed + failed
|
||||
print(f"测试结果: {passed}/{total} 通过,通过率{passed * 100 // total}%")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,9 +1,9 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 传输聊天页面
|
||||
// 创建时间: 2026-05-09
|
||||
// 更新时间: 2026-05-11
|
||||
// 作用: 与对端设备的聊天界面 — 消息气泡/文件传输/进度显示
|
||||
// 上次更新: 聊天页面顶部增加网络状态栏(延迟/健康度/信号强度)
|
||||
// 更新时间: 2026-05-12
|
||||
// 作用: 与对端设备的聊天界面 — 消息列表/输入发送/文件传输/网络状态
|
||||
// 上次更新: v4.2.1 拆分气泡/输入栏/网络状态栏为独立组件
|
||||
// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
@@ -11,7 +11,6 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
@@ -19,10 +18,11 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:xianyan/core/theme/app_theme.dart';
|
||||
import 'package:xianyan/core/theme/app_spacing.dart';
|
||||
import 'package:xianyan/core/theme/app_typography.dart';
|
||||
import 'package:xianyan/core/theme/app_radius.dart';
|
||||
import 'package:xianyan/features/file_transfer/models/models.dart';
|
||||
import 'package:xianyan/features/file_transfer/providers/providers.dart';
|
||||
import 'package:xianyan/features/file_transfer/services/voice_message_service.dart';
|
||||
import 'package:xianyan/features/file_transfer/presentation/pages/transfer_chat_bubbles.dart';
|
||||
import 'package:xianyan/features/file_transfer/presentation/pages/transfer_chat_input.dart';
|
||||
import 'package:xianyan/features/file_transfer/presentation/widgets/voice_recording_overlay.dart';
|
||||
|
||||
class TransferChatPage extends ConsumerStatefulWidget {
|
||||
@@ -213,7 +213,7 @@ class _TransferChatPageState extends ConsumerState<TransferChatPage> {
|
||||
style: AppTypography.headline.copyWith(color: ext.textPrimary),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
_buildStatusDot(widget.peerDevice.isOnline),
|
||||
ChatStatusDot(isOnline: widget.peerDevice.isOnline),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
@@ -235,9 +235,24 @@ class _TransferChatPageState extends ConsumerState<TransferChatPage> {
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
_buildNetworkStatusBar(ext),
|
||||
Expanded(child: _buildMessageList(ext, messages)),
|
||||
_buildInputBar(ext),
|
||||
ChatNetworkStatusBar(
|
||||
signalStrength: _signalStrength,
|
||||
latencyMs: _latencyMs,
|
||||
connectionType: _connectionType,
|
||||
healthLabel: _healthLabel,
|
||||
healthColor: _healthColor,
|
||||
),
|
||||
Expanded(child: _buildMessageList(messages)),
|
||||
ChatInputBar(
|
||||
controller: _inputController,
|
||||
focusNode: _focusNode,
|
||||
isRecording: _isRecording,
|
||||
hasText: _inputController.text.trim().isNotEmpty,
|
||||
onSend: _sendMessage,
|
||||
onVoiceStart: _startVoiceRecording,
|
||||
onVoiceEnd: _stopVoiceRecording,
|
||||
onAttachment: _showAttachmentSheet,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isRecording)
|
||||
@@ -253,166 +268,13 @@ class _TransferChatPageState extends ConsumerState<TransferChatPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNetworkStatusBar(AppThemeExtension ext) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.bgSecondary.withValues(alpha: 0.6),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: ext.textHint.withValues(alpha: 0.08),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildSignalBars(ext),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
_buildLatencyChip(ext),
|
||||
const Spacer(),
|
||||
_buildHealthBadge(ext),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSignalBars(AppThemeExtension ext) {
|
||||
final bars = <Widget>[];
|
||||
const barCount = 4;
|
||||
for (int i = 0; i < barCount; i++) {
|
||||
final threshold = (i + 1) / barCount;
|
||||
final isActive = _signalStrength >= threshold;
|
||||
final height = 6.0 + i * 3.0;
|
||||
bars.add(
|
||||
Container(
|
||||
width: 3,
|
||||
height: height,
|
||||
margin: const EdgeInsets.only(right: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? _healthColor
|
||||
: ext.textHint.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: bars,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLatencyChip(AppThemeExtension ext) {
|
||||
final latencyText = _latencyMs != null ? '${_latencyMs}ms' : '--ms';
|
||||
final typeIcon = _connectionType == 'lan'
|
||||
? CupertinoIcons.antenna_radiowaves_left_right
|
||||
: _connectionType == 'signaling'
|
||||
? CupertinoIcons.cloud
|
||||
: CupertinoIcons.antenna_radiowaves_left_right;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(typeIcon, size: 12, color: _healthColor),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
latencyText,
|
||||
style: AppTypography.caption2.copyWith(
|
||||
color: _healthColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHealthBadge(AppThemeExtension ext) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _healthColor.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 5,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _healthColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_healthLabel,
|
||||
style: AppTypography.caption2.copyWith(
|
||||
color: _healthColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusDot(bool isOnline) {
|
||||
return Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isOnline
|
||||
? CupertinoColors.systemGreen
|
||||
: CupertinoColors.systemGrey,
|
||||
),
|
||||
)
|
||||
.animate(onPlay: (c) => c.repeat())
|
||||
.shimmer(
|
||||
duration: 2.seconds,
|
||||
color: isOnline
|
||||
? CupertinoColors.systemGreen.withValues(alpha: 0.3)
|
||||
: Colors.transparent,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageList(
|
||||
AppThemeExtension ext,
|
||||
List<TransferMessage> messages,
|
||||
) {
|
||||
Widget _buildMessageList(List<TransferMessage> messages) {
|
||||
if (messages.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
widget.peerDevice.displayEmoji,
|
||||
style: const TextStyle(fontSize: 64),
|
||||
)
|
||||
.animate(onPlay: (c) => c.repeat())
|
||||
.shimmer(
|
||||
duration: 3.seconds,
|
||||
color: ext.accent.withValues(alpha: 0.2),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
'与 ${widget.peerDevice.alias} 的传输通道',
|
||||
style: AppTypography.body.copyWith(color: ext.textSecondary),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
'${widget.peerDevice.displayInfo} · ${widget.peerDevice.displayTransport}',
|
||||
style: AppTypography.footnote.copyWith(color: ext.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
return ChatEmptyState(
|
||||
deviceEmoji: widget.peerDevice.displayEmoji,
|
||||
deviceAlias: widget.peerDevice.alias,
|
||||
deviceInfo: widget.peerDevice.displayInfo,
|
||||
deviceTransport: widget.peerDevice.displayTransport,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -426,673 +288,29 @@ class _TransferChatPageState extends ConsumerState<TransferChatPage> {
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final msg = messages[index];
|
||||
return _buildMessageBubble(ext, msg);
|
||||
final taskId = msg.transferTaskId;
|
||||
return ChatMessageBubble(
|
||||
msg: msg,
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
callbacks: ChatTransferCallbacks(
|
||||
onPause: taskId != null
|
||||
? () => ref.read(transferProvider.notifier).pauseTask(taskId)
|
||||
: null,
|
||||
onResume: taskId != null
|
||||
? () => ref.read(transferProvider.notifier).resumeTask(taskId)
|
||||
: null,
|
||||
onRetry: taskId != null
|
||||
? () => ref.read(transferProvider.notifier).retryTask(taskId)
|
||||
: null,
|
||||
onCancel: taskId != null
|
||||
? () => ref.read(transferProvider.notifier).cancelTask(taskId)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(AppThemeExtension ext, TransferMessage msg) {
|
||||
if (msg.isSystemMessage) {
|
||||
return _buildSystemMessage(ext, msg);
|
||||
}
|
||||
|
||||
final isRemote = msg.isRemote;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: Align(
|
||||
alignment: isRemote ? Alignment.centerLeft : Alignment.centerRight,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: isRemote ? ext.bgCard : ext.accent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(AppRadius.lg),
|
||||
topRight: const Radius.circular(AppRadius.lg),
|
||||
bottomLeft: isRemote
|
||||
? const Radius.circular(AppRadius.xs)
|
||||
: const Radius.circular(AppRadius.lg),
|
||||
bottomRight: isRemote
|
||||
? const Radius.circular(AppRadius.lg)
|
||||
: const Radius.circular(AppRadius.xs),
|
||||
),
|
||||
),
|
||||
child: _buildMessageContent(ext, msg, isRemote),
|
||||
),
|
||||
),
|
||||
),
|
||||
).animate().fadeIn(duration: 200.milliseconds);
|
||||
}
|
||||
|
||||
Widget _buildMessageContent(
|
||||
AppThemeExtension ext,
|
||||
TransferMessage msg,
|
||||
bool isRemote,
|
||||
) {
|
||||
switch (msg.type) {
|
||||
case TransferMessageType.text:
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isRemote && msg.deviceAlias != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||||
child: Text(
|
||||
'${msg.deviceEmoji ?? ""} ${msg.deviceAlias}',
|
||||
style: AppTypography.caption2.copyWith(
|
||||
color: ext.textHint,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
SelectableText(
|
||||
msg.content,
|
||||
style: AppTypography.body.copyWith(color: ext.textPrimary),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
case TransferMessageType.image:
|
||||
return _buildImageBubble(ext, msg, isRemote);
|
||||
|
||||
case TransferMessageType.video:
|
||||
return _buildVideoBubble(ext, msg, isRemote);
|
||||
|
||||
case TransferMessageType.file:
|
||||
return _buildFileBubble(ext, msg, isRemote, '📁');
|
||||
|
||||
case TransferMessageType.progress:
|
||||
return _buildProgressBubble(ext, msg);
|
||||
|
||||
case TransferMessageType.pairingRequest:
|
||||
case TransferMessageType.pairingAccept:
|
||||
case TransferMessageType.pairingReject:
|
||||
return _buildPairingBubble(ext, msg);
|
||||
|
||||
default:
|
||||
return SelectableText(
|
||||
msg.displayContent,
|
||||
style: AppTypography.body.copyWith(color: ext.textPrimary),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFileBubble(
|
||||
AppThemeExtension ext,
|
||||
TransferMessage msg,
|
||||
bool isRemote,
|
||||
String emoji,
|
||||
) {
|
||||
final status = msg.transferStatus ?? TransferTaskStatus.waiting;
|
||||
final progress = msg.progress ?? 0.0;
|
||||
final isPaused = status == TransferTaskStatus.paused;
|
||||
final isFailed =
|
||||
status == TransferTaskStatus.failed ||
|
||||
status == TransferTaskStatus.rejected;
|
||||
final taskId = msg.transferTaskId;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isRemote && msg.deviceAlias != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||||
child: Text(
|
||||
'${msg.deviceEmoji ?? ""} ${msg.deviceAlias}',
|
||||
style: AppTypography.caption2.copyWith(
|
||||
color: ext.textHint,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: ext.bgSecondary,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 20)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
msg.fileName ?? msg.content,
|
||||
style: AppTypography.subhead.copyWith(
|
||||
color: ext.textPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${msg.fileSizeText} · ${status.label}',
|
||||
style: AppTypography.caption2.copyWith(color: ext.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildStatusIcon(ext, status),
|
||||
],
|
||||
),
|
||||
if (status.isActive || isPaused) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
ClipRRect(
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
child: LinearProgressIndicator(
|
||||
value: isPaused ? progress : progress,
|
||||
backgroundColor: ext.bgSecondary,
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
isPaused ? CupertinoColors.systemOrange : ext.accent,
|
||||
),
|
||||
minHeight: 4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
isPaused
|
||||
? '⏸ 已暂停 ${(progress * 100).toStringAsFixed(1)}%'
|
||||
: '${(progress * 100).toStringAsFixed(0)}%',
|
||||
style: AppTypography.caption2.copyWith(
|
||||
color: isPaused ? CupertinoColors.systemOrange : ext.textHint,
|
||||
),
|
||||
),
|
||||
if (taskId != null)
|
||||
_buildChatTransferControls(ext, taskId, status),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (isFailed) ...[
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
msg.errorMessage ?? '传输失败',
|
||||
style: AppTypography.caption2.copyWith(
|
||||
color: CupertinoColors.systemRed,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (taskId != null)
|
||||
_buildChatTransferControls(ext, taskId, status),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatTransferControls(
|
||||
AppThemeExtension ext,
|
||||
String taskId,
|
||||
TransferTaskStatus status,
|
||||
) {
|
||||
final notifier = ref.read(transferProvider.notifier);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (status.isActive)
|
||||
_buildChatControlBtn(
|
||||
ext: ext,
|
||||
onTap: () => notifier.pauseTask(taskId),
|
||||
label: '⏸️',
|
||||
bgColor: ext.bgSecondary,
|
||||
textColor: ext.textSecondary,
|
||||
),
|
||||
if (status == TransferTaskStatus.paused) ...[
|
||||
_buildChatControlBtn(
|
||||
ext: ext,
|
||||
onTap: () => notifier.resumeTask(taskId),
|
||||
label: '▶️',
|
||||
bgColor: ext.accent.withValues(alpha: 0.1),
|
||||
textColor: ext.accent,
|
||||
),
|
||||
_buildChatControlBtn(
|
||||
ext: ext,
|
||||
onTap: () => notifier.retryTask(taskId),
|
||||
label: '🔄',
|
||||
bgColor: CupertinoColors.activeGreen.withOpacity(0.1),
|
||||
textColor: CupertinoColors.activeGreen,
|
||||
marginLeft: AppSpacing.xs,
|
||||
),
|
||||
],
|
||||
if (status == TransferTaskStatus.failed ||
|
||||
status == TransferTaskStatus.rejected)
|
||||
_buildChatControlBtn(
|
||||
ext: ext,
|
||||
onTap: () => notifier.retryTask(taskId),
|
||||
label: '🔄',
|
||||
bgColor: ext.accent.withValues(alpha: 0.1),
|
||||
textColor: ext.accent,
|
||||
),
|
||||
if (status.isActive || status == TransferTaskStatus.paused) ...[
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
_buildChatControlBtn(
|
||||
ext: ext,
|
||||
onTap: () => notifier.cancelTask(taskId),
|
||||
label: '🚫',
|
||||
bgColor: CupertinoColors.systemRed.withOpacity(0.1),
|
||||
textColor: CupertinoColors.systemRed,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatControlBtn({
|
||||
required AppThemeExtension ext,
|
||||
required VoidCallback onTap,
|
||||
required String label,
|
||||
required Color bgColor,
|
||||
required Color textColor,
|
||||
double marginLeft = 0,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: marginLeft),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTypography.caption2.copyWith(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageBubble(
|
||||
AppThemeExtension ext,
|
||||
TransferMessage msg,
|
||||
bool isRemote,
|
||||
) {
|
||||
final imagePath = msg.filePath ?? msg.thumbnailPath;
|
||||
final status = msg.transferStatus ?? TransferTaskStatus.waiting;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isRemote && msg.deviceAlias != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||||
child: Text(
|
||||
'${msg.deviceEmoji ?? ""} ${msg.deviceAlias}',
|
||||
style: AppTypography.caption2.copyWith(
|
||||
color: ext.textHint,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (imagePath != null && File(imagePath).existsSync())
|
||||
ClipRRect(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.6,
|
||||
maxHeight: 300,
|
||||
),
|
||||
child: Image.file(
|
||||
File(imagePath),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
_buildFileBubble(ext, msg, isRemote, '🖼️'),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildFileBubble(ext, msg, isRemote, '🖼️'),
|
||||
if (status.isActive) ...[
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
ClipRRect(
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
child: LinearProgressIndicator(
|
||||
value: msg.progress ?? 0.0,
|
||||
backgroundColor: ext.bgSecondary,
|
||||
valueColor: AlwaysStoppedAnimation(ext.accent),
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${msg.fileName ?? "图片"} · ${msg.fileSizeText}',
|
||||
style: AppTypography.caption2.copyWith(color: ext.textHint),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoBubble(
|
||||
AppThemeExtension ext,
|
||||
TransferMessage msg,
|
||||
bool isRemote,
|
||||
) {
|
||||
final status = msg.transferStatus ?? TransferTaskStatus.waiting;
|
||||
final thumbnailPath = msg.thumbnailPath ?? msg.filePath;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isRemote && msg.deviceAlias != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||||
child: Text(
|
||||
'${msg.deviceEmoji ?? ""} ${msg.deviceAlias}',
|
||||
style: AppTypography.caption2.copyWith(
|
||||
color: ext.textHint,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (thumbnailPath != null && File(thumbnailPath).existsSync())
|
||||
ClipRRect(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.6,
|
||||
maxHeight: 200,
|
||||
),
|
||||
child: Image.file(
|
||||
File(thumbnailPath),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
_buildFileBubble(ext, msg, isRemote, '🎬'),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: 200,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: ext.bgSecondary,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: const Center(
|
||||
child: Text('🎬', style: TextStyle(fontSize: 36)),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color.fromRGBO(0, 0, 0, 0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
CupertinoIcons.play_fill,
|
||||
color: CupertinoColors.white,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (status.isActive) ...[
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
ClipRRect(
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
child: LinearProgressIndicator(
|
||||
value: msg.progress ?? 0.0,
|
||||
backgroundColor: ext.bgSecondary,
|
||||
valueColor: AlwaysStoppedAnimation(ext.accent),
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${msg.fileName ?? "视频"} · ${msg.fileSizeText}',
|
||||
style: AppTypography.caption2.copyWith(color: ext.textHint),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressBubble(AppThemeExtension ext, TransferMessage msg) {
|
||||
final progress = msg.progress ?? 0.0;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
msg.displayContent,
|
||||
style: AppTypography.subhead.copyWith(color: ext.textPrimary),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
ClipRRect(
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: ext.bgSecondary,
|
||||
valueColor: AlwaysStoppedAnimation(ext.accent),
|
||||
minHeight: 4,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPairingBubble(AppThemeExtension ext, TransferMessage msg) {
|
||||
final isSuccess = msg.type == TransferMessageType.pairingAccept;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSuccess
|
||||
? CupertinoColors.systemGreen.withValues(alpha: 0.1)
|
||||
: CupertinoColors.systemRed.withValues(alpha: 0.1),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isSuccess
|
||||
? CupertinoIcons.checkmark_circle
|
||||
: CupertinoIcons.xmark_circle,
|
||||
color: isSuccess
|
||||
? CupertinoColors.systemGreen
|
||||
: CupertinoColors.systemRed,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Flexible(
|
||||
child: Text(
|
||||
msg.displayContent,
|
||||
style: AppTypography.footnote.copyWith(
|
||||
color: isSuccess
|
||||
? CupertinoColors.systemGreen.darkColor
|
||||
: CupertinoColors.systemRed.darkColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSystemMessage(AppThemeExtension ext, TransferMessage msg) {
|
||||
return Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
vertical: AppSpacing.xs,
|
||||
horizontal: AppSpacing.md,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.bgSecondary,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Text(
|
||||
msg.content,
|
||||
style: AppTypography.caption2.copyWith(color: ext.textHint),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon(AppThemeExtension ext, TransferTaskStatus status) {
|
||||
switch (status) {
|
||||
case TransferTaskStatus.completed:
|
||||
return const Icon(
|
||||
CupertinoIcons.checkmark_circle_fill,
|
||||
color: CupertinoColors.systemGreen,
|
||||
size: 20,
|
||||
);
|
||||
case TransferTaskStatus.failed:
|
||||
case TransferTaskStatus.rejected:
|
||||
return const Icon(
|
||||
CupertinoIcons.xmark_circle_fill,
|
||||
color: CupertinoColors.systemRed,
|
||||
size: 20,
|
||||
);
|
||||
case TransferTaskStatus.transferring:
|
||||
return SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CupertinoActivityIndicator(color: ext.accent),
|
||||
);
|
||||
case TransferTaskStatus.paused:
|
||||
return Icon(CupertinoIcons.pause_circle, color: ext.textHint, size: 20);
|
||||
default:
|
||||
return Icon(CupertinoIcons.clock, color: ext.textHint, size: 20);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 输入栏
|
||||
// ============================================================
|
||||
|
||||
Widget _buildInputBar(AppThemeExtension ext) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.bgCard,
|
||||
border: Border(top: BorderSide(color: ext.bgElevated, width: 0.5)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => _showAttachmentSheet(ext),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: ext.bgSecondary,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Icon(CupertinoIcons.plus, color: ext.accent, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: CupertinoTextField(
|
||||
controller: _inputController,
|
||||
focusNode: _focusNode,
|
||||
placeholder: '发送消息或文件...',
|
||||
placeholderStyle: AppTypography.body.copyWith(
|
||||
color: ext.textHint,
|
||||
),
|
||||
style: AppTypography.body.copyWith(color: ext.textPrimary),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.bgSecondary,
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
maxLines: 4,
|
||||
minLines: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
if (_inputController.text.trim().isEmpty)
|
||||
GestureDetector(
|
||||
onLongPressStart: (_) => _startVoiceRecording(),
|
||||
onLongPressEnd: (_) => _stopVoiceRecording(),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: _isRecording
|
||||
? CupertinoColors.systemRed
|
||||
: ext.accent,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Icon(
|
||||
_isRecording ? CupertinoIcons.mic_fill : CupertinoIcons.mic,
|
||||
color: CupertinoColors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: _sendMessage,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: ext.accent,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: const Icon(
|
||||
CupertinoIcons.arrow_up,
|
||||
color: CupertinoColors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _inputController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
@@ -1142,7 +360,7 @@ class _TransferChatPageState extends ConsumerState<TransferChatPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showAttachmentSheet(AppThemeExtension ext) {
|
||||
void _showAttachmentSheet() {
|
||||
showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoActionSheet(
|
||||
|
||||
Reference in New Issue
Block a user