From 5fb269cc0a32c53ccc562d18fb0bfef4b36c095a Mon Sep 17 00:00:00 2001 From: 24kycj <1637269896@qq.com> Date: Wed, 24 Jun 2026 19:24:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=9C=AC=E5=9C=B0=E4=BC=98?= =?UTF-8?q?=E5=85=88=E4=BF=9D=E5=AD=98=E4=B8=8E=20design=20=E9=A1=B5?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保存先写本地缓存,登录后可选云端同步;退出登录不再因文件操作跳转登录 - 修复 Layui 遮罩残留、design2 保存后线条、toast 被 finally 提前关闭 - 保存过程恢复 loading spinner;成功提示 2.5 秒 - 云端 payload 瘦身与体积超限提示;后端 schema 迁移与 FileService 容错 Co-authored-by: Cursor --- .cursor/rules/agent-core.mdc | 2 +- .gitignore | 1 + README.md | 4 +- backend-web/{ => database}/schema.sql | 0 backend-web/scripts/README.md | 20 +- backend-web/scripts/migrate-schema.php | 24 ++ backend-web/scripts/schema-migrate-lib.php | 160 ++++++++++++ backend-web/src/Core/ErrorHandler.php | 53 ++++ backend-web/src/Services/FileService.php | 128 +++++++--- backend-web/src/bootstrap.php | 2 + docker/docker-compose.yml | 2 +- docker/php/migrate-dev-schema.php | 147 +---------- frontend-web/assets/css/design.css | 11 + frontend-web/assets/css/layer-soon.css | 9 + frontend-web/config/local.js.example | 17 +- frontend-web/js/common/auth-session.js | 9 +- frontend-web/js/common/cloud-files.js | 100 ++++++-- frontend-web/js/common/member-activate.js | 2 + frontend-web/js/common/member-login-gate.js | 1 + frontend-web/js/common/member-pay-core.js | 2 + frontend-web/js/common/portal-auth.js | 3 + frontend-web/js/common/soon-toast.js | 74 +++++- frontend-web/js/design1/core.js | 8 +- frontend-web/js/design1/output.js | 14 +- frontend-web/js/design2/core.js | 21 +- frontend-web/js/design2/output.js | 23 +- frontend-web/js/design2/ui.js | 34 --- frontend-web/js/platform/web.js | 232 +++++++++++++----- .../pages/admin/assets/admin-shell.css | 49 ++++ frontend-web/pages/admin/assets/js/core/ui.js | 64 ++++- .../pages/admin/assets/js/views/orders.js | 7 +- .../pages/admin/assets/js/views/plans.js | 2 +- .../pages/admin/assets/js/views/settings.js | 5 +- .../pages/admin/assets/js/views/templates.js | 4 +- .../pages/admin/assets/js/views/users.js | 17 +- frontend-web/pages/index.web.html | 1 + 36 files changed, 894 insertions(+), 358 deletions(-) rename backend-web/{ => database}/schema.sql (100%) create mode 100644 backend-web/scripts/migrate-schema.php create mode 100644 backend-web/scripts/schema-migrate-lib.php create mode 100644 backend-web/src/Core/ErrorHandler.php diff --git a/.cursor/rules/agent-core.mdc b/.cursor/rules/agent-core.mdc index f36a842..5283a5c 100644 --- a/.cursor/rules/agent-core.mdc +++ b/.cursor/rules/agent-core.mdc @@ -17,7 +17,7 @@ alwaysApply: true - **继承上下文**:结合整段对话;最新一条默认延续当前任务,区分细化与换方向。 - **回复**:简体中文;标识符与代码保持原文。结构:**结论** → **要点** → **命令**(若有)。少寒暄、少加粗;禁「保证/一定/完美/彻底解决」。 - **引用代码**:独占一行 `startLine:endLine:filepath`,行号与路径须真实。 -- **中文 UTF-8**:含中文文件 UTF-8 无 BOM;灌库用 `mysql` 导入 `backend-web/schema.sql`(禁 `type`/管道灌库);`bat` 不写中文 SQL。 +- **中文 UTF-8**:含中文文件 UTF-8 无 BOM;灌库在 `backend-web` 目录执行 `mysql ... < database/schema.sql`(禁 `type`/管道灌库);`bat` 不写中文 SQL。 - **本仓库配置**:后端 `backend-web/config/local.php`;前端 `frontend-web/config/local.js`(gitignore,全量更新勿覆盖;页面直接引该文件,设置 `SOON_DEPLOY_CONFIG`)。 ## 改动 diff --git a/.gitignore b/.gitignore index 69a8706..7ac6c3a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ backend-web/storage/templates/* *.pem *.key /soonDesign-back +/info diff --git a/README.md b/README.md index 8459ea6..f680fb1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ |---|---| | [frontend-electron/](frontend-electron/) | 桌面程序 | | [frontend-web/](frontend-web/) | Web 前端 | -| [backend-web/](backend-web/) | PHP API | +| [backend-web/](backend-web/) | PHP API(含 `database/schema.sql`) | | [docker/](docker/) | 本地 Docker 编排(非生产部署) | ## 本地开发 @@ -39,7 +39,7 @@ scripts\start.bat - Web 根目录:`frontend-web/` - PHP 根目录:`backend-web/public/` -- 数据库:导入 `backend-web/schema.sql` +- 数据库:在 `backend-web` 目录执行 `mysql ... < database/schema.sql`;已有库 `php scripts/migrate-schema.php` - 后端:`backend-web/config/local.php.example` → `local.php` - 前端 API:`frontend-web/config/local.js`(生产配置,勿覆盖) diff --git a/backend-web/schema.sql b/backend-web/database/schema.sql similarity index 100% rename from backend-web/schema.sql rename to backend-web/database/schema.sql diff --git a/backend-web/scripts/README.md b/backend-web/scripts/README.md index 87964c3..c095272 100644 --- a/backend-web/scripts/README.md +++ b/backend-web/scripts/README.md @@ -33,10 +33,26 @@ vi config/local.php 改 `db`、`jwt_secret`、`site`、`storage` 路径。 ```bash -mysql -u root -p soondesign < schema.sql +mysql -u root -p soondesign < database/schema.sql +php scripts/migrate-schema.php ``` -再执行上面「权限」整段粘贴。 +`migrate-schema.php` 用于已有库增量升级(可重复执行)。再执行上面「权限」整段粘贴。 + +### MySQL 大包(云端保存失败时) + +若保存报「文件过大」或 `max_allowed_packet`,在宝塔 / MySQL 配置中把 `max_allowed_packet` 调到 **32M** 或以上,重启 MySQL 后生效。可临时验证: + +```sql +SET GLOBAL max_allowed_packet = 33554432; +``` + +持久化写入 `my.cnf`(示例): + +```ini +[mysqld] +max_allowed_packet = 32M +``` ## 初始化管理员 diff --git a/backend-web/scripts/migrate-schema.php b/backend-web/scripts/migrate-schema.php new file mode 100644 index 0000000..01e9230 --- /dev/null +++ b/backend-web/scripts/migrate-schema.php @@ -0,0 +1,24 @@ +getMessage() . PHP_EOL); + exit(1); +} diff --git a/backend-web/scripts/schema-migrate-lib.php b/backend-web/scripts/schema-migrate-lib.php new file mode 100644 index 0000000..1b9ba89 --- /dev/null +++ b/backend-web/scripts/schema-migrate-lib.php @@ -0,0 +1,160 @@ +prepare( + 'SELECT COUNT(*) FROM information_schema.COLUMNS ' + . 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t AND COLUMN_NAME = :c' + ); + $stmt->execute(['t' => $table, 'c' => $column]); + return (int)$stmt->fetchColumn() > 0; +} + +/** @return int Number of SQL statements executed */ +function soon_schema_migrate(PDO $pdo, string $logPrefix = 'migrate-schema'): int +{ + $applied = 0; + $run = static function (string $sql) use ($pdo, $logPrefix, &$applied): void { + $pdo->exec($sql); + ++$applied; + fwrite(STDOUT, "{$logPrefix}: {$sql}" . PHP_EOL); + }; + + $alters = []; + if (!soon_schema_column_exists($pdo, 'subscriptions', 'source_order_id')) { + $alters[] = 'ALTER TABLE subscriptions ADD COLUMN source_order_id BIGINT UNSIGNED DEFAULT NULL AFTER plan_id'; + $alters[] = 'ALTER TABLE subscriptions ADD KEY source_order (source_order_id)'; + } + if (!soon_schema_column_exists($pdo, 'pay_orders', 'refund_status')) { + $alters[] = "ALTER TABLE pay_orders ADD COLUMN refund_status ENUM('none','pending','approved','rejected') NOT NULL DEFAULT 'none' AFTER status"; + $alters[] = 'ALTER TABLE pay_orders ADD COLUMN refund_reason TEXT DEFAULT NULL AFTER refund_status'; + $alters[] = 'ALTER TABLE pay_orders ADD COLUMN refund_note TEXT DEFAULT NULL AFTER refund_reason'; + $alters[] = 'ALTER TABLE pay_orders ADD COLUMN cancelled_at DATETIME DEFAULT NULL AFTER paid_at'; + $alters[] = 'ALTER TABLE pay_orders ADD COLUMN refunded_at DATETIME DEFAULT NULL AFTER cancelled_at'; + $alters[] = 'ALTER TABLE pay_orders ADD KEY refund_status (refund_status)'; + } + if (!soon_schema_column_exists($pdo, 'plans', 'description')) { + $alters[] = 'ALTER TABLE plans ADD COLUMN description VARCHAR(255) DEFAULT NULL AFTER name'; + $alters[] = 'ALTER TABLE plans ADD COLUMN max_files INT UNSIGNED NOT NULL DEFAULT 0 AFTER quota_mb'; + $alters[] = 'ALTER TABLE plans ADD COLUMN features TEXT DEFAULT NULL AFTER duration_days'; + $alters[] = 'ALTER TABLE plans ADD COLUMN sort_order INT NOT NULL DEFAULT 0 AFTER features'; + $alters[] = 'ALTER TABLE plans ADD COLUMN is_recommended TINYINT(1) NOT NULL DEFAULT 0 AFTER sort_order'; + } + if (!soon_schema_column_exists($pdo, 'users', 'admin_level')) { + $alters[] = "ALTER TABLE users ADD COLUMN admin_level ENUM('full','ops') DEFAULT NULL AFTER role"; + } + if (!soon_schema_column_exists($pdo, 'soon_files', 'thumb')) { + $alters[] = 'ALTER TABLE soon_files ADD COLUMN thumb MEDIUMTEXT DEFAULT NULL AFTER version'; + } + + foreach ($alters as $sql) { + $run($sql); + } + + if (soon_schema_column_exists($pdo, 'users', 'admin_level')) { + $pdo->exec( + "UPDATE users SET admin_level = 'full' WHERE role = 'admin' AND (admin_level IS NULL OR admin_level = '')" + ); + fwrite(STDOUT, "{$logPrefix}: admin_level backfill for existing admins" . PHP_EOL); + } + + if (soon_schema_column_exists($pdo, 'plans', 'description')) { + $legacyCodes = ['pro_monthly' => 'member_monthly', 'pro_yearly' => 'member_yearly']; + foreach ($legacyCodes as $oldCode => $newCode) { + $stmt = $pdo->prepare('SELECT id FROM plans WHERE code = :c LIMIT 1'); + $stmt->execute(['c' => $oldCode]); + if ($stmt->fetch()) { + $conflict = $pdo->prepare('SELECT id FROM plans WHERE code = :c LIMIT 1'); + $conflict->execute(['c' => $newCode]); + if ($conflict->fetch()) { + $pdo->prepare('UPDATE plans SET is_active = 0 WHERE code = :c')->execute(['c' => $oldCode]); + } else { + $pdo->prepare('UPDATE plans SET code = :new WHERE code = :old')->execute(['new' => $newCode, 'old' => $oldCode]); + } + } + } + + $quota = 2048; + $maxFiles = 200; + $seed = [ + ['free', '免费版', '免费体验设计与编辑', 0, $quota, $maxFiles, 0, + '["设计与编辑工具免费使用"]', 0, 0, 1], + ['member_lifetime', '永久会员', '一次激活,永久使用预览交付能力', 100, $quota, $maxFiles, 0, + '["高清预览","成品打印","云端保存","导出设计文件","永久有效"]', 10, 1, 1], + ['member_monthly', '月度订阅', '按月灵活使用,随时续订', 1999, $quota, $maxFiles, 30, + '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 个月"]', 20, 0, 0], + ['member_quarterly', '季度订阅', '连续三个月,更省心', 5299, $quota, $maxFiles, 90, + '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 季"]', 25, 0, 0], + ['member_yearly', '年度订阅', '全年畅享,性价比更高', 19999, $quota, $maxFiles, 365, + '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 年"]', 30, 0, 0], + ]; + $upsert = $pdo->prepare( + 'INSERT INTO plans (code, name, description, price_cents, quota_mb, max_files, duration_days, features, sort_order, is_recommended, is_active) ' + . 'VALUES (:c, :n, :d, :p, :q, :mf, :dd, :f, :so, :ir, :a) ' + . 'ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), price_cents=VALUES(price_cents), ' + . 'quota_mb=VALUES(quota_mb), max_files=VALUES(max_files), duration_days=VALUES(duration_days), ' + . 'features=VALUES(features), sort_order=VALUES(sort_order), is_recommended=VALUES(is_recommended), is_active=VALUES(is_active)' + ); + foreach ($seed as $row) { + $upsert->execute([ + 'c' => $row[0], 'n' => $row[1], 'd' => $row[2], 'p' => $row[3], 'q' => $row[4], + 'mf' => $row[5], 'dd' => $row[6], 'f' => $row[7], 'so' => $row[8], 'ir' => $row[9], 'a' => $row[10], + ]); + } + fwrite(STDOUT, "{$logPrefix}: membership plans catalog synced" . PHP_EOL); + + $legacyFeatStmt = $pdo->query( + 'SELECT code, duration_days FROM plans WHERE features LIKE \'%预览成品效果%\' ' + . 'OR features LIKE \'%到期后恢复为非会员%\' OR features LIKE \'%会员权益一致%\' ' + . 'OR name LIKE \'%会员 ·%\'' + ); + $featUpd = $pdo->prepare('UPDATE plans SET features = :f WHERE code = :c'); + $coreFeatures = ['高清预览', '成品打印', '云端保存', '导出设计文件']; + foreach ($legacyFeatStmt->fetchAll() as $legacyRow) { + $code = (string)$legacyRow['code']; + $days = (int)$legacyRow['duration_days']; + if ($code === 'free') { + $featUpd->execute([ + 'f' => '["设计与编辑工具免费使用"]', + 'c' => $code, + ]); + continue; + } + $lines = $coreFeatures; + if ($days > 0) { + $period = $days >= 365 ? '1 年' : ($days === 90 ? '1 季' : ($days >= 30 && $days % 30 === 0 ? ((int)($days / 30)) . ' 个月' : $days . ' 天')); + $lines[] = '订阅周期:' . $period; + } + $featUpd->execute([ + 'f' => json_encode($lines, JSON_UNESCAPED_UNICODE), + 'c' => $code, + ]); + } + fwrite(STDOUT, "{$logPrefix}: legacy plan copy refreshed" . PHP_EOL); + } + + $run( + 'CREATE TABLE IF NOT EXISTS `soon_templates` (' + . '`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,' + . '`name` VARCHAR(120) NOT NULL,' + . '`type` TINYINT UNSIGNED NOT NULL DEFAULT 1,' + . '`thumb` MEDIUMTEXT DEFAULT NULL,' + . '`file_size` INT UNSIGNED NOT NULL DEFAULT 0,' + . '`sort_order` INT NOT NULL DEFAULT 0,' + . '`is_active` TINYINT(1) NOT NULL DEFAULT 1,' + . '`created_at` DATETIME NOT NULL,' + . '`updated_at` DATETIME NOT NULL,' + . 'PRIMARY KEY (`id`),' + . 'KEY `active_sort` (`is_active`, `sort_order`, `id`)' + . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + fwrite(STDOUT, "{$logPrefix}: soon_templates ensured" . PHP_EOL); + + return $applied; +} diff --git a/backend-web/src/Core/ErrorHandler.php b/backend-web/src/Core/ErrorHandler.php new file mode 100644 index 0000000..9856e1f --- /dev/null +++ b/backend-web/src/Core/ErrorHandler.php @@ -0,0 +1,53 @@ +getCode(); + $detail = $e->getMessage(); + if (str_contains($detail, 'max_allowed_packet') || str_contains($detail, 'Packet too large')) { + $code = 'payload_too_large'; + $msg = '文件过大,超出服务器数据库限制'; + $status = 413; + } elseif (str_contains($detail, 'Unknown column') || str_contains($detail, "doesn't exist")) { + $msg = '数据库结构不完整,请导入 database/schema.sql 或执行 php scripts/migrate-schema.php'; + } elseif ($sqlState === '22001' || str_contains($detail, 'Data too long')) { + $code = 'payload_too_large'; + $msg = '文件内容过长,无法保存'; + $status = 413; + } + } + + if ($debug) { + $msg .= ' (' . $e->getMessage() . ')'; + } + Json::fail($code, $msg, $status); + } +} diff --git a/backend-web/src/Services/FileService.php b/backend-web/src/Services/FileService.php index 22ca3e5..1d8c10b 100644 --- a/backend-web/src/Services/FileService.php +++ b/backend-web/src/Services/FileService.php @@ -6,12 +6,16 @@ namespace Soon\Api\Services; use Soon\Api\Core\Config; use Soon\Api\Core\Db; use Soon\Api\Core\Json; +use PDOException; +use Throwable; /** * .soon 文件服务:JSON 数据流模型,乐观锁、配额、软删除。 */ final class FileService { + private const MAX_JSON_BYTES = 33554432; + public static function usersRoot(): string { $dir = (string)Config::get('storage.users_dir', SOON_SERVER_ROOT . '/storage/users'); @@ -74,32 +78,40 @@ final class FileService public static function create(int $userId, string $name, string $json): array { - $maxFiles = MembershipService::maxFilesLimit($userId); - if (self::fileCount($userId) >= $maxFiles) { - Json::fail('file_limit_exceeded', '已达文件数量上限(' . $maxFiles . ' 个),请清理文件或续订', 413); + $name = self::normalizeName($name); + self::assertJsonPayload($json); + try { + $maxFiles = MembershipService::maxFilesLimit($userId); + if (self::fileCount($userId) >= $maxFiles) { + Json::fail('file_limit_exceeded', '已达文件数量上限(' . $maxFiles . ' 个),请清理文件或续订', 413); + } + $size = strlen($json); + $quota = self::quotaBytes($userId); + $used = self::usedBytes($userId); + if ($quota > 0 && $used + $size > $quota) { + Json::fail('quota_exceeded', '存储空间已满,请清理文件或续订', 413); + } + $now = date('Y-m-d H:i:s'); + $thumb = self::extractThumb($json); + $stmt = Db::pdo()->prepare( + 'INSERT INTO soon_files (user_id, name, json, size, version, thumb, created_at, updated_at) ' + . 'VALUES (:u, :n, :j, :s, 1, :th, :created_at, :updated_at)' + ); + $stmt->execute([ + 'u' => $userId, 'n' => $name, 'j' => $json, 's' => $size, 'th' => $thumb !== '' ? $thumb : null, + 'created_at' => $now, 'updated_at' => $now, + ]); + $id = (int)Db::pdo()->lastInsertId(); + return ['id' => $id, 'name' => $name, 'size' => $size, 'version' => 1, 'updated_at' => $now]; + } catch (PDOException $e) { + self::failDb($e); } - $size = strlen($json); - $quota = self::quotaBytes($userId); - $used = self::usedBytes($userId); - if ($quota > 0 && $used + $size > $quota) { - Json::fail('quota_exceeded', '存储空间已满,请清理文件或续订', 413); - } - $now = date('Y-m-d H:i:s'); - $thumb = TemplateService::thumbFromSoonJson($json); - $stmt = Db::pdo()->prepare( - 'INSERT INTO soon_files (user_id, name, json, size, version, thumb, created_at, updated_at) ' - . 'VALUES (:u, :n, :j, :s, 1, :th, :created_at, :updated_at)' - ); - $stmt->execute([ - 'u' => $userId, 'n' => $name, 'j' => $json, 's' => $size, 'th' => $thumb !== '' ? $thumb : null, - 'created_at' => $now, 'updated_at' => $now, - ]); - $id = (int)Db::pdo()->lastInsertId(); - return ['id' => $id, 'name' => $name, 'size' => $size, 'version' => 1, 'updated_at' => $now]; } public static function update(int $userId, int $id, string $name, string $json, ?int $expectedVersion): array { + $name = self::normalizeName($name); + self::assertJsonPayload($json); $newSize = strlen($json); $quota = self::quotaBytes($userId); $used = self::usedBytes($userId); @@ -121,7 +133,7 @@ final class FileService } $now = date('Y-m-d H:i:s'); $newVersion = (int)$row['version'] + 1; - $thumb = TemplateService::thumbFromSoonJson($json); + $thumb = self::extractThumb($json); $upd = $pdo->prepare( 'UPDATE soon_files SET name = :n, json = :j, size = :s, version = :v, thumb = :th, updated_at = :ts ' . 'WHERE id = :id AND version = :cv' @@ -137,17 +149,77 @@ final class FileService $pdo->commit(); return ['id' => $id, 'name' => $name, 'size' => $newSize, 'version' => $newVersion, 'updated_at' => $now]; } catch (\RuntimeException $e) { - if ($pdo->inTransaction()) $pdo->rollBack(); - if ($e->getMessage() === 'not_found') Json::fail('not_found', '文件不存在', 404); - if ($e->getMessage() === 'version_conflict') Json::fail('conflict', '版本冲突,请刷新后重试', 409); - if ($e->getMessage() === 'quota_exceeded') Json::fail('quota_exceeded', '存储空间已满', 413); + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + if ($e->getMessage() === 'not_found') { + Json::fail('not_found', '文件不存在', 404); + } + if ($e->getMessage() === 'version_conflict') { + Json::fail('conflict', '版本冲突,请刷新后重试', 409); + } + if ($e->getMessage() === 'quota_exceeded') { + Json::fail('quota_exceeded', '存储空间已满', 413); + } throw $e; - } catch (\Throwable $e) { - if ($pdo->inTransaction()) $pdo->rollBack(); + } catch (PDOException $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + self::failDb($e); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } throw $e; } } + private static function normalizeName(string $name): string + { + $name = trim($name); + if ($name === '') { + return 'untitled.soon'; + } + if (mb_strlen($name) > 190) { + Json::fail('bad_request', '文件名过长', 400); + } + return $name; + } + + private static function assertJsonPayload(string $json): void + { + if ($json === '') { + Json::fail('bad_request', '文件内容为空', 400); + } + $size = strlen($json); + if ($size > self::MAX_JSON_BYTES) { + Json::fail('payload_too_large', '文件内容过大(最大 32MB)', 413); + } + } + + private static function extractThumb(string $json): string + { + try { + return TemplateService::thumbFromSoonJson($json); + } catch (Throwable) { + return ''; + } + } + + /** @return never */ + private static function failDb(PDOException $e): void + { + $detail = $e->getMessage(); + if (str_contains($detail, 'max_allowed_packet') || str_contains($detail, 'Packet too large')) { + Json::fail('payload_too_large', '文件过大,超出服务器数据库限制', 413); + } + if (str_contains($detail, 'Unknown column') || str_contains($detail, "doesn't exist")) { + Json::fail('server_error', '数据库结构不完整,请导入 database/schema.sql 或执行 php scripts/migrate-schema.php', 500); + } + throw $e; + } + public static function softDelete(int $userId, int $id): void { $stmt = Db::pdo()->prepare('UPDATE soon_files SET deleted_at = :ts WHERE id = :id AND user_id = :u'); diff --git a/backend-web/src/bootstrap.php b/backend-web/src/bootstrap.php index 9781cc5..7841701 100644 --- a/backend-web/src/bootstrap.php +++ b/backend-web/src/bootstrap.php @@ -96,3 +96,5 @@ require SOON_API_ROOT . '/Services/PaymentConfigLoader.php'; require SOON_API_ROOT . '/Services/SettingsConfigLoader.php'; \Soon\Api\Services\PaymentConfigLoader::reload($config); \Soon\Api\Core\Config::init($config); +require SOON_API_ROOT . '/Core/ErrorHandler.php'; +\Soon\Api\Core\ErrorHandler::register(); diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7f023bb..c9c0dfc 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -15,7 +15,7 @@ services: - --collation-server=utf8mb4_unicode_ci volumes: - soondesign_mysql_data:/var/lib/mysql - - ../backend-web/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro + - ../backend-web/database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-rootlocal}"] interval: 5s diff --git a/docker/php/migrate-dev-schema.php b/docker/php/migrate-dev-schema.php index 8ffee79..8564de8 100644 --- a/docker/php/migrate-dev-schema.php +++ b/docker/php/migrate-dev-schema.php @@ -1,6 +1,8 @@ PDO::ERRMODE_EXCEPTION] ); + soon_schema_migrate($pdo, 'migrate-dev-schema'); } catch (Throwable $e) { fwrite(STDERR, 'migrate-dev-schema: ' . $e->getMessage() . PHP_EOL); exit(1); } - -function columnExists(PDO $pdo, string $table, string $column): bool -{ - $stmt = $pdo->prepare( - 'SELECT COUNT(*) FROM information_schema.COLUMNS ' - . 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t AND COLUMN_NAME = :c' - ); - $stmt->execute(['t' => $table, 'c' => $column]); - return (int)$stmt->fetchColumn() > 0; -} - -$alters = []; -if (!columnExists($pdo, 'subscriptions', 'source_order_id')) { - $alters[] = 'ALTER TABLE subscriptions ADD COLUMN source_order_id BIGINT UNSIGNED DEFAULT NULL AFTER plan_id'; - $alters[] = 'ALTER TABLE subscriptions ADD KEY source_order (source_order_id)'; -} -if (!columnExists($pdo, 'pay_orders', 'refund_status')) { - $alters[] = "ALTER TABLE pay_orders ADD COLUMN refund_status ENUM('none','pending','approved','rejected') NOT NULL DEFAULT 'none' AFTER status"; - $alters[] = 'ALTER TABLE pay_orders ADD COLUMN refund_reason TEXT DEFAULT NULL AFTER refund_status'; - $alters[] = 'ALTER TABLE pay_orders ADD COLUMN refund_note TEXT DEFAULT NULL AFTER refund_reason'; - $alters[] = 'ALTER TABLE pay_orders ADD COLUMN cancelled_at DATETIME DEFAULT NULL AFTER paid_at'; - $alters[] = 'ALTER TABLE pay_orders ADD COLUMN refunded_at DATETIME DEFAULT NULL AFTER cancelled_at'; - $alters[] = 'ALTER TABLE pay_orders ADD KEY refund_status (refund_status)'; -} - -if (!columnExists($pdo, 'plans', 'description')) { - $alters[] = 'ALTER TABLE plans ADD COLUMN description VARCHAR(255) DEFAULT NULL AFTER name'; - $alters[] = 'ALTER TABLE plans ADD COLUMN max_files INT UNSIGNED NOT NULL DEFAULT 0 AFTER quota_mb'; - $alters[] = 'ALTER TABLE plans ADD COLUMN features TEXT DEFAULT NULL AFTER duration_days'; - $alters[] = 'ALTER TABLE plans ADD COLUMN sort_order INT NOT NULL DEFAULT 0 AFTER features'; - $alters[] = 'ALTER TABLE plans ADD COLUMN is_recommended TINYINT(1) NOT NULL DEFAULT 0 AFTER sort_order'; -} - -if (!columnExists($pdo, 'users', 'admin_level')) { - $alters[] = "ALTER TABLE users ADD COLUMN admin_level ENUM('full','ops') DEFAULT NULL AFTER role"; -} - -if (!columnExists($pdo, 'soon_files', 'thumb')) { - $alters[] = 'ALTER TABLE soon_files ADD COLUMN thumb MEDIUMTEXT DEFAULT NULL AFTER version'; -} - -foreach ($alters as $sql) { - $pdo->exec($sql); - fwrite(STDOUT, "migrate-dev-schema: {$sql}" . PHP_EOL); -} - -if (columnExists($pdo, 'users', 'admin_level')) { - $pdo->exec( - "UPDATE users SET admin_level = 'full' WHERE role = 'admin' AND (admin_level IS NULL OR admin_level = '')" - ); - fwrite(STDOUT, 'migrate-dev-schema: admin_level backfill for existing admins' . PHP_EOL); -} - -if (columnExists($pdo, 'plans', 'description')) { - $legacyCodes = ['pro_monthly' => 'member_monthly', 'pro_yearly' => 'member_yearly']; - foreach ($legacyCodes as $oldCode => $newCode) { - $stmt = $pdo->prepare('SELECT id FROM plans WHERE code = :c LIMIT 1'); - $stmt->execute(['c' => $oldCode]); - if ($stmt->fetch()) { - $conflict = $pdo->prepare('SELECT id FROM plans WHERE code = :c LIMIT 1'); - $conflict->execute(['c' => $newCode]); - if ($conflict->fetch()) { - $pdo->prepare('UPDATE plans SET is_active = 0 WHERE code = :c')->execute(['c' => $oldCode]); - } else { - $pdo->prepare('UPDATE plans SET code = :new WHERE code = :old')->execute(['new' => $newCode, 'old' => $oldCode]); - } - } - } - - $quota = 2048; - $maxFiles = 200; - $seed = [ - ['free', '免费版', '免费体验设计与编辑', 0, $quota, $maxFiles, 0, - '["设计与编辑工具免费使用"]', 0, 0, 1], - ['member_lifetime', '永久会员', '一次激活,永久使用预览交付能力', 100, $quota, $maxFiles, 0, - '["高清预览","成品打印","云端保存","导出设计文件","永久有效"]', 10, 1, 1], - ['member_monthly', '月度订阅', '按月灵活使用,随时续订', 1999, $quota, $maxFiles, 30, - '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 个月"]', 20, 0, 0], - ['member_quarterly', '季度订阅', '连续三个月,更省心', 5299, $quota, $maxFiles, 90, - '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 季"]', 25, 0, 0], - ['member_yearly', '年度订阅', '全年畅享,性价比更高', 19999, $quota, $maxFiles, 365, - '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 年"]', 30, 0, 0], - ]; - $upsert = $pdo->prepare( - 'INSERT INTO plans (code, name, description, price_cents, quota_mb, max_files, duration_days, features, sort_order, is_recommended, is_active) ' - . 'VALUES (:c, :n, :d, :p, :q, :mf, :dd, :f, :so, :ir, :a) ' - . 'ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), price_cents=VALUES(price_cents), ' - . 'quota_mb=VALUES(quota_mb), max_files=VALUES(max_files), duration_days=VALUES(duration_days), ' - . 'features=VALUES(features), sort_order=VALUES(sort_order), is_recommended=VALUES(is_recommended), is_active=VALUES(is_active)' - ); - foreach ($seed as $row) { - $upsert->execute([ - 'c' => $row[0], 'n' => $row[1], 'd' => $row[2], 'p' => $row[3], 'q' => $row[4], - 'mf' => $row[5], 'dd' => $row[6], 'f' => $row[7], 'so' => $row[8], 'ir' => $row[9], 'a' => $row[10], - ]); - } - fwrite(STDOUT, 'migrate-dev-schema: membership plans catalog synced' . PHP_EOL); - - $legacyFeatStmt = $pdo->query( - 'SELECT code, duration_days FROM plans WHERE features LIKE \'%预览成品效果%\' ' - . 'OR features LIKE \'%到期后恢复为非会员%\' OR features LIKE \'%会员权益一致%\' ' - . 'OR name LIKE \'%会员 ·%\'' - ); - $featUpd = $pdo->prepare('UPDATE plans SET features = :f WHERE code = :c'); - $coreFeatures = ['高清预览', '成品打印', '云端保存', '导出设计文件']; - foreach ($legacyFeatStmt->fetchAll() as $legacyRow) { - $code = (string)$legacyRow['code']; - $days = (int)$legacyRow['duration_days']; - if ($code === 'free') { - $featUpd->execute([ - 'f' => '["设计与编辑工具免费使用"]', - 'c' => $code, - ]); - continue; - } - $lines = $coreFeatures; - if ($days > 0) { - $period = $days >= 365 ? '1 年' : ($days === 90 ? '1 季' : ($days >= 30 && $days % 30 === 0 ? ((int)($days / 30)) . ' 个月' : $days . ' 天')); - $lines[] = '订阅周期:' . $period; - } - $featUpd->execute([ - 'f' => json_encode($lines, JSON_UNESCAPED_UNICODE), - 'c' => $code, - ]); - } - fwrite(STDOUT, 'migrate-dev-schema: legacy plan copy refreshed' . PHP_EOL); -} - -$pdo->exec( - 'CREATE TABLE IF NOT EXISTS `soon_templates` (' - . '`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,' - . '`name` VARCHAR(120) NOT NULL,' - . '`type` TINYINT UNSIGNED NOT NULL DEFAULT 1,' - . '`thumb` MEDIUMTEXT DEFAULT NULL,' - . '`file_size` INT UNSIGNED NOT NULL DEFAULT 0,' - . '`sort_order` INT NOT NULL DEFAULT 0,' - . '`is_active` TINYINT(1) NOT NULL DEFAULT 1,' - . '`created_at` DATETIME NOT NULL,' - . '`updated_at` DATETIME NOT NULL,' - . 'PRIMARY KEY (`id`),' - . 'KEY `active_sort` (`is_active`, `sort_order`, `id`)' - . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' -); -fwrite(STDOUT, 'migrate-dev-schema: soon_templates ensured' . PHP_EOL); diff --git a/frontend-web/assets/css/design.css b/frontend-web/assets/css/design.css index 645ccf3..d8259b4 100644 --- a/frontend-web/assets/css/design.css +++ b/frontend-web/assets/css/design.css @@ -250,6 +250,17 @@ textarea:hover{ border: #646E76 solid 1px; color: #ffffff; font-size: 16px; + min-width: 160px; + max-width: 100%; + height: 32px; + line-height: 32px; + padding: 0 28px 0 8px; + box-sizing: border-box; + vertical-align: middle; +} + +.component-line .ui-select{ + width: calc(100% - 72px); } diff --git a/frontend-web/assets/css/layer-soon.css b/frontend-web/assets/css/layer-soon.css index 742d848..1d33ed5 100644 --- a/frontend-web/assets/css/layer-soon.css +++ b/frontend-web/assets/css/layer-soon.css @@ -215,6 +215,15 @@ body.soon-design-page .layui-layer.soon-layer--login-gate .layui-layer-setwin a. background: transparent !important; } +.layui-layer.soon-layer .layui-layer-content.soon-layer-content--form, +.layui-layer-soon-layer .layui-layer-content.soon-layer-content--form { + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; + padding: 0 !important; + box-sizing: border-box; +} + .layui-layer.soon-layer .layui-layer-content.soon-layer-content--scroll, .layui-layer-soon-layer .layui-layer-content.soon-layer-content--scroll { overflow-x: hidden !important; diff --git a/frontend-web/config/local.js.example b/frontend-web/config/local.js.example index 3a4b53c..ec41358 100644 --- a/frontend-web/config/local.js.example +++ b/frontend-web/config/local.js.example @@ -1,8 +1,9 @@ -// 复制为同目录 local.js(全量更新 frontend 时不要覆盖 local.js) -window.SOON_DEPLOY_CONFIG = { - api_v1_base: 'https://designadmin.cardsoon.com/api/v1', - api_admin_base: 'https://designadmin.cardsoon.com/api/admin', - front_base_url: 'https://design.cardsoon.com', - web_base: 'https://design.cardsoon.com', - version: '1.0.0' -}; +// 复制为同目录 local.js(全量更新 frontend 时不要覆盖 local.js) +window.SOON_DEPLOY_CONFIG = { + api_v1_base: 'https://designadmin.cardsoon.com/api/v1', + api_admin_base: 'https://designadmin.cardsoon.com/api/admin', + front_base_url: 'https://design.cardsoon.com', + web_base: 'https://design.cardsoon.com', + version: '1.0.0' +}; + diff --git a/frontend-web/js/common/auth-session.js b/frontend-web/js/common/auth-session.js index 4187056..4342f3d 100644 --- a/frontend-web/js/common/auth-session.js +++ b/frontend-web/js/common/auth-session.js @@ -85,16 +85,21 @@ fullUrl = getApiBase() + '/' + String(url).replace(/^\/+/, ''); } + var hadSession = !!(token || getRefreshToken()); + return fetch(fullUrl, opts).then(function (response) { if (response.status !== 401) return response; + if (!getRefreshToken()) return response; return refreshAccessToken().then(function (newToken) { var retryHeaders = Object.assign({}, opts.headers || {}); retryHeaders['Authorization'] = 'Bearer ' + newToken; var retryOpts = Object.assign({}, opts, { headers: retryHeaders }); return fetch(fullUrl, retryOpts); }).catch(function () { - clearSession(); - redirectToLogin(); + if (hadSession) { + clearSession(); + redirectToLogin(); + } return response; }); }); diff --git a/frontend-web/js/common/cloud-files.js b/frontend-web/js/common/cloud-files.js index ac5089f..408fb11 100644 --- a/frontend-web/js/common/cloud-files.js +++ b/frontend-web/js/common/cloud-files.js @@ -88,22 +88,29 @@ } }); if (hidden.length) canvas.renderAll(); + window._soonSkipDesign1CdMask = true; var mult = 0.18; var quality = 0.72; var url = ''; - for (var i = 0; i < 3; i++) { - try { - var candidate = canvas.toDataURL({ format: 'jpeg', quality: quality, multiplier: mult }); - if (candidate && candidate.indexOf('data:image/') === 0 && candidate.length <= THUMB_MAX) { - url = candidate; - break; - } - } catch (e) { /* ignore */ } - quality -= 0.18; - mult -= 0.04; + try { + for (var i = 0; i < 3; i++) { + try { + var candidate = canvas.toDataURL({ format: 'jpeg', quality: quality, multiplier: mult }); + if (candidate && candidate.indexOf('data:image/') === 0 && candidate.length <= THUMB_MAX) { + url = candidate; + break; + } + } catch (e) { /* ignore */ } + quality -= 0.18; + mult -= 0.04; + } + } finally { + window._soonSkipDesign1CdMask = false; } hidden.forEach(function (obj) { obj.visible = true; }); - if (hidden.length) canvas.renderAll(); + canvas.renderAll(); + if (typeof window.soonClearFabricOverlay === 'function') window.soonClearFabricOverlay(canvas); + if (canvas.requestRenderAll) canvas.requestRenderAll(); return url; } @@ -134,8 +141,20 @@ return soonEnsureSoonExt(name); } + function dismissLayerMask() { + if (typeof window.soonCleanupLayerMask === 'function') { + window.soonCleanupLayerMask(); + return; + } + soonHideOpenFileLoading(); + invokeLayerCleanup(); + } + function soonReportSaveError(err) { + if (typeof window.soonFinishSaveUi === 'function') window.soonFinishSaveUi(); + else dismissLayerMask(); if (err && err.conflictSynced) return; + if (err && err.apiNotified) return; if (typeof window.soonShowApiError === 'function') { window.soonShowApiError({ status: err && err.status, @@ -149,11 +168,27 @@ function soonWriteSoonContentWeb(fp, content, prevOpenKey, onDone, onError) { if (!window.platformBridge || !window.platformBridge.writeFile) return false; - window.platformBridge.writeFile(fp, content).then(function (res) { + if (typeof window.soonShowOpenFileLoading === 'function') window.soonShowOpenFileLoading(); + var writePromise; + try { + writePromise = window.platformBridge.writeFile(fp, content); + } catch (syncErr) { + dismissLayerMask(); + if (typeof onError === 'function') onError(syncErr); + else soonReportSaveError(syncErr); + return true; + } + if (!writePromise || typeof writePromise.then !== 'function') { + dismissLayerMask(); + return true; + } + writePromise.then(function (res) { if (typeof onDone === 'function') onDone(res, prevOpenKey); }).catch(function (err) { if (typeof onError === 'function') onError(err); else soonReportSaveError(err); + }).finally(function () { + dismissLayerMask(); }); return true; } @@ -656,37 +691,52 @@ }); } + function invokeLayerCleanup() { + if (typeof window.soonCleanupLayerMask === 'function') { + window.soonCleanupLayerMask(); + } + } + function soonReportOpenLoadError(message) { soonHideOpenFileLoading(); + invokeLayerCleanup(); soonToast(message || '文件加载失败', 'error'); } function soonShowOpenFileLoading() { if (typeof layer === 'undefined' || !layer.load) return; soonHideOpenFileLoading(); - window._soonOpenLoadIdx = layer.load(1, { shade: [0.12, '#000'] }); + window._soonOpenLoadIdx = layer.load(1, { shade: 0 }); } function soonHideOpenFileLoading() { - if (window._soonOpenLoadIdx == null) return; - if (typeof layer !== 'undefined' && layer.close) layer.close(window._soonOpenLoadIdx); + if (typeof layer !== 'undefined' && layer.closeAll) { + layer.closeAll('loading'); + } + if (window._soonOpenLoadIdx != null && typeof layer !== 'undefined' && layer.close) { + try { layer.close(window._soonOpenLoadIdx); } catch (e) { /* ignore */ } + } window._soonOpenLoadIdx = null; + invokeLayerCleanup(); } function soonToast(message, type) { if (typeof window.soonToast === 'function') window.soonToast(message, type); } - function soonRequireLogin(actionLabel) { + function soonRequireLogin(actionLabel, options) { + options = options || {}; if (soonGetAccessToken()) return true; var label = actionLabel || '继续'; - soonToast('请先登录后再' + label, 'warn'); - if (typeof window.soonRedirectToLogin === 'function') { - setTimeout(function () { window.soonRedirectToLogin(); }, 600); - } else if (typeof window.redirectToLogin === 'function') { - setTimeout(function () { window.redirectToLogin(); }, 600); - } else { - window.location.href = 'login.web.html'; + soonToast('请先登录后再' + (label || '操作'), 'warn'); + if (options.redirect) { + if (typeof window.soonRedirectToLogin === 'function') { + setTimeout(function () { window.soonRedirectToLogin(); }, 600); + } else if (typeof window.redirectToLogin === 'function') { + setTimeout(function () { window.redirectToLogin(); }, 600); + } else { + window.location.href = 'login.web.html'; + } } return false; } @@ -724,8 +774,8 @@ else if (info.status === 409) kind = 'warn'; else if (info.status === 413) kind = 'warn'; soonToast(info.message, kind); - if (info.status === 401) { - soonRequireLogin('操作'); + if (info.status === 401 && info.requireLogin) { + soonRequireLogin('操作', { redirect: true }); } } diff --git a/frontend-web/js/common/member-activate.js b/frontend-web/js/common/member-activate.js index 8b4e06e..728f58a 100644 --- a/frontend-web/js/common/member-activate.js +++ b/frontend-web/js/common/member-activate.js @@ -79,6 +79,7 @@ if (idx != null) layer.close(idx); if (payIdx != null) layer.close(payIdx); } + if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask(); if (fireStay && opts && typeof opts.onStay === 'function') opts.onStay(); } @@ -93,6 +94,7 @@ _gate.payIndex = null; if (typeof layer !== 'undefined' && gateIdx != null) layer.close(gateIdx); _gate = { index: null, payIndex: null, plan: null, gateOpts: null }; + if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask(); toast('恭喜您,会员已激活!', 'success'); if (typeof window.soonLoadMembership === 'function') { window.soonLoadMembership(true).then(function () { diff --git a/frontend-web/js/common/member-login-gate.js b/frontend-web/js/common/member-login-gate.js index 56c1a80..1ddb540 100644 --- a/frontend-web/js/common/member-login-gate.js +++ b/frontend-web/js/common/member-login-gate.js @@ -69,6 +69,7 @@ if (idx != null && typeof layer !== 'undefined') { try { layer.close(idx); } catch (e) { /* ignore */ } } + if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask(); } function bindModal(modal) { diff --git a/frontend-web/js/common/member-pay-core.js b/frontend-web/js/common/member-pay-core.js index 678ab01..489af25 100644 --- a/frontend-web/js/common/member-pay-core.js +++ b/frontend-web/js/common/member-pay-core.js @@ -607,6 +607,7 @@ displayChannels: channels, onPaid: function () { layer.close(idx); + if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask(); if (typeof opts.onPaid === 'function') opts.onPaid(); }, onBack: opts.onBack, @@ -617,6 +618,7 @@ }, end: function () { if (payCtrl) payCtrl.destroy(); + if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask(); if (typeof opts.onClose === 'function') opts.onClose(); }, }); diff --git a/frontend-web/js/common/portal-auth.js b/frontend-web/js/common/portal-auth.js index ed6acf3..61778c0 100644 --- a/frontend-web/js/common/portal-auth.js +++ b/frontend-web/js/common/portal-auth.js @@ -17,6 +17,9 @@ localStorage.removeItem('soon_access'); localStorage.removeItem('soon_refresh'); } + if (typeof window.soonApplyMembership === 'function') { + window.soonApplyMembership(null); + } } function initClearCache() { diff --git a/frontend-web/js/common/soon-toast.js b/frontend-web/js/common/soon-toast.js index 73c8887..2ff9e3b 100644 --- a/frontend-web/js/common/soon-toast.js +++ b/frontend-web/js/common/soon-toast.js @@ -1,14 +1,81 @@ (function () { 'use strict'; + function soonCleanupLayerMask() { + if (typeof layer !== 'undefined' && layer.closeAll) { + layer.closeAll('loading'); + } + if (window._soonOpenLoadIdx != null && typeof layer !== 'undefined' && layer.close) { + try { layer.close(window._soonOpenLoadIdx); } catch (e) { /* ignore */ } + window._soonOpenLoadIdx = null; + } + if (typeof document === 'undefined') return; + var layers = document.querySelectorAll('.layui-layer'); + for (var j = 0; j < layers.length; j++) { + if (layers[j].getAttribute('type') !== 'loading') continue; + var loadTimes = layers[j].getAttribute('times'); + if (loadTimes && typeof layer !== 'undefined' && layer.close) { + try { layer.close(loadTimes); } catch (e2) { /* ignore */ } + } + } + var shades = document.querySelectorAll('.layui-layer-shade'); + for (var i = shades.length - 1; i >= 0; i--) { + var shade = shades[i]; + var times = shade.getAttribute('times'); + var layerEl = times ? document.getElementById('layui-layer' + times) : null; + if (!layerEl || layerEl.getAttribute('type') === 'loading') { + if (shade.parentNode) shade.parentNode.removeChild(shade); + } + } + } + + function soonLayerDismiss(type) { + if (typeof layer !== 'undefined' && layer.closeAll) { + layer.closeAll(type); + } + soonCleanupLayerMask(); + } + + function soonClearFabricOverlay(canvas) { + if (!canvas || !canvas.contextTop) return; + if (typeof canvas.clearContext === 'function') { + canvas.clearContext(canvas.contextTop); + } + } + + function soonRefreshDesignCanvases() { + var names = ['canvas1', 'canvas2', 'canvas']; + for (var n = 0; n < names.length; n++) { + var c = window[names[n]]; + if (!c || typeof c.renderAll !== 'function') continue; + if (c.discardActiveObject) c.discardActiveObject(); + soonClearFabricOverlay(c); + if (c.requestRenderAll) c.requestRenderAll(); + else c.renderAll(); + } + } + + function soonFinishSaveUi() { + if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading(); + else soonCleanupLayerMask(); + soonRefreshDesignCanvases(); + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(function () { + soonCleanupLayerMask(); + soonRefreshDesignCanvases(); + }); + } + } + function soonToast(message, type) { if (typeof layer === 'undefined' || !layer.msg) return; var skin = 'soon-toast'; if (type === 'success') skin += ' soon-toast--success'; else if (type === 'error') skin += ' soon-toast--error'; else if (type === 'warn') skin += ' soon-toast--warn'; + var time = type === 'success' ? 2500 : (type === 'error' ? 4200 : 3600); layer.msg(message, { - time: 2400, + time: time, shade: 0, offset: '72px', icon: -1, @@ -16,5 +83,10 @@ }); } + window.soonCleanupLayerMask = soonCleanupLayerMask; + window.soonLayerDismiss = soonLayerDismiss; + window.soonClearFabricOverlay = soonClearFabricOverlay; + window.soonRefreshDesignCanvases = soonRefreshDesignCanvases; + window.soonFinishSaveUi = soonFinishSaveUi; window.soonToast = soonToast; })(); diff --git a/frontend-web/js/design1/core.js b/frontend-web/js/design1/core.js index 91a6ee1..13370cf 100644 --- a/frontend-web/js/design1/core.js +++ b/frontend-web/js/design1/core.js @@ -102,10 +102,10 @@ function applyFontList(data) { $("#count_font_family").append(""); } } -if (typeof ipcRenderer !== 'undefined' && ipcRenderer) { - ipcRenderer.on('font-list', function(event, data) { applyFontList(data); }); -} else if (typeof window !== 'undefined' && window.platformBridge && window.platformBridge.getSystemFonts) { +if (typeof window !== 'undefined' && window.platformBridge && window.platformBridge.getSystemFonts) { window.platformBridge.getSystemFonts().then(applyFontList); +} else if (typeof ipcRenderer !== 'undefined' && ipcRenderer) { + ipcRenderer.on('font-list', function(event, data) { applyFontList(data); }); } function applySysLan(data) { let lang = localStorage.getItem("lang"); @@ -1816,7 +1816,7 @@ function initCanvasEvents() { const ctx = currentCanvas.getContext(); let bgObj = (currentCanvas === canvas1) ? background_image1 : background_image2; - if (bgObj) { + if (bgObj && !window._soonSkipDesign1CdMask) { const width = currentCanvas.width; const height = currentCanvas.height; diff --git a/frontend-web/js/design1/output.js b/frontend-web/js/design1/output.js index e86b32c..17765ee 100644 --- a/frontend-web/js/design1/output.js +++ b/frontend-web/js/design1/output.js @@ -1204,6 +1204,9 @@ window.openFile = function open(file, jAlready) { }).catch(function () { if (typeof window.soonReportOpenLoadError === 'function') window.soonReportOpenLoadError('模板/文件加载失败'); else if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading(); + }).finally(function () { + if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading(); + if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask(); }); } try { @@ -1277,6 +1280,8 @@ function soonPageDesignType() { } function onCloudWriteDone(res, fallback, callback) { + if (typeof window.soonFinishSaveUi === 'function') window.soonFinishSaveUi(); + else if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask(); applyCloudWriteResult(res, fallback); if (res && res.fileKey && typeof window.soonClearPendingImport === 'function') { window.soonClearPendingImport(); @@ -1284,7 +1289,12 @@ function onCloudWriteDone(res, fallback, callback) { if (typeof window.soonRecentOnCloudSave === 'function') { window.soonRecentOnCloudSave(res, fallback, soonPageDesignType()); } - layer.msg(language_str("saveSucc") + cloudSaveSuccessLabel(res, fallback)); + var msg = language_str("saveSucc") + cloudSaveSuccessLabel(res, fallback); + if (res && res.syncFailed) { + msg = '已保存到本地,云端同步失败'; + } + if (typeof window.soonToast === 'function') window.soonToast(msg, res && res.syncFailed ? 'warn' : 'success'); + else layer.msg(msg, { shade: 0, time: 2500 }); if (callback && typeof callback === 'function') callback(); } @@ -1328,6 +1338,7 @@ function saveAs(op1, callback) { if (!guardWebSave()) return; var prevOpenKey = openAs.name || ''; if (typeof window.soonApplySavePreview === 'function') window.soonApplySavePreview(con_o, canvas1); + if (typeof window.soonRefreshDesignCanvases === 'function') window.soonRefreshDesignCanvases(); con_o.soonType = 1; var dialogApi = (typeof dialog !== 'undefined' && dialog) ? dialog : (window.platformBridge && window.platformBridge.showSaveDialog ? { showSaveDialog: function(opts) { return window.platformBridge.showSaveDialog(opts); } } : null); if (!dialogApi) return; @@ -1395,6 +1406,7 @@ function save(op1, callback) { if (!guardWebSave()) return; var prevOpenKey = openAs.name || ''; if (typeof window.soonApplySavePreview === 'function') window.soonApplySavePreview(con_o, canvas1); + if (typeof window.soonRefreshDesignCanvases === 'function') window.soonRefreshDesignCanvases(); con_o.soonType = 1; var content = JSON.stringify(con_o); var needsDialog = typeof window.soonNeedsSaveDialog === 'function' diff --git a/frontend-web/js/design2/core.js b/frontend-web/js/design2/core.js index 2aeba60..7b67185 100644 --- a/frontend-web/js/design2/core.js +++ b/frontend-web/js/design2/core.js @@ -552,10 +552,6 @@ function relayoutDesign2Canvas() { if (typeof window.soonDesign2ScaleBgSources === 'function') { window.soonDesign2ScaleBgSources(); } - if (window.is_bgi_add) { - canvas1.requestRenderAll(); - canvas2.requestRenderAll(); - } } function soonDesign2ScaleBgSources() { @@ -572,16 +568,6 @@ function soonDesign2ScaleBgSources() { } } -function soonDesign2BgOverlayMetrics(imgEl) { - if (!imgEl || !imgEl.complete || imgEl.naturalWidth <= 0) return null; - var z = zoom || window.zoom || 0.5; - var w = parseFloat(imgEl.style.width); - if (!w || w <= 0) w = imgEl.naturalWidth * z; - var h = parseFloat(imgEl.style.height); - if (!h || h <= 0) h = imgEl.naturalHeight * z; - return { w: w, h: h }; -} - function soonDesign2PrepareBgSources(done) { var sf = document.getElementById('source_front'); var sb = document.getElementById('source_back'); @@ -634,7 +620,6 @@ function soonDesign2Kickoff() { window.soonDesign2ScaleBgSources = soonDesign2ScaleBgSources; window.soonDesign2PrepareBgSources = soonDesign2PrepareBgSources; -window.soonDesign2BgOverlayMetrics = soonDesign2BgOverlayMetrics; window.soonDesign2Kickoff = soonDesign2Kickoff; initRulers(); @@ -2037,10 +2022,10 @@ function applyFontList(data) { $('#count_font_family').append("') } } -if (typeof ipcRenderer !== 'undefined' && ipcRenderer) { - ipcRenderer.on('font-list', function(event, data) { applyFontList(data); }) -} else if (typeof window !== 'undefined' && window.platformBridge && window.platformBridge.getSystemFonts) { +if (typeof window !== 'undefined' && window.platformBridge && window.platformBridge.getSystemFonts) { window.platformBridge.getSystemFonts().then(applyFontList) +} else if (typeof ipcRenderer !== 'undefined' && ipcRenderer) { + ipcRenderer.on('font-list', function(event, data) { applyFontList(data); }) } function applySysLan(data) { let lang = localStorage.getItem('lang') diff --git a/frontend-web/js/design2/output.js b/frontend-web/js/design2/output.js index 48bde86..cc6164e 100644 --- a/frontend-web/js/design2/output.js +++ b/frontend-web/js/design2/output.js @@ -1096,6 +1096,8 @@ function soonPageDesignType() { } function onCloudWriteDone(res, fallback, callback) { + if (typeof window.soonFinishSaveUi === 'function') window.soonFinishSaveUi(); + else if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask(); applyCloudWriteResult(res, fallback); if (res && res.fileKey && typeof window.soonClearPendingImport === 'function') { window.soonClearPendingImport(); @@ -1103,7 +1105,12 @@ function onCloudWriteDone(res, fallback, callback) { if (typeof window.soonRecentOnCloudSave === 'function') { window.soonRecentOnCloudSave(res, fallback, soonPageDesignType()); } - layer.msg(language_str('saveSucc') + cloudSaveSuccessLabel(res, fallback)); + var msg = language_str('saveSucc') + cloudSaveSuccessLabel(res, fallback); + if (res && res.syncFailed) { + msg = '已保存到本地,云端同步失败'; + } + if (typeof window.soonToast === 'function') window.soonToast(msg, res && res.syncFailed ? 'warn' : 'success'); + else layer.msg(msg, { shade: 0, time: 2500 }); if (callback && typeof callback === 'function') callback(); } @@ -1147,6 +1154,7 @@ function saveAs(op1, callback) { if (!guardWebSave()) return; var prevOpenKey = openAs.name || ''; if (typeof window.soonApplySavePreview === 'function') window.soonApplySavePreview(con_o, canvas1); + if (typeof window.soonRefreshDesignCanvases === 'function') window.soonRefreshDesignCanvases(); con_o.soonType = 2; var dialogApi = (typeof dialog !== 'undefined' && dialog) ? dialog : (window.platformBridge && window.platformBridge.showSaveDialog ? { showSaveDialog: function(opts) { return window.platformBridge.showSaveDialog(opts); } } : null); if (!dialogApi) return; @@ -1166,7 +1174,8 @@ function saveAs(op1, callback) { if (fs) { fs.writeFileSync(fp, content, 'utf8'); openAs.name = fp; - layer.msg(language_str('saveSucc') + openAs.name); + if (typeof window.soonToast === 'function') window.soonToast(language_str('saveSucc') + openAs.name, 'success'); + else layer.msg(language_str('saveSucc') + openAs.name, { shade: 0 }); saveHistory(); if (callback && typeof callback === 'function') callback(); } @@ -1212,6 +1221,7 @@ function save(op1, callback) { if (!guardWebSave()) return; var prevOpenKey = openAs.name || ''; if (typeof window.soonApplySavePreview === 'function') window.soonApplySavePreview(con_o, canvas1); + if (typeof window.soonRefreshDesignCanvases === 'function') window.soonRefreshDesignCanvases(); con_o.soonType = 2; var content = JSON.stringify(con_o); var needsDialog = typeof window.soonNeedsSaveDialog === 'function' @@ -1225,7 +1235,8 @@ function save(op1, callback) { var fs = typeof require !== 'undefined' ? require('fs') : null; if (fs) { fs.writeFileSync(openAs.name, content, 'utf8'); - layer.msg(language_str('saveSucc') + openAs.name); + if (typeof window.soonToast === 'function') window.soonToast(language_str('saveSucc') + openAs.name, 'success'); + else layer.msg(language_str('saveSucc') + openAs.name, { shade: 0 }); saveHistory(); if (callback && typeof callback === 'function') callback(); } @@ -1248,7 +1259,8 @@ function save(op1, callback) { if (fs) { fs.writeFileSync(fp, content, 'utf8'); openAs.name = fp; - layer.msg(language_str('saveSucc') + openAs.name); + if (typeof window.soonToast === 'function') window.soonToast(language_str('saveSucc') + openAs.name, 'success'); + else layer.msg(language_str('saveSucc') + openAs.name, { shade: 0 }); saveHistory(); if (callback && typeof callback === 'function') callback(); } @@ -1529,6 +1541,9 @@ window.openFile = function open(file, jAlready) { }).catch(function () { if (typeof window.soonReportOpenLoadError === 'function') window.soonReportOpenLoadError('模板/文件加载失败'); else if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading(); + }).finally(function () { + if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading(); + if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask(); }); } try { diff --git a/frontend-web/js/design2/ui.js b/frontend-web/js/design2/ui.js index a64e1a1..558c99b 100644 --- a/frontend-web/js/design2/ui.js +++ b/frontend-web/js/design2/ui.js @@ -365,8 +365,6 @@ $('#version_change').click(function () { if (typeof window.soonDesign2ScaleBgSources === 'function') { window.soonDesign2ScaleBgSources() } - canvas1.requestRenderAll() - canvas2.requestRenderAll() } sf.onerror = sf.onload sf.src = soonAsset('front_bg' + bg_version + '_2.png') @@ -2703,42 +2701,10 @@ if (typeof window._design2CanvasEventsInitialized === 'undefined') { // - mouse:up (已在 core.js 中处理) // 如果需要 canvas1 特定的处理逻辑,请修改 core.js 中的相应函数 - // 保留 canvas1 特定的 after:render 处理 - canvas1.on('after:render', function () { - if (!window.is_bgi_add) return - var image = document.getElementById('source_front') - var metrics = typeof window.soonDesign2BgOverlayMetrics === 'function' - ? window.soonDesign2BgOverlayMetrics(image) - : null - if (!metrics) return - ctx1.drawImage( - image, - (canvas1.width - metrics.w) / 2, - (canvas1.height - metrics.h) / 2, - metrics.w, - metrics.h - ) - }) // 保留 canvas1 特定的 text:changed 处理 canvas1.on('text:changed', (e) => { $('#text_text').val(e.target.get('text')) }) - // 保留 canvas2 特定的 after:render 处理 - canvas2.on('after:render', function () { - if (!window.is_bgi_add) return - var image = document.getElementById('source_back') - var metrics = typeof window.soonDesign2BgOverlayMetrics === 'function' - ? window.soonDesign2BgOverlayMetrics(image) - : null - if (!metrics) return - ctx2.drawImage( - image, - (canvas2.width - metrics.w) / 2, - (canvas2.height - metrics.h) / 2, - metrics.w, - metrics.h - ) - }) requestAnimationFrame(function () { requestAnimationFrame(function () { if (typeof window.soonDesign2Kickoff === 'function') { diff --git a/frontend-web/js/platform/web.js b/frontend-web/js/platform/web.js index 618386e..716bc4e 100644 --- a/frontend-web/js/platform/web.js +++ b/frontend-web/js/platform/web.js @@ -47,6 +47,57 @@ 'Accept': 'application/json' }; + var CLOUD_JSON_LIMIT = 16 * 1024 * 1024 - 65536; + var CLOUD_STRIP_KEYS = [ + 'frontColorPic', 'backColorPic', 'frontBlackPic', 'backBlackPic', 'backDisplayPic' + ]; + var CLOUD_REGEN_IMAGE_TYPES = { 2: 1, 8: 1, 9: 1 }; + + function slimCloudFabricSide(data, sideKey, metaKey) { + var canvas = data[sideKey]; + var metaList = data[metaKey]; + if (!canvas || !Array.isArray(canvas.objects)) return; + canvas.objects.forEach(function (obj, i) { + if (!obj || typeof obj.src !== 'string') return; + var meta = Array.isArray(metaList) && i > 0 ? metaList[i - 1] : null; + var t = meta && meta.type; + if (CLOUD_REGEN_IMAGE_TYPES[t]) delete obj.src; + }); + } + + function slimCloudPayload(data) { + CLOUD_STRIP_KEYS.forEach(function (key) { delete data[key]; }); + var preview = data.frontDisplayPic; + if (typeof preview === 'string' && preview.length > 120000) delete data.frontDisplayPic; + slimCloudFabricSide(data, 'f', 'fo'); + slimCloudFabricSide(data, 'b', 'bo'); + return data; + } + + function formatPayloadTooLargeMessage(sizeBytes) { + var mb = Math.max(0.1, Math.round(sizeBytes / 1024 / 1024 * 10) / 10); + return '设计文件约 ' + mb + 'MB,超过云端上传限制。请减少或压缩自定义图片;' + + '若仍失败,请让管理员将 MySQL max_allowed_packet 调至 32M 以上。'; + } + + function prepareCloudJsonStr(jsonStr) { + if (typeof jsonStr !== 'string') jsonStr = String(jsonStr || '{}'); + if (jsonStr.length <= CLOUD_JSON_LIMIT) return jsonStr; + var slim = jsonStr; + try { + var data = slimCloudPayload(JSON.parse(jsonStr)); + slim = JSON.stringify(data); + if (slim.length <= CLOUD_JSON_LIMIT) return slim; + delete data.frontDisplayPic; + slim = JSON.stringify(data); + if (slim.length <= CLOUD_JSON_LIMIT) return slim; + } catch (e) { /* ignore */ } + var err = new Error(formatPayloadTooLargeMessage(slim.length)); + err.code = 'payload_too_large'; + err.status = 413; + throw err; + } + function parseCloudRef(p) { return typeof window.soonParseFileKey === 'function' ? window.soonParseFileKey(p) : null; } @@ -83,13 +134,12 @@ var err = new Error(info.message || 'cloud_error'); err.status = info.status; err.code = info.code; + err.apiNotified = true; return Promise.reject(err); } function requireCloudAuth(actionLabel) { - if (getAccessToken()) return true; - if (typeof window.soonRequireLogin === 'function') return window.soonRequireLogin(actionLabel); - return false; + return !!getAccessToken(); } function navigateToPage(pathWithQuery) { @@ -106,21 +156,41 @@ return 'en'; } - function getSystemFonts() { - var list = [ - 'Arial', 'Arial Black', 'Comic Sans MS', 'Courier New', 'Georgia', - 'Impact', 'Microsoft YaHei', 'SimHei', 'SimSun', 'KaiTi', 'FangSong', - 'Times New Roman', 'Trebuchet MS', 'Verdana', 'PingFang SC', 'Hiragino Sans GB' - ]; + var WEB_FONT_FALLBACK = [ + 'Arial', 'Arial Black', 'Comic Sans MS', 'Courier New', 'Georgia', + 'Impact', 'Microsoft YaHei', 'SimHei', 'SimSun', 'KaiTi', 'FangSong', + 'Times New Roman', 'Trebuchet MS', 'Verdana', 'PingFang SC', 'Hiragino Sans GB', + 'Helvetica Neue', 'Segoe UI', 'Tahoma', 'STHeiti', 'STSong', 'STKaiti', 'STFangsong' + ]; + + function mergeFontNames() { + var set = {}; + WEB_FONT_FALLBACK.forEach(function (name) { set[name] = 1; }); if (typeof document !== 'undefined' && document.fonts && document.fonts.forEach) { - var set = {}; document.fonts.forEach(function (f) { var name = (f.family || '').replace(/^["']|["']$/g, ''); if (name) set[name] = 1; }); - list = Object.keys(set).length ? Object.keys(set).sort() : list; } - return Promise.resolve(list); + return Object.keys(set).sort(); + } + + function getSystemFonts() { + if (typeof window !== 'undefined' && window.queryLocalFonts) { + return window.queryLocalFonts().then(function (fonts) { + var set = {}; + WEB_FONT_FALLBACK.forEach(function (name) { set[name] = 1; }); + (fonts || []).forEach(function (f) { + var name = (f.fullName || f.family || '').replace(/^["']|["']$/g, ''); + if (name) set[name] = 1; + }); + var merged = Object.keys(set).sort(); + return merged.length ? merged : mergeFontNames(); + }).catch(function () { + return mergeFontNames(); + }); + } + return Promise.resolve(mergeFontNames()); } function listCloudFiles(pageOrLimit, sizeOrOffset) { @@ -152,6 +222,11 @@ function createCloudFile(name, jsonStr) { if (!requireCloudAuth('保存')) return Promise.reject(new Error('unauthorized')); + try { + jsonStr = prepareCloudJsonStr(jsonStr); + } catch (e) { + return Promise.reject(e); + } return authedFetch('files', { method: 'POST', headers: JSON_HEADERS, @@ -197,6 +272,11 @@ function updateCloudFile(id, name, jsonStr, version) { if (!requireCloudAuth('保存')) return Promise.reject(new Error('unauthorized')); + try { + jsonStr = prepareCloudJsonStr(jsonStr); + } catch (e) { + return Promise.reject(e); + } var body = { name: normalizeSoonName(name), json: jsonStr }; if (version != null) body.version = version; return authedFetch('files/' + id, { @@ -511,14 +591,23 @@ var name = input && input.value ? String(input.value).trim() : defaultName; name = resolveSaveDialogName(name, options, isSoon); layer.close(index); + if (typeof window.soonCleanupLayerMask === 'function') { + setTimeout(function () { window.soonCleanupLayerMask(); }, 0); + } resolve({ canceled: false, filePath: name, useCloud: !!isSoon }); }, btn2: function (index) { layer.close(index); + if (typeof window.soonCleanupLayerMask === 'function') { + setTimeout(function () { window.soonCleanupLayerMask(); }, 0); + } resolve({ canceled: true }); }, cancel: function (index) { layer.close(index); + if (typeof window.soonCleanupLayerMask === 'function') { + setTimeout(function () { window.soonCleanupLayerMask(); }, 0); + } resolve({ canceled: true }); } }); @@ -563,59 +652,75 @@ return cacheWriteForKey(res.fileKey, meta).then(function () { return res; }); } - if (!getAccessToken()) { - if (cloudRef && cloudRef.id) { - requireCloudAuth('保存'); - return Promise.reject(new Error('unauthorized')); + function resolveLocalSaveKey() { + if (name.indexOf('soondesign_session:') === 0) return name; + if (name.indexOf('soondesign_file:') === 0) return name; + if (typeof window.soonMakeSessionKey === 'function') { + return window.soonMakeSessionKey(name); } - var sessionKey = typeof window.soonMakeSessionKey === 'function' - ? window.soonMakeSessionKey(name) - : (name.indexOf('soondesign_session:') === 0 - ? name - : 'soondesign_session:' + fileName.replace(/\.soon$/i, '')); - var prevSessionKey = name.indexOf('soondesign_session:') === 0 ? name : ''; - function finishSessionSave() { - try { sessionStorage.setItem(sessionKey, 'idb'); } catch (e) { /* ignore */ } - return { fileKey: sessionKey, name: fileName, version: 0 }; + return 'soondesign_session:' + fileName.replace(/\.soon$/i, ''); + } + + function localSaveMeta(localKey) { + if (localKey.indexOf('soondesign_file:') === 0) { + return { source: 'cloud', name: fileName, type: parsedJson ? parsedJson.soonType : 1 }; } - if (prevSessionKey && prevSessionKey !== sessionKey && - typeof window.soonLocalRenameKey === 'function') { - return cacheWriteForKey(sessionKey, { source: 'session', name: fileName }).then(function () { - return window.soonLocalRenameKey(prevSessionKey, sessionKey, { source: 'session', name: fileName }) - .then(finishSessionSave); - }).catch(function () { - return Promise.reject(new Error('save_failed')); + return { source: 'session', name: fileName, type: parsedJson ? parsedJson.soonType : 1 }; + } + + function finishLocalSave(localKey) { + if (localKey.indexOf('soondesign_session:') === 0) { + try { sessionStorage.setItem(localKey, 'idb'); } catch (e) { /* ignore */ } + } + return { fileKey: localKey, name: fileName, version: 0, localOnly: true }; + } + + function saveLocalFirst() { + var localKey = resolveLocalSaveKey(); + var prevKey = (name.indexOf('soondesign_session:') === 0 || name.indexOf('soondesign_file:') === 0) ? name : ''; + var meta = localSaveMeta(localKey); + if (prevKey && prevKey !== localKey && typeof window.soonLocalRenameKey === 'function') { + return cacheWriteForKey(localKey, meta).then(function () { + return window.soonLocalRenameKey(prevKey, localKey, meta).then(function () { + return finishLocalSave(localKey); + }); }); } - return cacheWriteForKey(sessionKey, { source: 'session', name: fileName }).then(function () { - return finishSessionSave(); + return cacheWriteForKey(localKey, meta).then(function () { + return finishLocalSave(localKey); + }); + } + + function syncCloudIfAuthed(localResult) { + if (!getAccessToken()) return Promise.resolve(localResult); + if (cloudRef && cloudRef.id) { + var ver = cloudRef.version; + if (ver == null && window._soonFileMeta && window._soonFileMeta.id === cloudRef.id) { + ver = window._soonFileMeta.version; + } + var cloudSaveName = fileName; + if (typeof window.soonResolveCloudFileName === 'function') { + var resolved = window.soonResolveCloudFileName(name); + if (resolved) cloudSaveName = resolved; + } + return updateCloudFile(cloudRef.id, cloudSaveName, str, ver).then(function (res) { + return afterCloudSave(res, localResult.fileKey || name); + }).catch(function () { + localResult.syncFailed = true; + return localResult; + }); + } + var prevKey = localResult.fileKey || name; + return createCloudFile(fileName, str).then(function (res) { + return afterCloudSave(res, prevKey); }).catch(function () { - return Promise.reject(new Error('save_failed')); + localResult.syncFailed = true; + return localResult; }); } - if (!requireCloudAuth('保存')) { - return Promise.reject(new Error('unauthorized')); - } - - if (cloudRef && cloudRef.id) { - var ver = cloudRef.version; - if (ver == null && window._soonFileMeta && window._soonFileMeta.id === cloudRef.id) { - ver = window._soonFileMeta.version; - } - if (typeof window.soonResolveCloudFileName === 'function') { - var cloudSaveName = window.soonResolveCloudFileName(name); - if (cloudSaveName) fileName = cloudSaveName; - } - var prevCloudKey = name; - return updateCloudFile(cloudRef.id, fileName, str, ver).then(function (res) { - return afterCloudSave(res, prevCloudKey); - }); - } - - var prevKey = name.indexOf('soondesign_session:') === 0 ? name : ''; - return createCloudFile(fileName, str).then(function (res) { - return afterCloudSave(res, prevKey); + return saveLocalFirst().then(syncCloudIfAuthed).catch(function () { + return Promise.reject(new Error('save_failed')); }); }, readFile: function (pathOrHandle) { @@ -713,6 +818,11 @@ window.ipcRenderer = { send: function (ch, a1, a2) { if (ch === 'get-sys-language') { window._sysLanPending = true; setTimeout(function () { if (window._sysLanCb && window._sysLanPending) { window._sysLanCb(null, loc); window._sysLanPending = false; } }, 0); } + if (ch === 'get-sys-fonts' && bridge.getSystemFonts) { + bridge.getSystemFonts().then(function (fonts) { + if (typeof window._fontListCb === 'function') window._fontListCb(null, fonts || []); + }); + } if (ch === 'open-first-page' && bridge.openFirstPage) { bridge.openFirstPage(); } if (ch === 'open-design-page' && bridge.openDesignPage && (a1 !== undefined || a2 !== undefined)) { bridge.openDesignPage(a1 || '', a2 || 1); } if (ch === 'open-help-file' && bridge.openHelp) { bridge.openHelp(); } @@ -720,6 +830,14 @@ }, on: function (ch, cb) { if (ch === 'sys-lan') { window._sysLanCb = cb; if (window._sysLanPending) setTimeout(function () { if (window._sysLanCb) { window._sysLanCb(null, loc); window._sysLanPending = false; } }, 0); } + if (ch === 'font-list') { + window._fontListCb = cb; + if (bridge.getSystemFonts) { + bridge.getSystemFonts().then(function (fonts) { + if (typeof window._fontListCb === 'function') window._fontListCb(null, fonts || []); + }); + } + } if (ch === 'close') window._closeCb = cb; } }; diff --git a/frontend-web/pages/admin/assets/admin-shell.css b/frontend-web/pages/admin/assets/admin-shell.css index db209d7..f0e815f 100644 --- a/frontend-web/pages/admin/assets/admin-shell.css +++ b/frontend-web/pages/admin/assets/admin-shell.css @@ -486,12 +486,45 @@ body.soon-admin-page .soon-admin-date-wrap input[type="date"].soon-input::-webki border-radius: 4px; } .soon-admin-form { padding: var(--soon-space-md); } +.soon-admin-form--modal-shell { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + width: 100%; + padding: 0; + box-sizing: border-box; +} +.soon-admin-form__scroll { + flex: 1; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding: var(--soon-space-md); + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.18) transparent; +} +.soon-admin-form__scroll::-webkit-scrollbar { width: 8px; } +.soon-admin-form__scroll::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.16); + border-radius: 4px; +} +.soon-layer-content--form-fixed .soon-admin-form--modal-shell { + height: 100%; +} +.soon-admin-form--modal-shell.soon-admin-form--plan .soon-admin-form__scroll, +.soon-admin-tpl-modal.soon-admin-form--modal-shell .soon-admin-form__scroll { + padding: var(--soon-space-lg); +} .soon-admin-form--plan { padding: var(--soon-space-lg); } .soon-admin-form--plan .soon-admin-form-panel:last-of-type { margin-bottom: 0; } +.soon-admin-form--modal-shell .soon-admin-form-panel:last-of-type { + margin-bottom: 0; +} .soon-admin-form-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -677,6 +710,22 @@ body.soon-admin-page .soon-admin-date-wrap input[type="date"].soon-input::-webki padding-top: var(--soon-space-md); border-top: 1px solid var(--soon-border); } +.soon-admin-form__foot { + display: flex; + justify-content: flex-end; + gap: var(--soon-space-sm); + flex-shrink: 0; + margin-top: 0; + padding: var(--soon-space-md) var(--soon-space-lg); + border-top: 1px solid var(--soon-border); + background: var(--soon-bg-elevated); +} +.soon-admin-tpl-modal.soon-admin-form--modal-shell { + padding: 0; +} +.soon-admin-tpl-modal__actions.soon-admin-form__foot { + padding-top: var(--soon-space-md); +} .soon-admin-filter-bar { display: flex; diff --git a/frontend-web/pages/admin/assets/js/core/ui.js b/frontend-web/pages/admin/assets/js/core/ui.js index 5065573..3557ad6 100644 --- a/frontend-web/pages/admin/assets/js/core/ui.js +++ b/frontend-web/pages/admin/assets/js/core/ui.js @@ -141,10 +141,62 @@ setBtnLoading(btn, true, opts.loadingLabel); ret.finally(function () { setBtnLoading(btn, false); + if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask(); }); } } + function layerContentEl(layero) { + if (layero && typeof layero.find === 'function') { + return layero.find('.layui-layer-content')[0] || null; + } + if (layero && layero.querySelector) { + return layero.querySelector('.layui-layer-content'); + } + return null; + } + + /** 弹窗表单:内容区滚动,保存按钮固定底栏 */ + function layoutAdminFormModal(contentEl, opts) { + if (!contentEl || contentEl.dataset.soonFormLayout) return; + opts = opts || {}; + var root = contentEl.querySelector('.soon-admin-form, .soon-admin-tpl-modal'); + if (!root) return; + + var foot = root.querySelector('.soon-admin-form__actions, .soon-admin-tpl-modal__actions'); + if (!foot) { + var saveBtn = root.querySelector('#adminModalSave'); + if (!saveBtn) return; + foot = document.createElement('div'); + foot.className = 'soon-admin-form__actions'; + saveBtn.parentNode.insertBefore(foot, saveBtn); + foot.appendChild(saveBtn); + } + + var scroll = document.createElement('div'); + scroll.className = 'soon-admin-form__scroll'; + while (root.firstChild && root.firstChild !== foot) { + scroll.appendChild(root.firstChild); + } + if (!scroll.childNodes.length) return; + + root.insertBefore(scroll, foot); + root.classList.add('soon-admin-form--modal-shell'); + foot.classList.add('soon-admin-form__foot'); + + contentEl.classList.add('soon-layer-content--form'); + var area = opts.area || ['420px', 'auto']; + if (area[1] && area[1] !== 'auto') { + contentEl.classList.add('soon-layer-content--form-fixed'); + contentEl.style.height = area[1]; + contentEl.style.maxHeight = area[1]; + } else if (opts.maxHeight) { + contentEl.classList.add('soon-layer-content--form-fixed'); + contentEl.style.maxHeight = opts.maxHeight; + } + contentEl.dataset.soonFormLayout = '1'; + } + function openFormModal(title, formHtml, onSave, opts) { if (typeof layer === 'undefined') return; opts = opts || {}; @@ -156,16 +208,8 @@ maxWidth: opts.maxWidth || '96vw', content: formHtml, success: function (layero) { - var area = opts.area || ['420px', 'auto']; - if (area[1] && area[1] !== 'auto') { - var contentEl = null; - if (layero && typeof layero.find === 'function') { - contentEl = layero.find('.layui-layer-content')[0]; - } else if (layero && layero.querySelector) { - contentEl = layero.querySelector('.layui-layer-content'); - } - if (contentEl) contentEl.classList.add('soon-layer-content--scroll'); - } + var contentEl = layerContentEl(layero); + layoutAdminFormModal(contentEl, opts); var btn = document.getElementById('adminModalSave'); if (btn) { btn.onclick = function () { diff --git a/frontend-web/pages/admin/assets/js/views/orders.js b/frontend-web/pages/admin/assets/js/views/orders.js index 414d335..98a768a 100644 --- a/frontend-web/pages/admin/assets/js/views/orders.js +++ b/frontend-web/pages/admin/assets/js/views/orders.js @@ -4,6 +4,7 @@ var Api = function () { return window.AdminApi; }; var Ui = function () { return window.AdminUi; }; var I = function () { return window.AdminI18n; }; + var Form = function () { return window.AdminForm; }; var State = function () { return window.AdminState; }; var Filter = function () { return window.AdminFilter; }; var Menu = function () { return window.AdminMenu; }; @@ -162,7 +163,7 @@ '
' + '
' + - ''; + Form().actions('确认', 'adminModalSave') + ''; Ui().openFormModal(title, form, function () { return onSave(Ui().el('orderNote').value.trim()); }); @@ -246,7 +247,7 @@ openNoteModal(title, '可选:填写审核备注', function (note) { return Api().postJson('orders/' + btn.dataset.id + '/refund/approve', { note: note }).then(function (r) { if (r) { - layer.closeAll(); + soonLayerDismiss(); var gw = r.data && r.data.gateway; var msg = '退款已完成'; if (gw && gw.indexOf('skipped') === 0) msg += '(未配置支付密钥,仅标记退款并撤销会员)'; @@ -268,7 +269,7 @@ } return Api().postJson('orders/' + btn.dataset.id + '/refund/reject', { note: note }).then(function (r) { if (r) { - layer.closeAll(); + soonLayerDismiss(); soonToast('已拒绝退款申请', 'success'); Ui().closeDrawer(); render(); diff --git a/frontend-web/pages/admin/assets/js/views/plans.js b/frontend-web/pages/admin/assets/js/views/plans.js index d5cf258..e8463f2 100644 --- a/frontend-web/pages/admin/assets/js/views/plans.js +++ b/frontend-web/pages/admin/assets/js/views/plans.js @@ -192,7 +192,7 @@ }; return Api().postJson('plans', body).then(function (r) { if (r) { - layer.closeAll(); + soonLayerDismiss(); soonToast('已保存', 'success'); render(); } diff --git a/frontend-web/pages/admin/assets/js/views/settings.js b/frontend-web/pages/admin/assets/js/views/settings.js index f1a89c8..41963d8 100644 --- a/frontend-web/pages/admin/assets/js/views/settings.js +++ b/frontend-web/pages/admin/assets/js/views/settings.js @@ -87,19 +87,20 @@ var form = '
' + Form().field('配置键', '') + Form().field('配置值', Form().input('settingVal', { value: val, type: 'textarea', rows: 5 })) + - '
'; + Form().actions('保存', 'adminModalSave') + ''; Ui().openFormModal('修改 ' + key, form, function () { Ui().confirm('确认保存该配置?', function () { var saveBtn = Ui().el('adminModalSave'); Ui().setBtnLoading(saveBtn, true); Api().postJson('settings', { key: key, value: Ui().el('settingVal').value }).then(function (r) { if (r) { - layer.closeAll(); + soonLayerDismiss(); soonToast('配置已保存', 'success'); render(); } }).finally(function () { Ui().setBtnLoading(saveBtn, false); + if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask(); }); }); }); diff --git a/frontend-web/pages/admin/assets/js/views/templates.js b/frontend-web/pages/admin/assets/js/views/templates.js index 3c0f0d9..6a14e1e 100644 --- a/frontend-web/pages/admin/assets/js/views/templates.js +++ b/frontend-web/pages/admin/assets/js/views/templates.js @@ -284,7 +284,7 @@ }).then(function (r) { if (r) { revokeBlob(modalState); - layer.closeAll(); + soonLayerDismiss(); soonToast('已上传', 'success'); render(); } @@ -307,7 +307,7 @@ return Api().putJson('templates/' + plan.id, body).then(function (r) { if (r) { revokeBlob(modalState); - layer.closeAll(); + soonLayerDismiss(); soonToast('已保存', 'success'); render(); } diff --git a/frontend-web/pages/admin/assets/js/views/users.js b/frontend-web/pages/admin/assets/js/views/users.js index 2e480d1..1d82be3 100644 --- a/frontend-web/pages/admin/assets/js/views/users.js +++ b/frontend-web/pages/admin/assets/js/views/users.js @@ -8,6 +8,7 @@ var Filter = function () { return window.AdminFilter; }; var Menu = function () { return window.AdminMenu; }; var Router = function () { return window.AdminRouter; }; + var Form = function () { return window.AdminForm; }; function buildQuery() { var st = State().users; @@ -57,7 +58,7 @@ ? formField('管理级别', '', '运营管理员不可访问设置、审计与支付密钥') : '') + - ''; + Form().actions('创建', 'adminModalSave') + ''; Ui().openFormModal('新增用户', form, function () { var email = Ui().el('ucEmail').value.trim(); var pwd = Ui().el('ucPwd').value; @@ -76,7 +77,7 @@ } return Api().postJson('users', body).then(function (r) { if (r) { - layer.closeAll(); + soonLayerDismiss(); soonToast('用户已创建', 'success'); render(); } @@ -95,7 +96,7 @@ ? formField('管理级别', '
', '仅对管理员账号生效') : '') + - ''; + Form().actions('保存', 'adminModalSave') + ''; Ui().openFormModal('编辑用户 #' + user.id, form, function () { var body = { email: Ui().el('ueEmail').value.trim(), @@ -107,7 +108,7 @@ } return Api().putJson('users/' + user.id, body).then(function (r) { if (r) { - layer.closeAll(); + soonLayerDismiss(); soonToast('用户信息已更新', 'success'); render(); } @@ -121,7 +122,7 @@ var form = '
' + formField('新密码', '') + formField('确认密码', '') + - '
'; + Form().actions('重置密码', 'adminModalSave') + ''; Ui().openFormModal('重置密码', form, function () { var p1 = Ui().el('upPwd').value; var p2 = Ui().el('upPwd2').value; @@ -135,7 +136,7 @@ } return Api().postJson('users/' + userId + '/password', { password: p1 }).then(function (r) { if (r) { - layer.closeAll(); + soonLayerDismiss(); soonToast('密码已重置', 'success'); } }); @@ -153,7 +154,7 @@ formField('套餐', '') + formField('有效天数', '') + '

选择免费版将取消当前有效会员。

' + - ''; + Form().actions('保存', 'adminModalSave') + ''; Ui().openFormModal('设置会员', form, function () { var planSel = Ui().el('usPlan'); var daysVal = Ui().el('usDays').value.trim(); @@ -161,7 +162,7 @@ if (daysVal) body.days = parseInt(daysVal, 10); return Api().postJson('users/' + userId + '/subscription', body).then(function (r) { if (r) { - layer.closeAll(); + soonLayerDismiss(); soonToast('会员已更新', 'success'); render(); } diff --git a/frontend-web/pages/index.web.html b/frontend-web/pages/index.web.html index 7baa4b8..a1338d4 100644 --- a/frontend-web/pages/index.web.html +++ b/frontend-web/pages/index.web.html @@ -139,6 +139,7 @@ +