chore: 迁移依赖、移除sqlite3_flutter_libs并新增功能

1. 替换hive_flutter为hive_ce_flutter依赖
2. 从各平台插件列表移除sqlite3_flutter_libs
3. 重构API请求体格式,优化历史记录去重逻辑
4. 新增CTC笔记相关功能:桌面小部件、模板模型、本地存储
5. 新增表单收集服务和后台管理接口
6. 优化缓存配置、多语言文案和UI细节
7. 重构首页状态监听组件
This commit is contained in:
Developer
2026-06-15 10:04:52 +08:00
parent af14ed4121
commit ad00967c68
90 changed files with 4728 additions and 1028 deletions

View File

@@ -0,0 +1,185 @@
<?php
namespace app\admin\controller;
use app\common\controller\Backend;
/**
* 表单收集管理
* @icon fa fa-wpforms
* @time 2026-06-15
* @description 管理表单收集记录,查看/导出/标记处理状态
*/
class FormCollect extends Backend
{
protected $model = null;
protected $searchFields = 'id,email,source,title';
public function _initialize()
{
parent::_initialize();
$this->model = new \app\admin\model\FormCollect;
}
/**
* @name 安装/初始化数据表
* @desc 创建tool_form_collect表
*/
public function install()
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS `tool_form_collect` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`email` varchar(256) NOT NULL DEFAULT '' COMMENT '邮箱地址',
`source` varchar(64) NOT NULL DEFAULT '' COMMENT '来源标识',
`title` varchar(200) NOT NULL DEFAULT '' COMMENT '表单标题(自动生成)',
`extra_json` text COMMENT '扩展字段JSON',
`uid` varchar(128) NOT NULL DEFAULT '' COMMENT '用户ID',
`device_id` varchar(128) NOT NULL DEFAULT '' COMMENT '设备ID',
`ip` varchar(64) NOT NULL DEFAULT '' COMMENT '提交IP',
`status` enum('pending','processed','invalid') NOT NULL DEFAULT 'pending' COMMENT '状态',
`admin_remark` varchar(500) NOT NULL DEFAULT '' COMMENT '管理员备注',
`created_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
`updated_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_email` (`email`),
KEY `idx_source` (`source`),
KEY `idx_status` (`status`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='表单收集记录';
SQL;
try {
\think\Db::execute($sql);
$this->success('安装成功');
} catch (\Exception $e) {
$this->error('安装失败: ' . $e->getMessage());
}
}
/**
* @name 导出CSV
* @desc 导出表单收集记录为CSV文件
*/
public function export()
{
$ids = $this->request->param('ids', '');
$source = $this->request->param('source', '');
$status = $this->request->param('status', '');
$query = \think\Db::name('form_collect');
if (!empty($ids)) {
$query->where('id', 'in', $ids);
}
if (!empty($source)) {
$query->where('source', $source);
}
if (!empty($status)) {
$query->where('status', $status);
}
$list = $query->order('id', 'desc')->select();
$filename = 'form_collect_' . date('Ymd_His') . '.csv';
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename=' . $filename);
$output = fopen('php://output', 'w');
// BOM for Excel UTF-8
fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
// 表头
fputcsv($output, ['ID', '邮箱', '来源', '标题', '用户ID', '设备ID', 'IP', '状态', '管理员备注', '提交时间']);
foreach ($list as $row) {
fputcsv($output, [
$row['id'],
$row['email'],
$row['source'],
$row['title'],
$row['uid'],
$row['device_id'],
$row['ip'],
$row['status'],
$row['admin_remark'],
date('Y-m-d H:i:s', $row['created_at']),
]);
}
fclose($output);
exit;
}
/**
* @name 标记单条已处理
* @desc 将指定记录标记为已处理
*/
public function mark_processed()
{
$ids = $this->request->param('ids', '');
if (empty($ids)) {
$this->error('请选择记录');
}
$idArr = is_array($ids) ? $ids : explode(',', $ids);
$updated = \think\Db::name('form_collect')
->where('id', 'in', $idArr)
->update(['status' => 'processed', 'updated_at' => time()]);
$this->success("已处理 {$updated} 条记录");
}
/**
* @name 批量标记已处理
* @desc 将选中记录标记为已处理
*/
public function batch_process()
{
$ids = $this->request->param('ids', '');
if (empty($ids)) {
$this->error('请选择记录');
}
$idArr = explode(',', $ids);
$updated = \think\Db::name('form_collect')
->where('id', 'in', $idArr)
->update(['status' => 'processed', 'updated_at' => time()]);
$this->success("已处理 {$updated} 条记录");
}
/**
* @name 统计数据
* @desc 返回各来源的提交统计
*/
public function stats()
{
$total = \think\Db::name('form_collect')->count();
$pending = \think\Db::name('form_collect')->where('status', 'pending')->count();
$processed = \think\Db::name('form_collect')->where('status', 'processed')->count();
$invalid = \think\Db::name('form_collect')->where('status', 'invalid')->count();
// 按来源统计
$bySource = \think\Db::name('form_collect')
->field('source, COUNT(*) as cnt')
->group('source')
->order('cnt', 'desc')
->select();
// 今日新增
$todayStart = strtotime(date('Y-m-d'));
$todayCount = \think\Db::name('form_collect')
->where('created_at', '>=', $todayStart)
->count();
$this->success('ok', [
'total' => $total,
'pending' => $pending,
'processed' => $processed,
'invalid' => $invalid,
'today' => $todayCount,
'by_source' => $bySource,
]);
}
}

View File

@@ -0,0 +1,22 @@
<?php
return [
'Id' => 'ID',
'Email' => '邮箱地址',
'Source' => '来源',
'Title' => '标题',
'Extra_json' => '扩展信息',
'Uid' => '用户ID',
'Device_id' => '设备ID',
'Ip' => 'IP地址',
'Status' => '状态',
'Admin_remark' => '管理员备注',
'Created_at' => '提交时间',
'Updated_at' => '更新时间',
'Source app_gp_beta' => 'Google Play内测',
'Source register_subscribe' => '闲言邮箱订阅',
'Source beta_questionnaire' => 'Beta问卷',
'Status pending' => '待处理',
'Status processed' => '已处理',
'Status invalid' => '无效',
];

View File

@@ -0,0 +1,45 @@
<?php
namespace app\admin\model;
use think\Model;
/**
* 表单收集模型
* @time 2026-06-15
* @description 管理表单收集记录数据
*/
class FormCollect extends Model
{
protected $name = 'form_collect';
protected $autoWriteTimestamp = 'int';
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
protected $deleteTime = false;
/**
* 状态列表
*/
public function getStatusList()
{
return [
'pending' => '待处理',
'processed' => '已处理',
'invalid' => '无效',
];
}
/**
* 来源列表
*/
public function getSourceList()
{
return [
'app_gp_beta' => 'Google Play内测',
'register_subscribe' => '闲言邮箱订阅',
'beta_questionnaire' => 'Beta问卷',
];
}
}

View File

@@ -0,0 +1,58 @@
<form id="add-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Email')}:</label>
<div class="col-xs-12 col-sm-8">
<input id="c-email" data-rule="required" class="form-control" name="row[email]" type="email" placeholder="邮箱地址">
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Source')}:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-source" class="form-control" name="row[source]">
<option value="app_gp_beta">Google Play内测</option>
<option value="register_subscribe">闲言邮箱订阅</option>
<option value="beta_questionnaire">Beta问卷</option>
</select>
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Title')}:</label>
<div class="col-xs-12 col-sm-8">
<input id="c-title" class="form-control" name="row[title]" type="text" placeholder="留空则根据来源自动生成">
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Extra_json')}:</label>
<div class="col-xs-12 col-sm-8">
<textarea id="c-extra_json" class="form-control" name="row[extra_json]" rows="3" placeholder='扩展字段JSON如: {"device":"iPhone"}'></textarea>
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Uid')}:</label>
<div class="col-xs-12 col-sm-8">
<input id="c-uid" class="form-control" name="row[uid]" type="text" placeholder="用户ID">
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Status')}:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-status" class="form-control" name="row[status]">
<option value="pending">待处理</option>
<option value="processed">已处理</option>
<option value="invalid">无效</option>
</select>
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Admin_remark')}:</label>
<div class="col-xs-12 col-sm-8">
<input id="c-admin_remark" class="form-control" name="row[admin_remark]" type="text" placeholder="管理员备注">
</div>
</div>
<div class="form-group layer-footer">
<label class="control-label col-xs-12 col-sm-2"></label>
<div class="col-xs-12 col-sm-8">
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
</div>
</div>
</form>

View File

@@ -0,0 +1,56 @@
<form id="edit-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Email')}:</label>
<div class="col-xs-12 col-sm-8">
<input id="c-email" data-rule="required" class="form-control" name="row[email]" type="email" value="{$row.email|htmlentities}">
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Source')}:</label>
<div class="col-xs-12 col-sm-8">
<input class="form-control" type="text" value="{$row.source}" disabled style="background:#f5f5f5">
<input type="hidden" name="row[source]" value="{$row.source}">
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Title')}:</label>
<div class="col-xs-12 col-sm-8">
<input class="form-control" type="text" value="{$row.title|htmlentities}" disabled style="background:#f5f5f5">
<input type="hidden" name="row[title]" value="{$row.title|htmlentities}">
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Uid')}:</label>
<div class="col-xs-12 col-sm-8">
<input class="form-control" type="text" value="{$row.uid|htmlentities}" disabled style="background:#f5f5f5">
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2">{:__('Ip')}:</label>
<div class="col-xs-12 col-sm-8">
<input class="form-control" type="text" value="{$row.ip|htmlentities}" disabled style="background:#f5f5f5">
</div>
</div>
<div class="form-group" style="border-top:1px dashed #ddd;padding-top:15px;margin-top:10px">
<label class="control-label col-xs-12 col-sm-2" style="font-weight:bold;color:#333">处理状态:</label>
<div class="col-xs-12 col-sm-8">
<select id="c-status" class="form-control" name="row[status]" style="font-size:14px">
<option value="pending" {if $row.status=='pending'}selected{/if}>⏳ 待处理</option>
<option value="processed" {if $row.status=='processed'}selected{/if}>✅ 已处理</option>
<option value="invalid" {if $row.status=='invalid'}selected{/if}>❌ 无效</option>
</select>
</div>
</div>
<div class="form-group">
<label class="control-label col-xs-12 col-sm-2" style="font-weight:bold;color:#333">管理员备注:</label>
<div class="col-xs-12 col-sm-8">
<textarea id="c-admin_remark" class="form-control" name="row[admin_remark]" rows="3" placeholder="填写处理备注,如:已发送邀请码、已回复邮件等">{$row.admin_remark|htmlentities}</textarea>
</div>
</div>
<div class="form-group layer-footer">
<label class="control-label col-xs-12 col-sm-2"></label>
<div class="col-xs-12 col-sm-8">
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
</div>
</div>
</form>

View File

@@ -0,0 +1,25 @@
<div class="panel panel-default panel-intro">
{:build_heading()}
<div class="panel-body">
<div id="myTabContent" class="tab-content">
<div class="tab-pane fade active in" id="one">
<div class="widget-body no-padding">
<div id="toolbar" class="toolbar">
<a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}"><i class="fa fa-refresh"></i></a>
<a href="javascript:;" class="btn btn-success btn-add {:$auth->check('form_collect/add')?'':'hide'}" title="{:__('Add')}"><i class="fa fa-plus"></i> {:__('Add')}</a>
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('form_collect/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('form_collect/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
<a href="javascript:;" class="btn btn-success btn-batch-process btn-disabled disabled {:$auth->check('form_collect/batch_process')?'':'hide'}" title="批量标记已处理"><i class="fa fa-check-circle"></i> 批量处理</a>
<a href="javascript:;" class="btn btn-info btn-export {:$auth->check('form_collect/export')?'':'hide'}" title="导出CSV"><i class="fa fa-download"></i> 导出</a>
<a href="javascript:;" class="btn btn-warning btn-install {:$auth->check('form_collect/install')?'':'hide'}" title="安装数据表"><i class="fa fa-database"></i> 安装</a>
</div>
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
data-operate-edit="{:$auth->check('form_collect/edit')}"
data-operate-del="{:$auth->check('form_collect/del')}"
width="100%">
</table>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,323 @@
<?php
namespace app\api\controller;
use app\common\controller\Api;
use think\Db;
use think\Cache;
/**
* 表单收集 — 服务端API
* 创建时间: 2026-06-15
* 更新时间: 2026-06-15
* 作用: 收集用户表单信息(邮箱等),支持多来源页面,保留扩展
* 上次更新: v11.4.0 初始版本
* 安全: 频率限制 + 邮箱验证 + XSS过滤
*/
class FormCollect extends Api
{
protected $noNeedLogin = ['submit', 'list', 'install'];
protected $noNeedRight = ['*'];
// 频率限制每IP每分钟最多30次
private $rateLimitMax = 30;
private $rateLimitWindow = 60;
// 允许的来源标识
private $allowedSources = [
'app_gp_beta', // app.html Google Play内测
'register_subscribe', // 注册页面订阅闲言邮箱
'beta_questionnaire', // Beta页面问卷
];
public function _initialize()
{
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Device-Id, Token');
if ($this->request->method() === 'OPTIONS') {
http_response_code(204);
exit;
}
parent::_initialize();
$this->checkRateLimit();
}
// ─── 频率限制 ─────────────────────────────────────────
private function checkRateLimit()
{
$ip = $this->request->ip();
$key = 'rate_limit_fc_' . md5($ip);
$now = time();
$requests = Cache::get($key, []);
$requests = array_filter($requests, function ($t) use ($now) {
return ($now - $t) < $this->rateLimitWindow;
});
if (count($requests) >= $this->rateLimitMax) {
$this->error('请求过于频繁,请稍后再试', null, 429);
}
$requests[] = $now;
Cache::set($key, $requests, $this->rateLimitWindow);
}
// ─── POST /submit — 提交表单 ─────────────────────────
/**
* 提交表单信息
* POST /api/form_collect/submit
*
* 请求参数:
* - email: 邮箱地址(必填)
* - source: 来源标识(必填,如 app_gp_beta/register_subscribe/beta_questionnaire
* - title: 表单标题可选若不传则根据source自动生成
* - extra_json: 扩展字段JSON可选预留后续收集其他信息
* - uid: 用户ID可选已登录用户
* - device_id: 设备ID可选
*/
public function submit()
{
$email = $this->request->post('email', '');
$source = $this->request->post('source', '');
$title = $this->request->post('title', '');
$uid = $this->request->post('uid', '');
$deviceId = $this->request->post('device_id', '');
// extra_json从原始输入提取避免ThinkPHP对JSON请求体的类型检查报错
$extraJson = '';
$rawInput = file_get_contents('php://input');
$allParams = json_decode($rawInput, true);
if (json_last_error() === JSON_ERROR_NONE && isset($allParams['extra_json'])) {
// JSON请求体直接提取可能是对象/数组
$extraJson = (is_array($allParams['extra_json']))
? json_encode($allParams['extra_json'], JSON_UNESCAPED_UNICODE)
: $allParams['extra_json'];
} else {
// form-urlencoded请求体用parse_str提取
parse_str($rawInput, $rawParams);
if (isset($rawParams['extra_json'])) {
$extraJson = $rawParams['extra_json'];
}
}
// 参数验证
if (empty($email)) {
$this->error('邮箱地址必填', null, 400);
}
if (!$this->isValidEmail($email)) {
$this->error('请输入有效的邮箱地址', null, 400);
}
if (empty($source)) {
$this->error('来源标识必填', null, 400);
}
// 来源白名单校验必须在XSS过滤之前防止绕过
if (!in_array($source, $this->allowedSources)) {
$this->error('无效的来源标识', null, 400);
}
// 自动生成标题
if (empty($title)) {
$title = $this->generateTitle($source);
}
// XSS过滤extra_json单独处理不转义引号
$email = htmlspecialchars(trim($email), ENT_QUOTES, 'UTF-8');
$title = htmlspecialchars(trim($title), ENT_QUOTES, 'UTF-8');
$source = htmlspecialchars(trim($source), ENT_QUOTES, 'UTF-8');
$uid = htmlspecialchars(trim($uid), ENT_QUOTES, 'UTF-8');
$deviceId = htmlspecialchars(trim($deviceId), ENT_QUOTES, 'UTF-8');
// 验证extra_json在htmlspecialchars之前避免引号被转义
if (!empty($extraJson)) {
$decoded = json_decode($extraJson, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
$this->error('extra_json格式错误', null, 400);
}
// 递归过滤XSS
$decoded = $this->sanitizeArray($decoded);
$extraJson = json_encode($decoded, JSON_UNESCAPED_UNICODE);
}
// 检查重复提交(同邮箱+同来源24小时内
$duplicateKey = 'fc_dup_' . md5($email . '_' . $source);
if (Cache::get($duplicateKey)) {
$this->success('您已提交过,我们会尽快处理', [
'already_submitted' => true,
]);
}
$ip = $this->request->ip();
$now = time();
$record = [
'email' => $email,
'source' => $source,
'title' => $title,
'extra_json' => $extraJson,
'uid' => $uid,
'device_id' => $deviceId,
'ip' => htmlspecialchars($ip, ENT_QUOTES, 'UTF-8'),
'status' => 'pending',
'created_at' => $now,
'updated_at' => $now,
];
try {
Db::name('form_collect')->insert($record);
// 设置24小时防重复
Cache::set($duplicateKey, 1, 86400);
$this->success('提交成功', [
'submitted' => true,
'message' => '感谢您的提交,我们会尽快处理!',
]);
} catch (\think\exception\HttpResponseException $e) {
throw $e;
} catch (\Throwable $e) {
$this->error('提交失败: ' . $e->getMessage(), null, 500);
}
}
// ─── GET /list — 查询提交记录 ─────────────────────────
/**
* 查询表单提交记录(按来源或邮箱筛选)
* GET /api/form_collect/list
*/
public function list()
{
$source = $this->request->param('source', '');
$email = $this->request->param('email', '');
$page = max(intval($this->request->param('page', 1)), 1);
$limit = min(max(intval($this->request->param('limit', 20)), 1), 50);
$query = Db::name('form_collect');
if (!empty($source)) {
$query->where('source', $source);
}
if (!empty($email)) {
$query->where('email', $email);
}
$total = $query->count();
$list = $query->order('id', 'desc')->page($page, $limit)->select();
// 脱敏邮箱
foreach ($list as &$item) {
$item['email_masked'] = $this->maskEmail($item['email']);
}
$this->success('ok', [
'total' => $total,
'page' => $page,
'limit' => $limit,
'list' => $list,
]);
}
// ─── POST /install — 安装数据表 ────────────────────────
/**
* 安装form_collect数据库表
* POST /api/form_collect/install
*/
public function install()
{
try {
$prefix = config('database.prefix') ?: 'tool_';
$tableName = $prefix . 'form_collect';
$checkSql = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = '{$tableName}'";
$exists = Db::query($checkSql);
if (!empty($exists) && $exists[0]['COUNT(*)'] > 0) {
$this->success('ok', ['table' => $tableName, 'created' => false, 'exists' => true]);
return;
}
$sql = "CREATE TABLE `{$tableName}` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`email` varchar(256) NOT NULL DEFAULT '' COMMENT '邮箱地址',
`source` varchar(64) NOT NULL DEFAULT '' COMMENT '来源标识',
`title` varchar(200) NOT NULL DEFAULT '' COMMENT '表单标题(自动生成)',
`extra_json` text COMMENT '扩展字段JSON',
`uid` varchar(128) NOT NULL DEFAULT '' COMMENT '用户ID',
`device_id` varchar(128) NOT NULL DEFAULT '' COMMENT '设备ID',
`ip` varchar(64) NOT NULL DEFAULT '' COMMENT '提交IP',
`status` enum('pending','processed','invalid') NOT NULL DEFAULT 'pending' COMMENT '状态',
`admin_remark` varchar(500) NOT NULL DEFAULT '' COMMENT '管理员备注',
`created_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
`updated_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_email` (`email`),
KEY `idx_source` (`source`),
KEY `idx_status` (`status`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='表单收集记录'";
Db::execute($sql);
$this->success('ok', ['table' => $tableName, 'created' => true]);
} catch (\think\exception\HttpResponseException $e) {
throw $e;
} catch (\Throwable $e) {
$this->error('安装失败: ' . $e->getMessage(), null, 500);
}
}
// ─── 内部方法 ─────────────────────────────────────────
/**
* 验证邮箱格式
*/
private function isValidEmail($email)
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
/**
* 根据来源自动生成标题
*/
private function generateTitle($source)
{
$titleMap = [
'app_gp_beta' => 'Google Play内测资格申请',
'register_subscribe' => '闲言邮箱订阅',
'beta_questionnaire' => 'Beta问卷-填写Gmail',
];
return isset($titleMap[$source]) ? $titleMap[$source] : '表单提交-' . $source;
}
/**
* 邮箱脱敏
*/
private function maskEmail($email)
{
$parts = explode('@', $email);
if (count($parts) !== 2) return '***';
$name = $parts[0];
$domain = $parts[1];
if (mb_strlen($name) <= 2) {
return mb_substr($name, 0, 1) . '***@' . $domain;
}
return mb_substr($name, 0, 2) . '***@' . $domain;
}
/**
* 递归过滤数组中的XSS
*/
private function sanitizeArray($arr)
{
if (!is_array($arr)) {
return is_string($arr) ? htmlspecialchars($arr, ENT_QUOTES, 'UTF-8') : $arr;
}
foreach ($arr as $key => $val) {
$arr[$key] = $this->sanitizeArray($val);
}
return $arr;
}
}

View File

@@ -781,6 +781,13 @@ Route::rule([
'api/font_sync/install' => 'api/FontSync/install',
]);
// APP API路由 - 表单收集
Route::rule([
'api/form_collect/submit' => 'api/FormCollect/submit',
'api/form_collect/list' => 'api/FormCollect/list',
'api/form_collect/install' => 'api/FormCollect/install',
]);
// APP API路由 - 插件更新
Route::rule([
'api/plugin_update/checkOne' => 'api/PluginUpdate/checkOne',

View File

@@ -0,0 +1,344 @@
# 表单收集 API 文档
> @File: API_FORM_COLLECT_DOC.md
> @Time: 2026-06-15
> @Description: 表单收集接口文档 — 收集邮箱等信息,支持多来源页面,保留扩展
> @LastUpdate: v11.4.0 初始版本
---
## 1. 概述
表单收集模块用于统一收集用户提交的表单信息(当前主要收集邮箱),支持多来源页面自动标识,预留扩展字段供后续收集其他信息。
### 1.1 基础信息
| 项目 | 值 |
|------|-----|
| 基础URL | `https://tools.wktyl.com/api/form_collect` |
| 认证方式 | 无需登录 |
| 响应格式 | JSON |
| 编码 | UTF-8 |
### 1.2 来源标识source
不同页面提交时自动传入不同source后台自动生成对应标题
| source | 标题 | 来源页面 |
|--------|------|----------|
| app_gp_beta | Google Play内测资格申请 | app.html Google Play对话框 |
| register_subscribe | 闲言邮箱订阅 | 注册页面 |
| beta_questionnaire | Beta问卷-填写Gmail | Beta页面问卷 |
### 1.3 通用响应格式
```json
{
"code": 1,
"msg": "提示信息",
"time": "1718438400",
"data": {}
}
```
| 字段 | 说明 |
|------|------|
| code | 状态码1=成功0=失败 |
| msg | 提示信息 |
| data | 返回数据 |
---
## 2. 接口列表
| 方法 | URL | 说明 |
|------|-----|------|
| POST | /api/form_collect/submit | 提交表单 |
| GET | /api/form_collect/list | 查询提交记录 |
| POST | /api/form_collect/install | 安装数据表 |
---
## 3. 提交表单
**POST** `/api/form_collect/submit`
### 3.1 请求参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| email | string | ✅ | 邮箱地址 |
| source | string | ✅ | 来源标识app_gp_beta/register_subscribe/beta_questionnaire |
| title | string | ❌ | 表单标题不传则根据source自动生成 |
| extra_json | string | ❌ | 扩展字段JSON预留后续收集其他信息 |
| uid | string | ❌ | 用户ID已登录用户 |
| device_id | string | ❌ | 设备ID |
### 3.2 请求示例
```bash
# Google Play内测申请
curl -X POST 'https://tools.wktyl.com/api/form_collect/submit' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'email=test@gmail.com&source=app_gp_beta'
# 注册页面订阅
curl -X POST 'https://tools.wktyl.com/api/form_collect/submit' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'email=user@outlook.com&source=register_subscribe&uid=12345'
# Beta问卷带扩展字段
curl -X POST 'https://tools.wktyl.com/api/form_collect/submit' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'email=beta@gmail.com&source=beta_questionnaire&extra_json={"device":"iPhone 16","os":"iOS 19"}'
```
### 3.3 成功响应
```json
{
"code": 1,
"msg": "提交成功",
"data": {
"submitted": true,
"message": "感谢您的提交,我们会尽快处理!"
}
}
```
### 3.4 重复提交响应
同邮箱+同来源24小时内重复提交
```json
{
"code": 1,
"msg": "您已提交过,我们会尽快处理",
"data": {
"already_submitted": true
}
}
```
### 3.5 错误响应
| 场景 | code | msg |
|------|------|-----|
| 邮箱为空 | 0 | 邮箱地址必填 |
| 邮箱格式错误 | 0 | 请输入有效的邮箱地址 |
| 来源为空 | 0 | 来源标识必填 |
| extra_json格式错误 | 0 | extra_json格式错误 |
| 频率超限 | 0 | 请求过于频繁,请稍后再试 |
---
## 4. 查询提交记录
**GET** `/api/form_collect/list`
### 4.1 请求参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| source | string | ❌ | 按来源筛选 |
| email | string | ❌ | 按邮箱筛选 |
| page | int | ❌ | 页码默认1 |
| limit | int | ❌ | 每页条数默认20最大50 |
### 4.2 请求示例
```bash
# 查询Google Play内测申请
curl 'https://tools.wktyl.com/api/form_collect/list?source=app_gp_beta&page=1&limit=10'
# 按邮箱查询
curl 'https://tools.wktyl.com/api/form_collect/list?email=test@gmail.com'
```
### 4.3 成功响应
```json
{
"code": 1,
"msg": "ok",
"data": {
"total": 25,
"page": 1,
"limit": 20,
"list": [
{
"id": 1,
"email": "test@gmail.com",
"email_masked": "te***@gmail.com",
"source": "app_gp_beta",
"title": "Google Play内测资格申请",
"extra_json": "",
"uid": "",
"device_id": "",
"ip": "1.2.3.4",
"status": "pending",
"admin_remark": "",
"created_at": 1718438400,
"updated_at": 1718438400
}
]
}
}
```
---
## 5. 安装数据表
**POST** `/api/form_collect/install`
首次部署时调用,创建 `tool_form_collect` 表。
### 5.1 请求示例
```bash
curl -X POST 'https://tools.wktyl.com/api/form_collect/install'
```
### 5.2 成功响应
```json
{
"code": 1,
"msg": "ok",
"data": {
"table": "tool_form_collect",
"created": true
}
}
```
---
## 6. 数据库表结构
```sql
CREATE TABLE IF NOT EXISTS `tool_form_collect` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`email` varchar(256) NOT NULL DEFAULT '' COMMENT '邮箱地址',
`source` varchar(64) NOT NULL DEFAULT '' COMMENT '来源标识',
`title` varchar(200) NOT NULL DEFAULT '' COMMENT '表单标题(自动生成)',
`extra_json` text COMMENT '扩展字段JSON',
`uid` varchar(128) NOT NULL DEFAULT '' COMMENT '用户ID',
`device_id` varchar(128) NOT NULL DEFAULT '' COMMENT '设备ID',
`ip` varchar(64) NOT NULL DEFAULT '' COMMENT '提交IP',
`status` enum('pending','processed','invalid') NOT NULL DEFAULT 'pending' COMMENT '状态',
`admin_remark` varchar(500) NOT NULL DEFAULT '' COMMENT '管理员备注',
`created_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
`updated_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_email` (`email`),
KEY `idx_source` (`source`),
KEY `idx_status` (`status`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='表单收集记录';
```
---
## 7. 后台管理
### 7.1 管理页面
**URL**: `https://tools.wktyl.com/admin.php/form_collect`
**菜单位置**: 表单收集 → 表单收集管理
### 7.2 后台功能
| 功能 | 端点 | 说明 |
|------|------|------|
| 列表 | GET /form_collect | 查看/搜索/筛选提交记录 |
| 编辑 | POST /form_collect/edit | 修改状态、添加备注 |
| 删除 | POST /form_collect/del | 删除记录 |
| 导出 | GET /form_collect/export | 导出CSV |
| 批量处理 | POST /form_collect/batch_process | 批量标记已处理 |
| 统计 | GET /form_collect/stats | 各来源提交统计 |
| 安装 | GET /form_collect/install | 安装数据表 |
### 7.3 后台菜单注册SQL
```sql
INSERT INTO fa_auth_rule (type, pid, name, title, icon, condition, remark, ismenu, weigh, status)
SELECT 'file', 0, 'form_collect', '表单收集', 'fa fa-wpforms', '', '管理表单收集记录', 1, 0, 'normal'
WHERE NOT EXISTS (SELECT 1 FROM fa_auth_rule WHERE name = 'form_collect');
SET @parent_id = (SELECT id FROM fa_auth_rule WHERE name = 'form_collect' LIMIT 1);
INSERT IGNORE INTO fa_auth_rule (type, pid, name, title, icon, ismenu, weigh, status) VALUES
('file', @parent_id, 'form_collect/index', '查看', '', 0, 0, 'normal'),
('file', @parent_id, 'form_collect/add', '添加', '', 0, 0, 'normal'),
('file', @parent_id, 'form_collect/edit', '编辑', '', 0, 0, 'normal'),
('file', @parent_id, 'form_collect/del', '删除', '', 0, 0, 'normal'),
('file', @parent_id, 'form_collect/install', '安装', '', 0, 0, 'normal'),
('file', @parent_id, 'form_collect/export', '导出', '', 0, 0, 'normal'),
('file', @parent_id, 'form_collect/batch_process', '批量处理', '', 0, 0, 'normal'),
('file', @parent_id, 'form_collect/stats', '统计', '', 0, 0, 'normal');
```
---
## 8. 安全机制
| 机制 | 说明 |
|------|------|
| 频率限制 | 每IP每分钟最多10次请求 |
| 防重复 | 同邮箱+同来源24小时内不重复入库 |
| XSS过滤 | 所有输入字段htmlspecialchars处理 |
| 邮箱验证 | filter_var FILTER_VALIDATE_EMAIL |
| CORS | 支持跨域请求 |
---
## 9. 客户端对接
### 9.1 app.html — Google Play内测对话框
提交邮箱后调用API成功后显示提交动画不再跳转Google Forms。
```javascript
fetch('https://tools.wktyl.com/api/form_collect/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'email=' + encodeURIComponent(email) + '&source=app_gp_beta'
})
.then(res => res.json())
.then(data => {
if (data.code === 1) {
// 显示提交成功动画
}
});
```
### 9.2 注册页面 — 订阅闲言邮箱
```javascript
fetch('https://tools.wktyl.com/api/form_collect/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'email=' + encodeURIComponent(email) + '&source=register_subscribe&uid=' + uid
});
```
### 9.3 Beta页面 — 问卷填写Gmail
```javascript
fetch('https://tools.wktyl.com/api/form_collect/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'email=' + encodeURIComponent(email) + '&source=beta_questionnaire&uid=' + uid
});
```
---
## 10. 变更日志
| 版本 | 日期 | 变更 |
|------|------|------|
| v11.4.0 | 2026-06-15 | 初始版本邮箱收集、多来源标识、后台管理、CSV导出 |

View File

@@ -0,0 +1,180 @@
/**
* 表单收集后台管理JS
* 创建时间: 2026-06-15
* 更新时间: 2026-06-15
* 作用: 列表展示、添加/编辑弹窗、批量处理、导出CSV、标记已处理
* 上次更新: 初始版本
*/
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
var Controller = {
index: function () {
Table.api.init({
extend: {
index_url: 'form_collect/index' + location.search,
add_url: 'form_collect/add',
edit_url: 'form_collect/edit',
del_url: 'form_collect/del',
table: 'form_collect',
}
});
var table = $("#table");
table.bootstrapTable({
url: $.fn.bootstrapTable.defaults.extend.index_url,
pk: 'id',
sortName: 'id',
sortOrder: 'desc',
columns: [
[
{checkbox: true},
{field: 'id', title: 'ID', sortable: true},
{field: 'email', title: __('Email'), operate: 'LIKE',
formatter: function(val) {
return '<span style="font-family:monospace">' + (val || '') + '</span>';
}
},
{field: 'source', title: __('Source'),
searchList: {
'app_gp_beta': 'Google Play内测',
'register_subscribe': '闲言邮箱订阅',
'beta_questionnaire': 'Beta问卷'
},
formatter: function(val) {
var map = {
'app_gp_beta': '<span class="label label-primary">Google Play内测</span>',
'register_subscribe': '<span class="label label-info">闲言邮箱订阅</span>',
'beta_questionnaire': '<span class="label label-warning">Beta问卷</span>'
};
return map[val] || '<span class="label label-default">' + val + '</span>';
}
},
{field: 'title', title: __('Title'), operate: 'LIKE',
formatter: function(val) {
return val || '-';
}
},
{field: 'status', title: __('Status'),
searchList: {
'pending': '待处理',
'processed': '已处理',
'invalid': '无效'
},
formatter: function(val) {
var map = {
'pending': '<span class="label label-warning">⏳ 待处理</span>',
'processed': '<span class="label label-success">✅ 已处理</span>',
'invalid': '<span class="label label-default">❌ 无效</span>'
};
return map[val] || '<span class="label label-default">' + val + '</span>';
}
},
{field: 'admin_remark', title: __('Admin_remark'), operate: 'LIKE',
formatter: function(val) {
if (!val) return '<span style="color:#999">-</span>';
return val.length > 20 ? val.substr(0, 20) + '...' : val;
}
},
{field: 'ip', title: __('Ip'), operate: 'LIKE',
formatter: function(val) {
return '<span style="font-family:monospace;font-size:12px">' + (val || '-') + '</span>';
}
},
{field: 'created_at', title: __('Created_at'), sortable: true, operate: 'RANGE',
addclass: 'datetimerange',
formatter: function(val) {
return val ? new Date(val * 1000).toLocaleString('zh-CN') : '-';
}
},
{field: 'operate', title: __('Operate'), table: table,
events: Table.api.events.operate,
formatter: Table.api.formatter.operate,
buttons: [
{
name: 'mark_processed',
text: '标记已处理',
title: '标记为已处理',
classname: 'btn btn-xs btn-success btn-mark-processed',
icon: 'fa fa-check',
hidden: function(row) {
return row.status === 'processed';
},
callback: function(data) {
Layer.confirm('确认将此记录标记为已处理?', function(index) {
Fast.api.ajax({
url: 'form_collect/mark_processed',
data: {ids: data.id}
}, function(data, ret) {
Layer.close(index);
Toastr.success(ret.msg || '操作成功');
$(".btn-refresh").trigger("click");
});
});
}
}
]
}
]
]
});
// 导出CSV按钮
$(document).on('click', '.btn-export', function() {
var ids = Table.api.selectedids(table);
var source = table.bootstrapTable('getOptions').queryParams.source || '';
var status = table.bootstrapTable('getOptions').queryParams.status || '';
var url = 'form_collect/export?1=1';
if (ids.length > 0) {
url += '&ids=' + ids.join(',');
}
if (source) url += '&source=' + source;
if (status) url += '&status=' + status;
window.location.href = url;
});
// 安装数据表按钮
$(document).on('click', '.btn-install', function() {
Layer.confirm('确认安装/初始化数据表?如果表已存在则不会重复创建。', function(index) {
Fast.api.ajax('form_collect/install', function(data, ret) {
Layer.close(index);
Toastr.success(ret.msg || '安装成功');
});
});
});
// 批量处理按钮
$(document).on('click', '.btn-batch-process', function() {
var ids = Table.api.selectedids(table);
if (ids.length === 0) {
Toastr.error('请先选择记录');
return;
}
Layer.confirm('确认将选中的 ' + ids.length + ' 条记录标记为已处理?', function(index) {
Fast.api.ajax({
url: 'form_collect/batch_process',
data: {ids: ids.join(',')}
}, function(data, ret) {
Layer.close(index);
Toastr.success(ret.msg || '操作成功');
$(".btn-refresh").trigger("click");
});
});
});
Table.api.bindevent(table);
},
add: function () {
Controller.api.bindevent();
},
edit: function () {
Controller.api.bindevent();
},
api: {
bindevent: function () {
Form.api.bindevent($("form[role=form]"));
}
}
};
return Controller;
});