Compare commits

...

10 Commits

Author SHA1 Message Date
24kycj 0f196bdec8 新增忽略 2026-06-25 09:19:53 +08:00
24kycj 7dadc41bb1 fix(web): 弹窗登录注册后刷新顶栏,导出流程支持内嵌注册
弹窗登录/注册成功后隐藏 guest 顶栏并更新会员身份;导出打印 gate 内立即注册改为同层弹窗,注册完自动继续会员验证。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 09:18:38 +08:00
24kycj 5fb269cc0a fix(web): 本地优先保存与 design 页体验修复
- 保存先写本地缓存,登录后可选云端同步;退出登录不再因文件操作跳转登录

- 修复 Layui 遮罩残留、design2 保存后线条、toast 被 finally 提前关闭

- 保存过程恢复 loading spinner;成功提示 2.5 秒

- 云端 payload 瘦身与体积超限提示;后端 schema 迁移与 FileService 容错

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 19:24:34 +08:00
24kycj 4d19d7e814 优化支付宝支付流程与 Web 端体验
- 支付宝新窗口跳转,轮询与手动刷新增加加载反馈
- 支付回跳先校验订单状态,避免未付款误报成功
- 门户登录回调统一,云文件打开加载与版本冲突同步
- 精简本地 start.bat 与 README 说明

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 11:27:45 +08:00
24kycj 23b958753d 更新 2026-06-12 11:53:18 +08:00
24kycj 55a6029779 完善 .gitignore:忽略 backend storage 运行时文件
限流缓存、日志、用户文件、微信证书缓存等目录仅保留 .gitkeep;从版本库移除已误提交的 rate/*.json。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 11:07:48 +08:00
24kycj 4ce4486b26 Web 端本地缓存、首页与会员弹窗体验优化
新增 IndexedDB 与最近文件 L1 缓存、云保存/打开链路;首页支持下载与清空本地缓存;修复清空 storage 后语言初始化报错;优化 design2 背景加载与侧栏标签样式;完善会员激活/支付弹层样式;后端补充文件缩略图字段与 thumb 接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 11:05:44 +08:00
24kycj 877fd278c2 Web 端数据交互与 design2 布局修复
统一门户 JSON 契约:新增 GET /templates/{id},模板/云文件/保存/打开走虚拟 key;
layer 保存弹层替代 prompt,本地 .soon 已登录后台 POST 登记;修复保存静默失败与 design2 全宽布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 01:20:34 +08:00
24kycj ebe191b06d 永久会员与模板库后台化:预览门控、轻量首页与 Admin CRUD
- 会员改为永久激活方案;云端文件不再拦截非会员;预览弹窗内导出/打印才校验
- 首页最近文件支持本地记录,登录后与云端合并;移除独立会员页与订阅页
- 模板库入库管理:soon_templates 表、Admin 上传 CRUD、/templates 轻量列表与按需下载

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 23:52:56 +08:00
24kycj 152228d41f 前端配置迁至 config/local.js,完善支付、模板库与部署脚本
- 页面直接引用 local.js 设置 SOON_DEPLOY_CONFIG,移除 deploy-config
- Docker sync-config 生成 local.js;更新 README 与 agent-core 说明
- 模板库自动扫描 .soon;新增后端部署/种子/排查脚本
- 完善支付配置、订阅弹窗与后台支付管理页

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 20:48:51 +08:00
102 changed files with 8292 additions and 3142 deletions
+2 -1
View File
@@ -17,7 +17,8 @@ alwaysApply: true
- **继承上下文**:结合整段对话;最新一条默认延续当前任务,区分细化与换方向。 - **继承上下文**:结合整段对话;最新一条默认延续当前任务,区分细化与换方向。
- **回复**:简体中文;标识符与代码保持原文。结构:**结论** → **要点** → **命令**(若有)。少寒暄、少加粗;禁「保证/一定/完美/彻底解决」。 - **回复**:简体中文;标识符与代码保持原文。结构:**结论** → **要点** → **命令**(若有)。少寒暄、少加粗;禁「保证/一定/完美/彻底解决」。
- **引用代码**:独占一行 `startLine:endLine:filepath`,行号与路径须真实。 - **引用代码**:独占一行 `startLine:endLine:filepath`,行号与路径须真实。
- **中文 UTF-8**:含中文文件 UTF-8 无 BOM;灌库只用 `scripts/apply-mysql-utf8.ps1`(禁 `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`)。
## 改动 ## 改动
+11 -1
View File
@@ -6,14 +6,24 @@ npm-debug.log*
dist/*.zip dist/*.zip
_salvage/ _salvage/
backend-web/config/local.php backend-web/config/local.php
frontend-web/config/local.js
docker/.env docker/.env
docker/config/local.php docker/config/local.php
docker/config/deploy-config.js docker/config/local.js
soonModels/*.soon soonModels/*.soon
# backend-web 运行时 storage(仅保留各目录 .gitkeep
backend-web/storage/payment/* backend-web/storage/payment/*
backend-web/storage/cache/rate/*
backend-web/storage/cache/wechat_certs/*
backend-web/storage/logs/*
backend-web/storage/users/*
backend-web/storage/templates/*
!backend-web/storage/**/.gitkeep !backend-web/storage/**/.gitkeep
*.log *.log
*.tmp *.tmp
*.bak *.bak
*.pem *.pem
*.key *.key
/soonDesign-back
/info
/soonModels
+6 -9
View File
@@ -8,21 +8,18 @@
|---|---| |---|---|
| [frontend-electron/](frontend-electron/) | 桌面程序 | | [frontend-electron/](frontend-electron/) | 桌面程序 |
| [frontend-web/](frontend-web/) | Web 前端 | | [frontend-web/](frontend-web/) | Web 前端 |
| [backend-web/](backend-web/) | PHP API | | [backend-web/](backend-web/) | PHP API(含 `database/schema.sql` |
| [docker/](docker/) | 本地 Docker 编排(非生产部署) | | [docker/](docker/) | 本地 Docker 编排(非生产部署) |
## 本地开发 ## 本地开发
需 [Docker Desktop](https://www.docker.com/products/docker-desktop/)。 需 [Docker Desktop](https://www.docker.com/products/docker-desktop/)。本地只需运行:
```bat ```bat
docker\sync-config.ps1 :: 同步 API 地址到 deploy-config.js 与 backend local.php(改端口后必跑) scripts\start.bat
scripts\start.bat :: 启动
scripts\start.bat stop :: 停止(保留数据库)
scripts\start.bat reset :: 停止并清空数据库卷
``` ```
`frontend-web/assets/deploy-config.js` `docker/sync-config.ps1` 根据 `docker/.env` 端口生成,请勿手改 localhost 后提交;生产环境在部署时写入实际 API 地址。 脚本会自动:同步 `local.js` / `local.php`、启动 Docker 容器、等待 API。改 `docker/.env` 端口后重新运行即可。停止:`docker compose -p soondesign -f docker/docker-compose.yml down`
| 服务 | 默认地址 | | 服务 | 默认地址 |
|------|----------| |------|----------|
@@ -42,9 +39,9 @@ scripts\start.bat reset :: 停止并清空数据库卷
- Web 根目录:`frontend-web/` - Web 根目录:`frontend-web/`
- PHP 根目录:`backend-web/public/` - 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` - 后端:`backend-web/config/local.php.example``local.php`
- 前端 API`frontend-web/assets/deploy-config.js` - 前端 API`frontend-web/config/local.js`(生产配置,勿覆盖)
支付与 notify 说明见 [docs/PAYMENT.md](docs/PAYMENT.md)。列表分页契约见 [docs/API-PAGINATION.md](docs/API-PAGINATION.md)。 支付与 notify 说明见 [docs/PAYMENT.md](docs/PAYMENT.md)。列表分页契约见 [docs/API-PAGINATION.md](docs/API-PAGINATION.md)。
+5 -1
View File
@@ -26,7 +26,8 @@ return [
], ],
'storage' => [ 'storage' => [
'users_dir' => '/www/wwwroot/designadmin.cardsoon.com/backend/storage/users', 'users_dir' => '/www/wwwroot/designadmin.cardsoon.com/backend/storage/users',
'models_dir' => '/www/wwwroot/design.cardsoon.com/soonModels', 'models_dir' => '/www/wwwroot/designadmin.cardsoon.com/soonModels',
'templates_dir' => '/www/wwwroot/designadmin.cardsoon.com/backend/storage/templates',
], ],
'limits' => [ 'limits' => [
'free_quota_mb' => 20, 'free_quota_mb' => 20,
@@ -54,4 +55,7 @@ return [
'api_v3_key' => '', 'api_v3_key' => '',
'sandbox' => false, 'sandbox' => false,
], ],
'payment' => [
'display_channels' => 'alipay',
],
]; ];
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS `soon_files` (
`json` LONGTEXT NOT NULL, `json` LONGTEXT NOT NULL,
`size` INT UNSIGNED NOT NULL DEFAULT 0, `size` INT UNSIGNED NOT NULL DEFAULT 0,
`version` INT UNSIGNED NOT NULL DEFAULT 1, `version` INT UNSIGNED NOT NULL DEFAULT 1,
`thumb` MEDIUMTEXT DEFAULT NULL,
`created_at` DATETIME NOT NULL, `created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL, `updated_at` DATETIME NOT NULL,
`deleted_at` DATETIME DEFAULT NULL, `deleted_at` DATETIME DEFAULT NULL,
@@ -48,12 +49,14 @@ CREATE TABLE IF NOT EXISTS `plans` (
INSERT INTO `plans` (`code`,`name`,`description`,`price_cents`,`quota_mb`,`max_files`,`duration_days`,`features`,`sort_order`,`is_recommended`,`is_active`) VALUES INSERT INTO `plans` (`code`,`name`,`description`,`price_cents`,`quota_mb`,`max_files`,`duration_days`,`features`,`sort_order`,`is_recommended`,`is_active`) VALUES
('free','免费版','免费体验设计与编辑',0,2048,200,0, ('free','免费版','免费体验设计与编辑',0,2048,200,0,
'["设计与编辑工具免费使用"]',0,0,1), '["设计与编辑工具免费使用"]',0,0,1),
('member_lifetime','永久会员','一次激活,永久使用预览交付能力',100,2048,200,0,
'["高清预览","成品打印","云端保存","导出设计文件","永久有效"]',10,1,1),
('member_monthly','月度订阅','按月灵活使用,随时续订',1999,2048,200,30, ('member_monthly','月度订阅','按月灵活使用,随时续订',1999,2048,200,30,
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 个月"]',10,1,1), '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 个月"]',20,0,0),
('member_quarterly','季度订阅','连续三个月,更省心',5299,2048,200,90, ('member_quarterly','季度订阅','连续三个月,更省心',5299,2048,200,90,
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 季"]',15,0,1), '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 季"]',25,0,0),
('member_yearly','年度订阅','全年畅享,性价比更高',19999,2048,200,365, ('member_yearly','年度订阅','全年畅享,性价比更高',19999,2048,200,365,
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 年"]',20,0,1) '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 年"]',30,0,0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
name=VALUES(name), name=VALUES(name),
description=VALUES(description), description=VALUES(description),
@@ -63,7 +66,8 @@ ON DUPLICATE KEY UPDATE
duration_days=VALUES(duration_days), duration_days=VALUES(duration_days),
features=VALUES(features), features=VALUES(features),
sort_order=VALUES(sort_order), sort_order=VALUES(sort_order),
is_recommended=VALUES(is_recommended); is_recommended=VALUES(is_recommended),
is_active=VALUES(is_active);
CREATE TABLE IF NOT EXISTS `subscriptions` ( CREATE TABLE IF NOT EXISTS `subscriptions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -114,6 +118,20 @@ CREATE TABLE IF NOT EXISTS `audit_logs` (
KEY `admin` (`admin_id`, `created_at`) KEY `admin` (`admin_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
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;
CREATE TABLE IF NOT EXISTS `settings` ( CREATE TABLE IF NOT EXISTS `settings` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`key` VARCHAR(120) NOT NULL, `key` VARCHAR(120) NOT NULL,
+8
View File
@@ -9,6 +9,7 @@ use Soon\Api\Admin\Controllers\PaymentController;
use Soon\Api\Admin\Controllers\PlansController; use Soon\Api\Admin\Controllers\PlansController;
use Soon\Api\Admin\Controllers\SettingsController; use Soon\Api\Admin\Controllers\SettingsController;
use Soon\Api\Admin\Controllers\StatsController; use Soon\Api\Admin\Controllers\StatsController;
use Soon\Api\Admin\Controllers\TemplatesController;
use Soon\Api\Admin\Controllers\UsersController; use Soon\Api\Admin\Controllers\UsersController;
use Soon\Api\Core\Config; use Soon\Api\Core\Config;
use Soon\Api\Core\Json; use Soon\Api\Core\Json;
@@ -58,6 +59,12 @@ Router::post('/api/admin/orders/{id}/refund/reject', [OrdersController::class, '
Router::get('/api/admin/plans', [PlansController::class, 'list']); Router::get('/api/admin/plans', [PlansController::class, 'list']);
Router::post('/api/admin/plans', [PlansController::class, 'upsert']); Router::post('/api/admin/plans', [PlansController::class, 'upsert']);
Router::get('/api/admin/templates', [TemplatesController::class, 'list']);
Router::get('/api/admin/templates/{id}/thumb', [TemplatesController::class, 'thumb']);
Router::post('/api/admin/templates', [TemplatesController::class, 'create']);
Router::put('/api/admin/templates/{id}', [TemplatesController::class, 'update']);
Router::delete('/api/admin/templates/{id}', [TemplatesController::class, 'delete']);
Router::get('/api/admin/settings', [SettingsController::class, 'list']); Router::get('/api/admin/settings', [SettingsController::class, 'list']);
Router::post('/api/admin/settings', [SettingsController::class, 'set']); Router::post('/api/admin/settings', [SettingsController::class, 'set']);
Router::post('/api/admin/settings/batch', [SettingsController::class, 'setBatch']); Router::post('/api/admin/settings/batch', [SettingsController::class, 'setBatch']);
@@ -65,6 +72,7 @@ Router::post('/api/admin/settings/batch', [SettingsController::class, 'setBatch'
Router::get('/api/admin/audits', [AuditsController::class, 'list']); Router::get('/api/admin/audits', [AuditsController::class, 'list']);
Router::post('/api/admin/payment/upload', [PaymentController::class, 'upload']); Router::post('/api/admin/payment/upload', [PaymentController::class, 'upload']);
Router::post('/api/admin/payment/config', [PaymentController::class, 'saveConfig']);
Router::get('/api/admin/payment/status', [PaymentController::class, 'status']); Router::get('/api/admin/payment/status', [PaymentController::class, 'status']);
if (str_starts_with($path, '/api/admin/')) { if (str_starts_with($path, '/api/admin/')) {
+9 -1
View File
@@ -9,6 +9,7 @@ use Soon\Api\Controllers\PayController;
use Soon\Api\Controllers\PlanController; use Soon\Api\Controllers\PlanController;
use Soon\Api\Controllers\SettingsController; use Soon\Api\Controllers\SettingsController;
use Soon\Api\Controllers\SoonModelController; use Soon\Api\Controllers\SoonModelController;
use Soon\Api\Controllers\TemplateController;
use Soon\Api\Core\Config; use Soon\Api\Core\Config;
use Soon\Api\Core\Json; use Soon\Api\Core\Json;
use Soon\Api\Core\Router; use Soon\Api\Core\Router;
@@ -45,6 +46,8 @@ Router::get('/api/v1/auth/me', [AuthController::class, 'me']);
Router::get('/api/v1/files', [FileController::class, 'index']); Router::get('/api/v1/files', [FileController::class, 'index']);
Router::post('/api/v1/files', [FileController::class, 'create']); Router::post('/api/v1/files', [FileController::class, 'create']);
Router::get('/api/v1/files/{id}/thumb', [FileController::class, 'thumb']);
Router::get('/api/v1/files/{id}', [FileController::class, 'show']);
Router::put('/api/v1/files/{id}', [FileController::class, 'update']); Router::put('/api/v1/files/{id}', [FileController::class, 'update']);
Router::delete('/api/v1/files/{id}', [FileController::class, 'delete']); Router::delete('/api/v1/files/{id}', [FileController::class, 'delete']);
Router::get('/api/v1/files/{id}/download', [FileController::class, 'download']); Router::get('/api/v1/files/{id}/download', [FileController::class, 'download']);
@@ -61,7 +64,12 @@ Router::post('/api/v1/pay/orders/{order_no}/refund-request', [PayController::cla
Router::post('/api/v1/pay/alipay/notify', [PayController::class, 'alipayNotify']); Router::post('/api/v1/pay/alipay/notify', [PayController::class, 'alipayNotify']);
Router::post('/api/v1/pay/wechat/notify', [PayController::class, 'wechatNotify']); Router::post('/api/v1/pay/wechat/notify', [PayController::class, 'wechatNotify']);
Router::get('/api/v1/soon-models', [SoonModelController::class, 'index']); Router::get('/api/v1/templates', [TemplateController::class, 'index']);
Router::get('/api/v1/templates/{id}/thumb', [TemplateController::class, 'thumb']);
Router::get('/api/v1/templates/{id}/file', [TemplateController::class, 'file']);
Router::get('/api/v1/templates/{id}', [TemplateController::class, 'show']);
Router::get('/api/v1/soon-models/files/{name}', [SoonModelController::class, 'download']);
Router::get('/api/v1/soon-models', [TemplateController::class, 'index']);
Router::get('/api/v1/settings', [SettingsController::class, 'publicSettings']); Router::get('/api/v1/settings', [SettingsController::class, 'publicSettings']);
if (str_starts_with($path, '/api/v1/')) { if (str_starts_with($path, '/api/v1/')) {
+100
View File
@@ -0,0 +1,100 @@
# 部署脚本
## 权限(推荐:SSH 粘贴,勿 FTP 上传 .sh
FTP 会把脚本改成 CRLF,报 `set: pipefail`。**在服务器 SSH 整段粘贴:**
```bash
cd /www/wwwroot/designadmin.cardsoon.com
U=www; G=www; ROOT="$PWD"
mkdir -p "$ROOT/storage"/{users,logs,payment,cache/rate,cache/wechat_certs}
chown -R "$U:$G" "$ROOT"
find "$ROOT/src" "$ROOT/public" -type d -exec chmod 755 {} +
find "$ROOT/src" "$ROOT/public" -type f -exec chmod 644 {} +
chmod 755 "$ROOT/config"
test -f "$ROOT/config/local.php" && chmod 640 "$ROOT/config/local.php"
chmod 775 "$ROOT/storage/users" "$ROOT/storage/logs" "$ROOT/storage/cache/rate" "$ROOT/storage/cache/wechat_certs"
chmod 700 "$ROOT/storage/payment"
rm -f "$ROOT/storage/cache/rate/"*.json
echo OK
```
## 上线步骤
```bash
cd /www/wwwroot/designadmin.cardsoon.com
```
```bash
cp config/local.php.example config/local.php
vi config/local.php
```
`db``jwt_secret``site``storage` 路径。
```bash
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
```
## 初始化管理员
```bash
cd /www/wwwroot/designadmin.cardsoon.com
php scripts/seed-admin.php admin@cardsoon.com cardsoon
```
成功输出 `OK created admin``OK updated admin`。登录:`https://design.cardsoon.com/pages/admin/login.html`
模型目录:`.soon` 放后端站点(推荐,避免防跨站读不到):
```bash
mkdir -p /www/wwwroot/designadmin.cardsoon.com/soonModels
cp /www/wwwroot/design.cardsoon.com/soonModels/*.soon /www/wwwroot/designadmin.cardsoon.com/soonModels/
chmod 755 /www/wwwroot/designadmin.cardsoon.com/soonModels
chmod 644 /www/wwwroot/designadmin.cardsoon.com/soonModels/*.soon
```
`local.php`
```php
'models_dir' => '/www/wwwroot/designadmin.cardsoon.com/soonModels',
```
排查:
```bash
php scripts/check-soon-models.php
```
## 宝塔
- 网站运行目录:`public`
- Nginx`/api/v1/``index.php``/api/admin/``admin.php`
## 配置
后端只维护 `config/local.php`(从 `local.php.example` 复制)。
前端(`design.cardsoon.com`)只维护 `config/local.js`(从 `config/local.js.example` 复制);全量更新 frontend 时不要覆盖。
## 本地脚本
`setup-backend-perms.sh` 供 git 拉取使用;Windows/FTP 上传易坏,请用上面 SSH 粘贴块。
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "CLI only\n");
exit(1);
}
require dirname(__DIR__) . '/src/bootstrap.php';
use Soon\Api\Core\Config;
use Soon\Api\Services\SoonModelService;
$configured = rtrim((string)Config::get('storage.models_dir', ''), '/\\');
$local = SOON_SERVER_ROOT . '/soonModels';
fwrite(STDOUT, "configured models_dir: {$configured}\n");
fwrite(STDOUT, " is_dir=" . (@is_dir($configured) ? 'yes' : 'no') . ' readable=' . (@is_readable($configured) ? 'yes' : 'no') . "\n");
fwrite(STDOUT, "backend soonModels: {$local}\n");
fwrite(STDOUT, " is_dir=" . (@is_dir($local) ? 'yes' : 'no') . ' readable=' . (@is_readable($local) ? 'yes' : 'no') . "\n");
$roots = SoonModelService::roots();
fwrite(STDOUT, 'scan roots: ' . (count($roots) ? implode(', ', $roots) : '(none)') . "\n");
foreach ($roots as $root) {
$entries = @scandir($root) ?: [];
$soon = array_values(array_filter($entries, static fn(string $e): bool => str_ends_with(strtolower($e), '.soon')));
fwrite(STDOUT, " {$root} => " . count($soon) . " .soon\n");
foreach ($soon as $f) {
fwrite(STDOUT, " - {$f}\n");
}
}
$items = SoonModelService::list();
fwrite(STDOUT, 'API items: ' . count($items) . "\n");
foreach ($items as $item) {
fwrite(STDOUT, ' - ' . ($item['name'] ?? '') . ' (' . ($item['file'] ?? '') . ")\n");
}
if ($configured !== '' && !@is_readable($configured)) {
fwrite(STDOUT, "\nHint: PHP 读不到 configured 目录。把 .soon 复制到后端 {$local},或在宝塔后端站点防跨站放行:{$configured}\n");
}
exit(count($items) > 0 ? 0 : 1);
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/**
* Incremental DB migration (reads config/local.php). Safe to run repeatedly.
* Usage: cd backend-web && php scripts/migrate-schema.php
*/
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "CLI only\n");
exit(1);
}
require dirname(__DIR__) . '/src/bootstrap.php';
require __DIR__ . '/schema-migrate-lib.php';
use Soon\Api\Core\Db;
try {
$count = soon_schema_migrate(Db::pdo());
fwrite(STDOUT, 'migrate-schema: done (' . $count . " ALTER/CREATE executed)\n");
} catch (Throwable $e) {
fwrite(STDERR, 'migrate-schema: ' . $e->getMessage() . PHP_EOL);
exit(1);
}
+160
View File
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
/**
* Incremental schema patches (safe to run repeatedly).
*
* @param-out int $applied
*/
function soon_schema_column_exists(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;
}
/** @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;
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/**
* 创建或重置管理员(CLI,读取 config/local.php)。
* 用法: cd backend-web根目录 && php scripts/seed-admin.php [email] [password]
*/
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "CLI only\n");
exit(1);
}
require dirname(__DIR__) . '/src/bootstrap.php';
use Soon\Api\Core\Db;
$email = trim((string)($argv[1] ?? getenv('SOON_ADMIN_EMAIL') ?: 'admin@cardsoon.com'));
$plain = (string)($argv[2] ?? getenv('SOON_ADMIN_PASS') ?: 'cardsoon');
if ($email === '' || $plain === '') {
fwrite(STDERR, "usage: php scripts/seed-admin.php [email] [password]\n");
exit(1);
}
$pdo = Db::pdo();
$hash = password_hash($plain, PASSWORD_BCRYPT, ['cost' => 12]);
$now = date('Y-m-d H:i:s');
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$id = $stmt->fetchColumn();
if ($id) {
$pdo->prepare(
'UPDATE users SET password_hash = :h, role = :role, admin_level = :level, status = :status WHERE id = :id'
)->execute([
'h' => $hash,
'role' => 'admin',
'level' => 'full',
'status' => 'active',
'id' => (int)$id,
]);
fwrite(STDOUT, "OK updated admin #{$id} {$email}\n");
exit(0);
}
$pdo->prepare(
'INSERT INTO users (email, password_hash, role, admin_level, status, created_at) '
. 'VALUES (:email, :h, :role, :level, :status, :created_at)'
)->execute([
'email' => $email,
'h' => $hash,
'role' => 'admin',
'level' => 'full',
'status' => 'active',
'created_at' => $now,
]);
fwrite(STDOUT, "OK created admin {$email}\n");
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# cd 到 backend-web 根目录后: bash scripts/setup-backend-perms.sh [soonModels路径]
set -eu
ROOT="$(cd "${1:-.}" && pwd)"
MODELS="${2:-}"
U="${WEB_USER:-www}"
G="${WEB_GROUP:-www}"
[[ -d "$ROOT/public" && -d "$ROOT/src" ]] || { echo "错误: 请先 cd 到 backend-web"; exit 1; }
id "$U" &>/dev/null || { echo "错误: 用户 $U 不存在"; exit 1; }
mkdir -p "$ROOT/storage"/{users,logs,payment,cache/rate,cache/wechat_certs}
chown -R "$U:$G" "$ROOT"
find "$ROOT/src" "$ROOT/public" -type d -exec chmod 755 {} +
find "$ROOT/src" "$ROOT/public" -type f -exec chmod 644 {} +
chmod 755 "$ROOT/config"
[[ -f "$ROOT/config/local.php" ]] && chmod 640 "$ROOT/config/local.php"
chmod 775 "$ROOT/storage/users" "$ROOT/storage/logs" "$ROOT/storage/cache/rate" "$ROOT/storage/cache/wechat_certs"
chmod 700 "$ROOT/storage/payment"
rm -f "$ROOT/storage/cache/rate/"*.json 2>/dev/null || true
if [[ -n "$MODELS" ]]; then
mkdir -p "$MODELS"
chown -R "$U:$G" "$MODELS"
chmod 755 "$MODELS"
fi
for d in "$ROOT/storage/users" "$ROOT/storage/logs" "$ROOT/storage/payment"; do
sudo -u "$U" test -w "$d" || { echo "FAIL: $d"; exit 1; }
done
echo OK
@@ -5,6 +5,7 @@ namespace Soon\Api\Admin\Controllers;
use Soon\Api\Admin\Services\PaymentKeyStore; use Soon\Api\Admin\Services\PaymentKeyStore;
use Soon\Api\Core\Json; use Soon\Api\Core\Json;
use Soon\Api\Services\PaymentConfig;
final class PaymentController final class PaymentController
{ {
@@ -24,4 +25,14 @@ final class PaymentController
{ {
Json::ok(PaymentKeyStore::status($adminId)); Json::ok(PaymentKeyStore::status($adminId));
} }
public function saveConfig(int $adminId): void
{
$body = Json::readBody();
$items = $body['items'] ?? null;
if (!is_array($items)) {
Json::fail('bad_request', 'items 须为数组', 400);
}
Json::ok(PaymentConfig::saveParams($adminId, $items));
}
} }
@@ -57,9 +57,13 @@ final class PlansController
if ($code !== 'free' && $priceCents <= 0) { if ($code !== 'free' && $priceCents <= 0) {
Json::fail('bad_request', '付费套餐价格须大于 0', 400); Json::fail('bad_request', '付费套餐价格须大于 0', 400);
} }
if ($code !== 'free' && $durationDays <= 0) { $isLifetime = $code === 'member_lifetime';
if ($code !== 'free' && !$isLifetime && $durationDays <= 0) {
Json::fail('bad_request', '付费套餐须设置有效天数', 400); Json::fail('bad_request', '付费套餐须设置有效天数', 400);
} }
if ($isLifetime) {
$durationDays = 0;
}
if ($coreFeatures === []) { if ($coreFeatures === []) {
Json::fail('bad_request', '请至少填写一条权益说明', 400); Json::fail('bad_request', '请至少填写一条权益说明', 400);
} }
@@ -6,6 +6,8 @@ namespace Soon\Api\Admin\Controllers;
use Soon\Api\Core\Db; use Soon\Api\Core\Db;
use Soon\Api\Core\Json; use Soon\Api\Core\Json;
use Soon\Api\Services\AuditService; use Soon\Api\Services\AuditService;
use Soon\Api\Services\PaymentConfig;
use Soon\Api\Services\SettingsConfigLoader;
final class SettingsController final class SettingsController
{ {
@@ -30,6 +32,9 @@ final class SettingsController
); );
$stmt->execute(['k' => $key, 'v' => $value, 'ts' => date('Y-m-d H:i:s')]); $stmt->execute(['k' => $key, 'v' => $value, 'ts' => date('Y-m-d H:i:s')]);
AuditService::log($adminId, 'settings.set', 'setting:' . $key, ['value' => $value]); AuditService::log($adminId, 'settings.set', 'setting:' . $key, ['value' => $value]);
if (in_array($key, SettingsConfigLoader::PAYMENT_KEYS, true)) {
PaymentConfig::reloadRuntime();
}
Json::ok(['key' => $key]); Json::ok(['key' => $key]);
} }
@@ -63,6 +68,9 @@ final class SettingsController
Json::fail('bad_request', '无有效配置项', 400); Json::fail('bad_request', '无有效配置项', 400);
} }
AuditService::log($adminId, 'settings.set_batch', 'settings', ['keys' => $saved]); AuditService::log($adminId, 'settings.set_batch', 'settings', ['keys' => $saved]);
if (array_intersect($saved, SettingsConfigLoader::PAYMENT_KEYS) !== []) {
PaymentConfig::reloadRuntime();
}
Json::ok(['keys' => $saved]); Json::ok(['keys' => $saved]);
} }
} }
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Admin\Controllers;
use Soon\Api\Core\Json;
use Soon\Api\Services\AuditService;
use Soon\Api\Services\TemplateService;
final class TemplatesController
{
public function list(int $adminId): void
{
AuditService::log($adminId, 'templates.list', 'soon_templates');
Json::ok(['items' => TemplateService::listAdmin()]);
}
public function create(int $adminId): void
{
$body = Json::readBody();
$name = (string)($body['name'] ?? '');
$content = (string)($body['content'] ?? '');
$sortOrder = (int)($body['sort_order'] ?? 0);
$isActive = (int)($body['is_active'] ?? 1) === 1 ? 1 : 0;
if ($content === '') {
Json::fail('bad_request', 'content 必填(.soon JSON 文本)', 400);
}
$item = TemplateService::create($name, $content, $sortOrder, $isActive);
AuditService::log($adminId, 'templates.create', 'soon_templates:' . $item['id']);
Json::ok($item);
}
public function update(int $adminId, int $id): void
{
$body = Json::readBody();
$name = (string)($body['name'] ?? '');
$sortOrder = (int)($body['sort_order'] ?? 0);
$isActive = (int)($body['is_active'] ?? 1) === 1 ? 1 : 0;
$content = array_key_exists('content', $body) ? (string)$body['content'] : null;
if ($content !== null && trim($content) === '') {
$content = null;
}
$item = TemplateService::update($id, $name, $sortOrder, $isActive, $content);
AuditService::log($adminId, 'templates.update', 'soon_templates:' . $id);
Json::ok($item);
}
public function delete(int $adminId, int $id): void
{
TemplateService::delete($id);
AuditService::log($adminId, 'templates.delete', 'soon_templates:' . $id);
Json::ok(['id' => $id]);
}
public function thumb(int $adminId, int $id): void
{
AuditService::log($adminId, 'templates.thumb', 'soon_templates:' . $id);
TemplateService::outputThumbAdmin($id);
}
}
@@ -7,6 +7,7 @@ use Soon\Api\Core\Db;
use Soon\Api\Core\Json; use Soon\Api\Core\Json;
use Soon\Api\Services\AdminPermission; use Soon\Api\Services\AdminPermission;
use Soon\Api\Services\AuditService; use Soon\Api\Services\AuditService;
use Soon\Api\Services\MembershipService;
final class UsersController final class UsersController
{ {
@@ -270,9 +271,9 @@ final class UsersController
try { try {
$pdo->prepare('UPDATE subscriptions SET status = \'expired\' WHERE user_id = :u AND status = \'active\'') $pdo->prepare('UPDATE subscriptions SET status = \'expired\' WHERE user_id = :u AND status = \'active\'')
->execute(['u' => $id]); ->execute(['u' => $id]);
if ($plan['code'] !== 'free' && $durationDays > 0) { if ($plan['code'] !== 'free') {
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$expires = date('Y-m-d H:i:s', time() + $durationDays * 86400); $expires = MembershipService::subscriptionExpiresAt($durationDays);
$pdo->prepare( $pdo->prepare(
'INSERT INTO subscriptions (user_id, plan_id, status, started_at, expires_at) ' 'INSERT INTO subscriptions (user_id, plan_id, status, started_at, expires_at) '
. 'VALUES (:u, :p, \'active\', :sa, :ea)' . 'VALUES (:u, :p, \'active\', :sa, :ea)'
@@ -3,10 +3,9 @@ declare(strict_types=1);
namespace Soon\Api\Admin\Services; namespace Soon\Api\Admin\Services;
use Soon\Api\Core\Config;
use Soon\Api\Core\Json; use Soon\Api\Core\Json;
use Soon\Api\Services\AuditService; use Soon\Api\Services\AuditService;
use Soon\Api\Services\PaymentConfigLoader; use Soon\Api\Services\PaymentConfig;
/** /**
* 支付密钥保存到 storage/payment/,不入库。 * 支付密钥保存到 storage/payment/,不入库。
@@ -42,9 +41,7 @@ final class PaymentKeyStore
AuditService::log($adminId, 'payment.key.upload', $channel . ':' . $kind, [ AuditService::log($adminId, 'payment.key.upload', $channel . ':' . $kind, [
'bytes' => strlen($content), 'bytes' => strlen($content),
]); ]);
$config = Config::all(); PaymentConfig::reloadRuntime();
PaymentConfigLoader::merge($config);
Config::init($config);
return ['channel' => $channel, 'kind' => $kind, 'bytes' => strlen($content)]; return ['channel' => $channel, 'kind' => $kind, 'bytes' => strlen($content)];
} }
@@ -60,6 +57,9 @@ final class PaymentKeyStore
]; ];
} }
AuditService::log($adminId, 'payment.key.status', 'payment'); AuditService::log($adminId, 'payment.key.status', 'payment');
return ['items' => $items]; return array_merge(
['items' => $items],
PaymentConfig::adminSnapshot()
);
} }
} }
@@ -6,6 +6,7 @@ namespace Soon\Api\Controllers;
use Soon\Api\Core\Json; use Soon\Api\Core\Json;
use Soon\Api\Middleware\Auth; use Soon\Api\Middleware\Auth;
use Soon\Api\Services\AuthService; use Soon\Api\Services\AuthService;
use Soon\Api\Services\MembershipService;
final class AuthController final class AuthController
{ {
@@ -38,7 +39,6 @@ final class AuthController
public function me(): void public function me(): void
{ {
$user = Auth::require(); $user = Auth::require();
unset($user['password_hash']); Json::ok(array_merge($user, MembershipService::membershipSummary((int)$user['id'])));
Json::ok($user);
} }
} }
+20 -9
View File
@@ -6,7 +6,6 @@ namespace Soon\Api\Controllers;
use Soon\Api\Core\Json; use Soon\Api\Core\Json;
use Soon\Api\Middleware\Auth; use Soon\Api\Middleware\Auth;
use Soon\Api\Services\FileService; use Soon\Api\Services\FileService;
use Soon\Api\Services\MembershipService;
final class FileController final class FileController
{ {
@@ -39,17 +38,29 @@ final class FileController
public function create(): void public function create(): void
{ {
$u = Auth::require(); $u = Auth::require();
MembershipService::requireActiveMember($u['id']);
$body = Json::readBody(); $body = Json::readBody();
$name = (string)($body['name'] ?? 'untitled.soon'); $name = (string)($body['name'] ?? 'untitled.soon');
$json = (string)($body['json'] ?? '{}'); $json = (string)($body['json'] ?? '{}');
Json::ok(FileService::create($u['id'], $name, $json)); Json::ok(FileService::create($u['id'], $name, $json));
} }
public function show(int $id): void
{
$u = Auth::require();
$row = FileService::fetch($u['id'], $id);
Json::ok([
'id' => (int)$row['id'],
'name' => (string)$row['name'],
'version' => (int)$row['version'],
'size' => (int)$row['size'],
'updated_at' => $row['updated_at'],
'json' => (string)$row['json'],
]);
}
public function update(int $id): void public function update(int $id): void
{ {
$u = Auth::require(); $u = Auth::require();
MembershipService::requireActiveMember($u['id']);
$body = Json::readBody(); $body = Json::readBody();
$name = (string)($body['name'] ?? 'untitled.soon'); $name = (string)($body['name'] ?? 'untitled.soon');
$json = (string)($body['json'] ?? '{}'); $json = (string)($body['json'] ?? '{}');
@@ -64,15 +75,15 @@ final class FileController
Json::ok(['id' => $id]); Json::ok(['id' => $id]);
} }
public function thumb(int $id): void
{
$u = Auth::require();
FileService::outputThumb($u['id'], $id);
}
public function download(int $id): void public function download(int $id): void
{ {
$u = Auth::require(); $u = Auth::require();
$accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
$jsonRead = stripos($accept, 'application/json') !== false
&& stripos($accept, 'application/octet-stream') === false;
if (!$jsonRead) {
MembershipService::requireActiveMember($u['id']);
}
$row = FileService::fetch($u['id'], $id); $row = FileService::fetch($u['id'], $id);
$name = (string)$row['name']; $name = (string)$row['name'];
$json = (string)$row['json']; $json = (string)$row['json'];
@@ -8,6 +8,7 @@ use Soon\Api\Middleware\Auth;
use Soon\Api\Services\MembershipService; use Soon\Api\Services\MembershipService;
use Soon\Api\Services\AlipayClient; use Soon\Api\Services\AlipayClient;
use Soon\Api\Services\PayService; use Soon\Api\Services\PayService;
use Soon\Api\Services\PaymentConfig;
use Soon\Api\Services\WeChatPay\Client as WeChatClient; use Soon\Api\Services\WeChatPay\Client as WeChatClient;
final class PayController final class PayController
@@ -25,10 +26,12 @@ final class PayController
$u = Auth::require(); $u = Auth::require();
$body = Json::readBody(); $body = Json::readBody();
$planId = (int)($body['plan_id'] ?? 0); $planId = (int)($body['plan_id'] ?? 0);
$channel = (string)($body['channel'] ?? 'alipay'); $channels = PaymentConfig::displayChannels();
$channel = (string)($body['channel'] ?? $channels[0]);
if (!in_array($channel, ['alipay', 'wechat'], true)) { if (!in_array($channel, ['alipay', 'wechat'], true)) {
Json::fail('bad_request', '不支持的支付方式', 400); Json::fail('bad_request', '不支持的支付方式', 400);
} }
PaymentConfig::assertChannelAllowed($channel);
$ip = $_SERVER['REMOTE_ADDR'] ?? ''; $ip = $_SERVER['REMOTE_ADDR'] ?? '';
Json::ok(PayService::createOrder($u['id'], $planId, $channel, $ip)); Json::ok(PayService::createOrder($u['id'], $planId, $channel, $ip));
} }
@@ -17,9 +17,6 @@ final class PlanController
public function myPlan(): void public function myPlan(): void
{ {
$u = Auth::require(); $u = Auth::require();
Json::ok([ Json::ok(['membership' => MembershipService::currentPlan($u['id'])]);
'membership' => MembershipService::currentPlan($u['id']),
'recent_orders' => MembershipService::recentOrders($u['id']),
]);
} }
} }
@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace Soon\Api\Controllers; namespace Soon\Api\Controllers;
use Soon\Api\Core\Json; use Soon\Api\Core\Json;
use Soon\Api\Services\MembershipService; use Soon\Api\Services\PaymentConfig;
final class SettingsController final class SettingsController
{ {
public function publicSettings(): void public function publicSettings(): void
{ {
Json::ok(MembershipService::settings()); Json::ok([
'payment_display_channels' => PaymentConfig::displayChannels(),
]);
} }
} }
@@ -3,22 +3,28 @@ declare(strict_types=1);
namespace Soon\Api\Controllers; namespace Soon\Api\Controllers;
use Soon\Api\Core\Config;
use Soon\Api\Core\Json; use Soon\Api\Core\Json;
use Soon\Api\Services\SoonModelService;
final class SoonModelController final class SoonModelController
{ {
public function index(): void public function index(): void
{ {
$root = (string)Config::get('storage.models_dir', dirname(SOON_SERVER_ROOT) . '/soonModels'); $items = SoonModelService::list();
$manifest = $root . '/manifest.json'; Json::ok(['items' => $items, 'count' => count($items)]);
if (!is_file($manifest)) {
Json::ok(['items' => [], 'count' => 0]);
} }
$data = json_decode((string)file_get_contents($manifest), true);
if (!is_array($data)) { public function download(string $name): void
Json::fail('manifest_invalid', '模型清单已损坏', 500); {
$path = SoonModelService::resolvePath($name);
if ($path === null) {
Json::fail('not_found', '模板不存在', 404);
} }
Json::ok($data); $base = basename($path);
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: inline; filename="' . str_replace('"', '', $base) . '"');
header('Cache-Control: public, max-age=300');
readfile($path);
exit;
} }
} }
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Controllers;
use Soon\Api\Core\Json;
use Soon\Api\Services\TemplateService;
final class TemplateController
{
public function index(): void
{
header('Cache-Control: public, max-age=60, must-revalidate');
$items = TemplateService::listPublic();
Json::ok(['items' => $items, 'total' => count($items)]);
}
public function show(int $id): void
{
header('Cache-Control: public, max-age=60, must-revalidate');
Json::ok(TemplateService::fetchPublicJson($id));
}
public function thumb(int $id): void
{
TemplateService::outputThumb($id);
}
public function file(int $id): void
{
TemplateService::outputFile($id);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Core;
use PDOException;
use Throwable;
/**
* API 未捕获异常 → JSON 响应(避免空白 500)。
*/
final class ErrorHandler
{
public static function register(): void
{
set_exception_handler([self::class, 'handleException']);
}
public static function handleException(Throwable $e): void
{
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
if (!str_starts_with($path, '/api/')) {
http_response_code(500);
exit;
}
$debug = (bool)Config::get('app.debug', false);
$code = 'server_error';
$msg = '服务器错误,请稍后重试';
$status = 500;
if ($e instanceof PDOException) {
$msg = '数据库操作失败';
$sqlState = (string)$e->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);
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ final class AlipayClient
'version' => '1.0', 'version' => '1.0',
'notify_url' => (string)Config::get('site.base_url', '') . '/api/v1/pay/alipay/notify', 'notify_url' => (string)Config::get('site.base_url', '') . '/api/v1/pay/alipay/notify',
'return_url' => (string)Config::get('site.front_base_url', Config::get('site.base_url', '')) 'return_url' => (string)Config::get('site.front_base_url', Config::get('site.base_url', ''))
. '/pages/member.web.html?paid=' . rawurlencode($orderNo), . '/pages/index.web.html?paid=' . rawurlencode($orderNo),
'biz_content' => json_encode([ 'biz_content' => json_encode([
'out_trade_no' => $orderNo, 'out_trade_no' => $orderNo,
'product_code' => 'FAST_INSTANT_TRADE_PAY', 'product_code' => 'FAST_INSTANT_TRADE_PAY',
+114 -12
View File
@@ -6,12 +6,16 @@ namespace Soon\Api\Services;
use Soon\Api\Core\Config; use Soon\Api\Core\Config;
use Soon\Api\Core\Db; use Soon\Api\Core\Db;
use Soon\Api\Core\Json; use Soon\Api\Core\Json;
use PDOException;
use Throwable;
/** /**
* .soon 文件服务:JSON 数据流模型,乐观锁、配额、软删除。 * .soon 文件服务:JSON 数据流模型,乐观锁、配额、软删除。
*/ */
final class FileService final class FileService
{ {
private const MAX_JSON_BYTES = 33554432;
public static function usersRoot(): string public static function usersRoot(): string
{ {
$dir = (string)Config::get('storage.users_dir', SOON_SERVER_ROOT . '/storage/users'); $dir = (string)Config::get('storage.users_dir', SOON_SERVER_ROOT . '/storage/users');
@@ -55,7 +59,8 @@ final class FileService
public static function list(int $userId, int $limit, int $offset): array public static function list(int $userId, int $limit, int $offset): array
{ {
$stmt = Db::pdo()->prepare( $stmt = Db::pdo()->prepare(
'SELECT id, name, size, version, updated_at, created_at ' 'SELECT id, name, size, version, updated_at, created_at, '
. '(CASE WHEN thumb IS NOT NULL AND thumb <> "" THEN 1 ELSE 0 END) AS has_thumb '
. 'FROM soon_files WHERE user_id = :u AND deleted_at IS NULL ' . 'FROM soon_files WHERE user_id = :u AND deleted_at IS NULL '
. 'ORDER BY updated_at DESC LIMIT :lim OFFSET :off' . 'ORDER BY updated_at DESC LIMIT :lim OFFSET :off'
); );
@@ -63,11 +68,19 @@ final class FileService
$stmt->bindValue('lim', $limit, \PDO::PARAM_INT); $stmt->bindValue('lim', $limit, \PDO::PARAM_INT);
$stmt->bindValue('off', $offset, \PDO::PARAM_INT); $stmt->bindValue('off', $offset, \PDO::PARAM_INT);
$stmt->execute(); $stmt->execute();
return $stmt->fetchAll(); $rows = $stmt->fetchAll();
foreach ($rows as &$row) {
$row['has_thumb'] = (int)($row['has_thumb'] ?? 0) === 1;
}
unset($row);
return $rows;
} }
public static function create(int $userId, string $name, string $json): array public static function create(int $userId, string $name, string $json): array
{ {
$name = self::normalizeName($name);
self::assertJsonPayload($json);
try {
$maxFiles = MembershipService::maxFilesLimit($userId); $maxFiles = MembershipService::maxFilesLimit($userId);
if (self::fileCount($userId) >= $maxFiles) { if (self::fileCount($userId) >= $maxFiles) {
Json::fail('file_limit_exceeded', '已达文件数量上限(' . $maxFiles . ' 个),请清理文件或续订', 413); Json::fail('file_limit_exceeded', '已达文件数量上限(' . $maxFiles . ' 个),请清理文件或续订', 413);
@@ -79,17 +92,26 @@ final class FileService
Json::fail('quota_exceeded', '存储空间已满,请清理文件或续订', 413); Json::fail('quota_exceeded', '存储空间已满,请清理文件或续订', 413);
} }
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$thumb = self::extractThumb($json);
$stmt = Db::pdo()->prepare( $stmt = Db::pdo()->prepare(
'INSERT INTO soon_files (user_id, name, json, size, version, created_at, updated_at) ' 'INSERT INTO soon_files (user_id, name, json, size, version, thumb, created_at, updated_at) '
. 'VALUES (:u, :n, :j, :s, 1, :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, 'created_at' => $now, 'updated_at' => $now]); $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(); $id = (int)Db::pdo()->lastInsertId();
return ['id' => $id, 'name' => $name, 'size' => $size, 'version' => 1, 'updated_at' => $now]; return ['id' => $id, 'name' => $name, 'size' => $size, 'version' => 1, 'updated_at' => $now];
} catch (PDOException $e) {
self::failDb($e);
}
} }
public static function update(int $userId, int $id, string $name, string $json, ?int $expectedVersion): array 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); $newSize = strlen($json);
$quota = self::quotaBytes($userId); $quota = self::quotaBytes($userId);
$used = self::usedBytes($userId); $used = self::usedBytes($userId);
@@ -111,12 +133,14 @@ final class FileService
} }
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$newVersion = (int)$row['version'] + 1; $newVersion = (int)$row['version'] + 1;
$thumb = self::extractThumb($json);
$upd = $pdo->prepare( $upd = $pdo->prepare(
'UPDATE soon_files SET name = :n, json = :j, size = :s, version = :v, updated_at = :ts ' 'UPDATE soon_files SET name = :n, json = :j, size = :s, version = :v, thumb = :th, updated_at = :ts '
. 'WHERE id = :id AND version = :cv' . 'WHERE id = :id AND version = :cv'
); );
$upd->execute([ $upd->execute([
'n' => $name, 'j' => $json, 's' => $newSize, 'v' => $newVersion, 'n' => $name, 'j' => $json, 's' => $newSize, 'v' => $newVersion,
'th' => $thumb !== '' ? $thumb : null,
'ts' => $now, 'id' => $id, 'cv' => (int)$row['version'], 'ts' => $now, 'id' => $id, 'cv' => (int)$row['version'],
]); ]);
if ($upd->rowCount() === 0) { if ($upd->rowCount() === 0) {
@@ -125,17 +149,77 @@ final class FileService
$pdo->commit(); $pdo->commit();
return ['id' => $id, 'name' => $name, 'size' => $newSize, 'version' => $newVersion, 'updated_at' => $now]; return ['id' => $id, 'name' => $name, 'size' => $newSize, 'version' => $newVersion, 'updated_at' => $now];
} catch (\RuntimeException $e) { } catch (\RuntimeException $e) {
if ($pdo->inTransaction()) $pdo->rollBack(); if ($pdo->inTransaction()) {
if ($e->getMessage() === 'not_found') Json::fail('not_found', '文件不存在', 404); $pdo->rollBack();
if ($e->getMessage() === 'version_conflict') Json::fail('conflict', '版本冲突,请刷新后重试', 409); }
if ($e->getMessage() === 'quota_exceeded') Json::fail('quota_exceeded', '存储空间已满', 413); 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; throw $e;
} catch (\Throwable $e) { } catch (PDOException $e) {
if ($pdo->inTransaction()) $pdo->rollBack(); if ($pdo->inTransaction()) {
$pdo->rollBack();
}
self::failDb($e);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e; 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 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'); $stmt = Db::pdo()->prepare('UPDATE soon_files SET deleted_at = :ts WHERE id = :id AND user_id = :u');
@@ -152,4 +236,22 @@ final class FileService
} }
return $row; return $row;
} }
public static function outputThumb(int $userId, int $id): void
{
$row = self::fetch($userId, $id);
$thumb = trim((string)($row['thumb'] ?? ''));
if ($thumb === '' || !str_starts_with($thumb, 'data:image/')) {
$thumb = TemplateService::thumbFromSoonJson((string)$row['json']);
if ($thumb !== '') {
Db::pdo()->prepare('UPDATE soon_files SET thumb = :th WHERE id = :id AND user_id = :u')
->execute(['th' => $thumb, 'id' => $id, 'u' => $userId]);
}
}
if ($thumb === '') {
http_response_code(404);
exit;
}
TemplateService::outputThumbDataUrl($thumb, (string)$row['updated_at']);
}
} }
+45 -15
View File
@@ -5,7 +5,6 @@ namespace Soon\Api\Services;
use Soon\Api\Core\Config; use Soon\Api\Core\Config;
use Soon\Api\Core\Db; use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
/** /**
* 会员与配额服务。 * 会员与配额服务。
@@ -16,7 +15,8 @@ final class MembershipService
{ {
$stmt = Db::pdo()->query( $stmt = Db::pdo()->query(
'SELECT id, code, name, description, price_cents, quota_mb, max_files, duration_days, ' 'SELECT id, code, name, description, price_cents, quota_mb, max_files, duration_days, '
. 'features, sort_order, is_recommended, is_active FROM plans WHERE is_active = 1 ' . 'features, sort_order, is_recommended, is_active FROM plans '
. 'WHERE is_active = 1 AND code = \'member_lifetime\' AND price_cents > 0 '
. 'ORDER BY sort_order ASC, price_cents ASC' . 'ORDER BY sort_order ASC, price_cents ASC'
); );
$items = []; $items = [];
@@ -34,7 +34,7 @@ final class MembershipService
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
'SELECT p.*, s.id AS subscription_id, s.expires_at AS subscription_expires_at, s.started_at AS subscription_started_at ' 'SELECT p.*, s.id AS subscription_id, s.expires_at AS subscription_expires_at, s.started_at AS subscription_started_at '
. 'FROM subscriptions s JOIN plans p ON p.id = s.plan_id ' . 'FROM subscriptions s JOIN plans p ON p.id = s.plan_id '
. 'WHERE s.user_id = :u AND s.status = "active" AND s.expires_at > NOW() ' . 'WHERE s.user_id = :u AND s.status = \'active\' AND s.expires_at > NOW() '
. 'ORDER BY s.expires_at DESC LIMIT 1' . 'ORDER BY s.expires_at DESC LIMIT 1'
); );
$stmt->execute(['u' => $userId]); $stmt->execute(['u' => $userId]);
@@ -42,15 +42,25 @@ final class MembershipService
if ($row) { if ($row) {
$plan = self::enrichPlan($row); $plan = self::enrichPlan($row);
$durationDays = (int)($row['duration_days'] ?? 0);
$startedAt = $row['subscription_started_at'] ?? null;
if ($durationDays <= 0 || (string)($row['code'] ?? '') === 'member_lifetime') {
$plan['subscription'] = [
'type' => 'lifetime',
'activated_at' => $startedAt,
'status' => 'active',
];
} else {
$expiresAt = (string)($row['subscription_expires_at'] ?? ''); $expiresAt = (string)($row['subscription_expires_at'] ?? '');
$daysRemaining = self::daysUntil($expiresAt); $daysRemaining = self::daysUntil($expiresAt);
$plan['subscription'] = [ $plan['subscription'] = [
'id' => (int)$row['subscription_id'], 'id' => (int)$row['subscription_id'],
'started_at' => $row['subscription_started_at'] ?? null, 'started_at' => $startedAt,
'expires_at' => $expiresAt, 'expires_at' => $expiresAt,
'days_remaining' => $daysRemaining, 'days_remaining' => $daysRemaining,
'status' => $daysRemaining <= 7 ? 'expiring' : 'active', 'status' => $daysRemaining <= 7 ? 'expiring' : 'active',
]; ];
}
$plan['usage'] = $usage; $plan['usage'] = $usage;
$plan['tier'] = 'member'; $plan['tier'] = 'member';
$plan['is_member'] = true; $plan['is_member'] = true;
@@ -60,7 +70,7 @@ final class MembershipService
$memberQuota = (int)Config::get('limits.member_quota_mb', 2048); $memberQuota = (int)Config::get('limits.member_quota_mb', 2048);
$memberFiles = (int)Config::get('limits.member_max_files', 200); $memberFiles = (int)Config::get('limits.member_max_files', 200);
$freeQuota = (int)Config::get('limits.free_quota_mb', $memberQuota); $freeQuota = (int)Config::get('limits.free_quota_mb', $memberQuota);
$freeStmt = $pdo->prepare('SELECT * FROM plans WHERE code = "free" AND is_active = 1 LIMIT 1'); $freeStmt = $pdo->prepare('SELECT * FROM plans WHERE code = \'free\' AND is_active = 1 LIMIT 1');
$freeStmt->execute(); $freeStmt->execute();
$freeRow = $freeStmt->fetch(); $freeRow = $freeStmt->fetch();
if ($freeRow) { if ($freeRow) {
@@ -69,7 +79,7 @@ final class MembershipService
$plan = self::enrichPlan([ $plan = self::enrichPlan([
'id' => 0, 'id' => 0,
'code' => 'free', 'code' => 'free',
'name' => '免费版', 'name' => '普通用户',
'description' => '适合个人体验与轻量设计', 'description' => '适合个人体验与轻量设计',
'price_cents' => 0, 'price_cents' => 0,
'quota_mb' => $freeQuota, 'quota_mb' => $freeQuota,
@@ -98,18 +108,30 @@ final class MembershipService
{ {
$stmt = Db::pdo()->prepare( $stmt = Db::pdo()->prepare(
'SELECT 1 FROM subscriptions s JOIN plans p ON p.id = s.plan_id ' 'SELECT 1 FROM subscriptions s JOIN plans p ON p.id = s.plan_id '
. 'WHERE s.user_id = :u AND s.status = "active" AND s.expires_at > NOW() ' . 'WHERE s.user_id = :u AND s.status = \'active\' AND s.expires_at > NOW() '
. 'AND p.code <> "free" AND p.price_cents > 0 LIMIT 1' . 'AND p.code <> \'free\' AND p.price_cents > 0 LIMIT 1'
); );
$stmt->execute(['u' => $userId]); $stmt->execute(['u' => $userId]);
return (bool)$stmt->fetchColumn(); return (bool)$stmt->fetchColumn();
} }
public static function requireActiveMember(int $userId): void /** @return array{is_member:bool,tier:string,name:string,subscription:array<string,mixed>} */
public static function membershipSummary(int $userId): array
{ {
if (!self::isActiveMember($userId)) { if (self::isActiveMember($userId)) {
Json::fail('membership_required', '此功能需要订阅后使用', 403); return [
'is_member' => true,
'tier' => 'member',
'name' => '会员',
'subscription' => ['status' => 'active'],
];
} }
return [
'is_member' => false,
'tier' => 'free',
'name' => '普通用户',
'subscription' => ['status' => 'free'],
];
} }
/** @return array<int, array<string, mixed>> */ /** @return array<int, array<string, mixed>> */
@@ -158,9 +180,15 @@ final class MembershipService
return ['items' => $items, 'total' => $total, 'page' => $page, 'size' => $size]; return ['items' => $items, 'total' => $total, 'page' => $page, 'size' => $size];
} }
public static function previewAllowed(int $userId): bool private const LIFETIME_EXPIRES = '9999-12-31 23:59:59';
public static function subscriptionExpiresAt(int $durationDays, ?int $baseTs = null): string
{ {
return self::isActiveMember($userId); if ($durationDays <= 0) {
return self::LIFETIME_EXPIRES;
}
$baseTs = $baseTs ?? time();
return date('Y-m-d H:i:s', $baseTs + $durationDays * 86400);
} }
public static function settings(): array public static function settings(): array
@@ -255,6 +283,8 @@ final class MembershipService
} }
if ($durationDays > 0) { if ($durationDays > 0) {
$core[] = '订阅周期:' . self::periodLabel($durationDays); $core[] = '订阅周期:' . self::periodLabel($durationDays);
} elseif ($code === 'member_lifetime' || $durationDays <= 0) {
$core[] = '永久有效';
} }
return $core; return $core;
} }
@@ -342,12 +372,12 @@ final class MembershipService
$fileStmt->execute(['u' => $userId]); $fileStmt->execute(['u' => $userId]);
$row = $fileStmt->fetch() ?: ['files_count' => 0, 'storage_bytes' => 0]; $row = $fileStmt->fetch() ?: ['files_count' => 0, 'storage_bytes' => 0];
$bytes = (int)$row['storage_bytes']; $bytes = (int)$row['storage_bytes'];
$usedMb = $bytes > 0 ? round($bytes / 1024 / 1024, 2) : 0; $usedMb = $bytes > 0 ? round($bytes / 1024 / 1024, 2) : 0.0;
return [ return [
'files_count' => (int)$row['files_count'], 'files_count' => (int)$row['files_count'],
'storage_bytes' => $bytes, 'storage_bytes' => $bytes,
'used_mb' => $usedMb, 'used_mb' => $usedMb,
'used_display' => self::formatQuota(max(1, $usedMb)) !== '0 MB' ? self::formatBytes($bytes) : '0 MB', 'used_display' => $bytes > 0 ? self::formatBytes($bytes) : '0 MB',
]; ];
} }
+10 -1
View File
@@ -19,6 +19,9 @@ final class PayService
public static function createOrder(int $userId, int $planId, string $channel, string $clientIp): array public static function createOrder(int $userId, int $planId, string $channel, string $clientIp): array
{ {
if (MembershipService::isActiveMember($userId)) {
Json::fail('already_member', '您已是会员,无需重复激活', 409);
}
$plan = self::loadActivePlan($planId); $plan = self::loadActivePlan($planId);
self::expireStalePendingOrders($userId); self::expireStalePendingOrders($userId);
$existing = self::findReusablePendingOrder($userId, $planId, $channel); $existing = self::findReusablePendingOrder($userId, $planId, $channel);
@@ -70,6 +73,7 @@ final class PayService
if (!in_array($channel, ['alipay', 'wechat'], true)) { if (!in_array($channel, ['alipay', 'wechat'], true)) {
Json::fail('bad_request', '不支持的支付方式', 400); Json::fail('bad_request', '不支持的支付方式', 400);
} }
PaymentConfig::assertChannelAllowed($channel);
Db::pdo()->prepare('UPDATE pay_orders SET channel = :c WHERE id = :id') Db::pdo()->prepare('UPDATE pay_orders SET channel = :c WHERE id = :id')
->execute(['c' => $channel, 'id' => $order['id']]); ->execute(['c' => $channel, 'id' => $order['id']]);
$order['channel'] = $channel; $order['channel'] = $channel;
@@ -238,7 +242,12 @@ final class PayService
if ($activeExpires) { if ($activeExpires) {
$baseTs = max($baseTs, strtotime((string)$activeExpires)); $baseTs = max($baseTs, strtotime((string)$activeExpires));
} }
$expires = date('Y-m-d H:i:s', $baseTs + (int)$plan['duration_days'] * 86400); $durationDays = (int)$plan['duration_days'];
if ($durationDays <= 0) {
$expires = '9999-12-31 23:59:59';
} else {
$expires = date('Y-m-d H:i:s', $baseTs + $durationDays * 86400);
}
$pdo->prepare('UPDATE subscriptions SET status = "expired" WHERE user_id = :u AND status = "active"') $pdo->prepare('UPDATE subscriptions SET status = "expired" WHERE user_id = :u AND status = "active"')
->execute(['u' => $order['user_id']]); ->execute(['u' => $order['user_id']]);
$pdo->prepare( $pdo->prepare(
+255
View File
@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services;
use Soon\Api\Core\Config;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
/**
* 支付参数、密钥就绪检查与 Admin 读写。
*/
final class PaymentConfig
{
/** @var array<string, list<string>> */
private const REQUIRED = [
'alipay' => ['site.base_url', 'alipay.app_id', 'alipay.private_key', 'alipay.public_key'],
'wechat' => [
'site.base_url',
'wechat.app_id',
'wechat.mch_id',
'wechat.mch_serial_no',
'wechat.mch_private_key',
'wechat.api_v3_key',
'wechat.platform_cert',
],
];
/** @var array<string, array{label: string, hint: string}> */
public const PARAM_FIELDS = [
'site.base_url' => [
'label' => 'API 外网地址',
'hint' => '须 HTTPS 外网可达,用于支付 notify 回调',
],
'site.front_base_url' => [
'label' => '前端站点地址',
'hint' => '支付宝支付完成后的 return_url 跳转',
],
'alipay.app_id' => [
'label' => '支付宝 APPID',
'hint' => '开放平台应用 ID',
],
'alipay.sandbox' => [
'label' => '支付宝环境',
'hint' => '沙箱仅联调使用',
],
'wechat.app_id' => [
'label' => '微信 AppID',
'hint' => '关联支付的公众号/小程序/移动应用 AppID',
],
'wechat.mch_id' => [
'label' => '微信商户号',
'hint' => '微信支付商户号 mch_id',
],
'wechat.mch_serial_no' => [
'label' => '商户证书序列号',
'hint' => '商户 API 证书序列号,用于 V3 签名',
],
'wechat.sandbox' => [
'label' => '微信环境',
'hint' => '当前实现共用正式网关,仅作标记',
],
'payment.display_channels' => [
'label' => '前端展示渠道',
'hint' => '用户端可选的支付方式,默认仅支付宝',
],
];
/** @return list<string> */
public static function displayChannels(): array
{
$raw = Config::get('payment.display_channels');
if ($raw === null || $raw === '') {
return ['alipay'];
}
$parts = is_array($raw)
? $raw
: (preg_split('/[\s,]+/', (string)$raw, -1, PREG_SPLIT_NO_EMPTY) ?: []);
$allowed = ['alipay', 'wechat'];
$out = [];
foreach ($parts as $p) {
$p = strtolower(trim((string)$p));
if ($p !== '' && in_array($p, $allowed, true) && !in_array($p, $out, true)) {
$out[] = $p;
}
}
return $out !== [] ? $out : ['alipay'];
}
public static function assertChannelAllowed(string $channel): void
{
if (!in_array($channel, self::displayChannels(), true)) {
Json::fail('bad_request', '该支付方式暂未开放', 400);
}
}
public static function reloadRuntime(): void
{
$config = Config::all();
PaymentConfigLoader::merge($config);
SettingsConfigLoader::merge($config);
Config::init($config);
}
/** @return array<string, string> */
public static function paramValues(): array
{
$out = [];
foreach (SettingsConfigLoader::PAYMENT_KEYS as $key) {
$out[$key] = self::stringValue($key, Config::get($key));
}
return $out;
}
/** @param list<array{key: string, value: string}> $items */
public static function saveParams(int $adminId, array $items): array
{
$allowed = array_flip(SettingsConfigLoader::PAYMENT_KEYS);
$pdo = Db::pdo();
$stmt = $pdo->prepare(
'INSERT INTO settings (`key`, `value`, updated_at) VALUES (:k, :v, :ts) '
. 'ON DUPLICATE KEY UPDATE `value`=VALUES(`value`), updated_at=VALUES(updated_at)'
);
$saved = [];
$now = date('Y-m-d H:i:s');
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$key = trim((string)($item['key'] ?? ''));
if ($key === '' || !isset($allowed[$key])) {
continue;
}
$value = trim((string)($item['value'] ?? ''));
if ($key === 'site.base_url' && $value !== '' && !str_starts_with(strtolower($value), 'https://')) {
Json::fail('bad_request', 'API 外网地址须以 https:// 开头', 400);
}
if (str_ends_with($key, '.sandbox')) {
$value = in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true) ? '1' : '0';
}
if ($key === 'payment.display_channels') {
$parsed = self::parseDisplayChannelsValue($value);
if ($parsed === []) {
Json::fail('bad_request', '至少选择一种前端展示支付方式', 400);
}
$value = implode(',', $parsed);
}
$stmt->execute(['k' => $key, 'v' => $value, 'ts' => $now]);
$saved[] = $key;
}
if ($saved === []) {
Json::fail('bad_request', '无有效配置项', 400);
}
AuditService::log($adminId, 'payment.config.save', 'payment', ['keys' => $saved]);
self::reloadRuntime();
return ['keys' => $saved];
}
/** @return array<string, mixed> */
public static function adminSnapshot(): array
{
$baseUrl = rtrim((string)Config::get('site.base_url', ''), '/');
return [
'params' => self::paramValues(),
'display_channels' => self::displayChannels(),
'readiness' => self::readiness(),
'notify_urls' => [
'alipay' => $baseUrl !== '' ? $baseUrl . '/api/v1/pay/alipay/notify' : '',
'wechat' => $baseUrl !== '' ? $baseUrl . '/api/v1/pay/wechat/notify' : '',
],
'return_url_hint' => rtrim((string)Config::get('site.front_base_url', Config::get('site.base_url', '')), '/')
. '/pages/index.web.html?paid={order_no}',
];
}
/** @return array<string, array{ready: bool, missing: list<string>}> */
public static function readiness(): array
{
$out = [];
foreach (self::REQUIRED as $channel => $keys) {
$missing = [];
foreach ($keys as $key) {
if (!self::isPresent($key)) {
$missing[] = self::missingLabel($key);
}
}
$out[$channel] = ['ready' => $missing === [], 'missing' => $missing];
}
return $out;
}
private static function isPresent(string $key): bool
{
$val = Config::get($key);
if (is_bool($val)) {
return true;
}
if (is_string($val)) {
return trim($val) !== '';
}
return $val !== null && $val !== '';
}
private static function missingLabel(string $key): string
{
if (isset(self::PARAM_FIELDS[$key])) {
return self::PARAM_FIELDS[$key]['label'];
}
$map = [
'alipay.private_key' => '应用私钥',
'alipay.public_key' => '支付宝公钥',
'wechat.mch_private_key' => '商户私钥',
'wechat.api_v3_key' => 'APIv3 密钥',
'wechat.platform_cert' => '平台证书',
];
return $map[$key] ?? $key;
}
private static function stringValue(string $key, mixed $value): string
{
if (str_ends_with($key, '.sandbox')) {
return $value ? '1' : '0';
}
if ($key === 'payment.display_channels') {
if (is_array($value)) {
return implode(',', self::parseDisplayChannelsList($value));
}
$parsed = self::parseDisplayChannelsValue((string)($value ?? ''));
return $parsed !== [] ? implode(',', $parsed) : 'alipay';
}
return is_string($value) ? $value : (string)($value ?? '');
}
/** @return list<string> */
private static function parseDisplayChannelsValue(string $value): array
{
return self::parseDisplayChannelsList(
preg_split('/[\s,]+/', trim($value), -1, PREG_SPLIT_NO_EMPTY) ?: []
);
}
/** @param list<string> $parts @return list<string> */
private static function parseDisplayChannelsList(array $parts): array
{
$allowed = ['alipay', 'wechat'];
$out = [];
foreach ($parts as $p) {
$p = strtolower(trim((string)$p));
if ($p !== '' && in_array($p, $allowed, true) && !in_array($p, $out, true)) {
$out[] = $p;
}
}
return $out;
}
}
@@ -38,4 +38,10 @@ final class PaymentConfigLoader
$config[$section][$key] = $content; $config[$section][$key] = $content;
} }
} }
public static function reload(array &$config): void
{
self::merge($config);
SettingsConfigLoader::merge($config);
}
} }
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services;
use Soon\Api\Core\Db;
use Throwable;
/**
* 将 settings 表中白名单键覆盖到 Config(点号路径)。
*/
final class SettingsConfigLoader
{
/** @var list<string> */
public const PAYMENT_KEYS = [
'site.base_url',
'site.front_base_url',
'payment.display_channels',
'alipay.app_id',
'alipay.sandbox',
'wechat.app_id',
'wechat.mch_id',
'wechat.mch_serial_no',
'wechat.sandbox',
];
public static function merge(array &$config): void
{
try {
$stmt = Db::pdo()->query('SELECT `key`, `value` FROM settings');
$rows = $stmt->fetchAll();
} catch (Throwable) {
return;
}
$allowed = array_flip(self::PAYMENT_KEYS);
foreach ($rows as $row) {
$key = (string)($row['key'] ?? '');
if ($key === '' || !isset($allowed[$key])) {
continue;
}
$value = (string)($row['value'] ?? '');
self::setNested($config, $key, self::castValue($key, $value));
}
}
private static function castValue(string $key, string $value): mixed
{
if (str_ends_with($key, '.sandbox')) {
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
return $value;
}
private static function setNested(array &$config, string $dotKey, mixed $value): void
{
$segments = explode('.', $dotKey);
$ref = &$config;
foreach ($segments as $i => $seg) {
if ($i === count($segments) - 1) {
$ref[$seg] = $value;
return;
}
if (!isset($ref[$seg]) || !is_array($ref[$seg])) {
$ref[$seg] = [];
}
$ref = &$ref[$seg];
}
}
}
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services;
use Soon\Api\Core\Config;
/**
* 扫描 models_dir 下 .soon 文件,解析名称、类型与缩略图。
*/
final class SoonModelService
{
private const META_HEAD_BYTES = 16384;
private const META_TAIL_BYTES = 32768;
/** @return list<string> */
public static function roots(): array
{
$candidates = [
(string)Config::get('storage.models_dir', ''),
SOON_SERVER_ROOT . '/soonModels',
];
$out = [];
foreach ($candidates as $path) {
$path = rtrim($path, '/\\');
if ($path === '' || isset($out[$path])) {
continue;
}
if (@is_dir($path) && @is_readable($path)) {
$out[$path] = true;
}
}
return array_keys($out);
}
/** @return list<array<string, mixed>> */
public static function list(): array
{
$apiBase = rtrim((string)Config::get('site.base_url', ''), '/');
$items = [];
$seen = [];
foreach (self::roots() as $root) {
foreach (self::scanSoonNames($root) as $base) {
if (isset($seen[$base])) {
continue;
}
$path = $root . DIRECTORY_SEPARATOR . $base;
$item = self::buildItem($path, $base, $apiBase);
if ($item !== null) {
$seen[$base] = true;
$items[] = $item;
}
}
}
usort($items, static fn(array $a, array $b): int => strnatcasecmp((string)$a['name'], (string)$b['name']));
return $items;
}
public static function resolvePath(string $filename): ?string
{
$base = basename(urldecode($filename));
if ($base === '' || $base[0] === '.' || !self::isSoonName($base)) {
return null;
}
foreach (self::roots() as $root) {
$path = $root . DIRECTORY_SEPARATOR . $base;
if (@is_file($path) && @is_readable($path)) {
return $path;
}
}
return null;
}
/** @return list<string> */
private static function scanSoonNames(string $root): array
{
$entries = @scandir($root);
if ($entries === false) {
return [];
}
$names = [];
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..' || $entry === '' || $entry[0] === '.') {
continue;
}
if (!self::isSoonName($entry)) {
continue;
}
$path = $root . DIRECTORY_SEPARATOR . $entry;
if (@is_file($path)) {
$names[] = $entry;
}
}
return $names;
}
private static function isSoonName(string $name): bool
{
return str_ends_with(strtolower($name), '.soon');
}
/** @return array<string, mixed>|null */
private static function buildItem(string $path, string $base, string $apiBase): ?array
{
$size = @filesize($path);
if ($size === false || $size <= 0) {
return null;
}
$meta = self::readMetadata((int)$size, $path);
$name = pathinfo($base, PATHINFO_FILENAME);
if ($meta['title'] !== '') {
$name = $meta['title'];
} elseif ($meta['name'] !== '') {
$name = $meta['name'];
}
$fileUrl = ($apiBase !== '' ? $apiBase : '') . '/api/v1/soon-models/files/' . rawurlencode($base);
return [
'name' => $name,
'type' => $meta['type'],
'file' => $base,
'file_url' => $fileUrl,
'size' => (int)$size,
];
}
/**
* @return array{title:string,name:string,type:int}
*/
private static function readMetadata(int $size, string $path): array
{
$head = self::readBytes($path, 0, min(self::META_HEAD_BYTES, $size));
$tail = $size > self::META_HEAD_BYTES
? self::readBytes($path, max(0, $size - self::META_TAIL_BYTES), min(self::META_TAIL_BYTES, $size))
: '';
$raw = $head . $tail;
$type = 1;
if (preg_match('/"soonType"\s*:\s*(\d+)/', $raw, $m)) {
$type = (int)$m[1] === 2 ? 2 : 1;
} elseif (preg_match('/"backBlackPic"\s*:\s*"(?!")/', $raw)) {
$type = 2;
}
return [
'title' => self::matchJsonString($raw, 'title'),
'name' => self::matchJsonString($raw, 'name'),
'type' => $type,
];
}
private static function readBytes(string $path, int $offset, int $length): string
{
if ($length <= 0) {
return '';
}
$fh = @fopen($path, 'rb');
if ($fh === false) {
return '';
}
if ($offset > 0) {
fseek($fh, $offset);
}
$data = fread($fh, $length);
fclose($fh);
return is_string($data) ? $data : '';
}
private static function matchJsonString(string $raw, string $key): string
{
if (!preg_match('/"' . preg_quote($key, '/') . '"\s*:\s*"((?:\\\\.|[^"\\\\])*)"/u', $raw, $m)) {
return '';
}
$decoded = json_decode('"' . $m[1] . '"');
return is_string($decoded) ? trim($decoded) : '';
}
}
@@ -0,0 +1,461 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services;
use Soon\Api\Core\Config;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
final class TemplateService
{
private const THUMB_MAX_BYTES = 524288;
public static function storageDir(): string
{
$dir = (string)Config::get('storage.templates_dir', '');
if ($dir === '') {
$dir = SOON_SERVER_ROOT . '/storage/templates';
}
$dir = rtrim($dir, '/\\');
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return $dir;
}
/** @return list<array<string, mixed>> */
public static function listPublic(): array
{
$stmt = Db::pdo()->query(
'SELECT id, name, type, updated_at FROM soon_templates WHERE is_active = 1 '
. 'ORDER BY sort_order ASC, id ASC'
);
$items = [];
foreach ($stmt->fetchAll() as $row) {
$items[] = [
'id' => (int)$row['id'],
'name' => (string)$row['name'],
'type' => (int)$row['type'],
'updated_at' => (string)$row['updated_at'],
];
}
return $items;
}
/** @return list<array<string, mixed>> */
public static function listAdmin(): array
{
$stmt = Db::pdo()->query(
'SELECT id, name, type, file_size, sort_order, is_active, created_at, updated_at, '
. '(CASE WHEN thumb IS NOT NULL AND thumb <> "" THEN 1 ELSE 0 END) AS has_thumb '
. 'FROM soon_templates ORDER BY sort_order ASC, id ASC'
);
$items = [];
foreach ($stmt->fetchAll() as $row) {
$items[] = [
'id' => (int)$row['id'],
'name' => (string)$row['name'],
'type' => (int)$row['type'],
'file_size' => (int)$row['file_size'],
'sort_order' => (int)$row['sort_order'],
'is_active' => (int)$row['is_active'] === 1,
'has_thumb' => (int)$row['has_thumb'] === 1,
'created_at' => $row['created_at'],
'updated_at' => $row['updated_at'],
];
}
return $items;
}
/** @return array<string, mixed>|null */
public static function find(int $id): ?array
{
$stmt = Db::pdo()->prepare('SELECT * FROM soon_templates WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row ?: null;
}
public static function filePath(int $id): ?string
{
$path = self::storageDir() . DIRECTORY_SEPARATOR . $id . '.soon';
return is_file($path) && is_readable($path) ? $path : null;
}
/** @return array{type:int, thumb:string} */
public static function parseSoonContent(string $json): array
{
$json = trim($json);
if ($json === '') {
Json::fail('bad_request', '模板内容为空', 400);
}
$data = json_decode($json, true);
if (!is_array($data)) {
Json::fail('bad_request', '模板须为有效的 JSON.soon', 400);
}
return self::metaFromSoonData($data);
}
/** @param array<string, mixed> $data @return array{type:int, thumb:string} */
private static function metaFromSoonData(array $data): array
{
$type = 1;
if (!empty($data['soonType']) && (int)$data['soonType'] === 2) {
$type = 2;
} elseif (!empty($data['backBlackPic'])) {
$type = 2;
}
return ['type' => $type, 'thumb' => self::extractThumb($data)];
}
/** @return array{type:int, thumb:string} */
private static function parseSoonMeta(string $json): array
{
$json = trim($json);
if ($json === '') {
return ['type' => 1, 'thumb' => ''];
}
$data = json_decode($json, true);
if (!is_array($data)) {
return ['type' => 1, 'thumb' => ''];
}
return self::metaFromSoonData($data);
}
public static function ensureThumbStored(int $id): string
{
$row = self::find($id);
if (!$row) {
return '';
}
$thumb = trim((string)($row['thumb'] ?? ''));
if ($thumb !== '' && str_starts_with($thumb, 'data:image/')) {
return $thumb;
}
$path = self::filePath($id);
if ($path === null) {
return '';
}
$json = @file_get_contents($path);
if ($json === false || $json === '') {
return '';
}
$meta = self::parseSoonMeta($json);
$thumb = $meta['thumb'];
if ($thumb === '') {
return '';
}
Db::pdo()->prepare('UPDATE soon_templates SET thumb = :th, updated_at = :ua WHERE id = :id')
->execute(['th' => $thumb, 'ua' => date('Y-m-d H:i:s'), 'id' => $id]);
return $thumb;
}
/** @param array<string, mixed> $data */
private static function extractThumb(array $data): string
{
if (empty($data['frontDisplayPic']) || !is_string($data['frontDisplayPic'])) {
return '';
}
$candidate = trim($data['frontDisplayPic']);
if (!str_starts_with($candidate, 'data:image/')) {
return '';
}
if (strlen($candidate) <= self::THUMB_MAX_BYTES) {
return $candidate;
}
return self::compressDataUrl($candidate);
}
private static function compressDataUrl(string $dataUrl): string
{
if (!preg_match('#^data:(image/[a-zA-Z0-9.+-]+);base64,(.+)$#s', $dataUrl, $m)) {
return '';
}
$bin = base64_decode($m[2], true);
if ($bin === false || $bin === '') {
return '';
}
if (!function_exists('imagecreatefromstring')) {
return '';
}
$img = @imagecreatefromstring($bin);
if ($img === false) {
return '';
}
$w = imagesx($img);
$h = imagesy($img);
if ($w < 1 || $h < 1) {
imagedestroy($img);
return '';
}
$maxW = 360;
if ($w > $maxW) {
$newH = max(1, (int)round($h * ($maxW / $w)));
$scaled = imagescale($img, $maxW, $newH);
if ($scaled !== false) {
imagedestroy($img);
$img = $scaled;
}
}
ob_start();
imagejpeg($img, null, 82);
imagedestroy($img);
$jpeg = ob_get_clean();
if ($jpeg === false || $jpeg === '') {
return '';
}
$out = 'data:image/jpeg;base64,' . base64_encode($jpeg);
if (strlen($out) > self::THUMB_MAX_BYTES) {
return '';
}
return $out;
}
private static function emitThumbBinary(string $thumb, string $rev): void
{
if (!preg_match('#^data:(image/[a-zA-Z0-9.+-]+);base64,(.+)$#s', $thumb, $m)) {
http_response_code(404);
exit;
}
$bin = base64_decode($m[2], true);
if ($bin === false) {
http_response_code(404);
exit;
}
header('Content-Type: ' . $m[1]);
header('Cache-Control: public, max-age=300, must-revalidate');
header('ETag: "' . md5($rev . ':' . strlen($bin)) . '"');
header('Content-Length: ' . strlen($bin));
echo $bin;
exit;
}
public static function create(
string $name,
string $json,
int $sortOrder = 0,
int $isActive = 1
): array {
$name = trim($name);
if ($name === '') {
Json::fail('bad_request', '名称必填', 400);
}
if (mb_strlen($name) > 120) {
Json::fail('bad_request', '名称过长', 400);
}
$meta = self::parseSoonContent($json);
$now = date('Y-m-d H:i:s');
$pdo = Db::pdo();
$pdo->prepare(
'INSERT INTO soon_templates (name, type, thumb, file_size, sort_order, is_active, created_at, updated_at) '
. 'VALUES (:n, :t, :th, :sz, :so, :a, :ca, :ua)'
)->execute([
'n' => $name,
't' => $meta['type'],
'th' => $meta['thumb'],
'sz' => strlen($json),
'so' => $sortOrder,
'a' => $isActive ? 1 : 0,
'ca' => $now,
'ua' => $now,
]);
$id = (int)$pdo->lastInsertId();
self::writeFile($id, $json);
$item = self::find($id);
if (!$item) {
Json::fail('server_error', '模板创建失败', 500);
}
return [
'id' => $id,
'name' => (string)$item['name'],
'type' => (int)$item['type'],
'file_size' => (int)$item['file_size'],
'sort_order' => (int)$item['sort_order'],
'is_active' => (int)$item['is_active'] === 1,
'has_thumb' => trim((string)($item['thumb'] ?? '')) !== '',
'created_at' => $item['created_at'],
'updated_at' => $item['updated_at'],
];
}
public static function update(
int $id,
string $name,
int $sortOrder,
int $isActive,
?string $json = null
): array {
$row = self::find($id);
if (!$row) {
Json::fail('not_found', '模板不存在', 404);
}
$name = trim($name);
if ($name === '') {
Json::fail('bad_request', '名称必填', 400);
}
$type = (int)$row['type'];
$thumb = (string)($row['thumb'] ?? '');
$size = (int)$row['file_size'];
if ($json !== null && trim($json) !== '') {
$meta = self::parseSoonContent($json);
$type = $meta['type'];
$thumb = $meta['thumb'];
$size = strlen($json);
self::writeFile($id, $json);
} elseif ($thumb === '') {
$thumb = self::ensureThumbStored($id);
}
Db::pdo()->prepare(
'UPDATE soon_templates SET name = :n, type = :t, thumb = :th, file_size = :sz, '
. 'sort_order = :so, is_active = :a, updated_at = :ua WHERE id = :id'
)->execute([
'n' => $name,
't' => $type,
'th' => $thumb,
'sz' => $size,
'so' => $sortOrder,
'a' => $isActive ? 1 : 0,
'ua' => date('Y-m-d H:i:s'),
'id' => $id,
]);
$item = self::find($id);
if (!$item) {
Json::fail('server_error', '模板更新失败', 500);
}
return [
'id' => $id,
'name' => (string)$item['name'],
'type' => (int)$item['type'],
'file_size' => (int)$item['file_size'],
'sort_order' => (int)$item['sort_order'],
'is_active' => (int)$item['is_active'] === 1,
'has_thumb' => trim((string)($item['thumb'] ?? '')) !== '',
'created_at' => $item['created_at'],
'updated_at' => $item['updated_at'],
];
}
public static function delete(int $id): void
{
$row = self::find($id);
if (!$row) {
Json::fail('not_found', '模板不存在', 404);
}
Db::pdo()->prepare('DELETE FROM soon_templates WHERE id = :id')->execute(['id' => $id]);
$path = self::storageDir() . DIRECTORY_SEPARATOR . $id . '.soon';
if (is_file($path)) {
@unlink($path);
}
}
/** @return array<string, mixed> */
public static function fetchPublicJson(int $id): array
{
$row = self::find($id);
if (!$row || (int)$row['is_active'] !== 1) {
Json::fail('not_found', '模板不存在', 404);
}
$path = self::filePath($id);
if ($path === null) {
Json::fail('not_found', '模板文件缺失', 404);
}
$json = @file_get_contents($path);
if ($json === false || trim($json) === '') {
Json::fail('server_error', '模板内容读取失败', 500);
}
if (json_decode($json, true) === null && json_last_error() !== JSON_ERROR_NONE) {
Json::fail('server_error', '模板 JSON 无效', 500);
}
return [
'id' => $id,
'name' => (string)$row['name'],
'type' => (int)$row['type'],
'updated_at' => (string)$row['updated_at'],
'json' => $json,
];
}
public static function outputThumb(int $id): void
{
$row = self::find($id);
if (!$row || (int)$row['is_active'] !== 1) {
http_response_code(404);
exit;
}
$thumb = trim((string)($row['thumb'] ?? ''));
if ($thumb === '' || !str_starts_with($thumb, 'data:image/')) {
$thumb = self::ensureThumbStored($id);
}
if ($thumb === '') {
http_response_code(404);
exit;
}
$rev = (string)$row['updated_at'];
self::emitThumbBinary($thumb, $rev);
}
public static function outputThumbAdmin(int $id): void
{
$row = self::find($id);
if (!$row) {
http_response_code(404);
exit;
}
$thumb = trim((string)($row['thumb'] ?? ''));
if ($thumb === '' || !str_starts_with($thumb, 'data:image/')) {
$thumb = self::ensureThumbStored($id);
$row = self::find($id) ?: $row;
}
if ($thumb === '') {
http_response_code(404);
exit;
}
self::emitThumbBinary($thumb, (string)$row['updated_at']);
}
public static function outputFile(int $id): void
{
$row = self::find($id);
if (!$row || (int)$row['is_active'] !== 1) {
Json::fail('not_found', '模板不存在', 404);
}
$path = self::filePath($id);
if ($path === null) {
Json::fail('not_found', '模板文件缺失', 404);
}
$name = preg_replace('/[^\w\x{4e00}-\x{9fff}\-.]+/u', '_', (string)$row['name']);
if ($name === '') {
$name = 'template';
}
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: inline; filename="' . str_replace('"', '', $name) . '.soon"');
header('Cache-Control: public, max-age=86400, must-revalidate');
header('ETag: "' . md5((string)$row['updated_at'] . ':' . (int)$row['file_size']) . '"');
readfile($path);
exit;
}
public static function thumbFromSoonJson(string $json): string
{
return self::parseSoonMeta($json)['thumb'];
}
public static function outputThumbDataUrl(string $thumb, string $rev): void
{
self::emitThumbBinary($thumb, $rev);
}
private static function writeFile(int $id, string $json): void
{
$path = self::storageDir() . DIRECTORY_SEPARATOR . $id . '.soon';
if (@file_put_contents($path, $json, LOCK_EX) === false) {
Db::pdo()->prepare('DELETE FROM soon_templates WHERE id = :id')->execute(['id' => $id]);
Json::fail('server_error', '模板文件写入失败', 500);
}
}
}
+4 -1
View File
@@ -93,5 +93,8 @@ spl_autoload_register(static function (string $class): void {
require SOON_API_ROOT . '/Core/Config.php'; require SOON_API_ROOT . '/Core/Config.php';
\Soon\Api\Core\Config::init($config); \Soon\Api\Core\Config::init($config);
require SOON_API_ROOT . '/Services/PaymentConfigLoader.php'; require SOON_API_ROOT . '/Services/PaymentConfigLoader.php';
\Soon\Api\Services\PaymentConfigLoader::merge($config); require SOON_API_ROOT . '/Services/SettingsConfigLoader.php';
\Soon\Api\Services\PaymentConfigLoader::reload($config);
\Soon\Api\Core\Config::init($config); \Soon\Api\Core\Config::init($config);
require SOON_API_ROOT . '/Core/ErrorHandler.php';
\Soon\Api\Core\ErrorHandler::register();
@@ -1 +0,0 @@
{"start":1780902406,"count":11}
@@ -1 +0,0 @@
{"start":1780911074,"count":3}
@@ -1 +0,0 @@
{"start":1780907292,"count":2}
@@ -1 +0,0 @@
{"start":1780908575,"count":1}
@@ -1 +0,0 @@
{"start":1780893461,"count":1}
+6 -3
View File
@@ -15,7 +15,7 @@ services:
- --collation-server=utf8mb4_unicode_ci - --collation-server=utf8mb4_unicode_ci
volumes: volumes:
- soondesign_mysql_data:/var/lib/mysql - 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: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-rootlocal}"] test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-rootlocal}"]
interval: 5s interval: 5s
@@ -24,7 +24,10 @@ services:
start_period: 30s start_period: 30s
api: api:
image: php:8.2-cli-bookworm build:
context: ./php
dockerfile: Dockerfile.dev
image: soondesign-api:dev
container_name: soondesign-api container_name: soondesign-api
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -57,7 +60,7 @@ services:
volumes: volumes:
- ../frontend-web:/var/www/web:ro - ../frontend-web:/var/www/web:ro
- ../frontend-electron:/var/www/electron:ro - ../frontend-electron:/var/www/electron:ro
- ./config/deploy-config.js:/var/www/web/assets/deploy-config.js:ro - ./config/local.js:/var/www/web/config/local.js:ro
- ./nginx/frontend.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx/frontend.conf:/etc/nginx/conf.d/default.conf:ro
depends_on: depends_on:
- api - api
+8
View File
@@ -0,0 +1,8 @@
FROM php:8.2-cli-bookworm
RUN apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
libmariadb-dev-compat $PHPIZE_DEPS \
&& docker-php-ext-install -j"$(nproc)" pdo_mysql \
&& apt-get purge -y --auto-remove $PHPIZE_DEPS \
&& rm -rf /var/lib/apt/lists/*
+2 -10
View File
@@ -1,22 +1,13 @@
#!/bin/sh #!/bin/sh
set -e set -e
if ! php -m 2>/dev/null | grep -q pdo_mysql; then
echo "[api] installing pdo_mysql..."
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
libmariadb-dev-compat $PHPIZE_DEPS
docker-php-ext-install -j"$(nproc)" pdo_mysql
apt-get purge -y --auto-remove $PHPIZE_DEPS
rm -rf /var/lib/apt/lists/*
fi
host="${SOON_DB_HOST:-mysql}" host="${SOON_DB_HOST:-mysql}"
port="${SOON_DB_PORT:-3306}" port="${SOON_DB_PORT:-3306}"
user="${SOON_DB_USER:-soondesign}" user="${SOON_DB_USER:-soondesign}"
pass="${SOON_DB_PASS:-soondesign}" pass="${SOON_DB_PASS:-soondesign}"
db="${SOON_DB_NAME:-soondesign}" db="${SOON_DB_NAME:-soondesign}"
echo "[api] waiting for mysql..."
i=0 i=0
while [ "$i" -lt 60 ]; do while [ "$i" -lt 60 ]; do
if php -r " if php -r "
@@ -41,4 +32,5 @@ done
php /docker/php/migrate-dev-schema.php || true php /docker/php/migrate-dev-schema.php || true
php /docker/php/seed-dev-admin.php || true php /docker/php/seed-dev-admin.php || true
echo "[api] listening on :8080"
exec php -S 0.0.0.0:8080 -t /var/www/public /docker/php/router-dev.php exec php -S 0.0.0.0:8080 -t /var/www/public /docker/php/router-dev.php
+3 -121
View File
@@ -1,6 +1,8 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
require dirname(__DIR__, 2) . '/backend-web/scripts/schema-migrate-lib.php';
$host = getenv('SOON_DB_HOST') ?: 'mysql'; $host = getenv('SOON_DB_HOST') ?: 'mysql';
$port = (int)(getenv('SOON_DB_PORT') ?: 3306); $port = (int)(getenv('SOON_DB_PORT') ?: 3306);
$user = getenv('SOON_DB_USER') ?: 'soondesign'; $user = getenv('SOON_DB_USER') ?: 'soondesign';
@@ -14,128 +16,8 @@ try {
$pass, $pass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
); );
soon_schema_migrate($pdo, 'migrate-dev-schema');
} catch (Throwable $e) { } catch (Throwable $e) {
fwrite(STDERR, 'migrate-dev-schema: ' . $e->getMessage() . PHP_EOL); fwrite(STDERR, 'migrate-dev-schema: ' . $e->getMessage() . PHP_EOL);
exit(1); 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";
}
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_monthly', '月度订阅', '按月灵活使用,随时续订', 1999, $quota, $maxFiles, 30,
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 个月"]', 10, 1, 1],
['member_quarterly', '季度订阅', '连续三个月,更省心', 5299, $quota, $maxFiles, 90,
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 季"]', 15, 0, 1],
['member_yearly', '年度订阅', '全年畅享,性价比更高', 19999, $quota, $maxFiles, 365,
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 年"]', 20, 0, 1],
];
$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), '
. '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);
}
+6 -4
View File
@@ -8,7 +8,7 @@ Get-Content $EnvFile -Encoding UTF8 | ForEach-Object {
} }
$w, $e, $b = $ports.WEB_PORT, $ports.ELECTRON_PORT, $ports.BACKEND_PORT $w, $e, $b = $ports.WEB_PORT, $ports.ELECTRON_PORT, $ports.BACKEND_PORT
$deployJs = @" $localJs = @"
window.SOON_DEPLOY_CONFIG = { window.SOON_DEPLOY_CONFIG = {
api_v1_base: 'http://localhost:$b/api/v1', api_v1_base: 'http://localhost:$b/api/v1',
api_admin_base: 'http://localhost:$b/api/admin', api_admin_base: 'http://localhost:$b/api/admin',
@@ -44,6 +44,7 @@ return [
'storage' => [ 'storage' => [
'users_dir' => '/var/www/storage/users', 'users_dir' => '/var/www/storage/users',
'models_dir' => '/var/soonModels', 'models_dir' => '/var/soonModels',
'templates_dir' => '/var/www/storage/templates',
], ],
'limits' => ['free_quota_mb' => 20, 'preview_require_membership' => false], 'limits' => ['free_quota_mb' => 20, 'preview_require_membership' => false],
'rate_limits' => [ 'rate_limits' => [
@@ -63,8 +64,9 @@ return [
$dir = Join-Path $PSScriptRoot 'config' $dir = Join-Path $PSScriptRoot 'config'
New-Item -ItemType Directory -Force -Path $dir | Out-Null New-Item -ItemType Directory -Force -Path $dir | Out-Null
$enc = [Text.UTF8Encoding]::new($false) $enc = [Text.UTF8Encoding]::new($false)
[IO.File]::WriteAllText((Join-Path $dir 'deploy-config.js'), $deployJs.Trim() + "`n", $enc) [IO.File]::WriteAllText((Join-Path $dir 'local.js'), $localJs.Trim() + "`n", $enc)
[IO.File]::WriteAllText((Join-Path $dir 'local.php'), $localPhp.Trim() + "`n", $enc) [IO.File]::WriteAllText((Join-Path $dir 'local.php'), $localPhp.Trim() + "`n", $enc)
$webDeploy = Join-Path $Root 'frontend-web\assets\deploy-config.js' $webLocal = Join-Path $Root 'frontend-web\config\local.js'
[IO.File]::WriteAllText($webDeploy, $deployJs.Trim() + "`n", $enc) New-Item -ItemType Directory -Force -Path (Split-Path $webLocal -Parent) | Out-Null
[IO.File]::WriteAllText($webLocal, $localJs.Trim() + "`n", $enc)
+42 -2
View File
@@ -81,6 +81,7 @@ Authorization: Bearer <admin_token>
| 路径 | 说明 | | 路径 | 说明 |
|------|------| |------|------|
| `GET /api/admin/plans` | 套餐配置 | | `GET /api/admin/plans` | 套餐配置 |
| `GET/POST /api/admin/templates``PUT/DELETE /api/admin/templates/{id}` | 模板库 CRUD |
| `GET /api/admin/settings` | 系统键值 | | `GET /api/admin/settings` | 系统键值 |
| `GET /api/admin/payment/status` | PEM 文件元数据 | | `GET /api/admin/payment/status` | PEM 文件元数据 |
@@ -161,9 +162,46 @@ GET /api/v1/pay/orders?page=1&size=8
| 路径 | 形态 | | 路径 | 形态 |
|------|------| |------|------|
| `GET /api/v1/plans` | `{ items: [...] }` 全量活跃套餐 | | `GET /api/v1/plans` | `{ items: [...] }` 全量活跃套餐 |
| `GET /api/v1/plans/me` | 会员信息 + `recent_orders`(默认 8 条,非翻页 | | `GET /api/v1/auth/me` | 当前用户 + 会员摘要(`is_member` / `tier` / `name` |
| `GET /api/v1/plans/me` | 完整会员配额信息(云端用量等,按需调用) |
| `GET /api/v1/settings` | 扁平 key-value 对象,非 `items` 数组 | | `GET /api/v1/settings` | 扁平 key-value 对象,非 `items` 数组 |
| `GET /api/v1/soon-models` | manifest 内容 | | `GET /api/v1/templates` | 首页模板列表(仅 `id/name/type/updated_at`;缩略图按需拉取) |
| `GET /api/v1/templates/{id}` | 模板 JSON 数据(门户标准读取端点,见下) |
| `GET /api/v1/templates/{id}/thumb` | 模板缩略图(从 `.soon``frontDisplayPic` 提取) |
| `GET /api/v1/templates/{id}/file` | 裸 `.soon` 流(`Cache-Control: max-age=86400`);门户读缓存首选 |
| `GET /api/v1/files/{id}/thumb` | 云文件缩略图(需登录;从 `.soon``frontDisplayPic` 提取) |
| `GET /api/v1/soon-models` | 兼容别名,同 `GET /api/v1/templates` |
#### `GET /api/v1/templates/{id}`(门户读取模板数据)
`GET /api/v1/files/{id}` 对齐,返回 JSON 包装,**非**裸文件流:
```json
{
"ok": true,
"data": {
"id": 1,
"name": "示例模版",
"type": 1,
"updated_at": "2026-06-09 00:23:29",
"json": "{...}"
}
}
```
- `data.json`**字符串**(与 `files/{id}` 一致),前端 `JSON.parse(data.json)` 后加载画布
- 公开接口,无需登录
### Web 虚拟 key 前缀(门户设计页)
| 前缀 | 含义 | readJsonFile | 保存 writeFile |
|------|------|--------------|----------------|
| `soondesign_file:{id}:v{ver}` | 云端已登记文件 | `GET /files/{id}` | `PUT /files/{id}` |
| `soondesign_template:{id}` | 云端模板(只读源) | `GET /templates/{id}/file` | `POST /files`(首次保存新建) |
| `soondesign_session:...` | 本地/临时会话 | sessionStorage | 已登录 `POST /files`;未登录写 session |
- **保存/打开**走 JSON API**仅**首页「下载」走 `GET /files/{id}/download` 落盘 `.soon`
- 门户**读缓存**与最近文件本地优先策略见 [`WEB-LOCAL-CACHE.md`](WEB-LOCAL-CACHE.md);模板首次加载推荐 `GET /templates/{id}/file`(流式 `.soon`,避免 `GET /templates/{id}` 二次包装)
--- ---
@@ -199,3 +237,5 @@ GET /api/v1/pay/orders?page=1&size=8
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
| 2026-06-08 | 初版:统一 page/size/totalfiles 兼容 limit/offsetpay/orders 分页 | | 2026-06-08 | 初版:统一 page/size/totalfiles 兼容 limit/offsetpay/orders 分页 |
| 2026-06-08 | 新增 GET /templates/{id} JSON 契约;Web 虚拟 key 前缀;/file 标 legacy |
| 2026-06-08 | 新增 GET /files/{id}/thumbfiles list 增 `has_thumb`;模板 /file 长缓存 |
+16 -3
View File
@@ -1,6 +1,19 @@
# 支付联调 # 支付联调
## 密钥文件(Admin → 支付密钥 或 `backend-web/storage/payment/` ## Admin 支付配置(推荐
管理后台 **支付配置** 页可一次性维护:
| 类别 | 项 |
|------|-----|
| 站点 | `site.base_url`HTTPS 外网)、`site.front_base_url` |
| 前端展示 | `payment.display_channels`(默认 `alipay`;可 `alipay,wechat` |
| 支付宝 | `app_id`、沙箱开关、`private_key``public_key` |
| 微信 | `app_id``mch_id``mch_serial_no`、沙箱标记、`mch_private_key``api_v3_key``platform_cert` |
保存参数写入 `settings` 表并热加载;密钥上传至 `backend-web/storage/payment/*.pem`。页顶就绪状态可查看缺项。
## 密钥文件(亦可直接放入 `storage/payment/`
| 文件 | 用途 | | 文件 | 用途 |
|------|------| |------|------|
@@ -10,9 +23,9 @@
| wechat_api_v3_key.pem | APIv3 密钥(32 字节) | | wechat_api_v3_key.pem | APIv3 密钥(32 字节) |
| wechat_platform_cert.pem | 微信平台证书 | | wechat_platform_cert.pem | 微信平台证书 |
## local.php 必填项 ## local.php 默认值
`site.base_url`HTTPS 外网)、`alipay.app_id``wechat.mch_id``wechat.mch_serial_no` `local.php` / `local.php.example` 可预填上述项;Admin 中保存的值会覆盖同键配置。
## notify 路径 ## notify 路径
+400
View File
@@ -0,0 +1,400 @@
# Web 端本地缓存与最近文件优化(开发计划 · 细化版)
> 状态:P0P7 已实施。与 [API-PAGINATION.md](API-PAGINATION.md)「Web 虚拟 key」交叉引用。
---
## 0. 背景与约束
### 0.1 问题(已核实代码)
| 现象 | 根因 | 代码位置 |
|------|------|----------|
| `GET /templates/{id}` 2030s | PHP 二次 `json_encode` + 大响应 + 客户端双次 parse | `TemplateService::fetchPublicJson``web.js` ~268 行 |
| 重复打开仍打源站 | 无 IndexedDB | 全库无 `indexedDB` |
| 首页最近文件慢 | 每卡 `readJsonFile` 全量 | `index.js` ~381 行 |
| 保存后不显示 | Web `onCloudWriteDone` 未写 history | `design1/output.js` ~1266;仅 fs 分支 `saveHistory` |
| session 易超配额 | 大 JSON 写 session/localStorage | `cloud-files.js` `soonPutSoonSession` |
### 0.2 硬约束
- **不改** `.soon` 格式;**保留** base64。
- **不上 CDN**;靠本机 IDB + 传输路径精简 + L1 元数据。
- 保存仍 `POST/PUT /files` 传完整 `json`**仅读**本地优先。
- 配置:`frontend-web/config/local.js``backend-web/config/local.php`
### 0.3 三层架构
| 层 | 存储 | 内容 | 体积 |
|----|------|------|------|
| L1 | `localStorage` `soondesign_recent` | key/name/type/kind/fileId/thumbRef/time | <50KB |
| L2 | IndexedDB `soondesign_local` | 完整 `.soon` 对象 + thumbs | ≤20 条 blob |
| L3 | HTTP API | miss / 保存 / 可选元数据对账 | 按需 |
### 0.4 脚本加载顺序(三页统一)
`index.web.html` / `design1.web.html` / `design2.web.html`
```
config/local.js → auth → cloud-files.js → soon-local-store.js → soon-recent.js → web.js → 页面逻辑
```
---
## 1. 数据契约
### 1.1 L1`soondesign_recent`
```json
{
"version": 1,
"items": [
{
"key": "soondesign_file:12:v3",
"name": "我的卡片.soon",
"type": 1,
"kind": "cloud",
"fileId": 12,
"thumbRef": "soondesign_file:12:v3",
"time": "2026-06-08 14:30:00"
}
]
}
```
- `thumbRef`:与 L2 `thumbs` 的 cacheKey 相同(非独立字符串前缀)。
- 上限 **20**`soonRecentUpsert` 去重后 unshift,截断尾部。
- **首页以 L1 为展示主数据源**;`GET /files` 仅后台补 `name`/`version`,不默认注入未打开过的云端文件。
### 1.2 L2IndexedDB `soondesign_local` v1
| Store | keyPath | value |
|-------|---------|--------|
| `blobs` | `cacheKey` | `{ json, name, type, source, updatedAt, savedAt, bytes }` |
| `thumbs` | `cacheKey` | `{ dataUrl, savedAt }` |
**cacheKey**
| 来源 | cacheKey | 失效 |
|------|----------|------|
| 云文件 | `soondesign_file:{id}:v{ver}` | PUT 后 version+1 → 新 key;旧 blob 可 `soonLocalRemove` |
| 模板 | `soondesign_template:{id}` | 见 §1.4 |
| 会话 | `soondesign_session:...` | 迁入云 key 后 `soonLocalRenameKey` |
**LRU**`soonLocalEvictLRU(20)` 在每次 `put` 后执行;仅删 blob+thumb**不删 L1**。
### 1.3 L3:读取端点
| 场景 | 端点 | 禁止 |
|------|------|------|
| 模板首次 | `GET /api/v1/templates/{id}/file` | 门户默认不用 `GET /templates/{id}` 包装 |
| 云文件 miss | `GET /api/v1/files/{id}` | — |
| thumb 兜底 | `GET /templates/{id}/thumb`P6 `GET /files/{id}/thumb` | 禁止为 thumb 拉全量 json |
| 保存 | `POST`/`PUT /files` | 不变 |
### 1.4 模板缓存失效策略(细化)
1. 首页点模板前,`index.js` `openTemplateItem` 写入 `window._soonTemplateMeta = { id, name, type, updated_at }`(列表已有 `updated_at`)。
2. IDB `blobs``meta.updatedAt` 存该 `updated_at`
3. `readJsonFile` IDB hit 时:若 `_soonTemplateMeta.updated_at` 存在且与 blob.meta.updatedAt 不一致 → 删 blob`miss` 走网络。
4. 网络:`/templates/{id}/file` + parse`soonLocalPut` 带最新 `updatedAt`
5. 未经过首页的深链打开:无 meta 比较,信任 IDB(可接受;刷新列表后校正)。
### 1.5 共享工具 `soonExtractThumbFromJson(json)`
- 位置:`soon-local-store.js``soon-recent.js`
-`json.frontDisplayPic`;须 `data:image/` 开头。
- 超过 **102400** 字节则返回 `''`(不写 thumbs,首页用 type 默认图)。
- 打开/保存/导入成功后统一调用。
---
## 2. 核心流程
### 2.1 打开(readJsonFile
```mermaid
sequenceDiagram
participant UI as design_page
participant IDB as L2_IDB
participant L1 as L1_recent
participant API as L3_server
UI->>IDB: soonLocalGet(cacheKey)
alt hit_and_valid
IDB-->>UI: json
else miss_or_stale
UI->>API: GET file_or_files
API-->>UI: soon_bytes
UI->>IDB: soonLocalPut + PutThumb
UI->>L1: soonRecentUpsert
end
UI->>UI: openFile_loadFromJSON
```
**补充接入点**P3):`doOpenWithJson` 完成画布加载后 `soonRecentUpsert`(模板/云文件/会话均记录,kind 由 key 推断)。
### 2.2 保存(writeFile + onCloudWriteDone
**模板首次保存**`soondesign_template:*` 或空 key 时弹窗,默认文件名 `design` + `yyMMddHHmmss` + `.soon`(如 `design260610103010.soon`);保存成功后 L1 用 cloud/local 条目替换原 template 项(`soonRecentOnCloudSave` + `prevOpenKey`)。
```mermaid
sequenceDiagram
participant UI as save
participant IDB as L2
participant L1 as L1
participant API as server
UI->>IDB: soonLocalPut当前json
UI->>L1: soonRecentUpsert
alt cloud_key
UI->>API: PUT
API-->>UI: new_version
UI->>IDB: RenameKey_or_new_put
UI->>L1: upsert新key
else new_or_template
UI->>API: POST
end
```
### 2.3 首页最近文件
```mermaid
sequenceDiagram
participant Index as index
participant L1 as L1
participant IDB as thumbs
participant API as metadata_optional
Index->>L1: soonRecentList
loop each_card
Index->>IDB: soonLocalGetThumb(key)
alt no_thumb
Index->>Index: soonAsset默认图或thumb_URL
end
end
opt logged_in
Index->>API: listCloudFiles元数据
Index->>L1: 仅补name/version不增项
end
```
### 2.4 删除卡片
| 操作 | L1 | L2 | 服务器 |
|------|----|----|--------|
| 删本地项 | `soonRecentRemove` | `soonLocalRemove` | — |
| 删云文件 | `soonRecentRemove` | `soonLocalRemove` | `DELETE /files/{id}` |
| 旧 `removeLocalHistoryPath` | 改为调上述 API | 同上 | — |
---
## 3. 分阶段任务(可独立验收)
### Phase 0:契约文档 — 已完成
- [x] 本文档
- [x] `API-PAGINATION.md` 链接与 `/file` 推荐说明
---
### Phase 1IndexedDB 模块(1.5h
**新建** [`frontend-web/js/common/soon-local-store.js`](frontend-web/js/common/soon-local-store.js)
| 任务 ID | 任务 | 细节 |
|---------|------|------|
| P1.1 | `soonLocalOpen` | `indexedDB.open('soondesign_local', 1)``onupgradeneeded``blobs`/`thumbs` |
| P1.2 | `soonLocalGet/Put` | `json` 存对象;`bytes` 估算 `JSON.stringify` 长度 |
| P1.3 | `soonLocalPutThumb/GetThumb` | thumbs store |
| P1.4 | `soonLocalRemove` | 同时删 blobs+thumbs |
| P1.5 | `soonLocalRenameKey` | 读旧→写新→删旧(session→cloud |
| P1.6 | `soonLocalEvictLRU` | 按 `savedAt` 升序删至 ≤20 |
| P1.7 | `soonExtractThumbFromJson` | §1.5 |
| P1.8 | 降级 | `indexedDB` 不可用 → 所有 get 返回 null,put 静默失败 |
| P1.9 | HTML 引入 | 三页在 `web.js` 前引入 |
**验收**
- [ ] DevTools Application → IDB 可见 stores
- [ ] put/get 往返;第 21 条触发 evict
- [ ] 隐私模式不抛未捕获异常
---
### Phase 2web.js 读写穿透(2h
**改** [`frontend-web/js/platform/web.js`](frontend-web/js/platform/web.js)
| 任务 ID | 任务 | 细节 |
|---------|------|------|
| P2.1 | `resolveCacheKey(key)` | 模板/云/ session 统一;云用完整 `soondesign_file:id:vN` |
| P2.2 | read 模板 stale | §1.4miss 走 `GET .../templates/{id}/file` + `text()` + `JSON.parse` |
| P2.3 | read 云文件 | IDB miss → 现有 `authedFetch files/{id}`;成功后 put |
| P2.4 | read session | IDB → sessionStorage 兜底(过渡期) |
| P2.5 | read 后写 thumb | `soonExtractThumbFromJson` + `soonLocalPutThumb` |
| P2.6 | writeFile 成功 | `soonLocalPut`;云 POST/PUT 成功后若 key 变化 `soonLocalRenameKey` |
| P2.7 | write 未登录 | session key + IDB 双写;**停止** `localStorage.setItem(sessionKey, 大JSON)` |
**验收**
- [ ] 模板首次:Network 仅 `/templates/{id}/file`;二次 0 请求
- [ ] 云文件二次打开无 `GET /files/{id}`
- [ ] PUT 后 `openAs.name` 与新 version key 一致且 IDB 可命中
---
### Phase 3L1 最近列表 + 保存/打开写 L1(2h)
**新建** [`frontend-web/js/common/soon-recent.js`](frontend-web/js/common/soon-recent.js)
| 任务 ID | 任务 | 细节 |
|---------|------|------|
| P3.1 | `soonRecentUpsert` | 按 `key` 去重;字段 §1.1`getDate()` 时间格式与现 `saveHistory` 一致 |
| P3.2 | `soonRecentList/Remove` | — |
| P3.3 | `soonRecentKindFromKey` | `cloud`/`template`/`local` |
| P3.4 | `soonRecentMigrateFromHistory` | 读 `soondesign_history``path``key`;无 thumb**仅执行一次**`soondesign_recent_migrated` 标记) |
| P3.5 | `onCloudWriteDone` | design1/2 `output.js`upsert`res.fileKey`,`res.name`, soonType |
| P3.6 | `saveHistory` | Web`soonRecentUpsert`Electron fs 路径保留 |
| P3.7 | `cloud-files` | `soonOpenSoonJsonLocally`、import 成功 upsert |
| P3.8 | `openFile` 加载成功 | design1 `doOpenWithJson` 末尾;design2 `saveInitialState` 末尾 upsert |
| P3.9 | `index` 开模板 | `openTemplateItem` 设置 `_soonTemplateMeta`(§1.4 |
**验收**
- [ ] 云端 Ctrl+S 后回首页立即见卡片
- [ ] 打开模板/云文件后 L1 有对应项
- [ ] 旧 history 用户升级后 L1 有条目(可无 thumb)
---
### Phase 4:首页 loadHistory 改造(1.5h
**改** [`frontend-web/js/index.js`](frontend-web/js/index.js)
| 任务 ID | 任务 | 细节 |
|---------|------|------|
| P4.1 | 数据源 | `soonRecentList()` 为主;启动时 `soonRecentMigrateFromHistory` |
| P4.2 | 删除 merge 云端追加 | 移除 `mergeRecentItems` 中「云端未见过则 push」逻辑;改为 `enrichFromCloud(items, cloudMeta)` 只补 name/version |
| P4.3 | 渲染 thumb | `soonLocalGetThumb(item.key)` → fallback `soonAsset(bg_1/2)`;模板项可用 `/thumb` URL`updated_at``?v=` |
| P4.4 | 删除 readJsonFile 循环 | 移除 ~381 行;**删除** `fileExists` 灰显逻辑(改为始终可点;打开时 miss 再拉+toast |
| P4.5 | 删除 | `removeLocalHistoryPath``soonRecentRemove` + `soonLocalRemove` |
| P4.6 | 刷新 | `pageshow`/`visibilitychange``loadHistory`60s 节流 `_recentLoadedAt` |
| P4.7 | 分页 | 保留 `fileListState` 对 L1 items 分页 |
**验收**
- [ ] 首页 Network0× 全量 `files/{id}` / `templates/{id}`
- [ ] 12 卡仅 thumb 图或 0 额外请求(IDB 命中)
- [ ] 从设计页返回可见新保存项
---
### Phase 5session 迁移与清理(1h
| 任务 ID | 任务 | 细节 |
|---------|------|------|
| P5.1 | `soonPutSoonSession` | 正文 IDBsessionStorage 仅存 `{key}` 标记或短指针 |
| P5.2 | `readJsonFile` session | 优先 IDB |
| P5.3 | `soonTryConsumeCloudImport` | 成功:`soonLocalRenameKey` + `soonRecentUpsert` + 删 session |
| P5.4 | 清理 | 移除 `cloud-files`/`index` 对大 JSON 的 localStorage 写入 |
| P5.5 | `index` 删卡 | 清 sessionStorage 旧 key(保留现有 769 行逻辑并接 IDB) |
**验收**
- [ ] 本地 .soon → 编辑 → 保存 → key 为 `soondesign_file:*`
- [ ] sessionStorage 无 >1MB 字符串
---
### Phase 6:后端轻量增强(可选,1h)
| 任务 ID | 文件 | 细节 |
|---------|------|------|
| P6.1 | `TemplateService::outputFile` | `Cache-Control: public, max-age=86400, must-revalidate` |
| P6.2 | `FileController` + 路由 | `GET /api/v1/files/{id}/thumb` |
| P6.3 | `FileService` | 上传/更新时 `extractThumb` 入 DB(可复用 `TemplateService` 逻辑);`list``has_thumb` |
| P6.4 | `docs/API-PAGINATION.md` | 登记 thumb 端点 |
**验收**
- [ ] `curl -I .../templates/1/file``max-age=86400`
- [ ] 云 thumb 返回 `image/jpeg`
---
### Phase 7:全链路验收(0.5h
| # | 场景 | Network | UI |
|---|------|---------|-----|
| 1 | 首次开模板 | 1× `/templates/{id}/file` | 画布 OK |
| 2 | 再次开同模板 | 0 全量 | <1s |
| 3 | 模板改 updated_at 后再开 | 1× `/file` | 新内容 |
| 4 | 保存新文件(登录) | POST | 回首页有卡 |
| 5 | 再次开云文件 | 0 全量 | PUT 可保存 |
| 6 | 首页 12 最近 | 0 全量 json | thumb OK |
| 7 | 未登录保存 | 无 API | L1+IDB |
| 8 | 删云文件卡 | DELETE | L1+IDB 清除 |
| 9 | IDB 满 20 再开第 21 | 1× 网络 | 最旧 blob 淘汰 |
| 10 | 模板列表 | 1× `/templates` 元数据 | 不变 |
**部署**:前端 P1P5 同批;强刷 `soon-local-store.js`/`soon-recent.js`/`web.js`/`index.js`P6 可稍后。
---
## 4. 改动文件清单
| 阶段 | 文件 |
|------|------|
| P1 | `soon-local-store.js``pages/index|design1|design2.web.html` |
| P2 | `platform/web.js` |
| P3 | `soon-recent.js``design1/2/output.js``cloud-files.js``index.js`meta |
| P4 | `index.js` |
| P5 | `cloud-files.js``web.js` |
| P6 | `TemplateService.php``FileController.php``FileService.php``public/index.php``API-PAGINATION.md` |
**不改动**`.soon` schema`design*-back.js`Electron(后续可选对齐)。
---
## 5. 依赖关系
```mermaid
flowchart TD
P0[P0_done]
P1[P1_IDB]
P2[P2_web_bridge]
P3[P3_recent_L1]
P4[P4_index]
P5[P5_session]
P6[P6_backend_opt]
P7[P7_QA]
P0 --> P1 --> P2 --> P3 --> P4 --> P5 --> P7
P2 --> P6 --> P7
```
**顺序**P1 → P2 → P3 → P4 → P5 → P7P6 与 P3/P4 并行。
---
## 6. 风险与缓解
| 风险 | 缓解 |
|------|------|
| IDB 配额 | LRU 20thumb ≤100KBevict 失败 toast |
| 多设备 | 云 version 为准;409 提示刷新 |
| 模板 stale 漏检 | 首页列表带 `updated_at`;§1.4 |
| PUT 后双 version 缓存 | `onCloudWriteDone` rename + 删旧 key |
| 隐私模式 | 降级 session+网络,与现网一致 |
| 旧用户无 recent | `soonRecentMigrateFromHistory` 一次 |
---
## 7. 不在 scope
- CDN`.soon` 剥离 base64Electron IDBCRDT 离线合并。
---
**确认后**按 P1→P7 实施;每阶段勾选任务 ID 验收后再进入下一阶段。
+166 -10
View File
@@ -13,10 +13,10 @@ img {
padding: 0 !important; padding: 0 !important;
height: 100%; height: 100%;
} }
.col-left{ body:not(.soon-design-page) .col-left{
margin-right: 320px; margin-right: 320px;
} }
.col-right{ body:not(.soon-design-page) .col-right{
float: right; float: right;
height: 100%; height: 100%;
width: 320px; width: 320px;
@@ -250,6 +250,17 @@ textarea:hover{
border: #646E76 solid 1px; border: #646E76 solid 1px;
color: #ffffff; color: #ffffff;
font-size: 16px; 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);
} }
@@ -419,28 +430,58 @@ textarea:hover{
} }
/* ── Web 设计页:design1 / design2 统一视口布局(无页面滚动) ── */ /* ── Web 设计页:design1 / design2 统一视口布局(无页面滚动) ── */
html:has(body.soon-design-page),
body.soon-design-page,
body.soon-design-page .layui-fluid.main,
body.soon-design-page .layui-row {
width: 100%;
max-width: 100%;
}
html:has(body.soon-design-page) { html:has(body.soon-design-page) {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
body.soon-design-page,
body.soon-design-page *,
body.soon-design-page *::before,
body.soon-design-page *::after {
box-sizing: border-box;
}
body.soon-design-page { body.soon-design-page {
height: 100vh; height: 100vh;
max-height: 100vh; max-height: 100vh;
max-width: 100vw;
overflow: hidden; overflow: hidden;
margin: 0; margin: 0;
padding: 0 !important; padding: 0 !important;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box;
background-color: #1e1e1e; background-color: #1e1e1e;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
body.soon-design-page .soon-design-portal-bar,
body.soon-design-page .soon-portal-topbar { body.soon-design-page .soon-portal-topbar {
position: static; position: static !important;
width: 100%; width: 100%;
max-width: 100%;
min-width: 0;
flex-shrink: 0; flex-shrink: 0;
overflow: hidden;
}
body.soon-design-page .soon-portal-topbar__brand {
flex-shrink: 0;
}
body.soon-design-page .soon-portal-topbar__actions {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
justify-content: flex-end;
} }
body.soon-design-page #portal-topbar { body.soon-design-page #portal-topbar {
@@ -449,16 +490,18 @@ body.soon-design-page #portal-topbar {
body.soon-design-page .layui-fluid.main { body.soon-design-page .layui-fluid.main {
flex: 1; flex: 1;
min-width: 0;
min-height: 0; min-height: 0;
height: auto !important; height: auto !important;
overflow: hidden; overflow: hidden;
padding: 0 !important; padding: 0 !important;
display: flex; display: flex !important;
flex-direction: column; flex-direction: column;
} }
body.soon-design-page .layui-row { body.soon-design-page .layui-row {
flex: 1; flex: 1;
min-width: 0;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: row-reverse; flex-direction: row-reverse;
@@ -469,7 +512,7 @@ body.soon-design-page .layui-row {
} }
body.soon-design-page .col-left { body.soon-design-page .col-left {
flex: 1; flex: 1 1 0;
min-width: 0; min-width: 0;
margin-right: 0; margin-right: 0;
float: none; float: none;
@@ -501,15 +544,116 @@ body.soon-design-page .tools-bar {
background-color: #31373d; background-color: #31373d;
} }
body.soon-design-page .tools-bar.left-bar {
display: flex;
flex-direction: row;
align-items: center;
min-width: 0;
overflow: hidden;
}
body.soon-design-page .tools-bar.left-bar .mode-left {
display: flex;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
body.soon-design-page .tools-bar.left-bar .mode-warp {
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
flex-wrap: nowrap;
-ms-overflow-style: none;
scrollbar-width: none;
}
body.soon-design-page .tools-bar.left-bar .mode-warp::-webkit-scrollbar {
display: none;
}
body.soon-design-page .tools-bar.left-bar .mode3 {
float: none;
flex-shrink: 0;
line-height: normal;
margin-right: 12px;
display: flex;
align-items: center;
}
body.soon-design-page #version_change { body.soon-design-page #version_change {
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
min-width: 60px; min-width: 60px;
display: flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
body.soon-design-page .soon-side-tabs {
display: inline-flex;
flex-shrink: 0;
align-items: stretch;
margin-left: 10px;
height: 32px;
border: 1px solid #646E76;
border-radius: 6px;
overflow: hidden;
background-color: #2a2f35;
box-sizing: border-box;
}
body.soon-design-page .soon-side-tabs .ui-button {
margin-left: 0;
border: none;
border-radius: 0;
min-width: 48px;
height: 100%;
width: auto;
padding: 0 12px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
white-space: nowrap;
line-height: 1;
box-sizing: border-box;
background-color: transparent;
color: #e8eaed;
}
body.soon-design-page .soon-side-tabs .ui-button + .ui-button {
border-left: 1px solid #646E76;
}
body.soon-design-page .soon-side-tabs .ui-button.ui-button-active {
background-color: #646E76;
color: #fff;
}
body.soon-design-page .soon-side-tabs .ui-button:not(.ui-button-active):hover {
background-color: #4a535c;
}
body.soon-design-page .soon-side-tabs .ui-button.ui-button-active:hover {
background-color: #74808a;
}
body.soon-design-page .soon-tool-text-btn {
flex-shrink: 0;
height: 32px;
min-width: 52px;
margin-left: 10px;
padding: 0 12px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
white-space: nowrap;
line-height: 1;
box-sizing: border-box;
}
body.soon-design-page .img-warp { body.soon-design-page .img-warp {
flex-shrink: 0; flex-shrink: 0;
} }
@@ -559,15 +703,18 @@ body.soon-design-page .container .right {
body.soon-design-page #canvas-div { body.soon-design-page #canvas-div {
flex: 1; flex: 1;
min-width: 0;
min-height: 0; min-height: 0;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
max-width: 100%;
display: flex; display: flex;
} }
body.soon-design-page .canvas-wrapper { body.soon-design-page .canvas-wrapper {
width: 100%; width: 100%;
max-width: 100%;
height: 100%; height: 100%;
overflow: auto; overflow: auto;
position: relative; position: relative;
@@ -706,10 +853,19 @@ body.soon-design-page .guide-line-drag {
opacity: 0.9; opacity: 0.9;
} }
body.soon-design-page #source_front, body.soon-design-page.soon-design--type1 #source_front,
body.soon-design-page #source_front2, body.soon-design-page.soon-design--type1 #source_front2,
body.soon-design-page #source_back { body.soon-design-page.soon-design--type1 #source_back {
width: 2787px; width: 2787px;
height: 2787px; height: 2787px;
border-radius: 50%; border-radius: 50%;
} }
body.soon-design-page.soon-design--type2 #source_front,
body.soon-design-page.soon-design--type2 #source_front2,
body.soon-design-page.soon-design--type2 #source_back {
width: auto;
height: auto;
max-width: none;
border-radius: 0;
}
+202 -29
View File
@@ -20,15 +20,48 @@
color: var(--soon-text) !important; color: var(--soon-text) !important;
} }
.layui-layer.soon-layer.soon-layer--pay .layui-layer-content, /* layui 关闭/状态图标为深色 sprite,深底上需反色 */
.layui-layer-soon-layer.soon-layer--pay .layui-layer-content { .layui-layer.soon-layer .layui-layer-setwin .layui-layer-ico,
overflow: visible !important; .layui-layer-soon-layer .layui-layer-setwin .layui-layer-ico,
max-height: none !important; .layui-layer.soon-layer .layui-layer-ico.layui-layer-close,
.layui-layer-soon-layer .layui-layer-ico.layui-layer-close {
filter: brightness(0) invert(1);
opacity: 0.88;
}
.layui-layer.soon-layer .layui-layer-setwin .layui-layer-ico:hover,
.layui-layer-soon-layer .layui-layer-setwin .layui-layer-ico:hover {
opacity: 1;
}
.layui-layer.soon-layer.layui-layer-dialog .layui-layer-content .layui-layer-ico,
.layui-layer-soon-layer.layui-layer-dialog .layui-layer-content .layui-layer-ico {
filter: brightness(0) invert(1);
opacity: 0.85;
}
.layui-layer.soon-layer .layui-layer-content font,
.layui-layer-soon-layer .layui-layer-content font {
color: inherit !important;
} }
.layui-layer.soon-layer.soon-layer--pay, .layui-layer.soon-layer.soon-layer--pay,
.layui-layer-soon-layer.soon-layer--pay { .layui-layer-soon-layer.soon-layer--pay {
max-height: calc(100vh - 32px); width: min(480px, calc(100vw - 32px)) !important;
max-width: min(480px, calc(100vw - 32px)) !important;
max-height: calc(100vh - 32px) !important;
border-radius: var(--soon-radius-lg) !important;
overflow: hidden;
}
.layui-layer.soon-layer.soon-layer--pay .layui-layer-content,
.layui-layer-soon-layer.soon-layer--pay .layui-layer-content {
overflow: visible !important;
height: auto !important;
max-height: calc(100vh - 32px) !important;
padding: 0 !important;
box-sizing: border-box !important;
background: transparent !important;
} }
.layui-layer.soon-layer.soon-layer--subscribe, .layui-layer.soon-layer.soon-layer--subscribe,
@@ -52,6 +85,145 @@
right: 14px; right: 14px;
} }
.layui-layer.soon-layer.soon-layer--activate,
.layui-layer-soon-layer.soon-layer--activate {
width: min(480px, calc(100vw - 32px)) !important;
max-width: min(480px, calc(100vw - 32px)) !important;
max-height: calc(100vh - 32px) !important;
border-radius: var(--soon-radius-lg) !important;
overflow: hidden;
}
.layui-layer.soon-layer.soon-layer--activate .layui-layer-content,
.layui-layer-soon-layer.soon-layer--activate .layui-layer-content {
overflow: visible !important;
height: auto !important;
max-height: calc(100vh - 32px) !important;
padding: 0 !important;
box-sizing: border-box !important;
background: transparent !important;
}
.layui-layer.soon-layer.soon-layer--activate .layui-layer-setwin,
.layui-layer-soon-layer.soon-layer--activate .layui-layer-setwin,
.layui-layer.soon-layer.soon-layer--pay .layui-layer-setwin,
.layui-layer-soon-layer.soon-layer--pay .layui-layer-setwin,
.layui-layer.soon-layer.soon-layer--login-gate .layui-layer-setwin,
.layui-layer-soon-layer.soon-layer--login-gate .layui-layer-setwin {
top: 10px;
right: 10px;
z-index: 20;
}
/* title:false 时 layui 用 close2,默认 top/right:-28px 会裁到弹窗外;此处统一自定义关闭钮 */
.layui-layer.soon-layer.soon-layer--activate .layui-layer-setwin a.layui-layer-ico,
.layui-layer-soon-layer.soon-layer--activate .layui-layer-setwin a.layui-layer-ico,
.layui-layer.soon-layer.soon-layer--pay .layui-layer-setwin a.layui-layer-ico,
.layui-layer-soon-layer.soon-layer--pay .layui-layer-setwin a.layui-layer-ico,
.layui-layer.soon-layer.soon-layer--login-gate .layui-layer-setwin a.layui-layer-ico,
.layui-layer-soon-layer.soon-layer--login-gate .layui-layer-setwin a.layui-layer-ico,
body.soon-design-page .layui-layer.soon-layer--activate .layui-layer-setwin a.layui-layer-ico,
body.soon-design-page .layui-layer.soon-layer--pay .layui-layer-setwin a.layui-layer-ico,
body.soon-design-page .layui-layer.soon-layer--login-gate .layui-layer-setwin a.layui-layer-ico {
position: relative !important;
top: auto !important;
right: auto !important;
display: flex !important;
align-items: center;
justify-content: center;
width: 30px !important;
height: 30px !important;
margin: 0 !important;
border-radius: 8px;
background: rgba(255, 255, 255, 0.94) !important;
background-image: none !important;
filter: none !important;
opacity: 1 !important;
font-size: 0 !important;
line-height: 1 !important;
text-indent: 0 !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.28);
transition: background 0.15s, box-shadow 0.15s;
cursor: pointer;
}
.layui-layer.soon-layer.soon-layer--activate .layui-layer-setwin a.layui-layer-ico:hover,
.layui-layer-soon-layer.soon-layer--activate .layui-layer-setwin a.layui-layer-ico:hover,
.layui-layer.soon-layer.soon-layer--pay .layui-layer-setwin a.layui-layer-ico:hover,
.layui-layer-soon-layer.soon-layer--pay .layui-layer-setwin a.layui-layer-ico:hover,
.layui-layer.soon-layer.soon-layer--login-gate .layui-layer-setwin a.layui-layer-ico:hover,
.layui-layer-soon-layer.soon-layer--login-gate .layui-layer-setwin a.layui-layer-ico:hover,
body.soon-design-page .layui-layer.soon-layer--activate .layui-layer-setwin a.layui-layer-ico:hover,
body.soon-design-page .layui-layer.soon-layer--pay .layui-layer-setwin a.layui-layer-ico:hover,
body.soon-design-page .layui-layer.soon-layer--login-gate .layui-layer-setwin a.layui-layer-ico:hover {
background: #fff !important;
opacity: 1 !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.32);
}
.layui-layer.soon-layer.soon-layer--activate .layui-layer-setwin a.layui-layer-ico::before,
.layui-layer-soon-layer.soon-layer--activate .layui-layer-setwin a.layui-layer-ico::before,
.layui-layer.soon-layer.soon-layer--pay .layui-layer-setwin a.layui-layer-ico::before,
.layui-layer-soon-layer.soon-layer--pay .layui-layer-setwin a.layui-layer-ico::before,
.layui-layer.soon-layer.soon-layer--login-gate .layui-layer-setwin a.layui-layer-ico::before,
.layui-layer-soon-layer.soon-layer--login-gate .layui-layer-setwin a.layui-layer-ico::before,
body.soon-design-page .layui-layer.soon-layer--activate .layui-layer-setwin a.layui-layer-ico::before,
body.soon-design-page .layui-layer.soon-layer--pay .layui-layer-setwin a.layui-layer-ico::before,
body.soon-design-page .layui-layer.soon-layer--login-gate .layui-layer-setwin a.layui-layer-ico::before {
content: '×';
display: block;
position: static;
width: auto;
height: auto;
margin: 0;
padding: 0;
background: none !important;
transform: none;
font-family: Arial, Helvetica, sans-serif;
font-size: 20px;
font-weight: 600;
line-height: 1;
color: #1a1f24;
pointer-events: none;
}
.layui-layer.soon-layer.soon-layer--activate .layui-layer-setwin a.layui-layer-ico::after,
.layui-layer-soon-layer.soon-layer--activate .layui-layer-setwin a.layui-layer-ico::after,
.layui-layer.soon-layer.soon-layer--pay .layui-layer-setwin a.layui-layer-ico::after,
.layui-layer-soon-layer.soon-layer--pay .layui-layer-setwin a.layui-layer-ico::after,
.layui-layer.soon-layer.soon-layer--login-gate .layui-layer-setwin a.layui-layer-ico::after,
.layui-layer-soon-layer.soon-layer--login-gate .layui-layer-setwin a.layui-layer-ico::after {
display: none !important;
content: none !important;
}
.layui-layer.soon-layer.soon-layer--login-gate,
.layui-layer-soon-layer.soon-layer--login-gate {
width: min(400px, calc(100vw - 32px)) !important;
max-height: calc(100vh - 32px) !important;
border-radius: var(--soon-radius-lg);
overflow: hidden;
box-shadow: var(--soon-shadow);
}
.layui-layer.soon-layer.soon-layer--login-gate .layui-layer-content,
.layui-layer-soon-layer.soon-layer--login-gate .layui-layer-content {
overflow: visible !important;
height: auto !important;
max-height: none !important;
padding: 0 !important;
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,
.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; overflow-x: hidden !important;
@@ -104,13 +276,33 @@
} }
.layui-layer-dialog .layui-layer-content { .layui-layer-dialog .layui-layer-content {
background-color: var(--soon-bg-panel); background-color: var(--soon-bg-panel) !important;
color: var(--soon-text); color: var(--soon-text) !important;
} }
.layui-layer-dialog .layui-layer-title { .layui-layer-dialog .layui-layer-title {
background-color: var(--soon-bg-elevated); background-color: var(--soon-bg-elevated) !important;
color: var(--soon-text-strong); color: var(--soon-text-strong) !important;
border-bottom: 1px solid var(--soon-border-subtle); border-bottom: 1px solid var(--soon-border-subtle) !important;
}
body.soon-design-page .layui-layer .layui-layer-title {
background-color: var(--soon-bg-elevated) !important;
color: var(--soon-text-strong) !important;
border-bottom: 1px solid var(--soon-border-subtle) !important;
}
body.soon-design-page .layui-layer .layui-layer-content {
color: var(--soon-text) !important;
}
body.soon-design-page .layui-layer:not(.soon-layer--activate):not(.soon-layer--pay):not(.soon-layer--login-gate) .layui-layer-setwin .layui-layer-ico {
filter: brightness(0) invert(1);
opacity: 0.88;
}
body.soon-design-page .layui-layer:not(.soon-layer--activate):not(.soon-layer--pay):not(.soon-layer--login-gate) .layui-layer-setwin .layui-layer-ico:hover {
opacity: 1;
} }
.layui-layer.soon-toast, .layui-layer.soon-toast,
@@ -188,22 +380,3 @@
color: var(--soon-text-strong) !important; color: var(--soon-text-strong) !important;
border-radius: var(--soon-radius-sm) !important; border-radius: var(--soon-radius-sm) !important;
} }
.soon-design-portal-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
z-index: 10000;
flex-shrink: 0;
}
body.soon-design-page {
padding-top: 60px !important;
box-sizing: border-box;
}
body.soon-design-page .layui-fluid {
height: calc(100% - 60px) !important;
}
+1 -427
View File
@@ -803,433 +803,7 @@
opacity: 0.85; opacity: 0.85;
} }
/* ── 支付弹窗 ── */ @import url('pay-sheet.css');
.soon-pay-sheet {
padding: 0;
text-align: left;
overflow: hidden;
border-radius: 14px;
}
.soon-pay-sheet__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 22px 26px 20px;
background: linear-gradient(160deg, rgba(0, 150, 136, 0.12) 0%, rgba(30, 36, 42, 0.95) 55%);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.soon-pay-sheet__eyebrow {
display: block;
margin-bottom: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--soon-accent);
}
.soon-pay-sheet__title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: var(--soon-text-strong);
line-height: 1.3;
}
.soon-pay-sheet__order {
margin: 8px 0 0;
font-size: 11px;
font-family: ui-monospace, Consolas, monospace;
color: var(--soon-text-muted);
}
.soon-pay-sheet__head-price {
flex-shrink: 0;
text-align: right;
line-height: 1;
}
.soon-pay-sheet__currency {
font-size: 18px;
font-weight: 600;
color: var(--soon-text-muted);
vertical-align: top;
margin-right: 2px;
}
.soon-pay-sheet__amount {
font-size: 34px;
font-weight: 800;
color: var(--soon-text-strong);
letter-spacing: -0.03em;
}
.soon-pay-sheet__body {
padding: 22px 26px 0;
}
.soon-pay-stepper {
display: flex;
align-items: flex-start;
justify-content: center;
list-style: none;
margin: 0 0 24px;
padding: 0;
}
.soon-pay-stepper__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 72px;
flex-shrink: 0;
}
.soon-pay-stepper__bridge {
flex: 1;
min-width: 24px;
max-width: 56px;
height: 2px;
margin-top: 15px;
background: rgba(255, 255, 255, 0.1);
border-radius: 1px;
list-style: none;
}
.soon-pay-stepper__bridge.is-done {
background: linear-gradient(90deg, var(--soon-accent), rgba(77, 182, 172, 0.6));
}
.soon-pay-stepper__dot {
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: var(--soon-text-muted);
background: rgba(0, 0, 0, 0.35);
border: 2px solid rgba(255, 255, 255, 0.12);
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.soon-pay-stepper__label {
font-size: 11px;
color: var(--soon-text-muted);
text-align: center;
white-space: nowrap;
}
.soon-pay-stepper__item.is-active .soon-pay-stepper__dot {
color: #fff;
background: var(--soon-accent);
border-color: var(--soon-accent);
box-shadow: 0 0 0 4px rgba(0, 150, 136, 0.18);
}
.soon-pay-stepper__item.is-active .soon-pay-stepper__label {
color: var(--soon-text-strong);
font-weight: 600;
}
.soon-pay-stepper__item.is-done .soon-pay-stepper__dot {
color: #fff;
background: rgba(77, 182, 172, 0.35);
border-color: var(--soon-accent);
}
.soon-pay-stepper__item.is-done .soon-pay-stepper__label {
color: var(--soon-text);
}
.soon-pay-section {
margin-bottom: 18px;
}
.soon-pay-section__title {
margin: 0 0 12px;
font-size: 12px;
font-weight: 600;
color: var(--soon-text-muted);
letter-spacing: 0.06em;
}
.soon-pay-channels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.soon-pay-channel {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 12px 14px;
border: 1.5px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
cursor: pointer;
background: rgba(0, 0, 0, 0.18);
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
text-align: left;
font: inherit;
color: inherit;
}
.soon-pay-channel:hover {
border-color: rgba(255, 255, 255, 0.16);
background: rgba(0, 0, 0, 0.24);
}
.soon-pay-channel.is-active {
border-color: var(--soon-accent);
background: rgba(0, 150, 136, 0.07);
box-shadow: inset 0 0 0 1px rgba(0, 150, 136, 0.15);
}
.soon-pay-channel[data-ch="wechat"].is-active {
border-color: #07c160;
background: rgba(7, 193, 96, 0.08);
box-shadow: inset 0 0 0 1px rgba(7, 193, 96, 0.2);
}
.soon-pay-channel[data-ch="alipay"].is-active {
border-color: #1677ff;
background: rgba(22, 119, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(22, 119, 255, 0.2);
}
.soon-pay-channel__radio {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.2);
flex-shrink: 0;
position: relative;
}
.soon-pay-channel.is-active .soon-pay-channel__radio {
border-color: currentColor;
}
.soon-pay-channel.is-active .soon-pay-channel__radio::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: currentColor;
}
.soon-pay-channel[data-ch="wechat"].is-active .soon-pay-channel__radio { color: #07c160; }
.soon-pay-channel[data-ch="alipay"].is-active .soon-pay-channel__radio { color: #1677ff; }
.soon-pay-channel__icon {
width: 34px;
height: 34px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 800;
color: #fff;
flex-shrink: 0;
}
.soon-pay-channel__icon--wechat { background: linear-gradient(135deg, #06ae56, #07c160); }
.soon-pay-channel__icon--alipay { background: linear-gradient(135deg, #0958d9, #1677ff); }
.soon-pay-channel__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.soon-pay-channel__name {
font-size: 13px;
font-weight: 600;
color: var(--soon-text-strong);
}
.soon-pay-channel__hint {
font-size: 11px;
color: var(--soon-text-muted);
}
.soon-pay-qr-panel {
min-height: 168px;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
padding: 4px 0;
}
.soon-pay-sheet--checkout .soon-pay-qr-panel {
min-height: 0;
}
.soon-pay-sheet--checkout .soon-pay-stepper {
margin-bottom: 16px;
}
.soon-pay-checkout-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px 20px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(255, 255, 255, 0.06);
text-align: center;
}
.soon-pay-checkout-empty__icon {
width: 48px;
height: 48px;
color: rgba(255, 255, 255, 0.22);
margin-bottom: 4px;
}
.soon-pay-checkout-empty__icon svg {
width: 100%;
height: 100%;
}
.soon-pay-checkout-empty__title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--soon-text);
}
.soon-pay-checkout-empty__desc {
margin: 0;
font-size: 12px;
color: var(--soon-text-muted);
line-height: 1.55;
max-width: 260px;
}
.soon-pay-checkout-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 24px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.soon-pay-checkout-loading p {
margin: 0;
font-size: 12px;
color: var(--soon-text-muted);
}
.soon-pay-checkout-ready {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 22px 20px 18px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(255, 255, 255, 0.08);
text-align: center;
}
.soon-pay-checkout-ready--alipay {
padding: 18px 16px 14px;
gap: 8px;
}
.soon-pay-checkout-ready__brand {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 800;
color: #fff;
}
.soon-pay-checkout-ready__brand--wechat { background: linear-gradient(135deg, #06ae56, #07c160); }
.soon-pay-checkout-ready__brand--alipay { background: linear-gradient(135deg, #0958d9, #1677ff); }
.soon-pay-checkout-ready__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--soon-text-strong);
}
.soon-pay-checkout-ready__desc {
margin: 0;
font-size: 12px;
color: var(--soon-text-muted);
line-height: 1.5;
}
.soon-pay-checkout-ready__qr {
padding: 10px;
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
line-height: 0;
}
.soon-pay-checkout-ready__qr canvas,
.soon-pay-checkout-ready__qr img {
display: block;
border-radius: 4px;
}
.soon-pay-checkout-ready__btn {
min-width: 180px;
margin-top: 4px;
}
.soon-pay-sheet__foot {
padding: 12px 26px 18px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 4px;
}
.soon-pay-sheet--checkout .soon-pay-sheet__foot {
padding-top: 10px;
padding-bottom: 16px;
}
.soon-pay-status {
margin: 0 0 12px;
text-align: center;
font-size: 12px;
color: var(--soon-text-muted);
}
.soon-pay-status--ok { color: #4db6ac; }
.soon-pay-status--err { color: var(--soon-danger); }
.soon-pay-sheet__cta {
width: 100%;
}
.soon-pay-sheet__cta--secondary {
margin-top: 8px;
}
.soon-member-page ::-webkit-scrollbar { width: 8px; height: 8px; } .soon-member-page ::-webkit-scrollbar { width: 8px; height: 8px; }
.soon-member-page ::-webkit-scrollbar-thumb { .soon-member-page ::-webkit-scrollbar-thumb {
+540
View File
@@ -0,0 +1,540 @@
/* 支付确认弹窗(设计页 / 会员页共用) */
.soon-pay-sheet {
padding: 0;
text-align: left;
overflow: hidden;
border-radius: 14px;
background: var(--soon-bg-panel);
color: var(--soon-text);
}
.soon-pay-sheet__toolbar {
padding: 12px 16px 0;
}
.soon-pay-sheet__back {
font-size: 13px;
}
.soon-pay-sheet__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 22px 52px 20px 26px;
background: linear-gradient(160deg, rgba(0, 150, 136, 0.12) 0%, rgba(30, 36, 42, 0.95) 55%);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.soon-pay-sheet__head-main {
flex: 1;
min-width: 0;
}
.soon-pay-sheet__eyebrow {
display: block;
margin-bottom: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--soon-accent);
}
.soon-pay-sheet__title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: var(--soon-text-strong);
line-height: 1.3;
}
.soon-pay-sheet__order {
margin: 8px 0 0;
font-size: 11px;
font-family: ui-monospace, Consolas, monospace;
color: var(--soon-text-muted);
}
.soon-pay-sheet__head-price {
flex-shrink: 0;
text-align: right;
line-height: 1;
}
.soon-pay-sheet__currency {
font-size: 18px;
font-weight: 600;
color: var(--soon-text-muted);
vertical-align: top;
margin-right: 2px;
}
.soon-pay-sheet__amount {
font-size: 34px;
font-weight: 800;
color: var(--soon-text-strong);
letter-spacing: -0.03em;
}
.soon-pay-sheet__body {
padding: 22px 26px 0;
}
.soon-pay-stepper {
display: flex;
align-items: flex-start;
justify-content: center;
list-style: none;
margin: 0 0 24px;
padding: 0;
}
.soon-pay-stepper__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 72px;
flex-shrink: 0;
}
.soon-pay-stepper__bridge {
flex: 1;
min-width: 24px;
max-width: 56px;
height: 2px;
margin-top: 15px;
background: rgba(255, 255, 255, 0.1);
border-radius: 1px;
list-style: none;
}
.soon-pay-stepper__bridge.is-done {
background: linear-gradient(90deg, var(--soon-accent), rgba(77, 182, 172, 0.6));
}
.soon-pay-stepper__dot {
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: var(--soon-text-muted);
background: rgba(0, 0, 0, 0.35);
border: 2px solid rgba(255, 255, 255, 0.12);
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.soon-pay-stepper__label {
font-size: 11px;
color: var(--soon-text-muted);
text-align: center;
white-space: nowrap;
}
.soon-pay-stepper__item.is-active .soon-pay-stepper__dot {
color: #fff;
background: var(--soon-accent);
border-color: var(--soon-accent);
box-shadow: 0 0 0 4px rgba(0, 150, 136, 0.18);
}
.soon-pay-stepper__item.is-active .soon-pay-stepper__label {
color: var(--soon-text-strong);
font-weight: 600;
}
.soon-pay-stepper__item.is-done .soon-pay-stepper__dot {
color: #fff;
background: rgba(77, 182, 172, 0.35);
border-color: var(--soon-accent);
}
.soon-pay-stepper__item.is-done .soon-pay-stepper__label {
color: var(--soon-text);
}
.soon-pay-section {
margin-bottom: 18px;
}
.soon-pay-section__title {
margin: 0 0 12px;
font-size: 12px;
font-weight: 600;
color: var(--soon-text-muted);
letter-spacing: 0.06em;
}
.soon-pay-channels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.soon-pay-channels--single {
grid-template-columns: 1fr;
}
.soon-pay-channel.is-fixed {
cursor: default;
}
.soon-pay-channel.is-fixed:hover {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.18);
}
.soon-pay-channel.is-fixed.is-active:hover {
border-color: var(--soon-accent);
background: rgba(0, 150, 136, 0.07);
}
.soon-pay-channel.is-fixed .soon-pay-channel__radio {
border-radius: 50%;
background: currentColor;
opacity: 0.85;
}
.soon-pay-channel.is-fixed.is-active .soon-pay-channel__radio::after {
display: none;
}
.soon-pay-channel {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 12px 14px;
border: 1.5px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
cursor: pointer;
background: rgba(0, 0, 0, 0.18);
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
text-align: left;
font: inherit;
color: inherit;
box-sizing: border-box;
}
.soon-pay-channel:hover {
border-color: rgba(255, 255, 255, 0.16);
background: rgba(0, 0, 0, 0.24);
}
.soon-pay-channel.is-active {
border-color: var(--soon-accent);
background: rgba(0, 150, 136, 0.07);
box-shadow: inset 0 0 0 1px rgba(0, 150, 136, 0.15);
}
.soon-pay-channel[data-ch="wechat"].is-active {
border-color: #07c160;
background: rgba(7, 193, 96, 0.08);
box-shadow: inset 0 0 0 1px rgba(7, 193, 96, 0.2);
}
.soon-pay-channel[data-ch="alipay"].is-active {
border-color: #1677ff;
background: rgba(22, 119, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(22, 119, 255, 0.2);
}
.soon-pay-channel__radio {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.2);
flex-shrink: 0;
position: relative;
}
.soon-pay-channel.is-active .soon-pay-channel__radio {
border-color: currentColor;
}
.soon-pay-channel.is-active .soon-pay-channel__radio::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: currentColor;
}
.soon-pay-channel[data-ch="wechat"].is-active .soon-pay-channel__radio { color: #07c160; }
.soon-pay-channel[data-ch="alipay"].is-active .soon-pay-channel__radio { color: #1677ff; }
.soon-pay-channel__icon {
width: 34px;
height: 34px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 800;
color: #fff;
flex-shrink: 0;
}
.soon-pay-channel__icon--wechat { background: linear-gradient(135deg, #06ae56, #07c160); }
.soon-pay-channel__icon--alipay { background: linear-gradient(135deg, #0958d9, #1677ff); }
.soon-pay-channel__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.soon-pay-channel__name {
font-size: 13px;
font-weight: 600;
color: var(--soon-text-strong);
}
.soon-pay-channel__hint {
font-size: 11px;
color: var(--soon-text-muted);
}
.soon-pay-qr-panel {
min-height: 168px;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
padding: 4px 0;
}
.soon-pay-sheet--checkout .soon-pay-qr-panel {
min-height: 0;
}
.soon-pay-sheet--checkout .soon-pay-stepper {
margin-bottom: 16px;
}
.soon-pay-checkout-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px 20px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(255, 255, 255, 0.06);
text-align: center;
}
.soon-pay-checkout-empty__icon {
width: 48px;
height: 48px;
color: rgba(255, 255, 255, 0.22);
margin-bottom: 4px;
}
.soon-pay-checkout-empty__icon svg {
width: 100%;
height: 100%;
}
.soon-pay-checkout-empty__title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--soon-text);
}
.soon-pay-checkout-empty__desc {
margin: 0;
font-size: 12px;
color: var(--soon-text-muted);
line-height: 1.55;
max-width: 280px;
}
.soon-pay-checkout-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 24px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.soon-pay-checkout-loading p {
margin: 0;
font-size: 12px;
color: var(--soon-text-muted);
}
.soon-pay-checkout-ready {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 22px 20px 18px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(255, 255, 255, 0.08);
text-align: center;
}
.soon-pay-checkout-ready--alipay {
padding: 18px 16px 14px;
gap: 8px;
}
.soon-pay-checkout-ready__brand {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 800;
color: #fff;
}
.soon-pay-checkout-ready__brand--wechat { background: linear-gradient(135deg, #06ae56, #07c160); }
.soon-pay-checkout-ready__brand--alipay { background: linear-gradient(135deg, #0958d9, #1677ff); }
.soon-pay-checkout-ready__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--soon-text-strong);
}
.soon-pay-checkout-ready__desc {
margin: 0;
font-size: 12px;
color: var(--soon-text-muted);
line-height: 1.5;
}
.soon-pay-checkout-ready__qr {
padding: 10px;
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
line-height: 0;
}
.soon-pay-checkout-ready__qr canvas,
.soon-pay-checkout-ready__qr img {
display: block;
border-radius: 4px;
}
.soon-pay-checkout-ready__btn {
min-width: 180px;
margin-top: 4px;
}
.soon-pay-sheet__foot {
padding: 12px 26px 18px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 16px;
background: rgba(0, 0, 0, 0.08);
}
.soon-pay-sheet--checkout .soon-pay-sheet__foot {
padding-top: 10px;
padding-bottom: 16px;
}
.soon-pay-status {
margin: 0 0 8px;
text-align: center;
font-size: 12px;
color: var(--soon-text-muted);
transition: color 0.2s ease, opacity 0.2s ease;
}
.soon-pay-status--ok { color: #4db6ac; }
.soon-pay-status--err { color: var(--soon-danger); }
.soon-pay-status--warn { color: #ffb74d; }
.soon-pay-status.is-checking {
color: #4db6ac;
opacity: 0.85;
}
.soon-pay-poll {
display: none;
align-items: center;
justify-content: center;
gap: 8px;
margin: 0 0 10px;
font-size: 11px;
color: var(--soon-text-muted);
}
.soon-pay-sheet--polling .soon-pay-poll {
display: flex;
}
.soon-pay-poll__spin {
width: 12px;
height: 12px;
border: 2px solid rgba(255, 255, 255, 0.12);
border-top-color: #4db6ac;
border-radius: 50%;
animation: soon-pay-spin 0.75s linear infinite;
flex-shrink: 0;
}
.soon-pay-poll.is-tick .soon-pay-poll__spin {
animation-duration: 0.45s;
}
.soon-pay-sheet--polling .soon-pay-checkout-ready {
border-color: rgba(77, 182, 172, 0.28);
animation: soon-pay-wait-pulse 2.4s ease-in-out infinite;
}
.soon-pay-sheet__cta {
width: 100%;
transition: opacity 0.2s ease;
}
.soon-pay-sheet__cta--secondary {
margin-top: 8px;
}
.soon-pay-sheet__cta.is-loading {
opacity: 0.72;
pointer-events: none;
}
.soon-pay-sheet__cta.is-loading::before {
content: '';
display: inline-block;
width: 12px;
height: 12px;
margin-right: 6px;
vertical-align: -2px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: currentColor;
border-radius: 50%;
animation: soon-pay-spin 0.75s linear infinite;
}
@keyframes soon-pay-spin {
to { transform: rotate(360deg); }
}
@keyframes soon-pay-wait-pulse {
0%, 100% { box-shadow: inset 0 0 0 0 rgba(77, 182, 172, 0); }
50% { box-shadow: inset 0 0 0 1px rgba(77, 182, 172, 0.22); }
}
+28 -45
View File
@@ -271,44 +271,12 @@
animation: soon-fadeInUp 0.4s; animation: soon-fadeInUp 0.4s;
} }
.soon-member-wrap { max-width: 960px; margin: 0 auto; } .soon-member-wrap { max-width: 960px; margin: 0 auto; }
.soon-template-card {
flex: 1 1 0;
min-width: 0;
max-width: 148px;
background: var(--soon-bg-panel);
border: 1px solid var(--soon-border-subtle);
border-radius: var(--soon-radius-sm);
overflow: hidden;
cursor: pointer;
transition: transform 0.2s var(--soon-ease), border-color 0.2s, box-shadow 0.2s;
animation: soon-fadeInUp 0.4s var(--soon-ease) backwards;
}
.soon-templates-strip .soon-empty--inline { .soon-templates-strip .soon-empty--inline {
flex: 1 1 100%; flex: 1 1 100%;
width: 100%; width: 100%;
min-width: 100%; min-width: 100%;
} }
.soon-template-card:hover {
border-color: var(--soon-accent);
background: var(--soon-bg-elevated);
}
.soon-template-card img {
width: 100%;
height: 100px;
object-fit: cover;
background: var(--soon-bg-deep);
}
.soon-template-card span {
display: block;
padding: var(--soon-space-sm);
font-size: 12px;
color: var(--soon-text);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.soon-member-header { .soon-member-header {
@@ -506,9 +474,12 @@
} }
.soon-home .card-list > .card, .soon-home .card-list > .card,
.soon-home-recent-grid > .card { .soon-home-recent-grid > .card,
.soon-home .soon-templates-strip > .card {
width: 168px; width: 168px;
max-width: 168px; max-width: 168px;
flex-shrink: 0;
margin: 0;
} }
.soon-section--recent #filePager { .soon-section--recent #filePager {
@@ -518,7 +489,8 @@
border-top: 1px solid var(--soon-border-subtle); border-top: 1px solid var(--soon-border-subtle);
} }
.soon-home .card-list .rect { .soon-home .card-list .rect,
.soon-home .soon-templates-strip .rect {
width: 168px; width: 168px;
height: 160px; height: 160px;
background: var(--soon-bg-panel); background: var(--soon-bg-panel);
@@ -535,11 +507,13 @@
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
} }
.soon-home .card-list .rect:hover { .soon-home .card-list .rect:hover,
.soon-home .soon-templates-strip .rect:hover {
border-color: var(--soon-accent); border-color: var(--soon-accent);
background: var(--soon-bg-elevated); background: var(--soon-bg-elevated);
} }
.soon-home .card-list .rect img { .soon-home .card-list .rect img,
.soon-home .soon-templates-strip .rect img {
display: block; display: block;
width: 100%; width: 100%;
height: 135px; height: 135px;
@@ -553,9 +527,12 @@
border-radius: 4px; border-radius: 4px;
transition: transform 0.3s; transition: transform 0.3s;
} }
.soon-home .card-list .rect1 img { transform: scale(0.8); } .soon-home .card-list .rect1 img,
.soon-home .card-list .rect1:hover img { transform: scale(0.85); } .soon-home .soon-templates-strip .rect1 img { transform: scale(0.8); }
.soon-home .card-list .tip { .soon-home .card-list .rect1:hover img,
.soon-home .soon-templates-strip .rect1:hover img { transform: scale(0.85); }
.soon-home .card-list .tip,
.soon-home .soon-templates-strip .tip {
flex-shrink: 0; flex-shrink: 0;
width: 100%; width: 100%;
height: 25px; height: 25px;
@@ -571,7 +548,8 @@
border-top: 1px solid var(--soon-border-subtle); border-top: 1px solid var(--soon-border-subtle);
background: rgba(0, 0, 0, 0.15); background: rgba(0, 0, 0, 0.15);
} }
.soon-home .card-list .rect:hover .tip { color: var(--soon-text-strong); } .soon-home .card-list .rect:hover .tip,
.soon-home .soon-templates-strip .rect:hover .tip { color: var(--soon-text-strong); }
.soon-home .card-list .soon-file-card__actions { .soon-home .card-list .soon-file-card__actions {
position: absolute; position: absolute;
top: 6px; top: 6px;
@@ -636,7 +614,8 @@
max-width: none; max-width: none;
} }
.soon-home .card-list .rect:not(.rect1)::after { .soon-home .card-list .rect:not(.rect1)::after,
.soon-home .soon-templates-strip .rect:not(.rect1)::after {
content: ''; content: '';
position: absolute; position: absolute;
left: 50%; left: 50%;
@@ -736,18 +715,22 @@
min-height: 300px; min-height: 300px;
} }
.soon-home .card-list > .card, .soon-home .card-list > .card,
.soon-home-recent-grid > .card { .soon-home-recent-grid > .card,
.soon-home .soon-templates-strip > .card {
width: 148px; width: 148px;
max-width: 148px; max-width: 148px;
} }
.soon-home .card-list .rect { .soon-home .card-list .rect,
.soon-home .soon-templates-strip .rect {
width: 148px; width: 148px;
height: 150px; height: 150px;
} }
.soon-home .card-list .rect img { .soon-home .card-list .rect img,
.soon-home .soon-templates-strip .rect img {
height: 125px; height: 125px;
} }
.soon-home .card-list .rect:not(.rect1)::after { .soon-home .card-list .rect:not(.rect1)::after,
.soon-home .soon-templates-strip .rect:not(.rect1)::after {
transform: translate(-50%, calc(-50% - 12px)); transform: translate(-50%, calc(-50% - 12px));
width: 20px; width: 20px;
height: 20px; height: 20px;
+241
View File
@@ -1,3 +1,5 @@
@import url('pay-sheet.css');
.soon-subscribe-modal { .soon-subscribe-modal {
text-align: left; text-align: left;
background: var(--soon-bg-panel); background: var(--soon-bg-panel);
@@ -222,3 +224,242 @@
opacity: 0.45; opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;
} }
.soon-login-gate {
text-align: left;
background: var(--soon-bg-panel);
color: var(--soon-text);
border-radius: var(--soon-radius-lg);
overflow: hidden;
}
.soon-login-gate__head {
padding: 22px 48px 16px 22px;
border-bottom: 1px solid var(--soon-border-subtle);
background: linear-gradient(160deg, rgba(0, 150, 136, 0.12) 0%, transparent 100%);
}
.soon-login-gate__eyebrow {
display: block;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--soon-accent);
}
.soon-login-gate__title {
margin: 0;
font-size: 19px;
font-weight: 700;
color: var(--soon-text-strong);
line-height: 1.35;
}
.soon-login-gate__desc {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.55;
color: var(--soon-text-muted);
}
.soon-login-gate__form {
padding: 18px 22px 8px;
display: flex;
flex-direction: column;
gap: 12px;
}
.soon-login-gate__field.soon-input-wrap {
margin-bottom: 0;
}
.soon-login-gate__input.soon-input {
height: 42px;
padding: 0 14px 0 40px;
background: var(--soon-bg-deep);
border: 1px solid var(--soon-border-subtle);
border-radius: var(--soon-radius-sm);
color: var(--soon-text-strong);
font-size: 14px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.soon-login-gate__input.soon-input::placeholder {
color: var(--soon-text-muted);
}
.soon-login-gate__input.soon-input:focus {
border-color: var(--soon-accent);
box-shadow: var(--soon-focus-ring);
}
.soon-login-gate__input.soon-input:-webkit-autofill,
.soon-login-gate__input.soon-input:-webkit-autofill:hover,
.soon-login-gate__input.soon-input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--soon-text-strong);
caret-color: var(--soon-text-strong);
transition: background-color 99999s ease-out 0s;
-webkit-box-shadow: 0 0 0 1000px var(--soon-bg-deep) inset;
box-shadow: 0 0 0 1000px var(--soon-bg-deep) inset;
border: 1px solid var(--soon-border-subtle);
}
.soon-login-gate__error {
margin: 0;
padding: 8px 10px;
border-radius: var(--soon-radius-sm);
background: rgba(229, 115, 115, 0.12);
border: 1px solid rgba(229, 115, 115, 0.28);
color: #fca5a5;
font-size: 13px;
line-height: 1.45;
}
.soon-login-gate__error[hidden] {
display: none !important;
}
.soon-login-gate__submit {
margin-top: 2px;
height: 42px;
font-size: 14px;
font-weight: 600;
}
.soon-login-gate__links {
margin: 0;
padding: 0 0 4px;
font-size: 13px;
text-align: center;
color: var(--soon-text-muted);
}
.soon-login-gate__links a {
color: var(--soon-accent-hover);
text-decoration: none;
}
.soon-login-gate__links a:hover {
text-decoration: underline;
}
.soon-login-gate__foot {
padding: 12px 22px 18px;
border-top: 1px solid var(--soon-border-subtle);
background: rgba(0, 0, 0, 0.12);
}
.soon-login-gate__stay {
display: block;
width: 100%;
padding: 9px 16px;
border: 1px solid var(--soon-border-subtle);
border-radius: var(--soon-radius-sm);
background: transparent;
color: var(--soon-text-muted);
font-size: 13px;
line-height: 1.4;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.soon-login-gate__stay:hover {
color: var(--soon-text);
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
}
.soon-activate-price {
padding: 16px 20px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
gap: 6px;
}
.soon-activate-price__label {
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
}
.soon-activate-price__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
.soon-activate-channels {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 16px;
}
.soon-activate-load {
margin: 12px 0 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
min-height: 18px;
}
.soon-activate-modal {
border-radius: var(--soon-radius-lg);
overflow: hidden;
}
.soon-activate-modal .soon-subscribe-modal__head {
padding: 22px 52px 18px 24px;
}
.soon-activate-modal .soon-subscribe-modal__body {
padding: 18px 24px 20px;
}
.soon-activate-modal .soon-subscribe-modal__foot {
padding: 16px 24px 20px;
}
.soon-activate-price__value {
font-size: 32px;
font-weight: 700;
color: var(--soon-text-strong);
letter-spacing: -0.02em;
}
.soon-activate-channel {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-radius: 10px;
border: 1.5px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.18);
font-size: 13px;
font-weight: 500;
color: var(--soon-text);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
.soon-activate-channel input[type="radio"] {
position: absolute;
opacity: 0;
pointer-events: none;
}
.soon-activate-channel.is-active {
border-color: #1677ff;
background: rgba(22, 119, 255, 0.1);
box-shadow: inset 0 0 0 1px rgba(22, 119, 255, 0.18);
}
.soon-activate-channel.is-active::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: #1677ff;
flex-shrink: 0;
}
-7
View File
@@ -1,7 +0,0 @@
window.SOON_DEPLOY_CONFIG = {
api_v1_base: 'http://localhost:8102/api/v1',
api_admin_base: 'http://localhost:8102/api/admin',
front_base_url: 'http://localhost:8100',
web_base: 'http://localhost:8100',
version: 'local-dev'
};
-7
View File
@@ -1,7 +0,0 @@
{
"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"
}
+9
View File
@@ -0,0 +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'
};
+118
View File
@@ -38,4 +38,122 @@
var n = String(rel || '').replace(/^\/+/, ''); var n = String(rel || '').replace(/^\/+/, '');
return window.SOON_JS_BASE + n; return window.SOON_JS_BASE + n;
}; };
/** 等 HTMLImage 解码完成后再 toDataURL / setSrc,避免间歇性空白背景 */
window.soonFabricImageWhenReady = function (image, cb, skipEmbed) {
if (!image) {
if (typeof cb === 'function') cb(null);
return;
}
function finalize() {
if (skipEmbed) {
if (typeof cb === 'function') cb(image);
return;
}
try {
var dataUrl = image.toDataURL();
if (dataUrl && dataUrl.length > 500) {
image.setSrc(dataUrl, function (img) {
if (typeof cb === 'function') cb(img || image);
});
return;
}
} catch (e) { /* ignore */ }
if (typeof cb === 'function') cb(image);
}
var el = image.getElement ? image.getElement() : null;
if (!el) {
finalize();
return;
}
if (el.complete && el.naturalWidth > 0) {
finalize();
return;
}
el.onload = function () { finalize(); };
el.onerror = function () {
if (typeof cb === 'function') cb(null);
};
};
window.soonFabricImageFromAsset = function (assetName, onReady, onFail, retryLeft, skipEmbed) {
if (typeof fabric === 'undefined' || !fabric.Image) {
if (typeof onFail === 'function') onFail();
return;
}
var retries = retryLeft == null ? 2 : retryLeft;
var url = window.soonAsset(assetName);
fabric.Image.fromURL(url, function (image) {
if (!image || !image.width) {
if (retries > 0) {
setTimeout(function () {
window.soonFabricImageFromAsset(assetName, onReady, onFail, retries - 1, skipEmbed);
}, 150);
return;
}
if (typeof onFail === 'function') onFail();
return;
}
function deliver(img) {
if (img && typeof onReady === 'function') onReady(img);
else if (retries > 0) {
setTimeout(function () {
window.soonFabricImageFromAsset(assetName, onReady, onFail, retries - 1, skipEmbed);
}, 150);
} else if (typeof onFail === 'function') onFail();
}
if (skipEmbed) {
window.soonFabricImageWhenReady(image, deliver, true);
return;
}
window.soonFabricImageWhenReady(image, deliver);
}, null, { crossOrigin: 'anonymous' });
};
window.soonRunWhenCanvasReady = function (selector, fn) {
function tryRun() {
var el = typeof selector === 'string' ? document.querySelector(selector) : selector;
var w = el ? (el.clientWidth || el.width || 0) : 0;
if ((!w || w < 50) && window.SOON_DEPLOY_CONFIG) {
requestAnimationFrame(function () {
requestAnimationFrame(tryRun);
});
return;
}
if (typeof fn === 'function') fn(w || 800);
}
tryRun();
};
var SOON_LOCALE_CODES = { zh: 1, ozh: 1, en: 1 };
window.soonNormalizeLocaleCode = function (loc) {
if (!loc) return 'zh';
if (SOON_LOCALE_CODES[loc]) return loc;
var s = String(loc);
if (s.indexOf('zh') === 0) return s.indexOf('TW') >= 0 ? 'ozh' : 'zh';
return 'en';
};
window.soonResolveLocale = function (cb) {
if (typeof cb !== 'function') return;
var stored;
try { stored = localStorage.getItem('lang'); } catch (e) { stored = null; }
if (stored && SOON_LOCALE_CODES[stored]) {
cb(stored);
return;
}
var bridge = window.platformBridge;
if (!bridge || typeof bridge.getLocale !== 'function') {
cb('zh');
return;
}
var ret;
try { ret = bridge.getLocale(); } catch (e) { cb('zh'); return; }
if (ret && typeof ret.then === 'function') {
ret.then(function (loc) { cb(window.soonNormalizeLocaleCode(loc)); }).catch(function () { cb('zh'); });
return;
}
cb(window.soonNormalizeLocaleCode(ret));
};
})(); })();
+5
View File
@@ -85,16 +85,21 @@
fullUrl = getApiBase() + '/' + String(url).replace(/^\/+/, ''); fullUrl = getApiBase() + '/' + String(url).replace(/^\/+/, '');
} }
var hadSession = !!(token || getRefreshToken());
return fetch(fullUrl, opts).then(function (response) { return fetch(fullUrl, opts).then(function (response) {
if (response.status !== 401) return response; if (response.status !== 401) return response;
if (!getRefreshToken()) return response;
return refreshAccessToken().then(function (newToken) { return refreshAccessToken().then(function (newToken) {
var retryHeaders = Object.assign({}, opts.headers || {}); var retryHeaders = Object.assign({}, opts.headers || {});
retryHeaders['Authorization'] = 'Bearer ' + newToken; retryHeaders['Authorization'] = 'Bearer ' + newToken;
var retryOpts = Object.assign({}, opts, { headers: retryHeaders }); var retryOpts = Object.assign({}, opts, { headers: retryHeaders });
return fetch(fullUrl, retryOpts); return fetch(fullUrl, retryOpts);
}).catch(function () { }).catch(function () {
if (hadSession) {
clearSession(); clearSession();
redirectToLogin(); redirectToLogin();
}
return response; return response;
}); });
}); });
+672 -69
View File
@@ -2,9 +2,23 @@
'use strict'; 'use strict';
var FILE_PREFIX = 'soondesign_file:'; var FILE_PREFIX = 'soondesign_file:';
var TEMPLATE_PREFIX = 'soondesign_template:';
function soonParseTemplateKey(key) {
if (!key || typeof key !== 'string') return null;
var m = key.match(/^soondesign_template:(\d+)$/);
if (!m) return null;
return { id: parseInt(m[1], 10) };
}
function soonMakeTemplateKey(id) {
return TEMPLATE_PREFIX + id;
}
function soonIsWebPortal() { function soonIsWebPortal() {
return typeof window !== 'undefined' && window.platformBridge && !window.fs; if (typeof window === 'undefined') return false;
if (window.SOON_DEPLOY_CONFIG) return true;
return !!(window.platformBridge && window.fs == null);
} }
function soonGetAccessToken() { function soonGetAccessToken() {
@@ -25,14 +39,279 @@
return FILE_PREFIX + id + ':v' + version; return FILE_PREFIX + id + ':v' + version;
} }
function soonIsTemplateKey(key) {
return !!(key && typeof key === 'string' && key.indexOf(TEMPLATE_PREFIX) === 0);
}
function soonNeedsSaveDialog(key) {
return !key || soonIsTemplateKey(key);
}
function soonDefaultNewSoonName() {
var d = new Date();
function pad(n) { return n < 10 ? '0' + n : String(n); }
var yy = String(d.getFullYear()).slice(-2);
var stamp = yy + pad(d.getMonth() + 1) + pad(d.getDate()) +
pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds());
return 'design' + stamp + '.soon';
}
function soonEnsureSoonExt(name) {
var n = String(name || 'design.soon').trim();
if (!/\.soon$/i.test(n)) n = (n.replace(/\.soon$/i, '') || 'design') + '.soon';
return n;
}
function soonSessionSlug(nameOrKey) {
var s = String(nameOrKey || 'design').replace(/^soondesign_session:/, '').split(/[/\\]/).pop();
s = s.replace(/\.soon$/i, '');
s = s.replace(/-\d{10,}$/, '');
return s || 'design';
}
function soonMakeSessionKey(nameOrKey) {
return 'soondesign_session:' + soonSessionSlug(nameOrKey);
}
function soonSessionDisplayName(keyOrName) {
return soonEnsureSoonExt(soonSessionSlug(keyOrName));
}
function soonBuildSavePreviewPic(canvas) {
var THUMB_MAX = 102400;
if (!canvas || typeof canvas.getObjects !== 'function') return '';
var hidden = [];
canvas.getObjects().forEach(function (obj) {
if (obj.isGuideLine) {
hidden.push(obj);
obj.visible = false;
}
});
if (hidden.length) canvas.renderAll();
window._soonSkipDesign1CdMask = true;
var mult = 0.18;
var quality = 0.72;
var url = '';
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; });
canvas.renderAll();
if (typeof window.soonClearFabricOverlay === 'function') window.soonClearFabricOverlay(canvas);
if (canvas.requestRenderAll) canvas.requestRenderAll();
return url;
}
function soonApplySavePreview(con_o, canvas) {
if (!con_o) return con_o;
var pic = soonBuildSavePreviewPic(canvas);
if (pic) con_o.frontDisplayPic = pic;
return con_o;
}
function soonSaveDialogDefaultPath(openKey) {
if (soonNeedsSaveDialog(openKey)) return soonDefaultNewSoonName();
return openKey || undefined;
}
function soonEnsureSaveFileName(fp) {
var name = String(fp || 'design.soon');
if (typeof window !== 'undefined' && window.platformBridge && window.fs == null) {
return soonEnsureSoonExt(name);
}
if (typeof window !== 'undefined' && window.path && window.path.extname) {
if (window.path.extname(name) !== '.soon') {
name = (name.replace(/\.soon$/i, '') || 'design') + '.soon';
}
} else if (!/\.soon$/i.test(name)) {
name = (name.replace(/\.soon$/i, '') || 'design') + '.soon';
}
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,
message: (err && err.message) || '保存失败',
code: err && err.code
});
} else {
soonToast((err && err.message) || '保存失败', 'error');
}
}
function soonWriteSoonContentWeb(fp, content, prevOpenKey, onDone, onError) {
if (!window.platformBridge || !window.platformBridge.writeFile) return false;
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;
}
function soonDownloadBlob(filename, content, mime) {
var name = soonEnsureSoonExt(filename || 'design.soon');
var blob = content instanceof Blob
? content
: new Blob([content], { type: mime || 'application/json' });
var a = document.createElement('a');
a.download = name;
a.href = URL.createObjectURL(blob);
a.click();
URL.revokeObjectURL(a.href);
}
function soonDownloadRecentItem(item) {
if (!item) return Promise.resolve();
var key = item.filePath || item.key || '';
var name = soonEnsureSoonExt(item.name || 'design.soon');
var kind = item.kind || (typeof window.soonRecentKindFromKey === 'function'
? window.soonRecentKindFromKey(key) : 'local');
if (kind === 'template') {
var tpl = soonParseTemplateKey(key);
if (!tpl) {
soonToast('无法下载模板', 'error');
return Promise.resolve();
}
var base = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || '';
if (!base) {
soonToast('下载地址未配置', 'error');
return Promise.resolve();
}
return fetch(base + '/templates/' + tpl.id + '/file', { headers: { Accept: 'application/json' } })
.then(function (r) {
if (!r.ok) throw new Error('download_failed');
return r.text();
})
.then(function (text) {
soonDownloadBlob(name, text, 'application/json');
})
.catch(function () {
soonToast('模板下载失败', 'error');
});
}
if (kind === 'cloud') {
function tryCloudApiDownload() {
if (!item.fileId || !soonGetAccessToken()) return Promise.resolve(false);
var bridge = window.platformBridge;
if (!bridge || typeof bridge.downloadCloudFile !== 'function') return Promise.resolve(false);
return bridge.downloadCloudFile(item.fileId, name).then(function () { return true; }).catch(function () {
soonToast('下载失败', 'error');
return true;
});
}
if (key && typeof window.soonLocalGet === 'function') {
return window.soonLocalGet(key).then(function (hit) {
if (hit && hit.json) {
soonDownloadBlob(name, JSON.stringify(hit.json), 'application/json');
return;
}
return tryCloudApiDownload().then(function (done) {
if (!done) soonToast('请先打开或保存该文件', 'warn');
});
});
}
return tryCloudApiDownload().then(function (done) {
if (!done) soonToast('请先打开或保存该文件', 'warn');
});
}
if (key.indexOf('soondesign_session:') === 0 || kind === 'local') {
if (typeof window.soonLocalGet !== 'function') {
soonToast('本地缓存不可用', 'error');
return Promise.resolve();
}
return window.soonLocalGet(key).then(function (hit) {
if (!hit || !hit.json) {
soonToast('请先打开或保存该文件', 'warn');
return;
}
soonDownloadBlob(name, JSON.stringify(hit.json), 'application/json');
});
}
soonToast('无法下载该文件', 'warn');
return Promise.resolve();
}
function soonLookupRecentCloudName(fileId) {
if (!fileId || typeof window.soonRecentList !== 'function') return '';
var items = window.soonRecentList();
for (var i = 0; i < items.length; i++) {
if (items[i].fileId === fileId && items[i].name) return items[i].name;
}
return '';
}
function soonResolveCloudFileName(fileKey) {
if (!fileKey || fileKey.indexOf(FILE_PREFIX) !== 0) return '';
var parsed = soonParseFileKey(fileKey);
if (!parsed) return '';
var meta = window._soonFileMeta;
if (meta && meta.id === parsed.id && meta.name) return meta.name;
var recentName = soonLookupRecentCloudName(parsed.id);
if (recentName) return recentName;
return '';
}
function soonNormalizeSoonName(pathOrName) { function soonNormalizeSoonName(pathOrName) {
var n = String(pathOrName || 'design.soon'); var n = String(pathOrName || 'design.soon');
if (n.indexOf(FILE_PREFIX) === 0) { if (n.indexOf(FILE_PREFIX) === 0) {
var meta = window._soonFileMeta; var resolved = soonResolveCloudFileName(n);
return (meta && meta.name) ? meta.name : 'design.soon'; if (resolved) return resolved;
return 'design.soon';
}
if (n.indexOf(TEMPLATE_PREFIX) === 0) {
return soonDefaultNewSoonName();
} }
if (n.indexOf('soondesign_session:') === 0) { if (n.indexOf('soondesign_session:') === 0) {
n = n.replace(/^soondesign_session:/, ''); return soonSessionDisplayName(n);
} }
n = n.split(/[/\\]/).pop(); n = n.split(/[/\\]/).pop();
if (!/\.soon$/i.test(n)) n = (n.replace(/\.soon$/i, '') || 'design') + '.soon'; if (!/\.soon$/i.test(n)) n = (n.replace(/\.soon$/i, '') || 'design') + '.soon';
@@ -45,15 +324,77 @@
return soonMakeFileKey(data.id, data.version); return soonMakeFileKey(data.id, data.version);
} }
function soonSyncOpenNavigation(fileKey, type) {
if (typeof sessionStorage === 'undefined') {
if (!fileKey || fileKey.indexOf(FILE_PREFIX) !== 0) window._soonFileMeta = null;
return;
}
try {
if (fileKey) {
sessionStorage.setItem('soondesign_open_file', fileKey);
sessionStorage.setItem('soondesign_open_type', String(type || 1));
if (fileKey.indexOf(FILE_PREFIX) === 0 && window._soonFileMeta) {
sessionStorage.setItem('soondesign_open_meta', JSON.stringify(window._soonFileMeta));
} else {
sessionStorage.removeItem('soondesign_open_meta');
if (fileKey.indexOf(FILE_PREFIX) !== 0) window._soonFileMeta = null;
}
} else {
sessionStorage.removeItem('soondesign_open_file');
sessionStorage.removeItem('soondesign_open_type');
sessionStorage.removeItem('soondesign_open_meta');
window._soonFileMeta = null;
}
} catch (e) { /* ignore quota */ }
}
function soonResolveOpenFileKey(getParams) {
var params = typeof getParams === 'function' ? getParams() : null;
if (!params || typeof params.has !== 'function') return '';
var file = params.has('file') ? (params.get('file') || '') : null;
if (file === null && typeof sessionStorage !== 'undefined') {
try {
file = sessionStorage.getItem('soondesign_open_file') || '';
if (file) {
sessionStorage.removeItem('soondesign_open_file');
sessionStorage.removeItem('soondesign_open_type');
}
} catch (e) {
file = '';
}
}
return file || '';
}
function soonConsumeOpenMeta(fileKey) {
if (!fileKey || fileKey.indexOf(FILE_PREFIX) !== 0) {
window._soonFileMeta = null;
return;
}
if (typeof sessionStorage === 'undefined') return;
try {
var metaRaw = sessionStorage.getItem('soondesign_open_meta');
if (metaRaw) {
window._soonFileMeta = JSON.parse(metaRaw);
sessionStorage.removeItem('soondesign_open_meta');
}
} catch (e) { /* ignore */ }
}
function soonBindFileMeta(fileKey, opts) { function soonBindFileMeta(fileKey, opts) {
if (!fileKey || fileKey.indexOf(FILE_PREFIX) !== 0) return; if (!fileKey || fileKey.indexOf(FILE_PREFIX) !== 0) {
window._soonFileMeta = null;
return;
}
var parsed = soonParseFileKey(fileKey); var parsed = soonParseFileKey(fileKey);
if (!parsed) return; if (!parsed) return;
var prev = window._soonFileMeta || {}; var prev = window._soonFileMeta || {};
var name = (opts && opts.name) || (prev.id === parsed.id ? prev.name : '') || '';
if (!name) name = soonLookupRecentCloudName(parsed.id);
window._soonFileMeta = { window._soonFileMeta = {
id: parsed.id, id: parsed.id,
version: parsed.version != null ? parsed.version : prev.version, version: parsed.version != null ? parsed.version : prev.version,
name: (opts && opts.name) || (prev.id === parsed.id ? prev.name : '') || '' name: name
}; };
} }
@@ -66,9 +407,8 @@
var _membership = { var _membership = {
loaded: false, loaded: false,
isMember: false, isMember: false,
name: '免费版', name: '普通用户',
tier: 'free', tier: 'free',
expiresAt: null,
loading: null, loading: null,
}; };
@@ -81,18 +421,6 @@
return '/api/v1'; return '/api/v1';
} }
function formatExpireDate(iso) {
if (!iso) return '';
var d = new Date(String(iso).replace(' ', 'T'));
if (isNaN(d.getTime())) return '';
var y = d.getFullYear();
var mo = String(d.getMonth() + 1);
var day = String(d.getDate());
if (mo.length < 2) mo = '0' + mo;
if (day.length < 2) day = '0' + day;
return y + '-' + mo + '-' + day;
}
function soonApplyMembership(data) { function soonApplyMembership(data) {
var m = data || {}; var m = data || {};
var sub = m.subscription || {}; var sub = m.subscription || {};
@@ -103,9 +431,8 @@
_membership = { _membership = {
loaded: true, loaded: true,
isMember: !!isMember, isMember: !!isMember,
name: m.name || (isMember ? '订阅版' : '免费版'), name: isMember ? (m.name || '会员') : '普通用户',
tier: m.tier || (isMember ? 'member' : 'free'), tier: m.tier || (isMember ? 'member' : 'free'),
expiresAt: sub.expires_at || null,
loading: null, loading: null,
}; };
if (typeof window.soonRefreshPortalIdentity === 'function') { if (typeof window.soonRefreshPortalIdentity === 'function') {
@@ -132,13 +459,10 @@
var cls = 'soon-portal-identity'; var cls = 'soon-portal-identity';
if (_membership.isMember) { if (_membership.isMember) {
cls += ' soon-portal-identity--member'; cls += ' soon-portal-identity--member';
text = _membership.name || '订阅版'; text = _membership.name || '会员';
if (_membership.expiresAt) {
text += ' · 至 ' + formatExpireDate(_membership.expiresAt);
}
} else { } else {
cls += ' soon-portal-identity--free'; cls += ' soon-portal-identity--free';
text = _membership.name || '免费版'; text = _membership.name || '普通用户';
} }
el.className = cls; el.className = cls;
el.textContent = text; el.textContent = text;
@@ -154,16 +478,22 @@
soonApplyMembership(null); soonApplyMembership(null);
return Promise.resolve(_membership); return Promise.resolve(_membership);
} }
var url = soonApiBase() + '/plans/me'; var url = soonApiBase() + '/auth/me';
var fetchFn = typeof window.soonAuthedFetch === 'function' var fetchFn = typeof window.soonAuthedFetch === 'function'
? window.soonAuthedFetch(url, { headers: { Accept: 'application/json' } }) ? window.soonAuthedFetch(url, { headers: { Accept: 'application/json' } })
: fetch(url, { headers: { Authorization: 'Bearer ' + soonGetAccessToken(), Accept: 'application/json' } }); : fetch(url, { headers: { Authorization: 'Bearer ' + soonGetAccessToken(), Accept: 'application/json' } });
_membership.loading = fetchFn _membership.loading = fetchFn
.then(function (r) { return r.json(); }) .then(function (r) {
if (r.status === 401) {
soonApplyMembership(null);
return null;
}
return r.json();
})
.then(function (j) { .then(function (j) {
if (j && j.ok && j.data) { if (j && j.ok && j.data) {
soonApplyMembership(j.data.membership || j.data); soonApplyMembership(j.data);
} else { } else if (j !== null) {
soonApplyMembership(null); soonApplyMembership(null);
} }
return _membership; return _membership;
@@ -182,44 +512,224 @@
return !!_membership.isMember; return !!_membership.isMember;
} }
function soonMemberPageUrl() { function soonGuardPreviewDeliver(actionLabel, onAllowed) {
return 'member.web.html'; if (typeof onAllowed !== 'function') return;
if (!soonIsWebPortal()) {
onAllowed();
return;
} }
if (!soonGetAccessToken()) {
function soonRequireMember(actionLabel) { if (typeof window.soonShowLoginGate === 'function') {
if (!soonGetAccessToken()) return soonRequireLogin(actionLabel); window.soonShowLoginGate({
if (soonIsMember()) return true; reason: actionLabel || '导出或打印',
if (typeof window.soonShowSubscribeGate === 'function') { onSuccess: function () {
window.soonShowSubscribeGate({ action: actionLabel || '使用' }); soonGuardPreviewDeliver(actionLabel, onAllowed);
},
});
} else { } else {
soonToast('订阅后可' + (actionLabel || '使用'), 'warn'); soonRequireLogin(actionLabel);
} }
return false; return;
}
function allowOrActivate() {
if (soonIsMember()) {
onAllowed();
return;
}
if (typeof window.soonShowActivateGate === 'function') {
window.soonShowActivateGate({
reason: actionLabel || '导出或打印',
onSuccess: function () {
onAllowed();
},
});
} else {
soonToast('请先激活会员后再' + (actionLabel || '操作'), 'warn');
}
}
if (!_membership.loaded) {
soonLoadMembership(false).then(allowOrActivate);
return;
}
allowOrActivate();
} }
function soonGuardCloudSave() { function soonGuardCloudSave() {
if (!soonIsWebPortal()) return true; return true;
return soonRequireMember('保存');
} }
function soonGuardMemberPreview() { function soonSoonTypeFromJson(j) {
if (!soonIsWebPortal()) return true; return j && j.soonType ? j.soonType : (j && j.backBlackPic ? 2 : 1);
return soonRequireMember('预览效果');
} }
function soonGuardMemberExport() { function soonPutSoonSession(j, fileName) {
if (!soonIsWebPortal()) return true; if (!j) return '';
return soonRequireMember('导出'); var displayName = soonEnsureSoonExt(fileName || 'design.soon');
var key = soonMakeSessionKey(displayName);
if (typeof window.soonLocalCacheAndThumb === 'function') {
window.soonLocalCacheAndThumb(key, j, { source: 'session', name: displayName });
try { sessionStorage.setItem(key, 'idb'); } catch (e) { /* ignore */ }
return key;
}
try {
sessionStorage.setItem(key, JSON.stringify(j));
return key;
} catch (e) {
return '';
}
}
function soonOpenSoonJsonLocally(j, fileName) {
var key = soonPutSoonSession(j, fileName);
if (!key) {
soonToast('无法打开文件(存储空间不足)', 'error');
return '';
}
if (typeof window.soonRecentUpsertFromOpen === 'function') {
window.soonRecentUpsertFromOpen(key, j);
}
soonScheduleCloudImport(key, fileName);
var type = soonSoonTypeFromJson(j);
if (window.platformBridge && window.platformBridge.openDesignPage) {
window.platformBridge.openDesignPage(key, type);
}
return key;
}
var PENDING_IMPORT_KEY = 'soondesign_pending_import';
function soonScheduleCloudImport(sessionKey, fileName) {
if (!soonGetAccessToken() || !sessionKey) return;
if (sessionKey.indexOf('soondesign_session:') !== 0) return;
try {
sessionStorage.setItem(PENDING_IMPORT_KEY, JSON.stringify({
sessionKey: sessionKey,
name: soonNormalizeSoonName(fileName || 'design.soon'),
at: Date.now()
}));
} catch (e) { /* ignore */ }
}
function soonClearPendingImport() {
try { sessionStorage.removeItem(PENDING_IMPORT_KEY); } catch (e) { /* ignore */ }
}
function soonTryConsumeCloudImport(currentFileKey) {
if (!currentFileKey || currentFileKey.indexOf('soondesign_session:') !== 0) return;
if (!soonGetAccessToken()) return;
if (typeof window.openAs !== 'undefined' && window.openAs && window.openAs.name &&
window.openAs.name.indexOf(FILE_PREFIX) === 0) {
soonClearPendingImport();
return;
}
var pendingRaw;
try {
pendingRaw = sessionStorage.getItem(PENDING_IMPORT_KEY);
} catch (e) {
return;
}
if (!pendingRaw) return;
var pending;
try {
pending = JSON.parse(pendingRaw);
} catch (e) {
try { sessionStorage.removeItem(PENDING_IMPORT_KEY); } catch (e2) { /* ignore */ }
return;
}
if (!pending || pending.sessionKey !== currentFileKey) return;
try { sessionStorage.removeItem(PENDING_IMPORT_KEY); } catch (e) { /* ignore */ }
function loadJsonForImport() {
if (typeof window.soonLocalGet === 'function') {
return window.soonLocalGet(currentFileKey).then(function (hit) {
if (hit && hit.json) return hit.json;
try {
var raw = sessionStorage.getItem(currentFileKey);
if (raw && raw !== 'idb') return JSON.parse(raw);
} catch (e) { /* ignore */ }
return null;
});
}
try {
var raw = sessionStorage.getItem(currentFileKey);
if (raw && raw !== 'idb') return JSON.parse(raw);
} catch (e) { /* ignore */ }
return Promise.resolve(null);
}
var bridge = window.platformBridge;
if (!bridge || typeof bridge.importSoonFile !== 'function') return;
loadJsonForImport().then(function (jsonObj) {
if (!jsonObj) return;
var oldKey = currentFileKey;
return bridge.importSoonFile(pending.name || 'design.soon', jsonObj).then(function (res) {
if (res && res.fileKey && typeof window.openAs !== 'undefined' && window.openAs) {
window.openAs.name = res.fileKey;
}
if (res && res.fileKey && typeof window.soonLocalRenameKey === 'function') {
window.soonLocalRenameKey(oldKey, res.fileKey, { source: 'cloud', name: res.name || pending.name });
}
try {
sessionStorage.removeItem(oldKey);
localStorage.removeItem(oldKey);
} catch (e) { /* ignore */ }
if (typeof window.soonRecentOnCloudSave === 'function') {
window.soonRecentOnCloudSave(res, oldKey, soonSoonTypeFromJson(jsonObj));
}
});
}).catch(function (err) {
if (typeof window.soonShowApiError === 'function') {
window.soonShowApiError({
status: err && err.status,
message: (err && err.message) || '云端登记失败,可稍后保存重试',
code: err && err.code
});
} else {
soonToast((err && err.message) || '云端登记失败,可稍后保存重试', 'warn');
}
});
}
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 });
}
function soonHideOpenFileLoading() {
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) { function soonToast(message, type) {
if (typeof window.soonToast === 'function') window.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; if (soonGetAccessToken()) return true;
var label = actionLabel || '继续'; var label = actionLabel || '继续';
soonToast('请先登录后再' + label, 'warn'); soonToast('请先登录后再' + (label || '操作'), 'warn');
if (options.redirect) {
if (typeof window.soonRedirectToLogin === 'function') { if (typeof window.soonRedirectToLogin === 'function') {
setTimeout(function () { window.soonRedirectToLogin(); }, 600); setTimeout(function () { window.soonRedirectToLogin(); }, 600);
} else if (typeof window.redirectToLogin === 'function') { } else if (typeof window.redirectToLogin === 'function') {
@@ -227,6 +737,7 @@
} else { } else {
window.location.href = 'login.web.html'; window.location.href = 'login.web.html';
} }
}
return false; return false;
} }
@@ -244,14 +755,11 @@
if (response && response.status === 409) { if (response && response.status === 409) {
return { status: 409, message: '文件已被其他端修改,请刷新后重试', code: code || 'conflict' }; return { status: 409, message: '文件已被其他端修改,请刷新后重试', code: code || 'conflict' };
} }
if (response && response.status === 403 && (code === 'membership_required' || msg.indexOf('订阅') >= 0 || msg.indexOf('会员') >= 0)) {
return { status: 403, message: msg || '此功能需要订阅后使用', code: code || 'membership_required' };
}
if (response && response.status === 413) { if (response && response.status === 413) {
if (code === 'file_limit_exceeded') { if (code === 'file_limit_exceeded') {
return { status: 413, message: msg || '文件数量已达上限,请清理文件或续订', code: code }; return { status: 413, message: msg || '文件数量已达上限,请清理文件', code: code };
} }
return { status: 413, message: msg || '存储空间不足,请清理文件或续订', code: code || 'quota_exceeded' }; return { status: 413, message: msg || '存储空间不足,请清理文件', code: code || 'quota_exceeded' };
} }
if (response && response.status === 404) { if (response && response.status === 404) {
return { status: 404, message: '文件不存在', code: code || 'not_found' }; return { status: 404, message: '文件不存在', code: code || 'not_found' };
@@ -265,48 +773,143 @@
if (info.status === 401) kind = 'warn'; if (info.status === 401) kind = 'warn';
else if (info.status === 409) kind = 'warn'; else if (info.status === 409) kind = 'warn';
else if (info.status === 413) kind = 'warn'; else if (info.status === 413) kind = 'warn';
else if (info.status === 403 && info.code === 'membership_required') kind = 'warn';
soonToast(info.message, kind); soonToast(info.message, kind);
if (info.status === 401) { if (info.status === 401 && info.requireLogin) {
soonRequireLogin('操作'); soonRequireLogin('操作', { redirect: true });
} else if (info.status === 403 && info.code === 'membership_required') {
if (typeof window.soonShowSubscribeGate === 'function') {
window.soonShowSubscribeGate({ action: '使用' });
}
} }
} }
function soonDisplayFileName(pathOrKey) { function soonDisplayFileName(pathOrKey) {
if (!pathOrKey) return ''; if (!pathOrKey) return '';
if (pathOrKey.indexOf(TEMPLATE_PREFIX) === 0) {
var tplMeta = window._soonTemplateMeta;
var tplParsed = soonParseTemplateKey(pathOrKey);
if (tplMeta && tplParsed && tplMeta.id === tplParsed.id && tplMeta.name) {
return tplMeta.name;
}
return tplParsed ? ('模板 #' + tplParsed.id) : pathOrKey;
}
if (pathOrKey.indexOf(FILE_PREFIX) === 0) { if (pathOrKey.indexOf(FILE_PREFIX) === 0) {
var meta = window._soonFileMeta; var resolved = soonResolveCloudFileName(pathOrKey);
if (meta && meta.name) return meta.name; if (resolved) return resolved;
var parsed = soonParseFileKey(pathOrKey); var parsed = soonParseFileKey(pathOrKey);
return parsed ? ('文件 #' + parsed.id) : pathOrKey; return parsed ? ('文件 #' + parsed.id) : pathOrKey;
} }
if (pathOrKey.indexOf('soondesign_session:') === 0) { if (pathOrKey.indexOf('soondesign_session:') === 0) {
return pathOrKey.replace(/^soondesign_session:/, ''); return soonSessionDisplayName(pathOrKey);
} }
return String(pathOrKey).split(/[/\\]/).pop(); return String(pathOrKey).split(/[/\\]/).pop();
} }
function soonFetchOrderStatus(orderNo) {
var url = soonApiBase() + '/pay/orders/' + encodeURIComponent(orderNo);
var fetchFn = typeof window.soonAuthedFetch === 'function'
? window.soonAuthedFetch(url, { headers: { Accept: 'application/json' } })
: fetch(url, { headers: { Authorization: 'Bearer ' + soonGetAccessToken(), Accept: 'application/json' } });
return fetchFn.then(function (r) {
if (!r.ok) return null;
return r.json();
}).then(function (j) {
return j && j.ok && j.data ? j.data : null;
}).catch(function () {
return null;
});
}
function soonPollOrderPaid(orderNo, attemptsLeft, delayMs) {
return soonFetchOrderStatus(orderNo).then(function (data) {
if (data && data.status === 'paid') return data;
if (attemptsLeft <= 1) return data;
return new Promise(function (resolve) {
setTimeout(function () {
resolve(soonPollOrderPaid(orderNo, attemptsLeft - 1, delayMs));
}, delayMs);
});
});
}
function soonHandlePayReturn() {
var orderNo = null;
try {
orderNo = new URLSearchParams(location.search).get('paid');
} catch (e) { /* ignore */ }
try {
sessionStorage.removeItem('soon_pay_return');
} catch (e) { /* ignore */ }
if (!orderNo) return;
try {
var u = new URL(location.href);
u.searchParams.delete('paid');
history.replaceState(null, '', u.pathname + (u.search || '') + (u.hash || ''));
} catch (e) { /* ignore */ }
if (!soonGetAccessToken()) return;
soonPollOrderPaid(orderNo, 10, 3000).then(function (data) {
if (data && data.status === 'paid') {
return soonLoadMembership(true).then(function () {
soonRefreshPortalIdentity();
soonToast('支付成功,会员已激活', 'success');
});
}
if (data && data.status === 'cancelled') {
soonToast('订单已取消', 'warn');
return;
}
soonToast('支付尚未完成,如已付款请稍后在会员中心查看', 'warn');
});
}
window.soonIsWebPortal = soonIsWebPortal; window.soonIsWebPortal = soonIsWebPortal;
window.soonParseFileKey = soonParseFileKey; window.soonParseFileKey = soonParseFileKey;
window.soonMakeFileKey = soonMakeFileKey; window.soonMakeFileKey = soonMakeFileKey;
window.soonNormalizeSoonName = soonNormalizeSoonName; window.soonNormalizeSoonName = soonNormalizeSoonName;
window.soonResolveCloudFileName = soonResolveCloudFileName;
window.soonApplyCloudMeta = soonApplyCloudMeta; window.soonApplyCloudMeta = soonApplyCloudMeta;
window.soonBindFileMeta = soonBindFileMeta; window.soonBindFileMeta = soonBindFileMeta;
window.soonSyncOpenNavigation = soonSyncOpenNavigation;
window.soonResolveOpenFileKey = soonResolveOpenFileKey;
window.soonConsumeOpenMeta = soonConsumeOpenMeta;
window.soonFormatOpenTitle = soonFormatOpenTitle; window.soonFormatOpenTitle = soonFormatOpenTitle;
window.soonGuardCloudSave = soonGuardCloudSave; window.soonGuardCloudSave = soonGuardCloudSave;
window.soonGuardMemberPreview = soonGuardMemberPreview; window.soonGuardPreviewDeliver = soonGuardPreviewDeliver;
window.soonGuardMemberExport = soonGuardMemberExport; window.soonHandlePayReturn = soonHandlePayReturn;
window.soonOpenSoonJsonLocally = soonOpenSoonJsonLocally;
window.soonPutSoonSession = soonPutSoonSession;
window.soonScheduleCloudImport = soonScheduleCloudImport;
window.soonTryConsumeCloudImport = soonTryConsumeCloudImport;
window.soonClearPendingImport = soonClearPendingImport;
window.soonReportOpenLoadError = soonReportOpenLoadError;
window.soonShowOpenFileLoading = soonShowOpenFileLoading;
window.soonHideOpenFileLoading = soonHideOpenFileLoading;
window.soonParseTemplateKey = soonParseTemplateKey;
window.soonMakeTemplateKey = soonMakeTemplateKey;
window.soonSoonTypeFromJson = soonSoonTypeFromJson;
window.soonLoadMembership = soonLoadMembership; window.soonLoadMembership = soonLoadMembership;
window.soonApplyMembership = soonApplyMembership; window.soonApplyMembership = soonApplyMembership;
window.soonRefreshPortalIdentity = soonRefreshPortalIdentity; window.soonRefreshPortalIdentity = soonRefreshPortalIdentity;
window.soonIsMember = soonIsMember; window.soonIsMember = soonIsMember;
window.soonRequireMember = soonRequireMember;
window.soonRequireLogin = soonRequireLogin; window.soonRequireLogin = soonRequireLogin;
window.soonParseApiError = soonParseApiError; window.soonParseApiError = soonParseApiError;
window.soonShowApiError = soonShowApiError; window.soonShowApiError = soonShowApiError;
window.soonDisplayFileName = soonDisplayFileName; window.soonDisplayFileName = soonDisplayFileName;
window.soonIsTemplateKey = soonIsTemplateKey;
window.soonNeedsSaveDialog = soonNeedsSaveDialog;
window.soonDefaultNewSoonName = soonDefaultNewSoonName;
window.soonEnsureSoonExt = soonEnsureSoonExt;
window.soonSessionSlug = soonSessionSlug;
window.soonMakeSessionKey = soonMakeSessionKey;
window.soonSessionDisplayName = soonSessionDisplayName;
window.soonBuildSavePreviewPic = soonBuildSavePreviewPic;
window.soonApplySavePreview = soonApplySavePreview;
window.soonSaveDialogDefaultPath = soonSaveDialogDefaultPath;
window.soonEnsureSaveFileName = soonEnsureSaveFileName;
window.soonReportSaveError = soonReportSaveError;
window.soonWriteSoonContentWeb = soonWriteSoonContentWeb;
window.soonDownloadBlob = soonDownloadBlob;
window.soonDownloadRecentItem = soonDownloadRecentItem;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', soonHandlePayReturn);
} else {
soonHandlePayReturn();
}
})(); })();
+265
View File
@@ -0,0 +1,265 @@
(function () {
'use strict';
var GATE_FEATURES = ['预览内导出成品', '预览内打印'];
var _gate = { index: null, payIndex: null, plan: null, gateOpts: null };
var pay = window.SoonMemberPay;
function esc(s) {
if (pay && pay.esc) return pay.esc(s);
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function toast(msg, type) {
if (typeof window.soonToast === 'function') window.soonToast(msg, type);
}
function ensureLayer(cb) {
if (typeof layer !== 'undefined' && layer.open) {
if (layer.config) layer.config({ skin: 'soon-layer' });
cb();
return;
}
if (typeof layui !== 'undefined') {
layui.use(['layer'], function () {
window.layer = layui.layer;
layer.config({ skin: 'soon-layer' });
cb();
});
return;
}
toast('激活功能暂不可用,请刷新页面后重试', 'warn');
}
function planPriceDisplay(plan) {
if (!plan) return '';
return plan.price_display || (plan.price_cents / 100).toFixed(2);
}
function shellHtml(actionLabel, plan) {
var feat = GATE_FEATURES.map(function (t) {
return '<li>' + esc(t) + '</li>';
}).join('');
var price = plan ? planPriceDisplay(plan) : '—';
var channels = (pay && pay.displayChannels) ? pay.displayChannels() : ['alipay'];
var chHtml = channels.map(function (ch, i) {
var label = ch === 'wechat' ? '微信支付' : '支付宝';
return '<label class="soon-activate-channel' + (i === 0 ? ' is-active' : '') + '">' +
'<input type="radio" name="activate_channel" value="' + esc(ch) + '"' + (i === 0 ? ' checked' : '') + '>' +
esc(label) + '</label>';
}).join('');
return '<div class="soon-subscribe-modal soon-activate-modal">' +
'<header class="soon-subscribe-modal__head">' +
'<span class="soon-subscribe-modal__eyebrow">会员激活</span>' +
'<h3 class="soon-subscribe-modal__title">激活会员</h3>' +
'<p class="soon-subscribe-modal__desc">普通用户可使用全部设计工具;预览内' + esc(actionLabel || '导出或打印') + '需激活会员。</p>' +
'<ul class="soon-subscribe-modal__features">' + feat + '</ul>' +
'</header>' +
'<div class="soon-subscribe-modal__body">' +
'<div class="soon-activate-price"><span class="soon-activate-price__label">激活价格</span>' +
'<span class="soon-activate-price__value">¥' + esc(price) + '</span>' +
'<span class="soon-activate-price__hint">一次激活,永久有效</span></div>' +
'<div class="soon-activate-channels" data-role="channels">' + chHtml + '</div>' +
'<p class="soon-activate-load" data-role="plan-status"></p>' +
'</div>' +
'<footer class="soon-subscribe-modal__foot">' +
'<button type="button" class="soon-subscribe-modal__stay" data-action="stay">继续设计</button>' +
'<button type="button" class="soon-btn soon-subscribe-modal__pay" data-action="go-pay" disabled>立即激活</button>' +
'</footer></div>';
}
function closeGate(fireStay) {
var idx = _gate.index;
var payIdx = _gate.payIndex;
var opts = _gate.gateOpts;
_gate = { index: null, payIndex: null, plan: null, gateOpts: null };
if (typeof layer !== 'undefined') {
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();
}
function selectedChannel(modal) {
var checked = modal.querySelector('input[name="activate_channel"]:checked');
return checked ? checked.value : 'alipay';
}
function onPaySuccess() {
var gateIdx = _gate.index;
var opts = _gate.gateOpts;
_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 () {
if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity();
if (opts && typeof opts.onSuccess === 'function') opts.onSuccess();
});
return;
}
if (opts && typeof opts.onSuccess === 'function') opts.onSuccess();
}
function updatePayButton(modal) {
var btn = modal.querySelector('[data-action="go-pay"]');
var plan = _gate.plan;
if (!btn) return;
if (!plan) {
btn.disabled = true;
btn.textContent = '立即激活';
return;
}
btn.disabled = false;
btn.textContent = '立即激活 ¥' + planPriceDisplay(plan);
}
function loadPlan(modal) {
var statusEl = modal.querySelector('[data-role="plan-status"]');
if (!pay || !pay.apiGet) {
if (statusEl) statusEl.textContent = '激活模块未加载,请刷新页面';
updatePayButton(modal);
return;
}
if (statusEl) statusEl.textContent = '正在加载激活方案…';
updatePayButton(modal);
pay.apiGet('/plans').then(function (ps) {
if (!ps.ok) {
if (statusEl) statusEl.textContent = ps.message || '方案加载失败';
updatePayButton(modal);
return;
}
var items = (ps.data && ps.data.items) || [];
var plan = items[0] || null;
if (!plan) {
if (statusEl) statusEl.textContent = '暂无可用激活方案';
updatePayButton(modal);
return;
}
_gate.plan = plan;
if (statusEl) statusEl.textContent = '';
var priceEl = modal.querySelector('.soon-activate-price__value');
if (priceEl) priceEl.textContent = '¥' + planPriceDisplay(plan);
updatePayButton(modal);
}).catch(function () {
if (statusEl) statusEl.textContent = '网络错误,请稍后重试';
updatePayButton(modal);
});
}
function bindModal(modal) {
modal.addEventListener('click', function (e) {
var stay = e.target.closest('[data-action="stay"]');
if (stay) {
e.preventDefault();
closeGate(true);
return;
}
var goPay = e.target.closest('[data-action="go-pay"]');
if (goPay) {
e.preventDefault();
if (goPay.disabled || !_gate.plan || !pay || !pay.openPayModal) return;
if (_gate.payIndex != null) {
try { layer.close(_gate.payIndex); } catch (err) { /* ignore */ }
_gate.payIndex = null;
}
_gate.payIndex = pay.openPayModal({
planId: _gate.plan.id,
planName: _gate.plan.name,
priceDisplay: planPriceDisplay(_gate.plan),
channel: selectedChannel(modal),
shadeClose: true,
onPaid: onPaySuccess,
onClose: function () {
_gate.payIndex = null;
updatePayButton(modal);
},
});
}
var ch = e.target.closest('.soon-activate-channel');
if (ch) {
modal.querySelectorAll('.soon-activate-channel').forEach(function (el) {
el.classList.remove('is-active');
});
ch.classList.add('is-active');
var input = ch.querySelector('input[type="radio"]');
if (input) input.checked = true;
}
});
loadPlan(modal);
}
function soonShowActivateGate(opts) {
opts = opts || {};
if (!pay) {
toast('激活功能暂不可用,请刷新页面后重试', 'warn');
return;
}
if (pay.loadDisplayChannels) pay.loadDisplayChannels();
ensureLayer(function () {
if (_gate.index != null) {
try { layer.close(_gate.index); } catch (e) { /* ignore */ }
}
if (_gate.payIndex != null) {
try { layer.close(_gate.payIndex); } catch (e) { /* ignore */ }
}
_gate = { index: null, payIndex: null, plan: null, gateOpts: opts };
var width = Math.min(480, window.innerWidth - 32);
layer.open({
type: 1,
skin: 'soon-layer',
title: false,
closeBtn: 1,
fixed: true,
offset: 'auto',
shade: [0.68, '#000'],
shadeClose: true,
maxWidth: width,
area: [width + 'px', 'auto'],
content: shellHtml(opts.reason, null),
success: function (layero, index) {
var layerEl = layero && layero[0] ? layero[0] : layero;
if (layerEl && layerEl.classList) {
layerEl.classList.add('soon-layer--activate');
}
var content = layerEl && layerEl.querySelector ? layerEl.querySelector('.layui-layer-content') : null;
if (content) {
content.style.padding = '0';
content.style.overflow = 'visible';
}
var modal = layerEl.querySelector('.soon-activate-modal');
_gate.index = index;
_gate.gateOpts = opts;
if (modal) bindModal(modal);
requestAnimationFrame(function () {
if (typeof layer.style === 'function') layer.style(index);
});
},
end: function () {
var payIdx = _gate.payIndex;
if (payIdx != null) {
try { layer.close(payIdx); } catch (e) { /* ignore */ }
}
_gate = { index: null, payIndex: null, plan: null, gateOpts: null };
},
});
});
}
window.soonShowActivateGate = soonShowActivateGate;
if (!window._soonActivateFocusBound) {
window._soonActivateFocusBound = true;
window.addEventListener('focus', function () {
if (typeof window.soonLoadMembership !== 'function') return;
window.soonLoadMembership(true).then(function () {
if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity();
});
});
}
})();
+208
View File
@@ -0,0 +1,208 @@
(function () {
'use strict';
var _gate = { index: null, opts: null };
var MAIL_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16v12H4z"/><path d="M4 8l8 5 8-5"/></svg>';
var LOCK_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="11" width="14" height="10" rx="1"/><path d="M8 11V8a4 4 0 118 0v3"/></svg>';
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function toast(msg, type) {
if (typeof window.soonToast === 'function') window.soonToast(msg, type);
}
function apiBase() {
var cfg = window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base;
if (cfg) return String(cfg).replace(/\/+$/, '');
if (window.location) return window.location.origin + '/api/v1';
return '/api/v1';
}
function ensureLayer(cb) {
if (typeof layer !== 'undefined' && layer.open) {
if (layer.config) layer.config({ skin: 'soon-layer' });
cb();
return;
}
if (typeof layui !== 'undefined') {
layui.use(['layer'], function () {
window.layer = layui.layer;
layer.config({ skin: 'soon-layer' });
cb();
});
return;
}
toast('登录功能暂不可用,请刷新页面后重试', 'warn');
}
function shellHtml(panel, reason) {
var isReg = panel === 'register';
var action = esc(reason || '导出或打印');
return '<div class="soon-login-gate">' +
'<header class="soon-login-gate__head">' +
'<span class="soon-login-gate__eyebrow">' + (isReg ? '注册' : '登录') + '</span>' +
'<h3 class="soon-login-gate__title">' + (isReg ? '注册后继续' : '登录后继续') + '</h3>' +
'<p class="soon-login-gate__desc">' +
(isReg ? '注册并登录后即可' + action + '成品。' : '预览内' + action + '成品需先登录账号。') +
'</p></header>' +
'<form class="soon-login-gate__form" data-role="' + (isReg ? 'register' : 'login') + '-form">' +
'<div class="soon-input-wrap soon-login-gate__field">' +
'<span class="soon-input-icon" aria-hidden="true">' + MAIL_ICON + '</span>' +
'<input type="email" name="email" required class="soon-input soon-login-gate__input" placeholder="邮箱" autocomplete="email">' +
'</div>' +
'<div class="soon-input-wrap soon-login-gate__field">' +
'<span class="soon-input-icon" aria-hidden="true">' + LOCK_ICON + '</span>' +
'<input type="password" name="password" required' +
(isReg ? ' minlength="8"' : '') +
' class="soon-input soon-login-gate__input" placeholder="' +
(isReg ? '密码(至少 8 位)' : '密码') +
'" autocomplete="' + (isReg ? 'new-password' : 'current-password') + '">' +
'</div>' +
'<p class="soon-login-gate__error" data-role="' + (isReg ? 'register' : 'login') + '-error" hidden></p>' +
'<button type="submit" class="soon-btn soon-btn--block soon-login-gate__submit">' +
(isReg ? '注册并登录' : '登录') +
'</button>' +
'<p class="soon-login-gate__links">' +
(isReg ? '已有账号?<a href="#" data-action="show-login">去登录</a>' : '还没有账号?<a href="#" data-action="show-register">立即注册</a>') +
'</p></form>' +
'<footer class="soon-login-gate__foot">' +
'<button type="button" class="soon-login-gate__stay" data-action="stay">继续设计</button>' +
'</footer></div>';
}
function closeGate() {
var idx = _gate.index;
_gate = { index: null, opts: null };
if (idx != null && typeof layer !== 'undefined') {
try { layer.close(idx); } catch (e) { /* ignore */ }
}
if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask();
}
function finishAuthSuccess() {
var opts = _gate.opts;
closeGate();
var chain = typeof window.soonRefreshPortalAuthUi === 'function'
? Promise.resolve(window.soonRefreshPortalAuthUi())
: Promise.resolve();
return chain.then(function () {
if (typeof window.soonReloadRecentFiles === 'function') window.soonReloadRecentFiles();
if (opts && typeof opts.onSuccess === 'function') opts.onSuccess();
});
}
function storeTokens(data) {
if (!data || !data.access_token) return false;
localStorage.setItem('soon_access', data.access_token);
if (data.refresh_token) localStorage.setItem('soon_refresh', data.refresh_token);
return true;
}
function swapPanel(modal, panel) {
if (!modal || !modal.parentNode) return;
var parent = modal.parentNode;
var reason = _gate.opts && _gate.opts.reason;
modal.outerHTML = shellHtml(panel, reason);
var root = parent.querySelector('.soon-login-gate');
if (root) bindModal(root);
}
function bindAuthForm(modal, role, path, failMsg) {
var form = modal.querySelector('[data-role="' + role + '-form"]');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var errEl = modal.querySelector('[data-role="' + role + '-error"]');
if (errEl) {
errEl.hidden = true;
errEl.textContent = '';
}
var email = (form.email && form.email.value || '').trim();
var password = form.password ? form.password.value : '';
fetch(apiBase() + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, password: password }),
}).then(function (r) { return r.json(); }).then(function (j) {
if (!j.ok || !storeTokens(j.data)) {
if (errEl) {
errEl.textContent = (j && j.message) || failMsg;
errEl.hidden = false;
}
return;
}
finishAuthSuccess();
}).catch(function () {
if (errEl) {
errEl.textContent = '网络错误,请稍后重试';
errEl.hidden = false;
}
});
});
}
function bindModal(modal) {
modal.addEventListener('click', function (e) {
if (e.target.closest('[data-action="stay"]')) {
e.preventDefault();
closeGate();
return;
}
if (e.target.closest('[data-action="show-register"]')) {
e.preventDefault();
swapPanel(modal, 'register');
return;
}
if (e.target.closest('[data-action="show-login"]')) {
e.preventDefault();
swapPanel(modal, 'login');
}
});
bindAuthForm(modal, 'login', '/auth/login', '登录失败');
bindAuthForm(modal, 'register', '/auth/register', '注册失败');
}
function openGate(opts, panel) {
opts = opts || {};
ensureLayer(function () {
if (_gate.index != null) {
try { layer.close(_gate.index); } catch (e) { /* ignore */ }
}
_gate.opts = opts;
var width = Math.min(400, window.innerWidth - 32);
layer.open({
type: 1,
skin: 'soon-layer',
title: false,
closeBtn: 1,
shade: [0.62, '#000'],
shadeClose: true,
area: [width + 'px', 'auto'],
offset: 'auto',
content: shellHtml(panel, opts.reason),
success: function (layero, index) {
var layerEl = layero && layero[0] ? layero[0] : layero;
if (layerEl && layerEl.classList) layerEl.classList.add('soon-layer--login-gate');
var content = layerEl && layerEl.querySelector ? layerEl.querySelector('.layui-layer-content') : null;
if (content) {
content.style.padding = '0';
content.style.overflow = 'visible';
content.style.background = 'transparent';
}
var modal = layerEl.querySelector('.soon-login-gate');
_gate.index = index;
if (modal) bindModal(modal);
},
end: function () {
_gate = { index: null, opts: null };
},
});
});
}
window.soonShowLoginGate = function (opts) { openGate(opts, 'login'); };
window.soonShowRegisterGate = function (opts) { openGate(opts, 'register'); };
})();
+249 -65
View File
@@ -2,7 +2,14 @@
'use strict'; 'use strict';
var POLL_TIMEOUT_MS = 5 * 60 * 1000; var POLL_TIMEOUT_MS = 5 * 60 * 1000;
var PAY_STEP_LABELS = ['选择支付', '扫码付款', '订阅生效']; var PAY_STEP_LABELS = ['选择支付', '扫码付款', '激活生效'];
var _displayChannels = ['alipay'];
var _settingsPromise = null;
var CHANNEL_UI = {
alipay: { name: '支付宝', hint: '网页支付', icon: '支', iconCls: 'alipay' },
wechat: { name: '微信支付', hint: '扫码付款', icon: '微', iconCls: 'wechat' },
};
function esc(s) { function esc(s) {
if (s == null) return ''; if (s == null) return '';
@@ -70,12 +77,34 @@
} }
} }
function submitAlipayForm(payForm) { var ALIPAY_WINDOW_NAME = 'soon_alipay_pay';
function openAlipayPayWindow() {
try {
var w = window.open('about:blank', ALIPAY_WINDOW_NAME);
if (w) {
w.document.title = '支付宝收银台';
w.document.body.innerHTML =
'<p style="font-family:sans-serif;text-align:center;padding:48px 24px;color:#333">正在跳转支付宝…</p>';
}
return w;
} catch (e) {
return null;
}
}
function submitAlipayForm(payForm, payWindow) {
if (!payForm || !payForm.gateway || !payForm.params) return false; if (!payForm || !payForm.gateway || !payForm.params) return false;
if (!isSafeAlipayGateway(payForm.gateway)) return false; if (!isSafeAlipayGateway(payForm.gateway)) return false;
var win = payWindow;
if (!win || win.closed) {
win = openAlipayPayWindow();
}
if (!win) return false;
var form = document.createElement('form'); var form = document.createElement('form');
form.method = 'POST'; form.method = 'POST';
form.action = payForm.gateway; form.action = payForm.gateway;
form.target = ALIPAY_WINDOW_NAME;
form.acceptCharset = 'UTF-8'; form.acceptCharset = 'UTF-8';
form.style.display = 'none'; form.style.display = 'none';
Object.keys(payForm.params).forEach(function (key) { Object.keys(payForm.params).forEach(function (key) {
@@ -89,9 +118,91 @@
}); });
document.body.appendChild(form); document.body.appendChild(form);
form.submit(); form.submit();
setTimeout(function () {
try {
document.body.removeChild(form);
} catch (e) { /* ignore */ }
}, 0);
return true; return true;
} }
function parseDisplayChannels(raw) {
if (Array.isArray(raw)) {
var list = raw.filter(function (ch) { return ch === 'alipay' || ch === 'wechat'; });
return list.length ? list : ['alipay'];
}
var parts = String(raw || 'alipay').split(/[\s,]+/).filter(Boolean);
var out = [];
parts.forEach(function (ch) {
if ((ch === 'alipay' || ch === 'wechat') && out.indexOf(ch) < 0) out.push(ch);
});
return out.length ? out : ['alipay'];
}
function loadDisplayChannels() {
if (_settingsPromise) return _settingsPromise;
_settingsPromise = apiGet('/settings').then(function (j) {
if (j.ok && j.data) {
if (j.data.payment_display_channels) {
_displayChannels = parseDisplayChannels(j.data.payment_display_channels);
} else if (j.data['payment.display_channels'] != null) {
_displayChannels = parseDisplayChannels(j.data['payment.display_channels']);
}
}
return _displayChannels;
}).catch(function () {
_displayChannels = ['alipay'];
return _displayChannels;
});
return _settingsPromise;
}
function displayChannels() {
return _displayChannels.slice();
}
function defaultChannel(channels) {
var list = channels || _displayChannels;
return list[0] || 'alipay';
}
function resolveChannel(requested, channels) {
var list = channels || _displayChannels;
if (requested && list.indexOf(requested) >= 0) return requested;
return defaultChannel(list);
}
function channelCardHtml(ch, active, selectable) {
var ui = CHANNEL_UI[ch];
if (!ui) return '';
var cls = 'soon-pay-channel' + (active ? ' is-active' : '') + (selectable ? '' : ' is-fixed');
var inner = '<span class="soon-pay-channel__radio" aria-hidden="true"></span>' +
'<span class="soon-pay-channel__icon soon-pay-channel__icon--' + ui.iconCls + '">' + ui.icon + '</span>' +
'<span class="soon-pay-channel__text"><span class="soon-pay-channel__name">' + ui.name + '</span>' +
'<span class="soon-pay-channel__hint">' + ui.hint + '</span></span>';
if (selectable) {
return '<button type="button" class="' + cls + '" data-ch="' + ch + '">' + inner + '</button>';
}
return '<div class="' + cls + '" data-ch="' + ch + '">' + inner + '</div>';
}
function payChannelsHtml(activeChannel, channels, opts) {
opts = opts || {};
channels = channels || _displayChannels;
var showChannel = activeChannel || defaultChannel(channels);
var selectable = opts.selectable !== false && channels.length > 1;
var list = selectable ? channels : [showChannel];
var cards = list.map(function (ch) {
return channelCardHtml(ch, ch === showChannel, selectable);
}).join('');
if (!cards) return '';
return '<section class="soon-pay-section soon-pay-section--channels' +
(selectable ? '' : ' soon-pay-section--channels-fixed') + '" id="payChannels">' +
'<h4 class="soon-pay-section__title">支付方式</h4>' +
'<div class="soon-pay-channels' + (list.length === 1 ? ' soon-pay-channels--single' : '') + '">' +
cards + '</div></section>';
}
function payStepLabel(step, channel) { function payStepLabel(step, channel) {
if (step === 2 && channel === 'alipay') return '网页支付'; if (step === 2 && channel === 'alipay') return '网页支付';
if (step === 2 && channel === 'wechat') return '扫码付款'; if (step === 2 && channel === 'wechat') return '扫码付款';
@@ -118,42 +229,50 @@
function updatePayStep(root, step, channel) { function updatePayStep(root, step, channel) {
if (!root) return; if (!root) return;
var html = payStepperHtml(step, channel || 'wechat'); var html = payStepperHtml(step, channel || defaultChannel());
var old = root.querySelector('#payStepper'); var old = root.querySelector('#payStepper');
if (old) old.outerHTML = html; if (old) old.outerHTML = html;
} }
function payEmptyStateHtml() { function payEmptyStateHtml(channel) {
var ui = CHANNEL_UI[channel] || CHANNEL_UI[defaultChannel()];
return '<div class="soon-pay-checkout-empty">' + return '<div class="soon-pay-checkout-empty">' +
'<div class="soon-pay-checkout-empty__icon" aria-hidden="true">' + '<div class="soon-pay-checkout-empty__icon" aria-hidden="true">' +
'<svg viewBox="0 0 48 48" fill="none"><rect x="8" y="12" width="32" height="24" rx="4" stroke="currentColor" stroke-width="1.5"/>' + '<svg viewBox="0 0 48 48" fill="none"><rect x="8" y="12" width="32" height="24" rx="4" stroke="currentColor" stroke-width="1.5"/>' +
'<path d="M8 20h32" stroke="currentColor" stroke-width="1.5"/>' + '<path d="M8 20h32" stroke="currentColor" stroke-width="1.5"/>' +
'<circle cx="32" cy="30" r="3" fill="currentColor" opacity="0.5"/></svg></div>' + '<circle cx="32" cy="30" r="3" fill="currentColor" opacity="0.5"/></svg></div>' +
'<p class="soon-pay-checkout-empty__title">等待生成付款</p>' + '<p class="soon-pay-checkout-empty__title">等待生成付款信息</p>' +
'<p class="soon-pay-checkout-empty__desc">确认支付方式后,将在此显示二维码或跳转链接</p></div>'; '<p class="soon-pay-checkout-empty__desc">点击下方按钮,将使用' + esc(ui.name) + '' + esc(ui.hint) + '</p></div>';
} }
function renderPayPayload(data, qrEl, orderNoEl) { function renderPayPayload(data, qrEl, orderNoEl, launchOpts) {
launchOpts = launchOpts || {};
if (orderNoEl && data.order_no) { if (orderNoEl && data.order_no) {
orderNoEl.textContent = '订单号 ' + data.order_no; orderNoEl.textContent = '订单号 ' + data.order_no;
orderNoEl.style.display = ''; orderNoEl.style.display = '';
} }
if (data.pay_form && data.pay_form.gateway) { if (data.pay_form && data.pay_form.gateway) {
if (!isSafeAlipayGateway(data.pay_form.gateway)) return '支付网关无效'; if (!isSafeAlipayGateway(data.pay_form.gateway)) return '支付网关无效';
sessionStorage.setItem('soon_pay_return', '1');
var payForm = data.pay_form; var payForm = data.pay_form;
var launched = launchOpts.launched === true;
qrEl.innerHTML = qrEl.innerHTML =
'<div class="soon-pay-checkout-ready soon-pay-checkout-ready--alipay">' + '<div class="soon-pay-checkout-ready soon-pay-checkout-ready--alipay">' +
'<div class="soon-pay-checkout-ready__brand soon-pay-checkout-ready__brand--alipay">支</div>' + '<div class="soon-pay-checkout-ready__brand soon-pay-checkout-ready__brand--alipay">支</div>' +
'<p class="soon-pay-checkout-ready__title">支付宝电脑网站支付</p>' + '<p class="soon-pay-checkout-ready__title">支付宝电脑网站支付</p>' +
'<p class="soon-pay-checkout-ready__desc">点击下方按钮跳转至支付宝收银台,支付完成后将自动返回</p>' + '<p class="soon-pay-checkout-ready__desc">' +
'<button type="button" class="soon-btn soon-pay-checkout-ready__btn" id="goAlipay">前往支付宝付款</button></div>'; (launched
? '支付窗口已打开,请在新窗口完成付款;本页将自动检测支付结果'
: '未能自动打开支付窗口,请允许弹窗后点击下方按钮') +
'</p>' +
'<button type="button" class="soon-btn soon-pay-checkout-ready__btn" id="goAlipay">' +
(launched ? '重新打开支付页面' : '打开支付宝付款') +
'</button></div>';
var alipayBtn = qrEl.querySelector('#goAlipay'); var alipayBtn = qrEl.querySelector('#goAlipay');
if (alipayBtn) { if (alipayBtn) {
alipayBtn.onclick = function () { alipayBtn.onclick = function () {
if (!submitAlipayForm(payForm)) { if (!submitAlipayForm(payForm)) {
if (typeof window.soonToast === 'function') { if (typeof window.soonToast === 'function') {
window.soonToast('无法跳转支付宝,请检查支付配置', 'warn'); window.soonToast('无法打开支付窗口,请允许浏览器弹窗', 'warn');
} }
} }
}; };
@@ -175,27 +294,13 @@
return '未获取到支付信息'; return '未获取到支付信息';
} }
function monthlyPlan(items) {
var found = null;
(items || []).forEach(function (p) {
if (p.code === 'member_monthly' || p.code === 'pro_monthly') found = p;
});
return found;
}
function billingSavePercent(items, plan) {
var monthly = monthlyPlan(items);
if (!monthly || monthly.price_cents <= 0 || !plan || plan.duration_days <= 30) return null;
var months = plan.duration_days / 30;
var full = monthly.price_cents * months;
if (full <= plan.price_cents) return null;
return Math.round((1 - plan.price_cents / full) * 100);
}
function paySheetHtml(opts) { function paySheetHtml(opts) {
opts = opts || {}; opts = opts || {};
var resumeOrderNo = opts.orderNo || null; var resumeOrderNo = opts.orderNo || null;
var channel = opts.channel || 'wechat'; var channels = opts.displayChannels || _displayChannels;
var channel = (resumeOrderNo && opts.channel)
? opts.channel
: resolveChannel(opts.channel, channels);
var embedded = opts.variant === 'embedded'; var embedded = opts.variant === 'embedded';
var backBtn = !embedded && opts.showBack var backBtn = !embedded && opts.showBack
? '<button type="button" class="soon-btn soon-btn--ghost soon-btn--sm soon-pay-sheet__back" id="payBack">返回选择方案</button>' ? '<button type="button" class="soon-btn soon-btn--ghost soon-btn--sm soon-pay-sheet__back" id="payBack">返回选择方案</button>'
@@ -203,7 +308,7 @@
var headBlock = embedded ? '' : var headBlock = embedded ? '' :
'<header class="soon-pay-sheet__head">' + '<header class="soon-pay-sheet__head">' +
'<div class="soon-pay-sheet__head-main">' + '<div class="soon-pay-sheet__head-main">' +
'<span class="soon-pay-sheet__eyebrow">' + (resumeOrderNo ? '继续支付' : '确认订阅') + '</span>' + '<span class="soon-pay-sheet__eyebrow">' + (resumeOrderNo ? '继续支付' : '确认激活') + '</span>' +
'<h3 class="soon-pay-sheet__title">' + esc(opts.planName || '') + '</h3>' + '<h3 class="soon-pay-sheet__title">' + esc(opts.planName || '') + '</h3>' +
'<p class="soon-pay-sheet__order" id="payOrderNo"' + '<p class="soon-pay-sheet__order" id="payOrderNo"' +
(resumeOrderNo ? '' : ' style="display:none"') + '>' + (resumeOrderNo ? '' : ' style="display:none"') + '>' +
@@ -222,23 +327,14 @@
'<div class="soon-pay-sheet__body">' + '<div class="soon-pay-sheet__body">' +
orderInBody + orderInBody +
payStepperHtml(1, channel) + payStepperHtml(1, channel) +
'<section class="soon-pay-section soon-pay-section--channels" id="payChannels">' + payChannelsHtml(channel, channels, { selectable: !resumeOrderNo && channels.length > 1 }) +
'<h4 class="soon-pay-section__title">支付方式</h4>' +
'<div class="soon-pay-channels">' +
'<button type="button" class="soon-pay-channel' + (channel === 'wechat' ? ' is-active' : '') + '" data-ch="wechat">' +
'<span class="soon-pay-channel__radio" aria-hidden="true"></span>' +
'<span class="soon-pay-channel__icon soon-pay-channel__icon--wechat">微</span>' +
'<span class="soon-pay-channel__text"><span class="soon-pay-channel__name">微信支付</span>' +
'<span class="soon-pay-channel__hint">扫码付款</span></span></button>' +
'<button type="button" class="soon-pay-channel' + (channel === 'alipay' ? ' is-active' : '') + '" data-ch="alipay">' +
'<span class="soon-pay-channel__radio" aria-hidden="true"></span>' +
'<span class="soon-pay-channel__icon soon-pay-channel__icon--alipay">支</span>' +
'<span class="soon-pay-channel__text"><span class="soon-pay-channel__name">支付宝</span>' +
'<span class="soon-pay-channel__hint">网页支付</span></span></button></div></section>' +
'<section class="soon-pay-section soon-pay-section--checkout">' + '<section class="soon-pay-section soon-pay-section--checkout">' +
'<div class="soon-pay-qr-panel" id="payQr">' + payEmptyStateHtml() + '</div></section>' + '<div class="soon-pay-qr-panel" id="payQr">' + payEmptyStateHtml(channel) + '</div></section>' +
'<div class="soon-pay-sheet__foot">' + '<div class="soon-pay-sheet__foot">' +
'<p class="soon-pay-status" id="payStatus"></p>' + '<p class="soon-pay-status" id="payStatus"></p>' +
'<div class="soon-pay-poll" id="payPollHint" aria-live="polite">' +
'<span class="soon-pay-poll__spin" aria-hidden="true"></span>' +
'<span class="soon-pay-poll__text">正在检测支付结果</span></div>' +
'<button type="button" class="soon-btn soon-pay-sheet__cta" id="payConfirm">' + '<button type="button" class="soon-btn soon-pay-sheet__cta" id="payConfirm">' +
(resumeOrderNo ? '继续支付' : '确认并支付') + '</button>' + (resumeOrderNo ? '继续支付' : '确认并支付') + '</button>' +
'<button type="button" class="soon-btn soon-btn--ghost soon-btn--sm soon-pay-sheet__cta soon-pay-sheet__cta--secondary" id="payManualRefresh" style="display:none">已完成支付,刷新状态</button>' + '<button type="button" class="soon-btn soon-btn--ghost soon-btn--sm soon-pay-sheet__cta soon-pay-sheet__cta--secondary" id="payManualRefresh" style="display:none">已完成支付,刷新状态</button>' +
@@ -249,7 +345,10 @@
opts = opts || {}; opts = opts || {};
var planId = opts.planId; var planId = opts.planId;
var resumeOrderNo = opts.orderNo || null; var resumeOrderNo = opts.orderNo || null;
var channel = opts.channel || 'wechat'; var channels = opts.displayChannels || _displayChannels;
var channel = (resumeOrderNo && opts.channel)
? opts.channel
: resolveChannel(opts.channel, channels);
var onPaid = typeof opts.onPaid === 'function' ? opts.onPaid : function () {}; var onPaid = typeof opts.onPaid === 'function' ? opts.onPaid : function () {};
var pollTimer = null; var pollTimer = null;
var pollStartedAt = 0; var pollStartedAt = 0;
@@ -260,10 +359,14 @@
var manualBtn = sheetRoot.querySelector('#payManualRefresh'); var manualBtn = sheetRoot.querySelector('#payManualRefresh');
var statusEl = sheetRoot.querySelector('#payStatus'); var statusEl = sheetRoot.querySelector('#payStatus');
var pollHint = sheetRoot.querySelector('#payPollHint');
var confirmBtn = sheetRoot.querySelector('#payConfirm'); var confirmBtn = sheetRoot.querySelector('#payConfirm');
var qrEl = sheetRoot.querySelector('#payQr'); var qrEl = sheetRoot.querySelector('#payQr');
var orderNoEl = sheetRoot.querySelector('#payOrderNo'); var orderNoEl = sheetRoot.querySelector('#payOrderNo');
var backBtn = sheetRoot.querySelector('#payBack'); var backBtn = sheetRoot.querySelector('#payBack');
var checking = false;
var pollTickTimer = null;
var MANUAL_REFRESH_LABEL = '已完成支付,刷新状态';
function setStatus(text, type) { function setStatus(text, type) {
if (!statusEl) return; if (!statusEl) return;
@@ -272,6 +375,20 @@
statusEl.style.display = text ? 'block' : 'none'; statusEl.style.display = text ? 'block' : 'none';
} }
function setPolling(active) {
sheetRoot.classList.toggle('soon-pay-sheet--polling', !!active);
if (!active && pollHint) pollHint.classList.remove('is-tick');
}
function flashPollTick() {
if (!pollHint) return;
pollHint.classList.add('is-tick');
if (pollTickTimer) clearTimeout(pollTickTimer);
pollTickTimer = setTimeout(function () {
pollHint.classList.remove('is-tick');
}, 450);
}
function setStep(step) { function setStep(step) {
updatePayStep(sheetRoot, step, channel); updatePayStep(sheetRoot, step, channel);
sheetRoot.classList.toggle('soon-pay-sheet--checkout', step >= 2); sheetRoot.classList.toggle('soon-pay-sheet--checkout', step >= 2);
@@ -284,36 +401,62 @@
clearInterval(pollTimer); clearInterval(pollTimer);
pollTimer = null; pollTimer = null;
} }
setPolling(false);
} }
function startPoll() { function startPoll() {
stopPoll(); stopPoll();
setPolling(true);
pollStartedAt = Date.now(); pollStartedAt = Date.now();
checkOrderStatus(false);
pollTimer = setInterval(function () { pollTimer = setInterval(function () {
if (Date.now() - pollStartedAt > POLL_TIMEOUT_MS) { if (Date.now() - pollStartedAt > POLL_TIMEOUT_MS) {
stopPoll(); stopPoll();
setStatus('自动检测超时,如已支付请点击下方按钮', 'err'); setStatus('自动检测超时,如已支付请点击下方按钮', 'err');
return; return;
} }
checkOrderStatus(); checkOrderStatus(false);
}, 3000); }, 3000);
} }
function handlePaid() { function handlePaid() {
stopPoll(); stopPoll();
setStep(3); setStep(3);
setStatus('支付成功,正在更新订阅…', 'ok'); setStatus('支付成功,正在更新会员状态…', 'ok');
setTimeout(onPaid, 600); setTimeout(onPaid, 600);
} }
function checkOrderStatus() { function checkOrderStatus(manual) {
if (!orderNo) return; if (!orderNo || checking) return;
checking = true;
if (statusEl) statusEl.classList.add('is-checking');
if (manual && manualBtn) {
manualBtn.disabled = true;
manualBtn.classList.add('is-loading');
manualBtn.textContent = '查询中…';
} else {
flashPollTick();
}
apiGet('/pay/orders/' + encodeURIComponent(orderNo)).then(function (st) { apiGet('/pay/orders/' + encodeURIComponent(orderNo)).then(function (st) {
if (st.ok && st.data && st.data.status === 'paid') handlePaid(); if (st.ok && st.data && st.data.status === 'paid') {
handlePaid();
return;
}
if (manual) {
setStatus('暂未收到支付结果,请完成付款后再试', 'warn');
}
}).finally(function () {
checking = false;
if (statusEl) statusEl.classList.remove('is-checking');
if (manual && manualBtn) {
manualBtn.disabled = false;
manualBtn.classList.remove('is-loading');
manualBtn.textContent = MANUAL_REFRESH_LABEL;
}
}); });
} }
if (manualBtn) manualBtn.onclick = checkOrderStatus; if (manualBtn) manualBtn.onclick = function () { checkOrderStatus(true); };
function resetPaymentView() { function resetPaymentView() {
paymentReady = false; paymentReady = false;
@@ -321,7 +464,7 @@
setStep(1); setStep(1);
if (manualBtn) manualBtn.style.display = 'none'; if (manualBtn) manualBtn.style.display = 'none';
if (confirmBtn) confirmBtn.style.display = ''; if (confirmBtn) confirmBtn.style.display = '';
qrEl.innerHTML = payEmptyStateHtml(); qrEl.innerHTML = payEmptyStateHtml(channel);
if (orderNoEl && !resumeOrderNo) { if (orderNoEl && !resumeOrderNo) {
orderNoEl.textContent = ''; orderNoEl.textContent = '';
orderNoEl.style.display = 'none'; orderNoEl.style.display = 'none';
@@ -329,9 +472,13 @@
setStatus(''); setStatus('');
} }
function applyCheckout(data) { function applyCheckout(data, payWindow) {
orderNo = data.order_no; orderNo = data.order_no;
var err = renderPayPayload(data, qrEl, orderNoEl); var launched = false;
if (channel === 'alipay' && data.pay_form && data.pay_form.gateway) {
launched = submitAlipayForm(data.pay_form, payWindow);
}
var err = renderPayPayload(data, qrEl, orderNoEl, { launched: launched });
if (err) { if (err) {
setStatus(err, 'err'); setStatus(err, 'err');
return; return;
@@ -340,7 +487,14 @@
setStep(2); setStep(2);
if (confirmBtn) confirmBtn.style.display = 'none'; if (confirmBtn) confirmBtn.style.display = 'none';
if (manualBtn) manualBtn.style.display = ''; if (manualBtn) manualBtn.style.display = '';
setStatus('等待支付结果,完成后将自动刷新', 'ok'); if (channel === 'alipay') {
setStatus(
launched ? '支付窗口已打开,请在新窗口完成付款' : '未能自动打开支付窗口,请允许弹窗或点击下方按钮',
launched ? 'ok' : 'err'
);
} else {
setStatus('请扫码完成支付,本页将自动检测支付结果', 'ok');
}
startPoll(); startPoll();
} }
@@ -349,6 +503,7 @@
setStatus('正在生成付款信息…'); setStatus('正在生成付款信息…');
qrEl.innerHTML = '<div class="soon-pay-checkout-loading"><div class="soon-skeleton soon-skeleton--h200"></div>' + qrEl.innerHTML = '<div class="soon-pay-checkout-loading"><div class="soon-skeleton soon-skeleton--h200"></div>' +
'<p>正在连接支付渠道…</p></div>'; '<p>正在连接支付渠道…</p></div>';
var payWindow = channel === 'alipay' ? openAlipayPayWindow() : null;
var req; var req;
if (resumeOrderNo || orderNo) { if (resumeOrderNo || orderNo) {
var existingNo = orderNo || resumeOrderNo; var existingNo = orderNo || resumeOrderNo;
@@ -359,19 +514,21 @@
req.then(function (j) { req.then(function (j) {
if (confirmBtn) confirmBtn.disabled = false; if (confirmBtn) confirmBtn.disabled = false;
if (!j.ok) { if (!j.ok) {
if (payWindow && !payWindow.closed) payWindow.close();
setStatus(j.message || '下单失败', 'err'); setStatus(j.message || '下单失败', 'err');
qrEl.innerHTML = payEmptyStateHtml(); qrEl.innerHTML = payEmptyStateHtml(channel);
return; return;
} }
applyCheckout(j.data); applyCheckout(j.data, payWindow);
}).catch(function () { }).catch(function () {
if (confirmBtn) confirmBtn.disabled = false; if (confirmBtn) confirmBtn.disabled = false;
if (payWindow && !payWindow.closed) payWindow.close();
setStatus('网络错误,请重试', 'err'); setStatus('网络错误,请重试', 'err');
qrEl.innerHTML = payEmptyStateHtml(); qrEl.innerHTML = payEmptyStateHtml(channel);
}); });
} }
sheetRoot.querySelectorAll('.soon-pay-channel').forEach(function (el) { sheetRoot.querySelectorAll('.soon-pay-channel:not(.is-fixed)').forEach(function (el) {
el.onclick = function () { el.onclick = function () {
if (el.classList.contains('is-active')) return; if (el.classList.contains('is-active')) return;
sheetRoot.querySelectorAll('.soon-pay-channel').forEach(function (x) { x.classList.remove('is-active'); }); sheetRoot.querySelectorAll('.soon-pay-channel').forEach(function (x) { x.classList.remove('is-active'); });
@@ -386,6 +543,10 @@
function destroy() { function destroy() {
stopPoll(); stopPoll();
if (pollTickTimer) {
clearTimeout(pollTickTimer);
pollTickTimer = null;
}
paymentReady = false; paymentReady = false;
orderNo = resumeOrderNo; orderNo = resumeOrderNo;
} }
@@ -399,29 +560,41 @@
function openPayModal(opts) { function openPayModal(opts) {
opts = opts || {}; opts = opts || {};
if (typeof layer === 'undefined' || !layer.open) return null; if (typeof layer === 'undefined' || !layer.open) return null;
var channels = _displayChannels;
var channel = (opts.orderNo && opts.channel)
? opts.channel
: resolveChannel(opts.channel, channels);
var html = paySheetHtml({ var html = paySheetHtml({
planName: opts.planName, planName: opts.planName,
priceDisplay: opts.priceDisplay, priceDisplay: opts.priceDisplay,
orderNo: opts.orderNo, orderNo: opts.orderNo,
channel: opts.channel || 'wechat', channel: channel,
displayChannels: channels,
}); });
var payCtrl = null; var payCtrl = null;
var width = Math.min(480, window.innerWidth - 32);
var idx = layer.open({ var idx = layer.open({
type: 1, type: 1,
skin: 'soon-layer', skin: 'soon-layer',
title: false, title: false,
closeBtn: 1, closeBtn: 1,
fixed: true,
offset: 'auto',
shade: [0.72, '#000'],
shadeClose: !!opts.shadeClose, shadeClose: !!opts.shadeClose,
area: ['480px'], maxWidth: width,
area: [width + 'px', 'auto'],
content: html, content: html,
success: function (layero) { success: function (layero, index) {
var layerEl = layero && layero[0] ? layero[0] : layero; var layerEl = layero && layero[0] ? layero[0] : layero;
if (layerEl && layerEl.classList) { if (layerEl && layerEl.classList) {
layerEl.classList.add('soon-layer--pay'); layerEl.classList.add('soon-layer--pay');
var layerContent = layerEl.querySelector('.layui-layer-content'); var layerContent = layerEl.querySelector('.layui-layer-content');
if (layerContent) { if (layerContent) {
layerContent.style.padding = '0';
layerContent.style.overflow = 'visible'; layerContent.style.overflow = 'visible';
layerContent.style.maxHeight = 'none'; layerContent.style.maxHeight = 'none';
layerContent.style.background = 'transparent';
} }
} }
var sheet = layerEl.querySelector('.soon-pay-sheet'); var sheet = layerEl.querySelector('.soon-pay-sheet');
@@ -430,35 +603,46 @@
planName: opts.planName, planName: opts.planName,
priceDisplay: opts.priceDisplay, priceDisplay: opts.priceDisplay,
orderNo: opts.orderNo, orderNo: opts.orderNo,
channel: opts.channel || 'wechat', channel: channel,
displayChannels: channels,
onPaid: function () { onPaid: function () {
layer.close(idx); layer.close(idx);
if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask();
if (typeof opts.onPaid === 'function') opts.onPaid(); if (typeof opts.onPaid === 'function') opts.onPaid();
}, },
onBack: opts.onBack, onBack: opts.onBack,
}); });
requestAnimationFrame(function () {
if (typeof layer.style === 'function') layer.style(index);
});
}, },
end: function () { end: function () {
if (payCtrl) payCtrl.destroy(); if (payCtrl) payCtrl.destroy();
if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask();
if (typeof opts.onClose === 'function') opts.onClose(); if (typeof opts.onClose === 'function') opts.onClose();
}, },
}); });
return idx; return idx;
} }
loadDisplayChannels();
window.SoonMemberPay = { window.SoonMemberPay = {
apiBase: apiBase, apiBase: apiBase,
apiGet: apiGet, apiGet: apiGet,
apiPost: apiPost, apiPost: apiPost,
esc: esc, esc: esc,
monthlyPlan: monthlyPlan,
billingSavePercent: billingSavePercent,
paySheetHtml: paySheetHtml, paySheetHtml: paySheetHtml,
bindPaySheet: bindPaySheet, bindPaySheet: bindPaySheet,
openPayModal: openPayModal, openPayModal: openPayModal,
payEmptyStateHtml: payEmptyStateHtml, payEmptyStateHtml: payEmptyStateHtml,
renderPayPayload: renderPayPayload, renderPayPayload: renderPayPayload,
submitAlipayForm: submitAlipayForm, submitAlipayForm: submitAlipayForm,
openAlipayPayWindow: openAlipayPayWindow,
loadDisplayChannels: loadDisplayChannels,
displayChannels: displayChannels,
defaultChannel: defaultChannel,
resolveChannel: resolveChannel,
POLL_TIMEOUT_MS: POLL_TIMEOUT_MS, POLL_TIMEOUT_MS: POLL_TIMEOUT_MS,
}; };
})(); })();
-104
View File
@@ -1,104 +0,0 @@
(function () {
'use strict';
var CHECK_SVG = '<svg viewBox="0 0 12 12" fill="none" stroke-width="2" stroke="currentColor">' +
'<path d="M2 6l3 3 5-6"/></svg>';
function esc(s) {
if (window.SoonMemberPay && window.SoonMemberPay.esc) return window.SoonMemberPay.esc(s);
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function isPlanFeatureLine(line) {
if (!line) return false;
return line.indexOf('到期') < 0
&& line.indexOf('订阅有效期') < 0
&& line.indexOf('订阅周期') < 0
&& line.indexOf('权益一致') < 0;
}
function featureItem(text) {
return '<li><span class="soon-plan-card__check">' + CHECK_SVG + '</span><span>' + esc(text) + '</span></li>';
}
function billingSavePercent(items, plan) {
if (window.SoonMemberPay && window.SoonMemberPay.billingSavePercent) {
return window.SoonMemberPay.billingSavePercent(items, plan);
}
return null;
}
function planCardHtml(plan, ctx) {
ctx = ctx || {};
var items = ctx.allPlans || [];
var mode = ctx.mode || 'page';
var isMember = !!ctx.isMember;
var currentCode = ctx.currentCode || '';
var selectedId = ctx.selectedId;
var feat = plan.is_recommended ? ' soon-plan-card--featured' : '';
var cur = plan.code === currentCode ? ' soon-plan-card--current' : '';
var isGate = mode === 'gate';
var isPicker = mode === 'picker' || isGate;
var picker = isPicker ? ' soon-plan-card--picker' : '';
if (isGate) picker += ' soon-plan-card--gate';
var selected = isPicker && plan.id === selectedId ? ' soon-plan-card--selected' : '';
var ribbon = plan.is_recommended
? '<span class="soon-plan-card__ribbon soon-plan-card__ribbon--rec">推荐</span>'
: (plan.code === currentCode ? '<span class="soon-plan-card__ribbon soon-plan-card__ribbon--current">当前</span>' : '');
var savePct = billingSavePercent(items, plan);
if (savePct) ribbon += '<span class="soon-plan-card__save">省 ' + savePct + '%</span>';
var badgeCls = ribbon ? ' soon-plan-card--badged' : '';
if (savePct) badgeCls += ' soon-plan-card--save';
var period = plan.duration_days > 0
? '<span class="soon-plan-card__period">/' + esc(plan.period_label || plan.duration_days + '天') + '</span>'
: '';
var features = (plan.features || []).filter(isPlanFeatureLine).map(featureItem).join('');
var desc = plan.description || '';
var price = esc(plan.price_display || (plan.price_cents / 100).toFixed(2));
var inner =
ribbon +
'<div class="soon-plan-card__head">' +
'<div class="soon-plan-card__name">' + esc(plan.name) + '</div>' +
(desc ? '<p class="soon-plan-card__desc">' + esc(desc) + '</p>' : '') +
'<div class="soon-plan-card__price-row">' +
'<span class="soon-plan-card__currency">¥</span>' +
'<span class="soon-plan-card__price">' + price + '</span>' + period + '</div></div>';
if (!isGate) {
inner += '<div class="soon-plan-card__body">' +
'<ul class="soon-plan-card__features">' + features + '</ul></div>';
}
if (isPicker) {
return '<button type="button" class="soon-plan-card' + feat + cur + badgeCls + picker + selected + '" ' +
'data-action="pick-plan" data-id="' + plan.id + '" aria-pressed="' + (plan.id === selectedId ? 'true' : 'false') + '">' +
inner + '</button>';
}
var cta = '<button type="button" class="soon-btn soon-plan-card__cta" data-id="' + plan.id +
'" data-name="' + esc(plan.name) + '" data-price="' + price + '">' +
(isMember ? '续订' : '立即订阅') + '</button>';
return '<article class="soon-plan-card' + feat + cur + badgeCls + '">' + inner +
'<div class="soon-plan-card__foot">' + cta + '</div></article>';
}
function plansGridHtml(plans, ctx) {
return (plans || []).map(function (p) {
return planCardHtml(p, ctx);
}).join('');
}
window.SoonMemberPlan = {
isPlanFeatureLine: isPlanFeatureLine,
planCardHtml: planCardHtml,
plansGridHtml: plansGridHtml,
};
})();
-306
View File
@@ -1,306 +0,0 @@
(function () {
'use strict';
var GATE_FEATURES = ['高清预览', '成品打印', '云端保存', '导出设计文件'];
var _gate = { index: null, payIndex: null, plans: [], selectedId: null, gateOpts: null };
var pay = window.SoonMemberPay;
var plansUi = window.SoonMemberPlan;
function esc(s) {
if (pay && pay.esc) return pay.esc(s);
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function toast(msg, type) {
if (typeof window.soonToast === 'function') window.soonToast(msg, type);
}
function emptyGate() {
return { index: null, payIndex: null, plans: [], selectedId: null, gateOpts: null };
}
function ensureLayer(cb) {
if (typeof layer !== 'undefined' && layer.open) {
if (layer.config) layer.config({ skin: 'soon-layer' });
cb();
return;
}
if (typeof layui !== 'undefined') {
layui.use(['layer'], function () {
window.layer = layui.layer;
layer.config({ skin: 'soon-layer' });
cb();
});
return;
}
toast('订阅功能暂不可用,请刷新页面后重试', 'warn');
}
function actionCopy(actionLabel) {
var label = (actionLabel || '使用').trim();
if (label.indexOf('预览') >= 0 || label.indexOf('导出') >= 0) return '预览、打印与导出成品';
if (label.indexOf('保存') >= 0 || label.indexOf('另存') >= 0) return '保存或另存到云端';
if (label.indexOf('下载') >= 0) return '下载设计文件';
return label;
}
function shellHtml(actionLabel) {
var feat = GATE_FEATURES.map(function (t) {
return '<li>' + esc(t) + '</li>';
}).join('');
return '<div class="soon-subscribe-modal">' +
'<header class="soon-subscribe-modal__head">' +
'<span class="soon-subscribe-modal__eyebrow">订阅功能</span>' +
'<h3 class="soon-subscribe-modal__title">开通订阅,解锁完整能力</h3>' +
'<p class="soon-subscribe-modal__desc">免费版可使用设计工具;订阅后可' + esc(actionCopy(actionLabel)) + '。</p>' +
'<ul class="soon-subscribe-modal__features">' + feat + '</ul>' +
'</header>' +
'<div class="soon-subscribe-modal__body">' +
'<div class="soon-subscribe-modal__section-head">' +
'<h4 class="soon-subscribe-modal__section-title"><span class="soon-subscribe-modal__section-mark"></span>订阅方案</h4>' +
'<p class="soon-subscribe-modal__section-sub">选择订阅周期,功能相同,随时续订</p></div>' +
'<section class="soon-plans-grid soon-subscribe-plans-grid" data-role="plan-picker">' +
'<p class="soon-subscribe-plans__loading">正在加载订阅方案…</p></section>' +
'</div>' +
'<footer class="soon-subscribe-modal__foot">' +
'<button type="button" class="soon-subscribe-modal__stay" data-action="stay">继续设计</button>' +
'<button type="button" class="soon-btn soon-subscribe-modal__pay" data-action="go-pay" disabled>立即支付</button>' +
'</footer></div>';
}
function findPlan(id) {
var pid = Number(id);
for (var i = 0; i < _gate.plans.length; i++) {
if (_gate.plans[i].id === pid) return _gate.plans[i];
}
return null;
}
function planPriceDisplay(plan) {
if (!plan) return '';
return plan.price_display || (plan.price_cents / 100).toFixed(2);
}
function renderPlanPicker(modal) {
var picker = modal.querySelector('[data-role="plan-picker"]');
if (!picker || !plansUi) return;
if (!_gate.plans.length) {
picker.innerHTML = '<p class="soon-subscribe-plans__empty">暂无可用订阅方案</p>';
return;
}
picker.innerHTML = plansUi.plansGridHtml(_gate.plans, {
allPlans: _gate.plans,
selectedId: _gate.selectedId,
mode: 'gate',
});
}
function updatePayButton(modal) {
var btn = modal.querySelector('[data-action="go-pay"]');
var plan = findPlan(_gate.selectedId);
if (!btn) return;
if (!plan) {
btn.disabled = true;
btn.textContent = '立即支付';
return;
}
btn.disabled = false;
btn.textContent = '立即支付 ¥' + planPriceDisplay(plan);
}
function selectPlan(modal, planId) {
var plan = findPlan(planId);
if (!plan) return;
_gate.selectedId = plan.id;
renderPlanPicker(modal);
updatePayButton(modal);
}
function closeGate(fireStay) {
var idx = _gate.index;
var payIdx = _gate.payIndex;
var opts = _gate.gateOpts;
_gate = emptyGate();
if (typeof layer !== 'undefined') {
if (idx != null) layer.close(idx);
if (payIdx != null) layer.close(payIdx);
}
if (fireStay && opts && typeof opts.onStay === 'function') opts.onStay();
}
function onPaySuccess() {
var gateIdx = _gate.index;
var opts = _gate.gateOpts;
_gate = emptyGate();
if (typeof layer !== 'undefined') {
if (gateIdx != null) layer.close(gateIdx);
}
toast('恭喜您,订阅已成功生效!', 'success');
if (typeof window.soonLoadMembership === 'function') {
window.soonLoadMembership(true).then(function () {
if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity();
});
}
if (opts && typeof opts.onSuccess === 'function') opts.onSuccess();
}
function openPayStep(modal) {
var plan = findPlan(_gate.selectedId);
if (!plan || !pay || !pay.openPayModal) {
toast('请先选择订阅方案', 'warn');
return;
}
if (_gate.payIndex != null) {
try { layer.close(_gate.payIndex); } catch (e) { /* ignore */ }
_gate.payIndex = null;
}
var priceDisplay = planPriceDisplay(plan);
_gate.payIndex = pay.openPayModal({
planId: plan.id,
planName: plan.name,
priceDisplay: priceDisplay,
shadeClose: true,
onPaid: onPaySuccess,
onClose: function () {
_gate.payIndex = null;
updatePayButton(modal);
},
});
}
function renderPlansError(modal, message) {
var picker = modal.querySelector('[data-role="plan-picker"]');
if (!picker) return;
picker.innerHTML = '<p class="soon-subscribe-plans__empty">' + esc(message) +
'<br><button type="button" class="soon-btn soon-btn--sm" data-action="retry-plans">重试</button></p>';
updatePayButton(modal);
}
function loadPlans(modal) {
var picker = modal.querySelector('[data-role="plan-picker"]');
if (!picker) return;
if (!pay || !plansUi) {
renderPlansError(modal, '订阅模块未加载,请刷新页面');
return;
}
picker.innerHTML = '<p class="soon-subscribe-plans__loading">正在加载订阅方案…</p>';
updatePayButton(modal);
pay.apiGet('/plans').then(function (ps) {
if (!ps.ok) {
renderPlansError(modal, ps.message || '方案加载失败');
return;
}
var paid = ((ps.data && ps.data.items) || []).filter(function (p) {
return p.code !== 'free' && p.price_cents > 0;
});
if (!paid.length) {
renderPlansError(modal, '暂无可用订阅方案');
return;
}
_gate.plans = paid;
var def = paid[0];
for (var i = 0; i < paid.length; i++) {
if (paid[i].is_recommended) { def = paid[i]; break; }
}
selectPlan(modal, def.id);
}).catch(function () {
renderPlansError(modal, '网络错误,请稍后重试');
});
}
function bindModal(modal) {
modal.addEventListener('click', function (e) {
var stay = e.target.closest('[data-action="stay"]');
if (stay) {
e.preventDefault();
closeGate(true);
return;
}
var retry = e.target.closest('[data-action="retry-plans"]');
if (retry) {
e.preventDefault();
loadPlans(modal);
return;
}
var goPay = e.target.closest('[data-action="go-pay"]');
if (goPay) {
e.preventDefault();
if (!goPay.disabled) openPayStep(modal);
return;
}
var pick = e.target.closest('[data-action="pick-plan"]');
if (pick) {
e.preventDefault();
var id = Number(pick.dataset.id);
if (id) selectPlan(modal, id);
}
});
loadPlans(modal);
}
function openGate(opts) {
opts = opts || {};
if (_gate.index != null) {
try { layer.close(_gate.index); } catch (e) { /* ignore */ }
}
if (_gate.payIndex != null) {
try { layer.close(_gate.payIndex); } catch (e) { /* ignore */ }
}
_gate = emptyGate();
_gate.gateOpts = opts;
var width = Math.min(1000, window.innerWidth - 24);
layer.open({
type: 1,
skin: 'soon-layer',
title: false,
closeBtn: 1,
shadeClose: true,
area: [width + 'px', 'auto'],
content: shellHtml(opts.action),
success: function (layero, index) {
var layerEl = layero && layero[0] ? layero[0] : layero;
if (layerEl && layerEl.classList) layerEl.classList.add('soon-layer--subscribe');
var content = layerEl && layerEl.querySelector ? layerEl.querySelector('.layui-layer-content') : null;
if (content) content.style.padding = '0';
var modal = layerEl.querySelector('.soon-subscribe-modal');
if (!modal) return;
_gate.index = index;
_gate.gateOpts = opts;
bindModal(modal);
},
end: function () {
var payIdx = _gate.payIndex;
var closedOpts = _gate.gateOpts;
if (payIdx != null) {
try { layer.close(payIdx); } catch (e) { /* ignore */ }
}
_gate = emptyGate();
if (closedOpts && closedOpts.onClose) closedOpts.onClose();
},
});
}
function soonShowSubscribeGate(opts) {
if (!pay || !plansUi) {
toast('订阅功能暂不可用,请刷新页面后重试', 'warn');
return false;
}
ensureLayer(function () { openGate(opts || {}); });
return false;
}
window.soonShowSubscribeGate = soonShowSubscribeGate;
if (!window._soonSubscribeFocusBound) {
window._soonSubscribeFocusBound = true;
window.addEventListener('focus', function () {
if (typeof window.soonLoadMembership !== 'function') return;
window.soonLoadMembership(true).then(function () {
if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity();
});
});
}
})();
+159 -45
View File
@@ -1,81 +1,195 @@
(function () { (function () {
'use strict'; 'use strict';
var _opts = {};
var _logoutBound = false;
function showAuthedNav() { function showAuthedNav() {
var guest = document.getElementById('auth_guest'); var guest = document.getElementById('auth_guest');
var account = document.getElementById('auth_account'); var account = document.getElementById('auth_account');
var session = document.getElementById('auth_session'); var session = document.getElementById('auth_session');
var logout = document.getElementById('auth_logout');
if (guest) guest.style.display = 'none'; if (guest) guest.style.display = 'none';
if (account) account.style.display = 'inline-flex'; if (account) account.style.display = 'inline-flex';
if (session) session.style.display = 'inline-flex'; if (session) session.style.display = 'inline-flex';
if (logout) logout.style.display = 'inline-flex';
}
function showGuestNav() {
var guest = document.getElementById('auth_guest');
var account = document.getElementById('auth_account');
var session = document.getElementById('auth_session');
var admin = document.getElementById('auth_admin');
var avatar = document.getElementById('auth_avatar');
var identity = document.getElementById('auth_identity');
var logout = document.getElementById('auth_logout');
if (guest) guest.style.display = '';
if (account) account.style.display = 'none';
if (session) session.style.display = 'none';
if (admin) admin.style.display = 'none';
if (logout) logout.style.display = 'none';
if (avatar) {
avatar.style.display = 'none';
avatar.textContent = '';
avatar.title = '';
}
if (identity) {
identity.style.display = 'none';
identity.textContent = '';
}
} }
function clearSession() { function clearSession() {
if (typeof window.soonClearAuthSession === 'function') { if (typeof window.soonClearAuthSession === 'function') window.soonClearAuthSession();
window.soonClearAuthSession(); else {
} else {
localStorage.removeItem('soon_access'); localStorage.removeItem('soon_access');
localStorage.removeItem('soon_refresh'); localStorage.removeItem('soon_refresh');
} }
if (typeof window.soonApplyMembership === 'function') window.soonApplyMembership(null);
} }
function initTopbar(options) { function applyUserUi(data) {
options = options || {}; if (typeof window.soonApplyMembership === 'function') window.soonApplyMembership(data);
var tok = localStorage.getItem('soon_access') || '';
var login = document.getElementById('auth_login');
var reg = document.getElementById('auth_register');
var member = document.getElementById('auth_member');
var admin = document.getElementById('auth_admin'); var admin = document.getElementById('auth_admin');
var logout = document.getElementById('auth_logout');
var avatar = document.getElementById('auth_avatar'); var avatar = document.getElementById('auth_avatar');
if (data.role === 'admin' && admin) admin.style.display = 'inline-flex';
if (!tok) return; if (avatar && data.email) {
showAuthedNav();
if (login) login.style.display = 'none';
if (reg) reg.style.display = 'none';
if (member) member.style.display = 'inline-flex';
if (logout) logout.style.display = 'inline-flex';
var base = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || '';
if (!base) return;
var meFetch = typeof window.soonAuthedFetch === 'function'
? window.soonAuthedFetch(base + '/auth/me', { headers: { Authorization: 'Bearer ' + tok } })
: fetch(base + '/auth/me', { headers: { Authorization: 'Bearer ' + tok } });
meFetch
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.ok || !j.data) {
clearSession();
location.reload();
return;
}
if (j.data.role === 'admin' && admin) admin.style.display = 'inline-flex';
if (avatar && j.data.email) {
avatar.style.display = 'inline-flex'; avatar.style.display = 'inline-flex';
avatar.textContent = j.data.email.charAt(0).toUpperCase(); avatar.textContent = data.email.charAt(0).toUpperCase();
avatar.title = j.data.email; avatar.title = data.email;
} }
if (typeof window.soonLoadMembership === 'function') { if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity();
window.soonLoadMembership().then(function () {
if (typeof window.soonRefreshPortalIdentity === 'function') {
window.soonRefreshPortalIdentity();
} }
function initClearCache() {
var btn = document.getElementById('auth_clear_cache');
if (!btn || btn.getAttribute('data-soon-bound') === '1') return;
btn.setAttribute('data-soon-bound', '1');
btn.addEventListener('click', function (e) {
e.preventDefault();
if (typeof layui === 'undefined' || typeof window.soonClearLocalDesignCache !== 'function') return;
layui.use('layer', function () {
var layer = layui.layer;
layer.open({
type: 1,
skin: 'soon-layer',
title: '清除本地缓存',
content: '<div class="soon-dialog-body">将清除最近文件与本地缓存,不会退出登录。未上传的本地编辑将丢失。</div>',
btn: ['确定清除', '取消'],
btnAlign: 'r',
area: ['320px', 'auto'],
shadeClose: true,
yes: function (index) {
layer.close(index);
window.soonClearLocalDesignCache().then(function () {
if (typeof window.soonToast === 'function') window.soonToast('已清除本地缓存', 'success');
else layer.msg('已清除本地缓存', { icon: 1, time: 1200 });
setTimeout(function () {
if (/design[12]\.web\.html/i.test(location.pathname || '')) location.href = 'index.web.html';
else location.reload();
}, 400);
});
},
});
});
}); });
} }
})
.catch(function () { /* keep authed shell */ });
if (logout) { function bindLogoutOnce(options) {
if (_logoutBound) return;
var logout = document.getElementById('auth_logout');
if (!logout) return;
_logoutBound = true;
logout.addEventListener('click', function (e) { logout.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
clearSession(); clearSession();
showGuestNav();
if (options.logoutReload !== false) location.reload(); if (options.logoutReload !== false) location.reload();
else location.href = 'index.web.html'; else location.href = 'index.web.html';
}); });
} }
function bindPopupLink(id, fn) {
var el = document.getElementById(id);
if (!el || el.getAttribute('data-soon-popup-bound') === '1') return;
el.setAttribute('data-soon-popup-bound', '1');
el.addEventListener('click', function (e) {
e.preventDefault();
fn();
});
} }
window.soonPortalAuth = { init: initTopbar }; function bindPopupAuthLinks() {
if (typeof window.soonShowLoginGate !== 'function') return;
bindPopupLink('auth_login', function () {
window.soonShowLoginGate({ reason: '登录账号' });
});
if (typeof window.soonShowRegisterGate === 'function') {
bindPopupLink('auth_register', function () {
window.soonShowRegisterGate({ reason: '注册账号' });
});
}
}
function refreshAuthUi(options) {
options = options || _opts || {};
initClearCache();
bindLogoutOnce(options);
bindPopupAuthLinks();
var tok = localStorage.getItem('soon_access') || '';
if (!tok) {
showGuestNav();
return Promise.resolve();
}
showAuthedNav();
var base = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || '';
if (!base) {
if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity();
return Promise.resolve();
}
var meFetch = typeof window.soonAuthedFetch === 'function'
? window.soonAuthedFetch(base + '/auth/me', { headers: { Authorization: 'Bearer ' + tok } })
: fetch(base + '/auth/me', { headers: { Authorization: 'Bearer ' + tok } });
return meFetch
.then(function (r) {
if (r.status === 401) {
clearSession();
showGuestNav();
return null;
}
if (!r.ok) {
if (typeof window.soonToast === 'function') window.soonToast('网络异常,部分功能可能不可用', 'warn');
if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity();
return null;
}
return r.json();
})
.then(function (j) {
if (!j) return;
if (!j.ok || !j.data) {
clearSession();
showGuestNav();
return;
}
applyUserUi(j.data);
if (typeof options.onAuthed === 'function') options.onAuthed(j.data);
})
.catch(function () {
if (typeof window.soonToast === 'function') window.soonToast('网络异常,部分功能可能不可用', 'warn');
if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity();
});
}
function initTopbar(options) {
_opts = options || {};
return refreshAuthUi(_opts);
}
window.soonRefreshPortalAuthUi = refreshAuthUi;
window.soonPortalAuth = { init: initTopbar, refresh: refreshAuthUi };
})(); })();
+4 -5
View File
@@ -3,7 +3,6 @@
var STANDARD_LINKS = [ var STANDARD_LINKS = [
{ id: 'home', href: 'index.web.html', label: '设计首页', show: 'always' }, { id: 'home', href: 'index.web.html', label: '设计首页', show: 'always' },
{ id: 'member', href: 'member.web.html', label: '订阅', show: 'authed', elId: 'auth_member' },
{ id: 'admin', href: 'admin/index.html', label: '管理', show: 'admin', elId: 'auth_admin' }, { id: 'admin', href: 'admin/index.html', label: '管理', show: 'admin', elId: 'auth_admin' },
]; ];
@@ -22,7 +21,6 @@
'<span id="auth_identity" class="soon-portal-identity" style="display:none"></span>' + '<span id="auth_identity" class="soon-portal-identity" style="display:none"></span>' +
'</div>' + '</div>' +
'<nav class="soon-portal-topbar__session" id="auth_session" style="display:none" aria-label="账户">' + '<nav class="soon-portal-topbar__session" id="auth_session" style="display:none" aria-label="账户">' +
'<a href="member.web.html" id="auth_member" class="soon-portal-topbar__link" style="display:none">订阅</a>' +
'<a href="admin/index.html" id="auth_admin" class="soon-portal-topbar__link" style="display:none">管理</a>' + '<a href="admin/index.html" id="auth_admin" class="soon-portal-topbar__link" style="display:none">管理</a>' +
'<a href="#" id="auth_logout" class="soon-portal-topbar__link" style="display:none">退出</a>' + '<a href="#" id="auth_logout" class="soon-portal-topbar__link" style="display:none">退出</a>' +
'</nav>'; '</nav>';
@@ -30,7 +28,7 @@
function renderLinks(links, active) { function renderLinks(links, active) {
return links.map(function (lnk) { return links.map(function (lnk) {
if (lnk.id === 'member' || lnk.id === 'admin') return ''; if (lnk.id === 'admin') return '';
var cls = 'soon-portal-topbar__link' + (active === lnk.id ? ' is-active' : ''); var cls = 'soon-portal-topbar__link' + (active === lnk.id ? ' is-active' : '');
return '<a href="' + esc(lnk.href) + '" class="' + cls + '">' + esc(lnk.label) + '</a>'; return '<a href="' + esc(lnk.href) + '" class="' + cls + '">' + esc(lnk.label) + '</a>';
}).join(''); }).join('');
@@ -39,14 +37,15 @@
function toolsBlock(lang, linksHtml) { function toolsBlock(lang, linksHtml) {
return '<div class="soon-portal-topbar__tools">' + return '<div class="soon-portal-topbar__tools">' +
lang + lang +
(linksHtml ? '<nav class="soon-portal-topbar__links" aria-label="导航">' + linksHtml + '</nav>' : '') + (linksHtml ? '<nav class="soon-portal-topbar__links" aria-label="导航">' + linksHtml +
'<a href="#" id="auth_clear_cache" class="soon-portal-topbar__link">清除缓存</a></nav>' : '') +
'</div>'; '</div>';
} }
/** /**
* @param {object} opts * @param {object} opts
* @param {string} [opts.variant] standard | auth | design * @param {string} [opts.variant] standard | auth | design
* @param {string} [opts.active] home | member * @param {string} [opts.active] home
* @param {string} [opts.extraClass] * @param {string} [opts.extraClass]
*/ */
function html(opts) { function html(opts) {
+244
View File
@@ -0,0 +1,244 @@
(function () {
'use strict';
var DB_NAME = 'soondesign_local';
var DB_VERSION = 1;
var MAX_BLOBS = 20;
var THUMB_MAX_BYTES = 102400;
var dbPromise = null;
var idbAvailable = typeof indexedDB !== 'undefined';
function soonExtractThumbFromJson(json) {
if (!json || !json.frontDisplayPic || typeof json.frontDisplayPic !== 'string') return '';
var candidate = json.frontDisplayPic.trim();
if (candidate.indexOf('data:image/') !== 0) return '';
if (candidate.length > THUMB_MAX_BYTES) return '';
return candidate;
}
function txDone(tx) {
return new Promise(function (resolve, reject) {
tx.oncomplete = function () { resolve(); };
tx.onerror = function () { reject(tx.error || new Error('idb_tx_error')); };
tx.onabort = function () { reject(tx.error || new Error('idb_tx_abort')); };
});
}
function reqPromise(req) {
return new Promise(function (resolve, reject) {
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error || new Error('idb_req_error')); };
});
}
function soonLocalOpen() {
if (!idbAvailable) return Promise.resolve(null);
if (dbPromise) return dbPromise;
dbPromise = new Promise(function (resolve, reject) {
var req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = function (e) {
var db = e.target.result;
if (!db.objectStoreNames.contains('blobs')) {
db.createObjectStore('blobs', { keyPath: 'cacheKey' });
}
if (!db.objectStoreNames.contains('thumbs')) {
db.createObjectStore('thumbs', { keyPath: 'cacheKey' });
}
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () {
idbAvailable = false;
dbPromise = null;
reject(req.error || new Error('idb_open_failed'));
};
}).catch(function () {
idbAvailable = false;
dbPromise = null;
return null;
});
return dbPromise;
}
function soonLocalGet(cacheKey) {
if (!cacheKey || !idbAvailable) return Promise.resolve(null);
return soonLocalOpen().then(function (db) {
if (!db) return null;
var tx = db.transaction('blobs', 'readonly');
return reqPromise(tx.objectStore('blobs').get(cacheKey)).then(function (row) {
if (!row || row.json == null) return null;
return {
json: row.json,
meta: {
name: row.name,
type: row.type,
source: row.source,
updatedAt: row.updatedAt,
savedAt: row.savedAt,
bytes: row.bytes
}
};
});
}).catch(function () { return null; });
}
function soonLocalEvictLRU(max) {
max = max || MAX_BLOBS;
return soonLocalOpen().then(function (db) {
if (!db) return;
var tx = db.transaction(['blobs', 'thumbs'], 'readwrite');
var blobStore = tx.objectStore('blobs');
return reqPromise(blobStore.getAll()).then(function (rows) {
if (!rows || rows.length <= max) return txDone(tx);
rows.sort(function (a, b) { return (a.savedAt || 0) - (b.savedAt || 0); });
var toRemove = rows.length - max;
for (var i = 0; i < toRemove; i++) {
var k = rows[i].cacheKey;
blobStore.delete(k);
tx.objectStore('thumbs').delete(k);
}
return txDone(tx);
});
}).catch(function () { /* ignore */ });
}
function soonLocalPut(cacheKey, json, meta) {
if (!cacheKey || json == null || !idbAvailable) return Promise.resolve(false);
meta = meta || {};
var jsonObj = typeof json === 'string' ? (function () {
try { return JSON.parse(json); } catch (e) { return null; }
})() : json;
if (!jsonObj) return Promise.resolve(false);
var bytes = 0;
try { bytes = JSON.stringify(jsonObj).length; } catch (e) { bytes = 0; }
var row = {
cacheKey: cacheKey,
json: jsonObj,
name: meta.name || '',
type: meta.type != null ? meta.type : (jsonObj.soonType || 1),
source: meta.source || '',
updatedAt: meta.updatedAt || '',
savedAt: Date.now(),
bytes: bytes
};
return soonLocalOpen().then(function (db) {
if (!db) return false;
var tx = db.transaction('blobs', 'readwrite');
tx.objectStore('blobs').put(row);
return txDone(tx).then(function () {
return soonLocalEvictLRU(MAX_BLOBS).then(function () { return true; });
});
}).catch(function () { return false; });
}
function soonLocalPutThumb(cacheKey, dataUrl) {
if (!cacheKey || !dataUrl || !idbAvailable) return Promise.resolve(false);
if (String(dataUrl).length > THUMB_MAX_BYTES) return Promise.resolve(false);
return soonLocalOpen().then(function (db) {
if (!db) return false;
var tx = db.transaction('thumbs', 'readwrite');
tx.objectStore('thumbs').put({ cacheKey: cacheKey, dataUrl: dataUrl, savedAt: Date.now() });
return txDone(tx).then(function () { return true; });
}).catch(function () { return false; });
}
function soonLocalGetThumb(cacheKey) {
if (!cacheKey || !idbAvailable) return Promise.resolve('');
return soonLocalOpen().then(function (db) {
if (!db) return '';
var tx = db.transaction('thumbs', 'readonly');
return reqPromise(tx.objectStore('thumbs').get(cacheKey)).then(function (row) {
return (row && row.dataUrl) ? row.dataUrl : '';
});
}).catch(function () { return ''; });
}
function soonLocalRemove(cacheKey) {
if (!cacheKey || !idbAvailable) return Promise.resolve();
return soonLocalOpen().then(function (db) {
if (!db) return;
var tx = db.transaction(['blobs', 'thumbs'], 'readwrite');
tx.objectStore('blobs').delete(cacheKey);
tx.objectStore('thumbs').delete(cacheKey);
return txDone(tx);
}).catch(function () { /* ignore */ });
}
function soonLocalRenameKey(oldKey, newKey, meta) {
if (!oldKey || !newKey || oldKey === newKey) return Promise.resolve(false);
return soonLocalGet(oldKey).then(function (hit) {
if (!hit) return false;
return soonLocalPut(newKey, hit.json, Object.assign({}, hit.meta, meta || {})).then(function (ok) {
if (!ok) return false;
return soonLocalGetThumb(oldKey).then(function (thumb) {
var chain = Promise.resolve();
if (thumb) chain = soonLocalPutThumb(newKey, thumb);
return chain.then(function () { return soonLocalRemove(oldKey); }).then(function () { return true; });
});
});
}).catch(function () { return false; });
}
function soonLocalCacheAndThumb(cacheKey, json, meta) {
var thumb = soonExtractThumbFromJson(json);
return soonLocalPut(cacheKey, json, meta).then(function (ok) {
if (!ok) return false;
if (thumb) return soonLocalPutThumb(cacheKey, thumb).then(function () { return true; });
return true;
});
}
function soonLocalIsTemplateStale(cacheKey, blobMeta) {
if (!cacheKey || cacheKey.indexOf('soondesign_template:') !== 0) return false;
var tplMeta = window._soonTemplateMeta;
if (!tplMeta || !tplMeta.updated_at) return false;
if (!blobMeta || !blobMeta.updatedAt) return false;
return String(tplMeta.updated_at) !== String(blobMeta.updatedAt);
}
window.soonLocalOpen = soonLocalOpen;
window.soonLocalGet = soonLocalGet;
window.soonLocalPut = soonLocalPut;
window.soonLocalPutThumb = soonLocalPutThumb;
window.soonLocalGetThumb = soonLocalGetThumb;
window.soonLocalRemove = soonLocalRemove;
window.soonLocalRenameKey = soonLocalRenameKey;
window.soonLocalEvictLRU = soonLocalEvictLRU;
window.soonLocalCacheAndThumb = soonLocalCacheAndThumb;
window.soonLocalIsTemplateStale = soonLocalIsTemplateStale;
window.soonExtractThumbFromJson = soonExtractThumbFromJson;
var LS_DROP = ['soondesign_recent', 'soondesign_history', 'soondesign_recent_migrated'];
var SS_DROP = ['soondesign_open_file', 'soondesign_open_type', 'soondesign_open_meta', 'soondesign_pending_import'];
function dropStorageKeys(storage, prefix) {
if (!storage) return;
try {
var rm = [];
for (var i = 0; i < storage.length; i++) {
var k = storage.key(i);
if (k && k.indexOf(prefix) === 0) rm.push(k);
}
rm.forEach(function (k) { storage.removeItem(k); });
} catch (e) { /* ignore */ }
}
function soonClearLocalDesignCache() {
try {
LS_DROP.forEach(function (k) { localStorage.removeItem(k); });
dropStorageKeys(localStorage, 'soondesign_session:');
SS_DROP.forEach(function (k) { sessionStorage.removeItem(k); });
dropStorageKeys(sessionStorage, 'soondesign_session:');
} catch (e) { /* ignore */ }
dbPromise = null;
if (typeof indexedDB === 'undefined') return Promise.resolve();
return new Promise(function (resolve) {
var done = function () { resolve(); };
try {
var req = indexedDB.deleteDatabase(DB_NAME);
req.onsuccess = req.onerror = req.onblocked = done;
} catch (e2) { done(); }
});
}
window.soonClearLocalDesignCache = soonClearLocalDesignCache;
})();
+217
View File
@@ -0,0 +1,217 @@
(function () {
'use strict';
var RECENT_KEY = 'soondesign_recent';
var MIGRATED_KEY = 'soondesign_recent_migrated';
var HISTORY_KEY = 'soondesign_history';
var MAX_ITEMS = 20;
function nowTimeStr() {
var d = new Date();
function pad(n) { return n < 10 ? '0' + n : String(n); }
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' +
pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
}
function readRaw() {
try {
var raw = localStorage.getItem(RECENT_KEY);
if (!raw) return { version: 1, items: [] };
var j = JSON.parse(raw);
if (!j || !Array.isArray(j.items)) return { version: 1, items: [] };
return j;
} catch (e) {
return { version: 1, items: [] };
}
}
function writeRaw(data) {
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(data));
return true;
} catch (e) {
return false;
}
}
function soonRecentKindFromKey(key) {
if (!key) return 'local';
if (key.indexOf('soondesign_file:') === 0) return 'cloud';
if (key.indexOf('soondesign_template:') === 0) return 'template';
return 'local';
}
function parseFileId(key) {
var m = String(key).match(/^soondesign_file:(\d+)/);
return m ? parseInt(m[1], 10) : null;
}
function soonRecentList() {
return readRaw().items.slice();
}
function soonRecentRemove(key) {
if (!key) return;
var data = readRaw();
data.items = data.items.filter(function (it) { return it.key !== key; });
writeRaw(data);
}
function soonRecentUpsert(item) {
if (!item || !item.key) return;
var key = item.key;
var kind = item.kind || soonRecentKindFromKey(key);
var type = item.type != null ? item.type : 1;
var name = item.name || '';
if (!name && typeof window.soonDisplayFileName === 'function') {
name = window.soonDisplayFileName(key);
}
var entry = {
key: key,
name: name || 'design.soon',
type: type,
kind: kind,
fileId: item.fileId != null ? item.fileId : parseFileId(key),
thumbRef: item.thumbRef || key,
time: item.time || nowTimeStr()
};
var data = readRaw();
data.items = data.items.filter(function (it) { return it.key !== key; });
data.items.unshift(entry);
if (data.items.length > MAX_ITEMS) data.items = data.items.slice(0, MAX_ITEMS);
writeRaw(data);
}
function soonRecentUpsertFromOpen(fileKey, json) {
if (!fileKey) return;
var type = 1;
if (json) {
type = json.soonType ? json.soonType : (json.backBlackPic ? 2 : 1);
}
var name = '';
if (typeof window.soonDisplayFileName === 'function') name = window.soonDisplayFileName(fileKey);
var tplMeta = window._soonTemplateMeta;
if (tplMeta && tplMeta.name && fileKey.indexOf('soondesign_template:') === 0) {
name = tplMeta.name;
if (tplMeta.type) type = tplMeta.type;
}
var fileMeta = window._soonFileMeta;
if (fileMeta && fileMeta.name && fileKey.indexOf('soondesign_file:') === 0) {
name = fileMeta.name;
}
soonRecentUpsert({
key: fileKey,
name: name,
type: type,
kind: soonRecentKindFromKey(fileKey),
fileId: parseFileId(fileKey),
thumbRef: fileKey,
time: nowTimeStr(),
updated_at: (tplMeta && tplMeta.updated_at) ? tplMeta.updated_at : undefined
});
}
function soonRecentMigrateFromHistory() {
try {
if (localStorage.getItem(MIGRATED_KEY) === '1') return;
} catch (e) { return; }
var histRaw;
try {
histRaw = localStorage.getItem(HISTORY_KEY);
} catch (e) { return; }
if (!histRaw) {
try { localStorage.setItem(MIGRATED_KEY, '1'); } catch (e2) { /* ignore */ }
return;
}
var hist;
try {
hist = JSON.parse(histRaw);
} catch (e) {
try { localStorage.setItem(MIGRATED_KEY, '1'); } catch (e2) { /* ignore */ }
return;
}
var list = (hist && Array.isArray(hist.history)) ? hist.history : [];
list.forEach(function (h) {
if (!h || !h.path) return;
soonRecentUpsert({
key: h.path,
name: typeof window.soonDisplayFileName === 'function' ? window.soonDisplayFileName(h.path) : h.path,
type: h.type || 1,
kind: soonRecentKindFromKey(h.path),
fileId: parseFileId(h.path),
thumbRef: h.path,
time: h.time || nowTimeStr()
});
});
try { localStorage.setItem(MIGRATED_KEY, '1'); } catch (e) { /* ignore */ }
}
function soonRecentEnrichFromCloud(items, cloudItems) {
if (!items || !items.length || !cloudItems || !cloudItems.length) return items;
var byId = {};
cloudItems.forEach(function (c) {
if (c && c.id != null) byId[c.id] = c;
});
return items.map(function (it) {
if (!it.fileId || !byId[it.fileId]) return it;
var c = byId[it.fileId];
var copy = Object.assign({}, it);
if (c.name) copy.name = c.name;
var curKey = it.filePath || it.key;
if (c.version != null && typeof window.soonMakeFileKey === 'function') {
var newKey = window.soonMakeFileKey(c.id, c.version);
if (newKey !== curKey) {
soonRecentRemove(curKey);
copy.filePath = newKey;
copy.key = newKey;
copy.thumbRef = newKey;
soonRecentUpsert({
key: newKey,
name: copy.name,
type: copy.type,
kind: soonRecentKindFromKey(newKey),
fileId: c.id,
thumbRef: newKey
});
}
}
return copy;
});
}
function soonRecentOnCloudSave(res, prevKey, type) {
if (!res || !res.fileKey) return;
var dropKeys = {};
if (prevKey && prevKey !== res.fileKey) dropKeys[prevKey] = 1;
if (res.fileId != null) {
readRaw().items.forEach(function (it) {
if (it.fileId === res.fileId && it.key !== res.fileKey) dropKeys[it.key] = 1;
});
}
Object.keys(dropKeys).forEach(function (k) {
soonRecentRemove(k);
if (typeof window.soonLocalRemove === 'function') window.soonLocalRemove(k);
});
var name = res.name || '';
if (!name && typeof window.soonDisplayFileName === 'function') {
name = window.soonDisplayFileName(res.fileKey);
}
soonRecentUpsert({
key: res.fileKey,
name: name || 'design.soon',
type: type != null ? type : 1,
kind: soonRecentKindFromKey(res.fileKey),
fileId: res.fileId,
thumbRef: res.fileKey
});
}
window.soonRecentList = soonRecentList;
window.soonRecentRemove = soonRecentRemove;
window.soonRecentUpsert = soonRecentUpsert;
window.soonRecentUpsertFromOpen = soonRecentUpsertFromOpen;
window.soonRecentMigrateFromHistory = soonRecentMigrateFromHistory;
window.soonRecentKindFromKey = soonRecentKindFromKey;
window.soonRecentEnrichFromCloud = soonRecentEnrichFromCloud;
window.soonRecentOnCloudSave = soonRecentOnCloudSave;
})();
+73 -1
View File
@@ -1,14 +1,81 @@
(function () { (function () {
'use strict'; '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) { function soonToast(message, type) {
if (typeof layer === 'undefined' || !layer.msg) return; if (typeof layer === 'undefined' || !layer.msg) return;
var skin = 'soon-toast'; var skin = 'soon-toast';
if (type === 'success') skin += ' soon-toast--success'; if (type === 'success') skin += ' soon-toast--success';
else if (type === 'error') skin += ' soon-toast--error'; else if (type === 'error') skin += ' soon-toast--error';
else if (type === 'warn') skin += ' soon-toast--warn'; else if (type === 'warn') skin += ' soon-toast--warn';
var time = type === 'success' ? 2500 : (type === 'error' ? 4200 : 3600);
layer.msg(message, { layer.msg(message, {
time: 2400, time: time,
shade: 0, shade: 0,
offset: '72px', offset: '72px',
icon: -1, icon: -1,
@@ -16,5 +83,10 @@
}); });
} }
window.soonCleanupLayerMask = soonCleanupLayerMask;
window.soonLayerDismiss = soonLayerDismiss;
window.soonClearFabricOverlay = soonClearFabricOverlay;
window.soonRefreshDesignCanvases = soonRefreshDesignCanvases;
window.soonFinishSaveUi = soonFinishSaveUi;
window.soonToast = soonToast; window.soonToast = soonToast;
})(); })();
+117 -80
View File
@@ -6,7 +6,9 @@ $("#object_attribute").css("height", "470px");
$("#obj_list").css("height", document.body.clientHeight - 520); $("#obj_list").css("height", document.body.clientHeight - 520);
$(".obj_list").css("height", $("#obj_list").height() - 32); $(".obj_list").css("height", $("#obj_list").height() - 32);
$(".main").show(); var mainEl = document.querySelector('.layui-fluid.main');
if (mainEl) mainEl.style.display = 'flex';
else $(".main").show();
$(".canvas_box").css("height", document.body.clientHeight - 100); $(".canvas_box").css("height", document.body.clientHeight - 100);
zoom = $(".canvas_bg_img").width() / 2787 zoom = $(".canvas_bg_img").width() / 2787
$("#canvas1").attr("width", $(".canvas_box").width() / zoom); $("#canvas1").attr("width", $(".canvas_box").width() / zoom);
@@ -100,10 +102,10 @@ function applyFontList(data) {
$("#count_font_family").append("<option value='" + item + "'>" + item + "</option>"); $("#count_font_family").append("<option value='" + item + "'>" + item + "</option>");
} }
} }
if (typeof ipcRenderer !== 'undefined' && ipcRenderer) { if (typeof window !== 'undefined' && window.platformBridge && window.platformBridge.getSystemFonts) {
ipcRenderer.on('font-list', function(event, data) { applyFontList(data); });
} else if (typeof window !== 'undefined' && window.platformBridge && window.platformBridge.getSystemFonts) {
window.platformBridge.getSystemFonts().then(applyFontList); window.platformBridge.getSystemFonts().then(applyFontList);
} else if (typeof ipcRenderer !== 'undefined' && ipcRenderer) {
ipcRenderer.on('font-list', function(event, data) { applyFontList(data); });
} }
function applySysLan(data) { function applySysLan(data) {
let lang = localStorage.getItem("lang"); let lang = localStorage.getItem("lang");
@@ -148,6 +150,7 @@ function applySysLan(data) {
localStorage.setItem("lang", "zh"); localStorage.setItem("lang", "zh");
break; break;
case 'zh-TW': case 'zh-TW':
case 'ozh':
s_lan = "ozh"; s_lan = "ozh";
// 同步到全局作用域(用于 .jsc 文件加载) // 同步到全局作用域(用于 .jsc 文件加载)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -259,7 +262,7 @@ if (typeof ipcRenderer !== 'undefined' && ipcRenderer) {
applySysLan(window.platformBridge.getLocale()); applySysLan(window.platformBridge.getLocale());
} }
function onCloseConfirm() { function onCloseConfirm() {
layer.confirm('<font style="color:black">' + language_str("whetherSave") + '</font>', { layer.confirm(language_str("whetherSave"), {
btn: [language_str("save"), language_str("noSave"), language_str("cancel")] btn: [language_str("save"), language_str("noSave"), language_str("cancel")]
, btn3: function (index, layero) {} , btn3: function (index, layero) {}
}, function (index, layero) { }, function (index, layero) {
@@ -373,78 +376,75 @@ window.rotate = function (id) {
$(`#${id}`).attr("angle", angle); $(`#${id}`).attr("angle", angle);
} }
function soonPlaceBgOnCanvas(image, canvasSelector) {
var cw = $(canvasSelector).width() || (document.querySelector(canvasSelector) && document.querySelector(canvasSelector).clientWidth) || 800;
var ch = $(canvasSelector).height() || (document.querySelector(canvasSelector) && document.querySelector(canvasSelector).clientHeight) || 600;
image.left = (cw - image.width * image.get("scaleX")) / 2;
image.top = (ch - image.height * image.get("scaleY")) / 2;
image.selectable = false;
image.hoverable = false;
image.hoverCursor = "default";
}
function soonOpenPendingDesignFile() {
var file = typeof window.soonResolveOpenFileKey === 'function'
? window.soonResolveOpenFileKey(GetFile)
: (GetFile().get('file') || '');
if (file && file != 'empty') {
if (typeof window.soonConsumeOpenMeta === 'function') {
window.soonConsumeOpenMeta(file);
}
window.openFile(file);
}
}
function addBackground() { function addBackground() {
// 类型改变 if (is_bgi_add || window.is_bgi_add) return;
fabric.Image.fromURL(soonAsset('bg_front_1.png'), function (image) { var loadBg = typeof window.soonFabricImageFromAsset === 'function'
let width = $("#canvas1").width() ? window.soonFabricImageFromAsset
: null;
var whenReady = typeof window.soonRunWhenCanvasReady === 'function'
? window.soonRunWhenCanvasReady
: function (_sel, fn) { fn(); };
image.left = ($("#canvas1").width() - image.width * image.get("scaleX")) / 2; function finishBack(backImg) {
image.top = ($("#canvas1").height() - image.height * image.get("scaleY")) / 2; if (backImg) {
backImg.objectCaching = true;
image.selectable = false; soonPlaceBgOnCanvas(backImg, '#canvas2');
image.hoverable = false; background_image2 = backImg;
image.hoverCursor = "default" window.background_image2 = backImg;
image.setSrc(image.toDataURL(), function (image) { canvas2.add(backImg);
image.scale(1, 1);
background_image1 = image;
background_image = background_image1;
window.background_image1 = background_image1;
window.background_image = background_image;
canvas.add(image);
canvas.renderAll();
//pre_objs1.push(JSON.stringify(objs1));
//pre_json1.push(canvas1.toJSON(["selectable","hoverable","hoverCursor"]));
recordObjs1.push(JSON.stringify(objs1));
recordJson1.push(canvas1.toJSON(TO_JSON_PROPERTIES));
});
// 类型改变
fabric.Image.fromURL(soonAsset('bg_back.png'), function (image) {
image.left = ($("#canvas2").width() - image.width) / 2;
image.top = ($("#canvas2").height() - image.height) / 2;
image.selectable = false;
image.hoverable = false;
image.hoverCursor = "default"
image.setSrc(image.toDataURL(), function (image) {
background_image2 = image;
window.background_image2 = background_image2;
canvas2.add(image);
canvas2.renderAll(); canvas2.renderAll();
//pre_objs2.push(JSON.stringify(objs2));
//pre_json2.push(canvas2.toJSON(["selectable","hoverable","hoverCursor"]));
recordObjs2.push(JSON.stringify(objs2)); recordObjs2.push(JSON.stringify(objs2));
recordJson2.push(canvas2.toJSON(TO_JSON_PROPERTIES)); recordJson2.push(canvas2.toJSON(TO_JSON_PROPERTIES));
let file = GetFile().get('file');
if (!file && typeof sessionStorage !== 'undefined') {
try {
file = sessionStorage.getItem('soondesign_open_file');
if (file) {
sessionStorage.removeItem('soondesign_open_file');
sessionStorage.removeItem('soondesign_open_type');
} }
} catch (e) {}
}
if (file && file != 'empty') {
if (file.indexOf('soondesign_file:') === 0 && typeof sessionStorage !== 'undefined') {
try {
var metaRaw = sessionStorage.getItem('soondesign_open_meta');
if (metaRaw) {
window._soonFileMeta = JSON.parse(metaRaw);
sessionStorage.removeItem('soondesign_open_meta');
}
} catch (e) {}
}
var p = window.openFile(file)
if (p && p.then) p.then(function() {}, function() {})
}
});
});
is_bgi_add = true; is_bgi_add = true;
}); window.is_bgi_add = true;
soonOpenPendingDesignFile();
}
function startLoad() {
if (!loadBg) return;
loadBg('bg_front_1.png', function (frontImg) {
if (!frontImg) return;
frontImg.objectCaching = true;
frontImg.scale(1, 1);
soonPlaceBgOnCanvas(frontImg, '#canvas1');
background_image1 = frontImg;
background_image = frontImg;
window.background_image1 = frontImg;
window.background_image = frontImg;
canvas.add(frontImg);
canvas.renderAll();
recordObjs1.push(JSON.stringify(objs1));
recordJson1.push(canvas1.toJSON(TO_JSON_PROPERTIES));
loadBg('bg_back.png', finishBack, function () { loadBg('bg_back.png', finishBack, null, 2, true); }, 2, true);
}, function () {
setTimeout(startLoad, 200);
}, 2, true);
}
whenReady('#canvas1', startLoad);
} }
// 将 updateControls 附加到 window 对象,确保在 eval() 加载 .jsc 文件时也能访问 // 将 updateControls 附加到 window 对象,确保在 eval() 加载 .jsc 文件时也能访问
@@ -1517,7 +1517,20 @@ function handleObjectMoving(e) {
obj.set("angle", angleDeg); obj.set("angle", angleDeg);
obj.set("flipped", flipped); obj.set("flipped", flipped);
} }
updateControls(); soonScheduleUpdateControls();
}
var _soonUpdateControlsRaf = 0;
function soonScheduleUpdateControls() {
if (_soonUpdateControlsRaf) return;
_soonUpdateControlsRaf = requestAnimationFrame(function () {
_soonUpdateControlsRaf = 0;
window.updateControls();
});
}
function soonDesign1SetInteracting(on) {
window._soonDesign1Interacting = !!on;
} }
// 辅助函数:检查对象是否被锁定 // 辅助函数:检查对象是否被锁定
@@ -1625,17 +1638,28 @@ function initCanvasEvents() {
_canvasEventsInitialized = true; _canvasEventsInitialized = true;
[canvas1, canvas2].forEach(currentCanvas => { [canvas1, canvas2].forEach(currentCanvas => {
var _mouseMoveRaf = 0;
var _mouseMovePending = null;
// 1. 鼠标移动 // 1. 鼠标移动
currentCanvas.on("mouse:move", (e) => { currentCanvas.on("mouse:move", (e) => {
_mouseMovePending = { e: e, canvas: currentCanvas };
if (_mouseMoveRaf) return;
_mouseMoveRaf = requestAnimationFrame(function () {
_mouseMoveRaf = 0;
var payload = _mouseMovePending;
if (!payload) return;
var moveEvent = payload.e;
var moveCanvas = payload.canvas;
let offsetX = 0, offsetY = 0; let offsetX = 0, offsetY = 0;
if (currentCanvas === canvas2 && typeof background_image !== 'undefined') { if (moveCanvas === canvas2 && typeof background_image !== 'undefined') {
offsetX = background_image.left; offsetX = background_image.left;
offsetY = background_image.top; offsetY = background_image.top;
} }
if (e.pointer) { if (moveEvent.pointer) {
$("#posText").text("X: " + (e.pointer.x - offsetX).toFixed(0) + " Y: " + (e.pointer.y - offsetY).toFixed(0)); $("#posText").text("X: " + (moveEvent.pointer.x - offsetX).toFixed(0) + " Y: " + (moveEvent.pointer.y - offsetY).toFixed(0));
} }
updateRulerGuides(e.e); updateRulerGuides(moveEvent.e);
});
}); });
// 2. 鼠标移出 // 2. 鼠标移出
@@ -1728,12 +1752,19 @@ function initCanvasEvents() {
return false; return false;
} }
} }
soonDesign1SetInteracting(true);
handleObjectMoving(e); handleObjectMoving(e);
}); });
// 6. 变换事件 // 6. 变换事件
currentCanvas.on("object:rotating", updateControls); currentCanvas.on("object:rotating", function () {
currentCanvas.on("object:scaling", updateControls); soonDesign1SetInteracting(true);
soonScheduleUpdateControls();
});
currentCanvas.on("object:scaling", function () {
soonDesign1SetInteracting(true);
soonScheduleUpdateControls();
});
currentCanvas.on("object:rotated", () => { window.recordState(); }); currentCanvas.on("object:rotated", () => { window.recordState(); });
currentCanvas.on('text:editing:entered', () => { _textEdit = true; }); currentCanvas.on('text:editing:entered', () => { _textEdit = true; });
currentCanvas.on('text:editing:exited', () => { _textEdit = false; }); currentCanvas.on('text:editing:exited', () => { _textEdit = false; });
@@ -1741,6 +1772,7 @@ function initCanvasEvents() {
// 7. 对象修改后 // 7. 对象修改后
currentCanvas.on('object:modified', function (event) { currentCanvas.on('object:modified', function (event) {
try {
if (currentCanvas.getActiveObjects().length > 1) return; if (currentCanvas.getActiveObjects().length > 1) return;
let idx = getIndex(event.target); let idx = getIndex(event.target);
if (idx <= 0) return; if (idx <= 0) return;
@@ -1763,17 +1795,19 @@ function initCanvasEvents() {
if (objData.type == 4) $("#out_text_size").val(target.fontSize); if (objData.type == 4) $("#out_text_size").val(target.fontSize);
if (objData.type == 10) $("#count_size").val(target.fontSize); if (objData.type == 10) $("#count_size").val(target.fontSize);
currentCanvas.renderAll(); currentCanvas.renderAll();
updateControls();
} else if (objData.type == 5) { } else if (objData.type == 5) {
target.width *= target.scaleX; target.height *= target.scaleY; target.width *= target.scaleX; target.height *= target.scaleY;
target.scaleX = 1; target.scaleY = 1; target.scaleX = 1; target.scaleY = 1;
updateControls();
} }
window.recordState(); window.recordState();
// 更新对象列表以同步锁状态 // 更新对象列表以同步锁状态
if (typeof window.updateList === 'function') { if (typeof window.updateList === 'function') {
window.updateList(); window.updateList();
} }
} finally {
soonDesign1SetInteracting(false);
updateControls();
}
}); });
// ================================================== // ==================================================
// 8. 【完美修复版】渲染后绘制:外部蒙版 + 中心圆孔 (修复三角形Bug) // 8. 【完美修复版】渲染后绘制:外部蒙版 + 中心圆孔 (修复三角形Bug)
@@ -1782,7 +1816,7 @@ function initCanvasEvents() {
const ctx = currentCanvas.getContext(); const ctx = currentCanvas.getContext();
let bgObj = (currentCanvas === canvas1) ? background_image1 : background_image2; let bgObj = (currentCanvas === canvas1) ? background_image1 : background_image2;
if (bgObj) { if (bgObj && !window._soonSkipDesign1CdMask) {
const width = currentCanvas.width; const width = currentCanvas.width;
const height = currentCanvas.height; const height = currentCanvas.height;
@@ -1831,6 +1865,8 @@ function initCanvasEvents() {
ctx.restore(); ctx.restore();
} }
if (window._soonDesign1Interacting) return;
// 在遮罩之后重新绘制辅助线,确保辅助线在最上层 // 在遮罩之后重新绘制辅助线,确保辅助线在最上层
const objects = currentCanvas.getObjects(); const objects = currentCanvas.getObjects();
objects.forEach(obj => { objects.forEach(obj => {
@@ -2044,6 +2080,7 @@ function initRulers() {
// --- 3. 绘制标尺 (核心绘制循环) --- // --- 3. 绘制标尺 (核心绘制循环) ---
function updateAndDraw() { function updateAndDraw() {
if (window._soonDesign1Interacting) return;
// 每次重新获取尺寸,确保使用最新值 // 每次重新获取尺寸,确保使用最新值
// 使用多种方法尝试获取实际尺寸,确保准确 // 使用多种方法尝试获取实际尺寸,确保准确
let currentCanvasWidth = canvasWrapper.width() || canvasWrapper.outerWidth() || canvasWrapper[0].getBoundingClientRect().width || 800; let currentCanvasWidth = canvasWrapper.width() || canvasWrapper.outerWidth() || canvasWrapper[0].getBoundingClientRect().width || 800;
+116 -43
View File
@@ -52,9 +52,7 @@ async function saveImageAsPNG(buffer) {
} }
} }
// 将 display_func 附加到 window 对象,确保全局可访问
window.display_func = function display_func(img1, img2, img3) { window.display_func = function display_func(img1, img2, img3) {
if (typeof window.soonGuardMemberPreview === 'function' && !window.soonGuardMemberPreview()) return;
$("#base_control").hide(); $("#base_control").hide();
$("#line_control").hide(); $("#line_control").hide();
$("#pic_control").hide(); $("#pic_control").hide();
@@ -403,6 +401,7 @@ window.display_func = function display_func(img1, img2, img3) {
layer.open({ layer.open({
type: 1, type: 1,
skin: 'soon-layer',
area: [w, h], area: [w, h],
title: language_str("display"), //不显示标题栏"预览" title: language_str("display"), //不显示标题栏"预览"
shadeClose: true, //点击遮罩关闭 shadeClose: true, //点击遮罩关闭
@@ -418,16 +417,33 @@ window.display_func = function display_func(img1, img2, img3) {
</div> </div>
</div>`, </div>`,
btn: [language_str("output"), "打印"],//'导出' btn: [language_str("output"), "打印"],//'导出'
btn1: function (index, layero) { btn1: function () {
if (typeof window.soonGuardPreviewDeliver === 'function') {
window.soonGuardPreviewDeliver(language_str('output') || '导出', function () {
saveImageAsPNG(buffer); saveImageAsPNG(buffer);
});
} else {
saveImageAsPNG(buffer);
}
return false;
}, },
btn2: function () { btn2: function () {
if (typeof window.soonGuardPreviewDeliver === 'function') {
window.soonGuardPreviewDeliver('打印', function () {
if (window.platformBridge && window.platformBridge.printPdf) { if (window.platformBridge && window.platformBridge.printPdf) {
var blob = buffer instanceof Uint8Array ? new Blob([buffer], { type: 'image/png' }) : new Blob([buffer], { type: 'image/png' }); var blob = buffer instanceof Uint8Array ? new Blob([buffer], { type: 'image/png' }) : new Blob([buffer], { type: 'image/png' });
window.platformBridge.printPdf(blob); window.platformBridge.printPdf(blob);
} else if (typeof printJS === 'function') { } else if (typeof printJS === 'function') {
printJS({ printable: url3, type: 'image', style: 'img { width: 100%; height: auto; }' }); printJS({ printable: url3, type: 'image', style: 'img { width: 100%; height: auto; }' });
} }
});
} else if (window.platformBridge && window.platformBridge.printPdf) {
var blob = buffer instanceof Uint8Array ? new Blob([buffer], { type: 'image/png' }) : new Blob([buffer], { type: 'image/png' });
window.platformBridge.printPdf(blob);
} else if (typeof printJS === 'function') {
printJS({ printable: url3, type: 'image', style: 'img { width: 100%; height: auto; }' });
}
return false;
}, },
end: function () { end: function () {
if (printBlobUrl) try { URL.revokeObjectURL(printBlobUrl); } catch (e) {} if (printBlobUrl) try { URL.revokeObjectURL(printBlobUrl); } catch (e) {}
@@ -456,7 +472,6 @@ window.display_func = function display_func(img1, img2, img3) {
// 将 output 附加到 window 对象,确保全局可访问 // 将 output 附加到 window 对象,确保全局可访问
window.output = function output(callback = null, _save = save) { window.output = function output(callback = null, _save = save) {
if (typeof window.soonGuardMemberExport === 'function' && !window.soonGuardMemberExport()) return;
// 类型改变 // 类型改变
fabric.Image.fromURL(soonAsset('op_1.png'), function (i1) { fabric.Image.fromURL(soonAsset('op_1.png'), function (i1) {
i1.left = background_image.left; i1.left = background_image.left;
@@ -1083,8 +1098,14 @@ window.openFile = function open(file, jAlready) {
if (!file) return; if (!file) return;
if (typeof window.soonBindFileMeta === 'function') window.soonBindFileMeta(file); if (typeof window.soonBindFileMeta === 'function') window.soonBindFileMeta(file);
function doOpenWithJson(j) { function doOpenWithJson(j) {
if (!j || !background_image) return; if (!j || !background_image) {
if (!j.f || !j.f.objects || !Array.isArray(j.f.objects) || j.f.objects.length === 0) return; if (typeof window.soonReportOpenLoadError === 'function') window.soonReportOpenLoadError('模板/文件加载失败');
return;
}
if (!j.f || !j.f.objects || !Array.isArray(j.f.objects) || j.f.objects.length === 0) {
if (typeof window.soonReportOpenLoadError === 'function') window.soonReportOpenLoadError('模板/文件加载失败');
return;
}
if (!j.b || !j.b.objects || !Array.isArray(j.b.objects) || j.b.objects.length === 0) { if (!j.b || !j.b.objects || !Array.isArray(j.b.objects) || j.b.objects.length === 0) {
j.b = { version: '4.6.0', objects: [], hoverCursor: 'move' }; j.b = { version: '4.6.0', objects: [], hoverCursor: 'move' };
} }
@@ -1159,21 +1180,34 @@ window.openFile = function open(file, jAlready) {
var j2 = canvas2.toJSON(props); var j2 = canvas2.toJSON(props);
recordJson2.push(j2); recordJson2.push(j2);
}, 0); }, 0);
if (typeof window.soonTryConsumeCloudImport === 'function') window.soonTryConsumeCloudImport(file);
if (typeof window.soonRecentUpsertFromOpen === 'function') window.soonRecentUpsertFromOpen(file, j);
} }
}); });
} }
if (arguments.length >= 2 && jAlready) { if (arguments.length >= 2 && jAlready) {
doOpenWithJson(jAlready); doOpenWithJson(jAlready);
openAs.name = file; openAs.name = file;
if (typeof window.soonTryConsumeCloudImport === 'function') window.soonTryConsumeCloudImport(file);
return; return;
} }
if (window.platformBridge && window.platformBridge.readJsonFile) { if (window.platformBridge && window.platformBridge.readJsonFile) {
if (typeof window.soonShowOpenFileLoading === 'function') window.soonShowOpenFileLoading();
return window.platformBridge.readJsonFile(file).then(function(j) { return window.platformBridge.readJsonFile(file).then(function(j) {
if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading();
if (j) { if (j) {
doOpenWithJson(j); doOpenWithJson(j);
openAs.name = file; openAs.name = file;
} else if (typeof window.soonReportOpenLoadError === 'function') {
window.soonReportOpenLoadError('模板/文件加载失败');
} }
}).catch(function() {}); }).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 { try {
var fs = typeof require !== 'undefined' ? require('fs') : null; var fs = typeof require !== 'undefined' ? require('fs') : null;
@@ -1236,9 +1270,31 @@ function cloudSaveSuccessLabel(res, fallback) {
return fallback || ''; return fallback || '';
} }
function soonPageDesignType() {
try {
var p = new URLSearchParams(location.search);
return Number(p.get('type')) || 1;
} catch (e) {
return 1;
}
}
function onCloudWriteDone(res, fallback, callback) { function onCloudWriteDone(res, fallback, callback) {
if (typeof window.soonFinishSaveUi === 'function') window.soonFinishSaveUi();
else if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask();
applyCloudWriteResult(res, fallback); applyCloudWriteResult(res, fallback);
layer.msg(language_str("saveSucc") + cloudSaveSuccessLabel(res, fallback)); if (res && res.fileKey && typeof window.soonClearPendingImport === 'function') {
window.soonClearPendingImport();
}
if (typeof window.soonRecentOnCloudSave === 'function') {
window.soonRecentOnCloudSave(res, fallback, soonPageDesignType());
}
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(); if (callback && typeof callback === 'function') callback();
} }
@@ -1280,26 +1336,27 @@ function saveAs(op1, callback) {
let o = { f: j, b: {}, fo: objs1, bo: objs2 }; let o = { f: j, b: {}, fo: objs1, bo: objs2 };
let con_o = Object.assign(o, op1); let con_o = Object.assign(o, op1);
if (!guardWebSave()) return; 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); var dialogApi = (typeof dialog !== 'undefined' && dialog) ? dialog : (window.platformBridge && window.platformBridge.showSaveDialog ? { showSaveDialog: function(opts) { return window.platformBridge.showSaveDialog(opts); } } : null);
if (!dialogApi) return; if (!dialogApi) return;
dialogApi.showSaveDialog({ dialogApi.showSaveDialog({
title: language_str("saveFile"), title: language_str("saveFile"),
filters: [{ name: 'Soon File Type', extensions: ['soon'] }], filters: [{ name: 'Soon File Type', extensions: ['soon'] }],
defaultPath: openAs.name || undefined defaultPath: typeof window.soonSaveDialogDefaultPath === 'function' ? window.soonSaveDialogDefaultPath(openAs.name) : openAs.name
}).then(function(result) { }).then(function(result) {
if (result.canceled) return; if (result.canceled) return;
var fp = result.filePath || (result.fileHandle && result.fileHandle.name); var fp = typeof window.soonEnsureSaveFileName === 'function'
? window.soonEnsureSaveFileName(result.filePath || (result.fileHandle && result.fileHandle.name))
: (result.filePath || (result.fileHandle && result.fileHandle.name));
if (!fp) return; if (!fp) return;
var pathExt = (typeof window !== 'undefined' && window.path && window.path.extname) ? window.path.extname(fp) : (fp.indexOf('.') >= 0 ? fp.slice(fp.lastIndexOf('.')) : '');
if (pathExt !== '.soon') fp = (fp || 'design').replace(/\.soon$/i, '') + '.soon';
var content = JSON.stringify(con_o); var content = JSON.stringify(con_o);
con_o.soonType = 1; if (typeof window.soonWriteSoonContentWeb === 'function' &&
if (window.platformBridge && window.platformBridge.writeFile) { window.soonWriteSoonContentWeb(fp, content, prevOpenKey, function (res, pk) {
window.platformBridge.writeFile(fp, content).then(function (res) { onCloudWriteDone(res, pk, callback);
onCloudWriteDone(res, fp, callback); })) return;
}).catch(function () {});
return;
}
var fs = typeof require !== 'undefined' ? require('fs') : null; var fs = typeof require !== 'undefined' ? require('fs') : null;
if (fs) { if (fs) {
fs.writeFileSync(result.filePath || fp, content, 'utf8'); fs.writeFileSync(result.filePath || fp, content, 'utf8');
@@ -1308,7 +1365,9 @@ function saveAs(op1, callback) {
saveHistory(); saveHistory();
if (callback && typeof callback === 'function') callback(); if (callback && typeof callback === 'function') callback();
} }
}).catch(function(err) {}); }).catch(function (err) {
if (typeof window.soonReportSaveError === 'function') window.soonReportSaveError(err);
});
} }
function save(op1, callback) { function save(op1, callback) {
@@ -1345,15 +1404,19 @@ function save(op1, callback) {
let o = { f: j, b: {}, fo: objs1, bo: objs2 }; let o = { f: j, b: {}, fo: objs1, bo: objs2 };
let con_o = Object.assign(o, op1); let con_o = Object.assign(o, op1);
if (!guardWebSave()) return; if (!guardWebSave()) return;
if (openAs.name != "") { 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; con_o.soonType = 1;
var content = JSON.stringify(con_o); var content = JSON.stringify(con_o);
if (window.platformBridge && window.platformBridge.writeFile) { var needsDialog = typeof window.soonNeedsSaveDialog === 'function'
window.platformBridge.writeFile(openAs.name, content).then(function (res) { ? window.soonNeedsSaveDialog(openAs.name)
onCloudWriteDone(res, openAs.name, callback); : !openAs.name;
}).catch(function () {}); if (openAs.name && !needsDialog) {
return; if (typeof window.soonWriteSoonContentWeb === 'function' &&
} window.soonWriteSoonContentWeb(openAs.name, content, prevOpenKey, function (res, pk) {
onCloudWriteDone(res, pk, callback);
})) return;
var fs = typeof require !== 'undefined' ? require('fs') : null; var fs = typeof require !== 'undefined' ? require('fs') : null;
if (fs) { if (fs) {
fs.writeFileSync(openAs.name, content, 'utf8'); fs.writeFileSync(openAs.name, content, 'utf8');
@@ -1368,21 +1431,17 @@ function save(op1, callback) {
dialogApi.showSaveDialog({ dialogApi.showSaveDialog({
title: language_str("saveFile"), title: language_str("saveFile"),
filters: [{ name: 'Soon File Type', extensions: ['soon'] }], filters: [{ name: 'Soon File Type', extensions: ['soon'] }],
defaultPath: openAs.name || undefined defaultPath: typeof window.soonSaveDialogDefaultPath === 'function' ? window.soonSaveDialogDefaultPath(openAs.name) : openAs.name
}).then(async function(result) { }).then(function(result) {
if (result.canceled) return; if (result.canceled) return;
var fp = result.filePath || (result.fileHandle && result.fileHandle.name); var fp = typeof window.soonEnsureSaveFileName === 'function'
? window.soonEnsureSaveFileName(result.filePath || (result.fileHandle && result.fileHandle.name))
: (result.filePath || (result.fileHandle && result.fileHandle.name));
if (!fp) return; if (!fp) return;
var pathExt = (typeof window !== 'undefined' && window.path && window.path.extname) ? window.path.extname(fp) : (fp.indexOf('.') >= 0 ? fp.slice(fp.lastIndexOf('.')) : ''); if (typeof window.soonWriteSoonContentWeb === 'function' &&
if (pathExt !== '.soon') fp = (fp || 'design').replace(/\.soon$/i, '') + '.soon'; window.soonWriteSoonContentWeb(fp, content, prevOpenKey, function (res, pk) {
var content = JSON.stringify(con_o); onCloudWriteDone(res, pk, callback);
con_o.soonType = 1; })) return;
if (window.platformBridge && window.platformBridge.writeFile) {
window.platformBridge.writeFile(fp, content).then(function (res) {
onCloudWriteDone(res, fp, callback);
}).catch(function () {});
return;
}
var fs = typeof require !== 'undefined' ? require('fs') : null; var fs = typeof require !== 'undefined' ? require('fs') : null;
if (fs) { if (fs) {
fs.writeFileSync(result.filePath || fp, content, 'utf8'); fs.writeFileSync(result.filePath || fp, content, 'utf8');
@@ -1391,13 +1450,27 @@ function save(op1, callback) {
saveHistory(); saveHistory();
if (callback && typeof callback === 'function') callback(); if (callback && typeof callback === 'function') callback();
} }
}).catch(function(err) {}); }).catch(function (err) {
if (typeof window.soonReportSaveError === 'function') window.soonReportSaveError(err);
});
} }
window.saveHistory = function saveHistory() { window.saveHistory = function saveHistory() {
if (typeof window.soonIsWebPortal === 'function' && window.soonIsWebPortal()) return;
function doWrite(j) {
var currentPath = openAs.name; var currentPath = openAs.name;
if (!currentPath) return;
if (window.platformBridge && window.fs == null) {
if (typeof window.soonRecentUpsert === 'function') {
window.soonRecentUpsert({
key: currentPath,
name: typeof window.soonDisplayFileName === 'function' ? window.soonDisplayFileName(currentPath) : currentPath,
type: soonPageDesignType(),
kind: typeof window.soonRecentKindFromKey === 'function' ? window.soonRecentKindFromKey(currentPath) : 'local',
thumbRef: currentPath
});
}
return;
}
function doWrite(j) {
if (!currentPath) return; if (!currentPath) return;
var pathForHistory = currentPath; var pathForHistory = currentPath;
if (window.platformBridge && currentPath.indexOf('soondesign_session:') !== 0 && currentPath.indexOf('soondesign_file:') !== 0) { if (window.platformBridge && currentPath.indexOf('soondesign_session:') !== 0 && currentPath.indexOf('soondesign_file:') !== 0) {
+10 -8
View File
@@ -1345,7 +1345,7 @@ $("#open").click(function () {
OpenDialog(); OpenDialog();
return; return;
} }
layer.confirm('<font style="color:black">' + language_str("whetherSave") + '</font>', function (index) {//confirm 是否保存当前文件? layer.confirm(language_str("whetherSave"), function (index) {//confirm 是否保存当前文件?
if (typeof window.output === 'function') { if (typeof window.output === 'function') {
window.output(OpenDialog); window.output(OpenDialog);
} }
@@ -1355,7 +1355,6 @@ $("#open").click(function () {
OpenDialog(); OpenDialog();
}); });
function OpenDialog() { function OpenDialog() {
if (typeof window.soonRequireLogin === 'function' && !window.soonRequireLogin('打开文件')) return;
var dialogApi = (typeof dialog !== 'undefined' && dialog) ? dialog : (window.platformBridge && window.platformBridge.showOpenDialog ? { showOpenDialog: function(opts) { return window.platformBridge.showOpenDialog(opts); } } : null); var dialogApi = (typeof dialog !== 'undefined' && dialog) ? dialog : (window.platformBridge && window.platformBridge.showOpenDialog ? { showOpenDialog: function(opts) { return window.platformBridge.showOpenDialog(opts); } } : null);
if (!dialogApi) return; if (!dialogApi) return;
dialogApi.showOpenDialog({ dialogApi.showOpenDialog({
@@ -1376,10 +1375,14 @@ $("#open").click(function () {
} }
} }
function importAndOpen(j, fileName) { function importAndOpen(j, fileName) {
if (!window.platformBridge || !window.platformBridge.importSoonFile) return; if (typeof window.soonPutSoonSession === 'function') {
window.platformBridge.importSoonFile(fileName || 'design.soon', j).then(function(res) { var localKey = window.soonPutSoonSession(j, fileName || 'design.soon');
if (res && res.fileKey) openWithKey(res.fileKey, j); if (!localKey) return;
}).catch(function () {}); if (typeof window.soonScheduleCloudImport === 'function') {
window.soonScheduleCloudImport(localKey, fileName || 'design.soon');
}
openWithKey(localKey, j);
}
} }
if (file && file.text) { if (file && file.text) {
file.text().then(function(t) { file.text().then(function(t) {
@@ -1438,7 +1441,7 @@ $("#new").click(function () {
else if (window.platformBridge) window.platformBridge.openFirstPage(); else if (window.platformBridge) window.platformBridge.openFirstPage();
return; return;
} }
layer.confirm('<font style="color:black">' + language_str("whetherSave") + '</font>', function (index) {//confirm 是否保存当前文件? layer.confirm(language_str("whetherSave"), function (index) {//confirm 是否保存当前文件?
if (typeof window.output === 'function') { if (typeof window.output === 'function') {
window.output(function() { if (ipcRenderer) ipcRenderer.send('open-first-page'); else if (window.platformBridge) window.platformBridge.openFirstPage(); }); window.output(function() { if (ipcRenderer) ipcRenderer.send('open-first-page'); else if (window.platformBridge) window.platformBridge.openFirstPage(); });
} }
@@ -2365,7 +2368,6 @@ $("#help").click(function() {
// =========================================================== // ===========================================================
$('#display').on('click', function () { $('#display').on('click', function () {
if (typeof window.soonGuardMemberPreview === 'function' && !window.soonGuardMemberPreview()) return;
// 类型改变 // 类型改变
fabric.Image.fromURL(soonAsset('front_bg') + bg_version + '_1.png', function (i1) { fabric.Image.fromURL(soonAsset('front_bg') + bg_version + '_1.png', function (i1) {
i1.left = background_image.left; i1.left = background_image.left;
+212 -79
View File
@@ -2,15 +2,55 @@
// 注意:此代码在layui.use回调函数内部执行 // 注意:此代码在layui.use回调函数内部执行
// 全局变量已在主入口文件中定义,这里只需要初始化 // 全局变量已在主入口文件中定义,这里只需要初始化
$('#canvas1').attr('height', document.body.clientHeight - 100) function soonDesign2MainHeight() {
$('#canvas2').attr('height', document.body.clientHeight - 100) var main = document.querySelector('.layui-fluid.main');
$('#object_attribute').css('height', '470px') if (main && main.clientHeight > 40) return main.clientHeight;
$('#obj_list').css('height', document.body.clientHeight - 520) var tb = document.getElementById('portal-topbar');
$('.obj_list').css('height', $('#obj_list').height() - 32) return Math.max(480, window.innerHeight - (tb ? tb.offsetHeight : 0));
}
$('.main').show() function soonDesign2SidebarWidth() {
$('#canvas1').attr('width', $('#canvas-div').width()) var colRight = document.querySelector('body.soon-design-page .col-right');
$('#canvas2').attr('width', $('#canvas-div').width()) if (colRight && colRight.offsetWidth > 0) return colRight.offsetWidth;
return 320;
}
function soonDesign2ToolboxWidth() {
var toolbox = document.querySelector('body.soon-design-page .container .left');
if (toolbox && toolbox.offsetWidth > 0) return toolbox.offsetWidth;
return 50;
}
function soonDesign2CanvasWidth() {
var canvasDiv = document.getElementById('canvas-div');
if (canvasDiv && canvasDiv.clientWidth > 0) return canvasDiv.clientWidth;
var colLeft = document.querySelector('body.soon-design-page .col-left');
if (colLeft && colLeft.clientWidth > 0) {
return Math.max(320, colLeft.clientWidth - soonDesign2ToolboxWidth());
}
var docW = document.documentElement.clientWidth || window.innerWidth;
return Math.max(320, docW - soonDesign2SidebarWidth() - soonDesign2ToolboxWidth());
}
function layoutDesign2Shell() {
var isWeb = !!(window.SOON_DEPLOY_CONFIG);
var mainH = soonDesign2MainHeight();
var canvasW = soonDesign2CanvasWidth();
var canvasH = isWeb ? Math.max(400, mainH - 130) : (document.body.clientHeight - 100);
$('#canvas1').attr('width', canvasW).attr('height', canvasH);
$('#canvas2').attr('width', canvasW).attr('height', canvasH);
if (!isWeb) {
$('#object_attribute').css('height', '470px');
$('#obj_list').css('height', Math.max(120, mainH - 520));
$('.obj_list').css('height', Math.max(80, $('#obj_list').height() - 32));
}
return { canvasW: canvasW, canvasH: canvasH };
}
var mainEl = document.querySelector('.layui-fluid.main');
if (mainEl) mainEl.style.display = 'flex';
else $('.main').show();
var _design2Layout = layoutDesign2Shell();
// 初始化Canvas对象 // 初始化Canvas对象
// 优化性能:在创建 Fabric Canvas 之前,先设置 canvas context 的 willReadFrequently // 优化性能:在创建 Fabric Canvas 之前,先设置 canvas context 的 willReadFrequently
@@ -42,11 +82,20 @@ is_bgi_add = false
window.ctx1 = ctx1; window.ctx1 = ctx1;
window.ctx2 = ctx2; window.ctx2 = ctx2;
window.is_bgi_add = is_bgi_add; window.is_bgi_add = is_bgi_add;
canvas1.zoomToPoint(new fabric.Point(canvas1.width / 2, canvas1.height / 2), $('#canvas-div').width() / 1200 > 1 ? 1 : $('#canvas-div').width() / 1200) canvas1.zoomToPoint(new fabric.Point(canvas1.width / 2, canvas1.height / 2), (function () {
//console.log(canvas1.width / 2, canvas1.height / 2); var divW = $('#canvas-div').width() || _design2Layout.canvasW || 800;
//console.log($("#canvas-div").width() / 1200 > 1 ? 1 : $("#canvas-div").width() / 1200); return divW / 1200 > 1 ? 1 : divW / 1200;
canvas2.zoomToPoint(new fabric.Point(canvas2.width / 2, canvas2.height / 2), $('#canvas-div').width() / 1200 > 1 ? 1 : $('#canvas-div').width() / 1200) })())
zoom = $('#canvas-div').width() / 1200 > 1 ? 1 : $('#canvas-div').width() / 1200 canvas2.zoomToPoint(new fabric.Point(canvas2.width / 2, canvas2.height / 2), (function () {
var divW = $('#canvas-div').width() || _design2Layout.canvasW || 800;
return divW / 1200 > 1 ? 1 : divW / 1200;
})())
zoom = (function () {
var divW = $('#canvas-div').width() || _design2Layout.canvasW || 800;
var z = divW / 1200 > 1 ? 1 : divW / 1200;
return z > 0 ? z : 0.5;
})()
window.zoom = zoom;
// 初始化标尺 // 初始化标尺
function initRulers() { function initRulers() {
@@ -485,9 +534,95 @@ function initRulers() {
} }
} }
function relayoutDesign2Canvas() {
var layout = layoutDesign2Shell();
var divW = layout.canvasW;
zoom = divW / 1200 > 1 ? 1 : divW / 1200;
if (zoom <= 0) zoom = 0.5;
window.zoom = zoom;
canvas1.setWidth(layout.canvasW);
canvas1.setHeight(layout.canvasH);
canvas2.setWidth(layout.canvasW);
canvas2.setHeight(layout.canvasH);
canvas1.zoomToPoint(new fabric.Point(canvas1.width / 2, canvas1.height / 2), zoom);
canvas2.zoomToPoint(new fabric.Point(canvas2.width / 2, canvas2.height / 2), zoom);
canvas1.renderAll();
canvas2.renderAll();
initRulers();
if (typeof window.soonDesign2ScaleBgSources === 'function') {
window.soonDesign2ScaleBgSources();
}
}
function soonDesign2ScaleBgSources() {
var z = zoom || window.zoom || 0.5;
var sf = document.getElementById('source_front');
var sb = document.getElementById('source_back');
if (sf && sf.naturalWidth > 0) {
sf.style.width = (sf.naturalWidth * z) + 'px';
sf.style.height = (sf.naturalHeight * z) + 'px';
}
if (sb && sb.naturalWidth > 0) {
sb.style.width = (sb.naturalWidth * z) + 'px';
sb.style.height = (sb.naturalHeight * z) + 'px';
}
}
function soonDesign2PrepareBgSources(done) {
var sf = document.getElementById('source_front');
var sb = document.getElementById('source_back');
if (!sf || !sb) {
is_bgi_add = true;
window.is_bgi_add = true;
if (typeof done === 'function') done();
return;
}
var ver = typeof bg_version !== 'undefined' ? bg_version : 1;
var frontName = 'front_bg' + ver + '_2.png';
if (!(sf.complete && sf.naturalWidth > 0 && (sf.src || '').indexOf(frontName) !== -1)) {
sf.src = soonAsset(frontName);
}
var pending = 2;
function finishOne() {
pending--;
if (pending > 0) return;
soonDesign2ScaleBgSources();
is_bgi_add = true;
window.is_bgi_add = true;
canvas1.requestRenderAll();
canvas2.requestRenderAll();
if (typeof done === 'function') done();
}
function watch(img) {
if (img.complete && img.naturalWidth > 0) {
finishOne();
return;
}
img.onload = finishOne;
img.onerror = finishOne;
}
watch(sf);
watch(sb);
}
function soonDesign2StartBackground() {
soonDesign2PrepareBgSources(addBackground);
}
function soonDesign2Kickoff() {
if (window._soonDesign2KickoffDone) return;
window._soonDesign2KickoffDone = true;
if (window.SOON_DEPLOY_CONFIG && ($('#canvas-div').width() || 0) < 100) {
relayoutDesign2Canvas();
}
soonDesign2StartBackground();
}
window.soonDesign2ScaleBgSources = soonDesign2ScaleBgSources;
window.soonDesign2PrepareBgSources = soonDesign2PrepareBgSources;
window.soonDesign2Kickoff = soonDesign2Kickoff;
initRulers(); initRulers();
$('#source_front').width(zoom * $('#source_front').width())
$('#source_back').width(zoom * $('#source_back').width())
$('.canvas-container:eq(1)').hide() $('.canvas-container:eq(1)').hide()
canvas = canvas1 canvas = canvas1
window.canvas = canvas window.canvas = canvas
@@ -1590,72 +1725,71 @@ function getCoordsMaxY(acoords) {
return y; return y;
} }
function addBackground() { function soonPlaceBgOnCanvas(image, canvasSelector) {
// 类型改变 var cw = $(canvasSelector).width() || (document.querySelector(canvasSelector) && document.querySelector(canvasSelector).clientWidth) || 800
fabric.Image.fromURL(soonAsset('bg_front_2.png'), function (image) { var ch = $(canvasSelector).height() || (document.querySelector(canvasSelector) && document.querySelector(canvasSelector).clientHeight) || 600
image.left = ($('#canvas1').width() - image.width * image.get('scaleX')) / 2 image.left = (cw - image.width * image.get('scaleX')) / 2
image.top = ($('#canvas1').height() - image.height * image.get('scaleY')) / 2 image.top = (ch - image.height * image.get('scaleY')) / 2
image.selectable = false image.selectable = false
image.hoverable = false image.hoverable = false
image.hoverCursor = 'default' image.hoverCursor = 'default'
image.setSrc(image.toDataURL(), function (image) {
background_image1 = image
background_image = background_image1
window.background_image1 = background_image1;
window.background_image = background_image;
canvas1.add(image)
canvas1.renderAll()
recordObjs1.push(JSON.stringify(objs1))
const props = window.TO_JSON_PROPERTIES || ['selectable', 'hoverable', 'hoverCursor', 'text', 'fontStyle', 'fontWeight', 'underline', 'evented', 'linethrough', 'textBackgroundColor', 'diameter', 'flipped'];
recordJson1.push(canvas1.toJSON(props))
})
// 类型改变
fabric.Image.fromURL(soonAsset('bg_back_2.png'), function (image) {
image.left = ($('#canvas2').width() - image.width) / 2
image.top = ($('#canvas2').height() - image.height) / 2
image.selectable = false
image.hoverable = false
image.hoverCursor = 'default'
image.setSrc(image.toDataURL(), function (image) {
background_image2 = image
window.background_image2 = background_image2;
canvas2.add(image)
canvas2.renderAll()
recordObjs2.push(JSON.stringify(objs2))
recordJson2.push(canvas2.toJSON(TO_JSON_PROPERTIES))
let file = GetFile().get('file');
if (!file && typeof sessionStorage !== 'undefined') {
try {
file = sessionStorage.getItem('soondesign_open_file');
if (file) {
sessionStorage.removeItem('soondesign_open_file');
sessionStorage.removeItem('soondesign_open_type');
}
} catch (e) {}
} }
function soonOpenPendingDesignFile() {
var file = typeof window.soonResolveOpenFileKey === 'function'
? window.soonResolveOpenFileKey(GetFile)
: (GetFile().get('file') || '');
if (file && file != 'empty') { if (file && file != 'empty') {
if (file.indexOf('soondesign_file:') === 0 && typeof sessionStorage !== 'undefined') { if (typeof window.soonConsumeOpenMeta === 'function') {
try { window.soonConsumeOpenMeta(file);
var metaRaw = sessionStorage.getItem('soondesign_open_meta');
if (metaRaw) {
window._soonFileMeta = JSON.parse(metaRaw);
sessionStorage.removeItem('soondesign_open_meta');
} }
} catch (e) {} window.openFile(file);
} }
var p = window.openFile(file);
if (p && p.then) p.then(function() {}, function() {});
} }
})
}) function addBackground() {
is_bgi_add = true if (background_image1 && background_image2) return;
}) var loadBg = typeof window.soonFabricImageFromAsset === 'function'
? window.soonFabricImageFromAsset
: null;
var whenReady = typeof window.soonRunWhenCanvasReady === 'function'
? window.soonRunWhenCanvasReady
: function (_sel, fn) { fn(); };
var props = window.TO_JSON_PROPERTIES || ['selectable', 'hoverable', 'hoverCursor', 'text', 'fontStyle', 'fontWeight', 'underline', 'evented', 'linethrough', 'textBackgroundColor', 'diameter', 'flipped'];
function finishBack(backImg) {
if (backImg) {
soonPlaceBgOnCanvas(backImg, '#canvas2');
background_image2 = backImg;
window.background_image2 = backImg;
canvas2.add(backImg);
canvas2.renderAll();
recordObjs2.push(JSON.stringify(objs2));
recordJson2.push(canvas2.toJSON(TO_JSON_PROPERTIES));
}
soonOpenPendingDesignFile();
}
function startLoad() {
if (!loadBg) return;
loadBg('bg_front_2.png', function (frontImg) {
if (!frontImg) return;
soonPlaceBgOnCanvas(frontImg, '#canvas1');
background_image1 = frontImg;
background_image = frontImg;
window.background_image1 = frontImg;
window.background_image = frontImg;
canvas1.add(frontImg);
canvas1.renderAll();
recordObjs1.push(JSON.stringify(objs1));
recordJson1.push(canvas1.toJSON(props));
loadBg('bg_back_2.png', finishBack, function () { loadBg('bg_back_2.png', finishBack, null, true); }, true);
}, function () {
setTimeout(startLoad, 200);
}, null, true);
}
whenReady('#canvas1', startLoad);
} }
function addPic(pointer) { function addPic(pointer) {
@@ -1888,10 +2022,10 @@ function applyFontList(data) {
$('#count_font_family').append("<option value='" + item + "'>" + item + '</option>') $('#count_font_family').append("<option value='" + item + "'>" + item + '</option>')
} }
} }
if (typeof ipcRenderer !== 'undefined' && ipcRenderer) { if (typeof window !== 'undefined' && window.platformBridge && window.platformBridge.getSystemFonts) {
ipcRenderer.on('font-list', function(event, data) { applyFontList(data); })
} else if (typeof window !== 'undefined' && window.platformBridge && window.platformBridge.getSystemFonts) {
window.platformBridge.getSystemFonts().then(applyFontList) window.platformBridge.getSystemFonts().then(applyFontList)
} else if (typeof ipcRenderer !== 'undefined' && ipcRenderer) {
ipcRenderer.on('font-list', function(event, data) { applyFontList(data); })
} }
function applySysLan(data) { function applySysLan(data) {
let lang = localStorage.getItem('lang') let lang = localStorage.getItem('lang')
@@ -1936,6 +2070,7 @@ function applySysLan(data) {
localStorage.setItem('lang', 'zh') localStorage.setItem('lang', 'zh')
break break
case 'zh-TW': case 'zh-TW':
case 'ozh':
s_lan = 'ozh' s_lan = 'ozh'
// 同步到全局作用域(用于 .jsc 文件加载) // 同步到全局作用域(用于 .jsc 文件加载)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -2048,7 +2183,7 @@ if (typeof ipcRenderer !== 'undefined' && ipcRenderer) {
} }
function onCloseConfirm() { function onCloseConfirm() {
layer.confirm( layer.confirm(
'<font style="color:black">' + language_str('whetherSave') + '</font>', language_str('whetherSave'),
{ {
btn: [language_str('save'), language_str('noSave'), language_str('cancel')], btn: [language_str('save'), language_str('noSave'), language_str('cancel')],
btn3: function (index, layero) { } btn3: function (index, layero) { }
@@ -2073,8 +2208,6 @@ if (typeof ipcRenderer !== 'undefined' && ipcRenderer) {
window.platformBridge.onClose(onCloseConfirm) window.platformBridge.onClose(onCloseConfirm)
} }
addBackground() //添加背景 白色卡片
initAligningGuidelines(canvas1) initAligningGuidelines(canvas1)
initAligningGuidelines(canvas2) initAligningGuidelines(canvas2)
+127 -44
View File
@@ -1,6 +1,4 @@
// 将 display_func 附加到 window 对象,确保全局可访问
window.display_func = function display_func(img1, img2, img3) { window.display_func = function display_func(img1, img2, img3) {
if (typeof window.soonGuardMemberPreview === 'function' && !window.soonGuardMemberPreview()) return;
$('#base_control').hide() $('#base_control').hide()
$('#line_control').hide() $('#line_control').hide()
$('#pic_control').hide() $('#pic_control').hide()
@@ -335,6 +333,7 @@ window.display_func = function display_func(img1, img2, img3) {
layer.open({ layer.open({
type: 1, type: 1,
skin: 'soon-layer',
area: [w, h], area: [w, h],
title: language_str('display'), //不显示标题栏"预览" title: language_str('display'), //不显示标题栏"预览"
shadeClose: true, //点击遮罩关闭 shadeClose: true, //点击遮罩关闭
@@ -356,12 +355,19 @@ window.display_func = function display_func(img1, img2, img3) {
</div> </div>
</div>`, </div>`,
btn: btns, //'导出' btn: btns, //'导出'
btn1: function (index, layero) { btn1: function () {
if (typeof window.savePdf === 'function') { if (typeof window.soonGuardPreviewDeliver === 'function') {
window.soonGuardPreviewDeliver('导出', function () {
if (typeof window.savePdf === 'function') window.savePdf(pdfBlob);
});
} else if (typeof window.savePdf === 'function') {
window.savePdf(pdfBlob); window.savePdf(pdfBlob);
} }
return false;
}, },
btn2: function () { btn2: function () {
if (typeof window.soonGuardPreviewDeliver === 'function') {
window.soonGuardPreviewDeliver('打印', function () {
if (window.platformBridge && window.platformBridge.printPdf && (printPath3.indexOf('data:') === 0 || printPath4.indexOf('data:') === 0)) { if (window.platformBridge && window.platformBridge.printPdf && (printPath3.indexOf('data:') === 0 || printPath4.indexOf('data:') === 0)) {
if (btns[1] === '打印正面') window.platformBridge.printPdf(pdfBlob); if (btns[1] === '打印正面') window.platformBridge.printPdf(pdfBlob);
else if (btns[1] === '打印背面') window.platformBridge.printPdf(pdfBlob); else if (btns[1] === '打印背面') window.platformBridge.printPdf(pdfBlob);
@@ -372,13 +378,32 @@ window.display_func = function display_func(img1, img2, img3) {
} else if (btns[1] === '打印背面') { } else if (btns[1] === '打印背面') {
printJS({ printable: printPath4, type: 'image', style: 'img { width: 100%; height: auto; }' }); printJS({ printable: printPath4, type: 'image', style: 'img { width: 100%; height: auto; }' });
} }
});
} else if (window.platformBridge && window.platformBridge.printPdf && (printPath3.indexOf('data:') === 0 || printPath4.indexOf('data:') === 0)) {
if (btns[1] === '打印正面') window.platformBridge.printPdf(pdfBlob);
else if (btns[1] === '打印背面') window.platformBridge.printPdf(pdfBlob);
} else if (btns[1] === '打印正面') {
printJS({ printable: printPath3, type: 'image', style: 'img { width: 100%; height: auto; }' });
} else if (btns[1] === '打印背面') {
printJS({ printable: printPath4, type: 'image', style: 'img { width: 100%; height: auto; }' });
}
return false;
}, },
btn3: function () { btn3: function () {
if (typeof window.soonGuardPreviewDeliver === 'function') {
window.soonGuardPreviewDeliver('打印', function () {
if (window.platformBridge && window.platformBridge.printPdf) { if (window.platformBridge && window.platformBridge.printPdf) {
window.platformBridge.printPdf(pdfBlob); window.platformBridge.printPdf(pdfBlob);
return; return;
} }
printJS({ printable: printPath4, type: 'image', style: 'img { width: 100%; height: auto; }' }); printJS({ printable: printPath4, type: 'image', style: 'img { width: 100%; height: auto; }' });
});
} else if (window.platformBridge && window.platformBridge.printPdf) {
window.platformBridge.printPdf(pdfBlob);
} else {
printJS({ printable: printPath4, type: 'image', style: 'img { width: 100%; height: auto; }' });
}
return false;
}, },
end: function () { end: function () {
// 预览窗口关闭后,恢复所有对象的 selectable 和 evented 状态 // 预览窗口关闭后,恢复所有对象的 selectable 和 evented 状态
@@ -406,7 +431,6 @@ window.display_func = function display_func(img1, img2, img3) {
// 将 output 附加到 window 对象,确保全局可访问 // 将 output 附加到 window 对象,确保全局可访问
window.output = function output(callback = null, _save = save) { window.output = function output(callback = null, _save = save) {
if (typeof window.soonGuardMemberExport === 'function' && !window.soonGuardMemberExport()) return;
// 类型改变 // 类型改变
fabric.Image.fromURL(soonAsset('op_2.png'), function (i1) { fabric.Image.fromURL(soonAsset('op_2.png'), function (i1) {
i1.left = background_image.left i1.left = background_image.left
@@ -1062,9 +1086,31 @@ function cloudSaveSuccessLabel(res, fallback) {
return fallback || ''; return fallback || '';
} }
function soonPageDesignType() {
try {
var p = new URLSearchParams(location.search);
return Number(p.get('type')) || 2;
} catch (e) {
return 2;
}
}
function onCloudWriteDone(res, fallback, callback) { function onCloudWriteDone(res, fallback, callback) {
if (typeof window.soonFinishSaveUi === 'function') window.soonFinishSaveUi();
else if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask();
applyCloudWriteResult(res, fallback); applyCloudWriteResult(res, fallback);
layer.msg(language_str('saveSucc') + cloudSaveSuccessLabel(res, fallback)); if (res && res.fileKey && typeof window.soonClearPendingImport === 'function') {
window.soonClearPendingImport();
}
if (typeof window.soonRecentOnCloudSave === 'function') {
window.soonRecentOnCloudSave(res, fallback, soonPageDesignType());
}
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(); if (callback && typeof callback === 'function') callback();
} }
@@ -1106,33 +1152,37 @@ function saveAs(op1, callback) {
let o = { f: j, b: b, fo: objs1, bo: objs2 } let o = { f: j, b: b, fo: objs1, bo: objs2 }
let con_o = Object.assign(o, op1) let con_o = Object.assign(o, op1)
if (!guardWebSave()) return; 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); var dialogApi = (typeof dialog !== 'undefined' && dialog) ? dialog : (window.platformBridge && window.platformBridge.showSaveDialog ? { showSaveDialog: function(opts) { return window.platformBridge.showSaveDialog(opts); } } : null);
if (!dialogApi) return; if (!dialogApi) return;
dialogApi.showSaveDialog({ title: language_str('saveFile'), filters: [{ name: 'Soon File Type', extensions: ['soon'] }], defaultPath: openAs.name || undefined }) dialogApi.showSaveDialog({ title: language_str('saveFile'), filters: [{ name: 'Soon File Type', extensions: ['soon'] }], defaultPath: typeof window.soonSaveDialogDefaultPath === 'function' ? window.soonSaveDialogDefaultPath(openAs.name) : openAs.name })
.then(function(result) { .then(function(result) {
if (result.canceled) return; if (result.canceled) return;
var fp = result.filePath || (result.fileHandle && result.fileHandle.name); var fp = typeof window.soonEnsureSaveFileName === 'function'
? window.soonEnsureSaveFileName(result.filePath || (result.fileHandle && result.fileHandle.name))
: (result.filePath || (result.fileHandle && result.fileHandle.name));
if (!fp) return; if (!fp) return;
var ext = (typeof path !== 'undefined' && path && path.extname) ? path.extname(fp) : (fp.indexOf('.') >= 0 ? fp.slice(fp.lastIndexOf('.')) : '');
if (ext !== '.soon') fp = (fp || 'design').replace(/\.soon$/i, '') + '.soon';
con_o.soonType = 2;
var content = JSON.stringify(con_o); var content = JSON.stringify(con_o);
if (window.platformBridge && window.platformBridge.writeFile) { if (typeof window.soonWriteSoonContentWeb === 'function' &&
window.platformBridge.writeFile(fp, content).then(function (res) { window.soonWriteSoonContentWeb(fp, content, prevOpenKey, function (res, pk) {
onCloudWriteDone(res, fp, callback); onCloudWriteDone(res, pk, callback);
}).catch(function () {}); })) return;
return;
}
var fs = typeof require !== 'undefined' ? require('fs') : null; var fs = typeof require !== 'undefined' ? require('fs') : null;
if (fs) { if (fs) {
fs.writeFileSync(fp, content, 'utf8'); fs.writeFileSync(fp, content, 'utf8');
openAs.name = fp; 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(); saveHistory();
if (callback && typeof callback === 'function') callback(); if (callback && typeof callback === 'function') callback();
} }
}) })
.catch(function(err) {}); .catch(function (err) {
if (typeof window.soonReportSaveError === 'function') window.soonReportSaveError(err);
});
} }
function save(op1, callback) { function save(op1, callback) {
@@ -1168,20 +1218,25 @@ function save(op1, callback) {
let o = { f: j, b: b, fo: objs1, bo: objs2 } let o = { f: j, b: b, fo: objs1, bo: objs2 }
let con_o = Object.assign(o, op1) let con_o = Object.assign(o, op1)
con_o.soonType = 2
var content = JSON.stringify(con_o)
if (!guardWebSave()) return; if (!guardWebSave()) return;
if (openAs.name != '') { var prevOpenKey = openAs.name || '';
if (window.platformBridge && window.platformBridge.writeFile) { if (typeof window.soonApplySavePreview === 'function') window.soonApplySavePreview(con_o, canvas1);
window.platformBridge.writeFile(openAs.name, content).then(function (res) { if (typeof window.soonRefreshDesignCanvases === 'function') window.soonRefreshDesignCanvases();
onCloudWriteDone(res, openAs.name, callback); con_o.soonType = 2;
}).catch(function () {}); var content = JSON.stringify(con_o);
return; var needsDialog = typeof window.soonNeedsSaveDialog === 'function'
} ? window.soonNeedsSaveDialog(openAs.name)
: !openAs.name;
if (openAs.name && !needsDialog) {
if (typeof window.soonWriteSoonContentWeb === 'function' &&
window.soonWriteSoonContentWeb(openAs.name, content, prevOpenKey, function (res, pk) {
onCloudWriteDone(res, pk, callback);
})) return;
var fs = typeof require !== 'undefined' ? require('fs') : null; var fs = typeof require !== 'undefined' ? require('fs') : null;
if (fs) { if (fs) {
fs.writeFileSync(openAs.name, content, 'utf8'); 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(); saveHistory();
if (callback && typeof callback === 'function') callback(); if (callback && typeof callback === 'function') callback();
} }
@@ -1189,35 +1244,48 @@ function save(op1, callback) {
} }
var dialogApi = (typeof dialog !== 'undefined' && dialog) ? dialog : (window.platformBridge && window.platformBridge.showSaveDialog ? { showSaveDialog: function(opts) { return window.platformBridge.showSaveDialog(opts); } } : null); var dialogApi = (typeof dialog !== 'undefined' && dialog) ? dialog : (window.platformBridge && window.platformBridge.showSaveDialog ? { showSaveDialog: function(opts) { return window.platformBridge.showSaveDialog(opts); } } : null);
if (!dialogApi) return; if (!dialogApi) return;
dialogApi.showSaveDialog({ title: language_str('saveFile'), filters: [{ name: 'Soon File Type', extensions: ['soon'] }], defaultPath: openAs.name || undefined }) dialogApi.showSaveDialog({ title: language_str('saveFile'), filters: [{ name: 'Soon File Type', extensions: ['soon'] }], defaultPath: typeof window.soonSaveDialogDefaultPath === 'function' ? window.soonSaveDialogDefaultPath(openAs.name) : openAs.name })
.then(function(result) { .then(function(result) {
if (result.canceled) return; if (result.canceled) return;
var fp = result.filePath || (result.fileHandle && result.fileHandle.name); var fp = typeof window.soonEnsureSaveFileName === 'function'
? window.soonEnsureSaveFileName(result.filePath || (result.fileHandle && result.fileHandle.name))
: (result.filePath || (result.fileHandle && result.fileHandle.name));
if (!fp) return; if (!fp) return;
var ext = (typeof path !== 'undefined' && path && path.extname) ? path.extname(fp) : (fp.indexOf('.') >= 0 ? fp.slice(fp.lastIndexOf('.')) : ''); if (typeof window.soonWriteSoonContentWeb === 'function' &&
if (ext !== '.soon') fp = (fp || 'design').replace(/\.soon$/i, '') + '.soon'; window.soonWriteSoonContentWeb(fp, content, prevOpenKey, function (res, pk) {
if (window.platformBridge && window.platformBridge.writeFile) { onCloudWriteDone(res, pk, callback);
window.platformBridge.writeFile(fp, content).then(function (res) { })) return;
onCloudWriteDone(res, fp, callback);
}).catch(function () {});
return;
}
var fs = typeof require !== 'undefined' ? require('fs') : null; var fs = typeof require !== 'undefined' ? require('fs') : null;
if (fs) { if (fs) {
fs.writeFileSync(fp, content, 'utf8'); fs.writeFileSync(fp, content, 'utf8');
openAs.name = fp; 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(); saveHistory();
if (callback && typeof callback === 'function') callback(); if (callback && typeof callback === 'function') callback();
} }
}) })
.catch(function(err) {}); .catch(function (err) {
if (typeof window.soonReportSaveError === 'function') window.soonReportSaveError(err);
});
} }
window.saveHistory = function saveHistory() { window.saveHistory = function saveHistory() {
if (typeof window.soonIsWebPortal === 'function' && window.soonIsWebPortal()) return;
function doWrite(j) {
var currentPath = openAs.name; var currentPath = openAs.name;
if (!currentPath) return;
if (window.platformBridge && window.fs == null) {
if (typeof window.soonRecentUpsert === 'function') {
window.soonRecentUpsert({
key: currentPath,
name: typeof window.soonDisplayFileName === 'function' ? window.soonDisplayFileName(currentPath) : currentPath,
type: soonPageDesignType(),
kind: typeof window.soonRecentKindFromKey === 'function' ? window.soonRecentKindFromKey(currentPath) : 'local',
thumbRef: currentPath
});
}
return;
}
function doWrite(j) {
if (!currentPath) return; if (!currentPath) return;
var pathForHistory = currentPath; var pathForHistory = currentPath;
if (window.platformBridge && currentPath.indexOf('soondesign_session:') !== 0 && currentPath.indexOf('soondesign_file:') !== 0) { if (window.platformBridge && currentPath.indexOf('soondesign_session:') !== 0 && currentPath.indexOf('soondesign_file:') !== 0) {
@@ -1265,7 +1333,10 @@ window.openFile = function open(file, jAlready) {
if (!file) return; if (!file) return;
if (typeof window.soonBindFileMeta === 'function') window.soonBindFileMeta(file); if (typeof window.soonBindFileMeta === 'function') window.soonBindFileMeta(file);
function doOpenWithJson(j) { function doOpenWithJson(j) {
if (!j || !background_image) return; if (!j || !background_image) {
if (typeof window.soonReportOpenLoadError === 'function') window.soonReportOpenLoadError('模板/文件加载失败');
return;
}
openAs.name = file; openAs.name = file;
var left = background_image.left var left = background_image.left
let top = background_image.top let top = background_image.top
@@ -1416,6 +1487,8 @@ window.openFile = function open(file, jAlready) {
recordJson = recordJson2 recordJson = recordJson2
step = step2 step = step2
} }
if (typeof window.soonTryConsumeCloudImport === 'function') window.soonTryConsumeCloudImport(file);
if (typeof window.soonRecentUpsertFromOpen === 'function') window.soonRecentUpsertFromOpen(file, j);
} }
canvas1.loadFromJSON(j.f, function () { canvas1.loadFromJSON(j.f, function () {
@@ -1456,11 +1529,21 @@ window.openFile = function open(file, jAlready) {
return; return;
} }
if (window.platformBridge && window.platformBridge.readJsonFile) { if (window.platformBridge && window.platformBridge.readJsonFile) {
if (typeof window.soonShowOpenFileLoading === 'function') window.soonShowOpenFileLoading();
return window.platformBridge.readJsonFile(file).then(function(j) { return window.platformBridge.readJsonFile(file).then(function(j) {
if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading();
if (j) { if (j) {
doOpenWithJson(j); doOpenWithJson(j);
openAs.name = file; openAs.name = file;
} else if (typeof window.soonReportOpenLoadError === 'function') {
window.soonReportOpenLoadError('模板/文件加载失败');
} }
}).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 { try {
+25 -76
View File
@@ -359,9 +359,15 @@ $('#version_change').click(function () {
} else { } else {
bg_version = 1 bg_version = 1
} }
// 类型改变 var sf = document.getElementById('source_front')
$('#source_front').attr('src', soonAsset('front_bg') + bg_version + '_2.png') if (!sf) return
canvas.renderAll() sf.onload = function () {
if (typeof window.soonDesign2ScaleBgSources === 'function') {
window.soonDesign2ScaleBgSources()
}
}
sf.onerror = sf.onload
sf.src = soonAsset('front_bg' + bg_version + '_2.png')
}) })
$('#front_side').click(function () { $('#front_side').click(function () {
$(this).addClass('ui-button-active') $(this).addClass('ui-button-active')
@@ -1299,7 +1305,7 @@ $('#open').click(function () {
return; return;
} }
layer.confirm( layer.confirm(
'<font style="color:black">' + language_str('whetherSave') + '</font>', language_str('whetherSave'),
function (index) { function (index) {
if (typeof window.output === 'function') { if (typeof window.output === 'function') {
window.output(OpenDialog); window.output(OpenDialog);
@@ -1311,7 +1317,6 @@ $('#open').click(function () {
} }
) )
function OpenDialog() { function OpenDialog() {
if (typeof window.soonRequireLogin === 'function' && !window.soonRequireLogin('打开文件')) return;
var dialogApi = (typeof dialog !== 'undefined' && dialog) ? dialog : (window.platformBridge && window.platformBridge.showOpenDialog ? { showOpenDialog: function(opts) { return window.platformBridge.showOpenDialog(opts); } } : null); var dialogApi = (typeof dialog !== 'undefined' && dialog) ? dialog : (window.platformBridge && window.platformBridge.showOpenDialog ? { showOpenDialog: function(opts) { return window.platformBridge.showOpenDialog(opts); } } : null);
if (!dialogApi) return; if (!dialogApi) return;
dialogApi.showOpenDialog({ title: '请选择文件', buttonLabel: language_str('comf'), filters: [{ name: 'Soon File Type', extensions: ['soon'] }] }) dialogApi.showOpenDialog({ title: '请选择文件', buttonLabel: language_str('comf'), filters: [{ name: 'Soon File Type', extensions: ['soon'] }] })
@@ -1327,10 +1332,14 @@ $('#open').click(function () {
if (typeof window.openFile === 'function') window.openFile(fileKey, j); if (typeof window.openFile === 'function') window.openFile(fileKey, j);
} }
function importAndOpen(j, fileName) { function importAndOpen(j, fileName) {
if (!window.platformBridge || !window.platformBridge.importSoonFile) return; if (typeof window.soonPutSoonSession === 'function') {
window.platformBridge.importSoonFile(fileName || 'design.soon', j).then(function(res) { var localKey = window.soonPutSoonSession(j, fileName || 'design.soon');
if (res && res.fileKey) openWithKey(res.fileKey, j); if (!localKey) return;
}).catch(function () {}); if (typeof window.soonScheduleCloudImport === 'function') {
window.soonScheduleCloudImport(localKey, fileName || 'design.soon');
}
openWithKey(localKey, j);
}
} }
if (file && file.text) { if (file && file.text) {
file.text().then(function(t) { file.text().then(function(t) {
@@ -1357,7 +1366,7 @@ $('#new').click(function () {
return; return;
} }
layer.confirm( layer.confirm(
'<font style="color:black">' + language_str('whetherSave') + '</font>', language_str('whetherSave'),
function (index) { function (index) {
if (typeof window.output === 'function') { if (typeof window.output === 'function') {
window.output(goFirstPage); window.output(goFirstPage);
@@ -1370,58 +1379,6 @@ $('#new').click(function () {
) )
}) })
function chagneTitle() { } function chagneTitle() { }
function addBackground() {
// 类型改变
fabric.Image.fromURL(soonAsset('bg_front_2.png'), function (image) {
//image.set("scaleX",$("#canvas-div").width() / 1200 < 1 ? $("#canvas-div").width() / 1200 : 1)
image.left = ($('#canvas1').width() - image.width * image.get('scaleX')) / 2
image.top = ($('#canvas1').height() - image.height * image.get('scaleY')) / 2
image.selectable = false
image.hoverable = false
image.hoverCursor = 'default'
image.setSrc(image.toDataURL(), function (image) {
background_image1 = image
background_image = background_image1
canvas.add(image)
canvas.renderAll()
//pre_objs1.push(JSON.stringify(objs1));
recordObjs1.push(JSON.stringify(objs1))
const props = window.TO_JSON_PROPERTIES || ['selectable', 'hoverable', 'hoverCursor', 'text', 'fontStyle', 'fontWeight', 'underline', 'evented', 'linethrough', 'textBackgroundColor', 'diameter', 'flipped'];
recordJson1.push(canvas1.toJSON(props))
})
// 类型改变
fabric.Image.fromURL(soonAsset('bg_back_2.png'), function (image) {
image.left = ($('#canvas2').width() - image.width) / 2
image.top = ($('#canvas2').height() - image.height) / 2
image.selectable = false
image.hoverable = false
image.hoverCursor = 'default'
image.setSrc(image.toDataURL(), function (image) {
background_image2 = image
canvas2.add(image)
canvas2.renderAll()
//pre_objs2.push(JSON.stringify(objs2));
recordObjs2.push(JSON.stringify(objs2))
const props = window.TO_JSON_PROPERTIES || ['selectable', 'hoverable', 'hoverCursor', 'text', 'fontStyle', 'fontWeight', 'underline', 'evented', 'linethrough', 'textBackgroundColor', 'diameter', 'flipped'];
recordJson2.push(canvas2.toJSON(props))
let file = GetFile().get('file')
if (file && file != 'empty') {
if (typeof window.openFile === 'function') {
window.openFile(file);
}
}
})
})
is_bgi_add = true
})
}
function selectObject(target, index) { function selectObject(target, index) {
updateControls() updateControls()
@@ -2694,7 +2651,6 @@ $("#help").click(function() {
// =========================================================== // ===========================================================
$('#display').off('click').on('click', function () { $('#display').off('click').on('click', function () {
if (typeof window.soonGuardMemberPreview === 'function' && !window.soonGuardMemberPreview()) return;
// 类型改变 // 类型改变
fabric.Image.fromURL(soonAsset('front_bg') + bg_version + '_2.png', function (i1) { fabric.Image.fromURL(soonAsset('front_bg') + bg_version + '_2.png', function (i1) {
i1.left = background_image.left i1.left = background_image.left
@@ -2745,24 +2701,17 @@ if (typeof window._design2CanvasEventsInitialized === 'undefined') {
// - mouse:up (已在 core.js 中处理) // - mouse:up (已在 core.js 中处理)
// 如果需要 canvas1 特定的处理逻辑,请修改 core.js 中的相应函数 // 如果需要 canvas1 特定的处理逻辑,请修改 core.js 中的相应函数
// 保留 canvas1 特定的 after:render 处理
canvas1.on('after:render', function () {
if (is_bgi_add) {
var image = document.getElementById('source_front')
ctx1.drawImage(image, (canvas.width - $('#source_front').width()) / 2, (canvas.height - $('#source_front').height()) / 2, $('#source_front').width(), $('#source_front').height())
}
})
// 保留 canvas1 特定的 text:changed 处理 // 保留 canvas1 特定的 text:changed 处理
canvas1.on('text:changed', (e) => { canvas1.on('text:changed', (e) => {
$('#text_text').val(e.target.get('text')) $('#text_text').val(e.target.get('text'))
}) })
// 保留 canvas2 特定的 after:render 处理 requestAnimationFrame(function () {
canvas2.on('after:render', function () { requestAnimationFrame(function () {
if (is_bgi_add) { if (typeof window.soonDesign2Kickoff === 'function') {
var image = document.getElementById('source_back') window.soonDesign2Kickoff();
ctx2.drawImage(image, (canvas.width - $('#source_back').width()) / 2, (canvas1.height - $('#source_back').height()) / 2, $('#source_back').width(), $('#source_back').height())
} }
}) });
});
// 注意:以下事件已在 core.js 的 initCanvasEvents() 中统一处理,移除重复监听以避免冲突: // 注意:以下事件已在 core.js 的 initCanvasEvents() 中统一处理,移除重复监听以避免冲突:
// - mouse:down (通过 handleObjectSelected 处理) // - mouse:down (通过 handleObjectSelected 处理)
// - mouse:up (已在 core.js 中处理) // - mouse:up (已在 core.js 中处理)
+286 -203
View File
@@ -144,40 +144,18 @@ layui.use(['layer', 'form', 'jquery'], function () {
var lang = localStorage.getItem("lang"); window.soonResolveLocale(function (code) {
if (lang) { s_lan = code;
s_lan = lang;
langua_ge(s_lan); langua_ge(s_lan);
$("#language_select").val(s_lan); $("#language_select").val(s_lan);
} else if (window.platformBridge && window.platformBridge.getLocale) { try { localStorage.setItem("lang", s_lan); } catch (e) {}
window.platformBridge.getLocale().then(function (loc) {
if (loc && loc.indexOf('zh') === 0) {
s_lan = loc.indexOf('TW') >= 0 ? 'ozh' : 'zh';
} else {
s_lan = 'en';
}
langua_ge(s_lan);
$("#language_select").val(s_lan);
localStorage.setItem("lang", s_lan);
}); });
}
$('#language_select').on('change', function () { $('#language_select').on('change', function () {
@@ -199,14 +177,94 @@ layui.use(['layer', 'form', 'jquery'], function () {
try { return !!localStorage.getItem('soon_access'); } catch (e) { return false; } try { return !!localStorage.getItem('soon_access'); } catch (e) { return false; }
} }
function makeFileKey(id, version) { var fileListState = { page: 1, size: 12, total: 0, items: [] };
return typeof window.soonMakeFileKey === 'function' var templateListState = { page: 1, size: 8, total: 0, items: [] };
? window.soonMakeFileKey(id, version)
: ('soondesign_file:' + id + ':v' + version); function displayNameFromPath(path) {
if (!path) return 'design.soon';
if (typeof window.soonDisplayFileName === 'function') {
var fromHelper = window.soonDisplayFileName(path);
if (fromHelper) return fromHelper;
}
var sessionPrefix = 'soondesign_session:';
if (path.indexOf(sessionPrefix) === 0) {
var slug = path.substring(sessionPrefix.length).replace(/-\d{10,}$/, '');
return /\.soon$/i.test(slug) ? slug : (slug + '.soon');
}
if (path.indexOf('soondesign_file:') === 0) {
var meta = window._soonFileMeta;
if (meta && meta.name) return meta.name;
return path.replace(/^soondesign_file:(\d+).*/, '文件 #$1');
}
return get_filename(path) || 'design.soon';
} }
var fileListState = { page: 1, size: 12, total: 0 }; async function fetchCloudItems() {
var templateListState = { page: 1, size: 8, total: 0, items: [] }; if (!hasCloudAuth() || !window.platformBridge || !window.platformBridge.listCloudFiles) return [];
try {
var data = await window.platformBridge.listCloudFiles({ page: 1, size: 50 });
return (data && data.items) ? data.items : [];
} catch (e) {
return [];
}
}
function removeLocalHistoryPath(path) {
if (!path) return Promise.resolve();
if (typeof window.soonRecentRemove === 'function') window.soonRecentRemove(path);
if (typeof window.soonLocalRemove === 'function') {
return window.soonLocalRemove(path);
}
return Promise.resolve();
}
function recentFallbackThumb(type) {
var t = Number(type) || 1;
return soonAsset(t === 2 ? 'bg_2.png' : 'bg_1.png');
}
async function resolveRecentThumb(item) {
var realType = item.type || 1;
var fallback = recentFallbackThumb(realType);
var thumbKey = item.thumbRef || item.filePath || item.key;
if (typeof window.soonLocalGetThumb === 'function' && thumbKey) {
var localThumb = await window.soonLocalGetThumb(thumbKey);
if (localThumb) {
return typeof soonSafeImageUrl === 'function'
? soonSafeImageUrl(localThumb, fallback)
: localThumb;
}
}
if (item.kind === 'template' && item.filePath) {
var tplMatch = String(item.filePath).match(/^soondesign_template:(\d+)/);
if (tplMatch) {
var base = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || '';
if (base) {
var v = item.updated_at ? ('?v=' + encodeURIComponent(item.updated_at)) : '';
return base + '/templates/' + tplMatch[1] + '/thumb' + v;
}
}
}
if (item.kind === 'cloud' && item.fileId && hasCloudAuth()) {
var apiBase = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || '';
if (apiBase) return apiBase + '/files/' + item.fileId + '/thumb';
}
return fallback;
}
function recentDownloadBtnHtml() {
return '<button type="button" class="soon-file-card__action soon-file-card__download" title="下载 .soon" aria-label="下载">'
+ '<svg class="soon-file-card__icon" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">'
+ '<path d="M12 4v10m0 0l3.5-3.5M12 14l-3.5-3.5M5 20h14"/></svg></button>';
}
function recentDeleteBtnHtml() {
return '<button type="button" class="soon-file-card__action soon-file-card__delete" title="' + escapeAttr(language_str('deleteBtn')) + '" aria-label="' + escapeAttr(language_str('deleteBtn')) + '">'
+ '<svg class="soon-file-card__icon" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">'
+ '<path d="M4 7h16"/><path d="M7 7l1.1 11.1a.9.9 0 00.9.9h6a.9.9 0 00.9-.9L17 7"/>'
+ '<path d="M9.5 7V5.6A1.6 1.6 0 0111.1 4h1.8a1.6 1.6 0 011.6 1.6V7"/>'
+ '<path d="M10 11v5.5"/><path d="M14 11v5.5"/></svg></button>';
}
function renderFilePager() { function renderFilePager() {
var el = document.getElementById('filePager'); var el = document.getElementById('filePager');
@@ -242,46 +300,48 @@ layui.use(['layer', 'form', 'jquery'], function () {
} }
} }
async function loadHistory() { var _recentLoadedAt = 0;
async function loadHistory(force) {
try { try {
if (!force && _recentLoadedAt && (Date.now() - _recentLoadedAt) < 60000) {
return;
}
if (typeof window.soonRecentMigrateFromHistory === 'function') {
window.soonRecentMigrateFromHistory();
}
var recentCountEl = document.getElementById('recentCount'); var recentCountEl = document.getElementById('recentCount');
var filePagerEl = document.getElementById('filePager'); var items = typeof window.soonRecentList === 'function' ? window.soonRecentList() : [];
var mapped = items.map(function (it) {
if (!hasCloudAuth()) { return {
kind: it.kind || (typeof window.soonRecentKindFromKey === 'function' ? window.soonRecentKindFromKey(it.key) : 'local'),
if (recentCountEl) recentCountEl.textContent = '0'; filePath: it.key,
if (filePagerEl) filePagerEl.innerHTML = ''; fileId: it.fileId,
name: it.name,
$(".card-list").html(soonEmptyBlock('登录后查看文件', '请先登录以管理云端设计文件', type: it.type,
thumbRef: it.thumbRef || it.key,
'<a href="login.web.html" class="soon-btn soon-btn--sm soon-empty__action">去登录</a>')); updated_at: it.updated_at || ''
};
return;
}
if (!window.platformBridge || !window.platformBridge.listCloudFiles) {
$(".card-list").html("");
return;
}
var data = await window.platformBridge.listCloudFiles({
page: fileListState.page,
size: fileListState.size,
}); });
var items = (data && data.items) ? data.items : []; if (hasCloudAuth() && typeof window.soonRecentEnrichFromCloud === 'function') {
fileListState.total = (data && data.total != null) ? data.total : items.length; var cloudItems = await fetchCloudItems();
if (data && data.page) fileListState.page = data.page; mapped = window.soonRecentEnrichFromCloud(mapped, cloudItems);
}
fileListState.items = mapped;
fileListState.total = mapped.length;
var pages = Math.max(1, Math.ceil(fileListState.total / fileListState.size));
if (fileListState.page > pages) fileListState.page = pages;
_recentLoadedAt = Date.now();
if (recentCountEl) recentCountEl.textContent = String(fileListState.total); if (recentCountEl) recentCountEl.textContent = String(fileListState.total);
if (!items.length) { if (!mapped.length) {
$(".card-list").html(soonEmptyBlock('暂无文件', '点击「打开文件」导入,或新建模板开始设计')); $(".card-list").html(soonEmptyBlock('暂无文件', '点击「打开文件」导入,或新建模板开始设计'));
renderFilePager(); renderFilePager();
@@ -289,91 +349,39 @@ layui.use(['layer', 'form', 'jquery'], function () {
} }
var start = (fileListState.page - 1) * fileListState.size;
var slice = mapped.slice(start, start + fileListState.size);
var thumbRows = await Promise.all(slice.map(async function (item) {
var realType = item.type || 1;
return {
item: item,
src: await resolveRecentThumb(item),
fallbackSrc: recentFallbackThumb(realType)
};
}));
let h = ""; let h = "";
for (let item of items) { for (let row of thumbRows) {
let item = row.item;
let filePath = makeFileKey(item.id, item.version); let filePath = item.filePath;
let realType = item.type || 1;
let src = ""; let src = row.src;
let fallbackSrc = row.fallbackSrc;
let fileExists = true; let displayName = item.name || displayNameFromPath(filePath);
let imgStyle = "";
let realType = 1;
const soonData = await window.sysAPI.readJsonFile(filePath);
if (soonData) {
realType = soonData.soonType ? soonData.soonType : (soonData.backBlackPic ? 2 : 1);
var fallbackThumb = (realType == 2 || realType == "2") ? soonAsset('bg_2.png') : soonAsset('bg_1.png');
src = typeof soonSafeImageUrl === 'function'
? soonSafeImageUrl(soonData.frontDisplayPic, fallbackThumb)
: (soonData.frontDisplayPic || fallbackThumb);
if (!src) src = fallbackThumb;
} else {
fileExists = false;
src = soonAsset('bg_1.png');
imgStyle = "opacity: 0.6; filter: grayscale(100%);";
}
let displayName = item.name || ('文件 #' + item.id);
let cardTitle = displayName; let cardTitle = displayName;
var cardClass = 'card'; var cardClass = 'card';
if (!fileExists) { var actions = recentDownloadBtnHtml() + recentDeleteBtnHtml();
cardTitle = language_str("clickToDelete"); h += `<div class="${cardClass}" data-file="${escapeAttr(filePath)}" data-kind="${item.kind}" data-type="${realType}"${item.fileId ? (' data-id="' + item.fileId + '"') : ''} data-name="${escapeAttr(displayName)}" title="${escapeAttr(cardTitle)}">
cardClass += ' soon-card--lost';
}
var lostBadge = !fileExists
? ' <span class="soon-badge soon-badge--disabled">' + language_str("lost") + '</span>'
: '';
var dlBtn = '<button type="button" class="soon-file-card__action soon-file-card__download" title="下载 .soon" aria-label="下载">'
+ '<svg class="soon-file-card__icon" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">'
+ '<path d="M12 4v10m0 0l3.5-3.5M12 14l-3.5-3.5M5 20h14"/></svg></button>';
var delBtn = '<button type="button" class="soon-file-card__action soon-file-card__delete" title="' + escapeAttr(language_str('deleteBtn')) + '" aria-label="' + escapeAttr(language_str('deleteBtn')) + '">'
+ '<svg class="soon-file-card__icon" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">'
+ '<path d="M4 7h16"/><path d="M7 7l1.1 11.1a.9.9 0 00.9.9h6a.9.9 0 00.9-.9L17 7"/>'
+ '<path d="M9.5 7V5.6A1.6 1.6 0 0111.1 4h1.8a1.6 1.6 0 011.6 1.6V7"/>'
+ '<path d="M10 11v5.5"/><path d="M14 11v5.5"/></svg></button>';
h += `<div class="${cardClass}" data-file="${escapeAttr(filePath)}" data-id="${item.id}" data-name="${escapeAttr(displayName)}" title="${escapeAttr(cardTitle)}">
<div class="rect ${realType == 2 ? 'rect1' : ''}"> <div class="rect ${realType == 2 ? 'rect1' : ''}">
<div class="soon-file-card__actions">${dlBtn}${delBtn}</div> <div class="soon-file-card__actions">${actions}</div>
<img src="${escapeAttr(src)}" style="${imgStyle}" alt=""> <img src="${escapeAttr(src)}" alt="" onerror="this.onerror=null;this.src='${escapeAttr(fallbackSrc)}'">
<div class="tip">${escapeAttr(displayName)}${lostBadge}</div> <div class="tip">${escapeAttr(displayName)}</div>
</div> </div>
@@ -391,15 +399,13 @@ layui.use(['layer', 'form', 'jquery'], function () {
$(".card-list").html(soonEmptyBlock('加载失败', '请刷新页面重试', $(".card-list").html(soonEmptyBlock('加载失败', '请刷新页面重试',
'<button type="button" class="soon-btn soon-btn--sm soon-empty__action" id="fileListRetry">重试</button>')); '<button type="button" class="soon-btn soon-btn--sm soon-empty__action" id="fileListRetry">重试</button>'));
var retry = document.getElementById('fileListRetry'); var retry = document.getElementById('fileListRetry');
if (retry) retry.onclick = function () { loadHistory(); }; if (retry) retry.onclick = function () { loadHistory(true); };
if (document.getElementById('filePager')) document.getElementById('filePager').innerHTML = ''; if (document.getElementById('filePager')) document.getElementById('filePager').innerHTML = '';
} }
} }
function renderTemplatesError(message) { function renderTemplatesError(message) {
var grid = document.getElementById('templatesGrid'); var grid = document.getElementById('templatesGrid');
@@ -425,24 +431,64 @@ layui.use(['layer', 'form', 'jquery'], function () {
} }
function templateCardHtml(m, i) { function templateFallbackThumb(type) {
var defaultThumb = soonAsset('bg_1.png'); var t = Number(type) || 1;
var thumbSrc = typeof soonSafeImageUrl === 'function' return soonAsset(t === 2 ? 'bg_2.png' : 'bg_1.png');
? soonSafeImageUrl(m.thumbnail_url, defaultThumb) }
: (m.thumbnail_url || defaultThumb);
if (!thumbSrc) thumbSrc = defaultThumb; function resolveTemplateItem(m, base) {
var type = Number(m.type) || 1;
var id = m.id;
var v = m.updated_at ? ('?v=' + encodeURIComponent(m.updated_at)) : '';
return {
id: id,
name: m.name || m.title || '模板',
type: type,
updated_at: m.updated_at || '',
thumbSrc: id ? (base + '/templates/' + id + '/thumb' + v) : templateFallbackThumb(type)
};
}
function openTemplateItem(m) {
var type = Number(m.type) || 1;
var id = m && m.id;
if (!id) {
if (typeof window.soonToast === 'function') window.soonToast('模板不可用', 'error');
return;
}
window._soonTemplateMeta = {
id: id,
name: m.name || m.title || '模板',
type: type,
updated_at: m.updated_at || ''
};
var templateKey = typeof window.soonMakeTemplateKey === 'function'
? window.soonMakeTemplateKey(id)
: ('soondesign_template:' + id);
openDesign(type, templateKey);
}
function templateCardHtml(m, itemIndex) {
var type = Number(m.type) || 1;
var fallback = templateFallbackThumb(type);
var thumbSrc = m.thumbSrc || fallback;
var esc = typeof soonEscapeHtml === 'function' ? soonEscapeHtml : escapeAttr; var esc = typeof soonEscapeHtml === 'function' ? soonEscapeHtml : escapeAttr;
var name = esc(m.name || m.title || '模板'); var name = esc(m.name || m.title || '模板');
return '<div class="soon-template-card" style="animation-delay:' + (i * 0.05) + 's" data-type="' + (m.type || 1) + '">' + var rectClass = 'rect' + (type == 2 ? ' rect1' : '');
'<img src="' + esc(thumbSrc) + '" alt="">' + return '<div class="card" data-type="' + type + '" data-index="' + itemIndex + '" title="' + name + '">' +
'<span>' + name + '</span></div>'; '<div class="' + rectClass + '">' +
'<img src="' + esc(thumbSrc) + '" alt="" loading="lazy" onerror="this.onerror=null;this.src=\'' + esc(fallback) + '\'">' +
'<div class="tip">' + name + '</div>' +
'</div></div>';
} }
function bindTemplateCards(grid) { function bindTemplateCards(grid) {
if (!grid) return; if (!grid) return;
grid.querySelectorAll('.soon-template-card').forEach(function (card) { grid.querySelectorAll('.card[data-index]').forEach(function (card) {
card.onclick = function () { card.onclick = function () {
openDesign(Number(card.dataset.type) || 1, ''); var idx = Number(card.dataset.index);
var m = templateListState.items[idx];
if (m) openTemplateItem(m);
}; };
}); });
} }
@@ -482,12 +528,14 @@ layui.use(['layer', 'form', 'jquery'], function () {
var start = (templateListState.page - 1) * templateListState.size; var start = (templateListState.page - 1) * templateListState.size;
var slice = items.slice(start, start + templateListState.size); var slice = items.slice(start, start + templateListState.size);
grid.innerHTML = slice.map(function (m, i) { return templateCardHtml(m, i); }).join(''); grid.innerHTML = slice.map(function (m, i) { return templateCardHtml(m, start + i); }).join('');
bindTemplateCards(grid); bindTemplateCards(grid);
renderTemplatePager(); renderTemplatePager();
} }
async function loadTemplates() { var _templatesLoadedAt = 0;
async function loadTemplates(force) {
var grid = document.getElementById('templatesGrid'); var grid = document.getElementById('templatesGrid');
@@ -495,6 +543,10 @@ layui.use(['layer', 'form', 'jquery'], function () {
if (!grid) return; if (!grid) return;
if (!force && _templatesLoadedAt && (Date.now() - _templatesLoadedAt) < 60000) {
return;
}
var base = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || ''; var base = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || '';
if (!base) { if (!base) {
@@ -511,7 +563,7 @@ layui.use(['layer', 'form', 'jquery'], function () {
try { try {
var r = await fetch(base + '/soon-models'); var r = await fetch(base + '/templates');
var j = await r.json(); var j = await r.json();
@@ -524,10 +576,11 @@ layui.use(['layer', 'form', 'jquery'], function () {
} }
var items = (j.data && j.data.items) ? j.data.items : []; var items = (j.data && j.data.items) ? j.data.items : [];
templateListState.items = items; templateListState.items = items.map(function (it) { return resolveTemplateItem(it, base); });
templateListState.total = items.length; templateListState.total = templateListState.items.length;
templateListState.page = 1; templateListState.page = 1;
renderTemplatesPage(); renderTemplatesPage();
_templatesLoadedAt = Date.now();
} catch (e) { } catch (e) {
@@ -539,10 +592,25 @@ layui.use(['layer', 'form', 'jquery'], function () {
loadHistory(); loadHistory(true);
loadTemplates(); loadTemplates();
window.soonReloadRecentFiles = function () { loadHistory(true); };
window.soonReloadTemplates = function () { loadTemplates(true); };
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
loadTemplates(false);
loadHistory(false);
}
});
window.addEventListener('pageshow', function (e) {
if (e.persisted) {
loadTemplates(false);
loadHistory(false);
}
});
var templatePrevNav = document.getElementById('templatePrev'); var templatePrevNav = document.getElementById('templatePrev');
var templateNextNav = document.getElementById('templateNext'); var templateNextNav = document.getElementById('templateNext');
if (templatePrevNav) { if (templatePrevNav) {
@@ -559,19 +627,11 @@ layui.use(['layer', 'form', 'jquery'], function () {
$("#openfile").click(function () { OpenDialog(); }); $("#openfile").click(function () { OpenDialog(); });
$("#new1").click(function () { $("#new1").click(function () {
if (typeof window.soonRequireLogin === 'function' && !window.soonRequireLogin('新建')) return;
openDesign(1, ''); openDesign(1, '');
}); });
$("#new2").click(function () { $("#new2").click(function () {
if (typeof window.soonRequireLogin === 'function' && !window.soonRequireLogin('新建')) return;
openDesign(2, ''); openDesign(2, '');
}); });
@@ -594,6 +654,14 @@ layui.use(['layer', 'form', 'jquery'], function () {
await window.platformBridge.deleteCloudFile(fileId); await window.platformBridge.deleteCloudFile(fileId);
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }
if (typeof window.soonRecentList === 'function') {
window.soonRecentList().forEach(function (it) {
if (it.fileId === fileId) {
if (typeof window.soonRecentRemove === 'function') window.soonRecentRemove(it.key);
if (typeof window.soonLocalRemove === 'function') window.soonLocalRemove(it.key);
}
});
}
layer.msg(language_str("deleted"), { icon: 1, time: 1000 }); layer.msg(language_str("deleted"), { icon: 1, time: 1000 });
if (typeof onDone === 'function') onDone(); if (typeof onDone === 'function') onDone();
layer.close(index); layer.close(index);
@@ -607,16 +675,27 @@ layui.use(['layer', 'form', 'jquery'], function () {
var card = $(this).closest('.card'); var card = $(this).closest('.card');
var id = parseInt(card.attr('data-id'), 10); var filePath = card.attr('data-file') || '';
var kind = card.attr('data-kind') || 'local';
var fileId = parseInt(card.attr('data-id'), 10);
var name = card.attr('data-name') || 'design.soon'; var name = card.attr('data-name') || 'design.soon';
if (typeof window.soonRequireMember === 'function' && !window.soonRequireMember('下载')) return; var type = parseInt(card.attr('data-type'), 10) || 1;
if (id && window.platformBridge && window.platformBridge.downloadCloudFile) { var item = {
kind: kind,
window.platformBridge.downloadCloudFile(id, name).catch(function () {}); filePath: filePath,
key: filePath,
fileId: fileId || undefined,
name: name,
type: type
};
if (typeof window.soonDownloadRecentItem === 'function') {
window.soonDownloadRecentItem(item);
} }
}); });
@@ -627,23 +706,51 @@ layui.use(['layer', 'form', 'jquery'], function () {
var card = $(this).closest('.card'); var card = $(this).closest('.card');
var kind = card.attr('data-kind') || 'cloud';
var fileId = parseInt(card.attr('data-id'), 10); var fileId = parseInt(card.attr('data-id'), 10);
if (!fileId) return; var filePath = card.attr('data-file') || '';
confirmDeleteCloudFile(fileId, language_str('deleteFileConfirm'), loadHistory); if (kind === 'local' || !fileId) {
layer.open({
type: 1,
skin: 'soon-layer',
title: language_str("delTitle"),
content: '<div class="soon-dialog-body">' + language_str('deleteFileConfirm') + '</div>',
btn: [language_str("comfirm"), language_str("cancel")],
btnAlign: 'r',
area: ['320px', 'auto'],
resize: false,
shadeClose: true,
yes: function (index) {
removeLocalHistoryPath(filePath).then(function () {
if (filePath.indexOf('soondesign_session:') === 0) {
try {
sessionStorage.removeItem(filePath);
localStorage.removeItem(filePath);
} catch (err) { /* ignore */ }
}
layer.msg(language_str("deleted"), { icon: 1, time: 1000 });
loadHistory(true);
layer.close(index);
});
}
});
return;
}
confirmDeleteCloudFile(fileId, language_str('deleteFileConfirm'), function () { loadHistory(true); });
}); });
$(".card-list").on("click", '.card', async function () { $(".card-list").on("click", '.card', function () {
let filePath = $(this).attr("data-file") || $(this).attr("data"); let filePath = $(this).attr("data-file") || $(this).attr("data");
let type = Number($(this).attr("data-type")) || 1;
const soonData = await window.sysAPI.readJsonFile(filePath);
if (soonData) {
let type = soonData.soonType ? soonData.soonType : (soonData.backBlackPic ? 2 : 1);
if (typeof window.soonBindFileMeta === 'function') { if (typeof window.soonBindFileMeta === 'function') {
window.soonBindFileMeta(filePath, { name: $(this).attr('data-name') || '' }); window.soonBindFileMeta(filePath, { name: $(this).attr('data-name') || '' });
@@ -651,22 +758,12 @@ layui.use(['layer', 'form', 'jquery'], function () {
openDesign(type, filePath); openDesign(type, filePath);
} else {
var fileId = parseInt($(this).attr('data-id'), 10);
confirmDeleteCloudFile(fileId, language_str("delContent"), loadHistory);
}
}); });
function OpenDialog() { function OpenDialog() {
if (typeof window.soonRequireLogin === 'function' && !window.soonRequireLogin('打开文件')) return;
var dialogApi = window.platformBridge && window.platformBridge.showOpenDialog var dialogApi = window.platformBridge && window.platformBridge.showOpenDialog
? { showOpenDialog: function (opts) { return window.platformBridge.showOpenDialog(opts); } } ? { showOpenDialog: function (opts) { return window.platformBridge.showOpenDialog(opts); } }
@@ -713,21 +810,7 @@ layui.use(['layer', 'form', 'jquery'], function () {
} }
if (!window.platformBridge || !window.platformBridge.importSoonFile) return; window.soonOpenSoonJsonLocally(soonData, fileName);
try {
var res = await window.platformBridge.importSoonFile(fileName, soonData);
if (!res || !res.fileKey) return;
var type = soonData.soonType ? soonData.soonType : (soonData.backBlackPic ? 2 : 1);
openDesign(type, res.fileKey);
loadHistory();
} catch (err) { /* errors shown by bridge */ }
}).catch(function (err) { console.log(err); }); }).catch(function (err) { console.log(err); });
-471
View File
@@ -1,471 +0,0 @@
(function () {
'use strict';
var base = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || '/api/v1';
var currentMembership = null;
var orderState = { page: 1, size: 8, total: 0 };
var esc = typeof soonEscapeHtml === 'function' ? soonEscapeHtml : function (s) {
if (s == null) return '';
return String(s);
};
var STATUS_LABEL = {
pending: '待支付',
paid: '已支付',
cancelled: '已取消',
refunded: '已退款',
};
var CHANNEL_LABEL = { alipay: '支付宝', wechat: '微信' };
function apiFetch(url, opts) {
if (typeof soonAuthedFetch === 'function') return soonAuthedFetch(url, opts || {});
opts = opts || {};
var token = localStorage.getItem('soon_access') || '';
opts.headers = Object.assign({}, opts.headers || {});
if (token) opts.headers.Authorization = 'Bearer ' + token;
return fetch(url, opts);
}
function parseApiJson(r) {
return r.text().then(function (text) {
try {
return JSON.parse(text);
} catch (e) {
return { ok: false, message: '服务暂时不可用,请稍后重试' };
}
});
}
function apiGet(p) {
return apiFetch(base + p).then(function (r) {
if (!r.ok && r.status === 401) return { ok: false, message: '未登录或会话已过期' };
return parseApiJson(r);
});
}
function apiPost(p, body) {
return apiFetch(base + p, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
}).then(function (r) {
if (!r.ok && r.status === 401) return { ok: false, message: '未登录或会话已过期' };
return parseApiJson(r);
});
}
function usageMetrics(usage, quotaMb) {
var bytes = (usage && usage.storage_bytes) || 0;
var totalBytes = (quotaMb || 0) * 1024 * 1024;
var pctNum = totalBytes > 0 ? (bytes / totalBytes) * 100 : 0;
var pctLabel = pctNum > 0 && pctNum < 1 ? '<1' : String(Math.min(100, Math.round(pctNum)));
var barWidth = bytes > 0 ? Math.max(pctNum < 1 ? 0.8 : pctNum, 0.8) : 0;
return {
pctNum: pctNum,
pctLabel: pctLabel,
barWidth: Math.min(100, barWidth),
usedDisplay: (usage && usage.used_display) || '0 MB',
};
}
function showPayBanner() {
var b = document.getElementById('payBanner');
if (!b) return;
b.classList.add('is-visible');
setTimeout(function () { b.classList.remove('is-visible'); }, 5000);
}
function initLanguageSelect() {
var sel = document.getElementById('language_select');
if (!sel) return;
sel.value = localStorage.getItem('lang') || 'zh';
sel.onchange = function () { localStorage.setItem('lang', sel.value); };
}
function isPaidMember(m) {
if (!m) return false;
if (m.is_member === true) return true;
return m.tier === 'member' || m.tier === 'pro';
}
function statusPill(sub, isMember) {
if (!isMember) {
return '<span class="soon-member-status-pill soon-member-status-pill--free">免费版</span>';
}
var st = (sub && sub.status) || 'active';
var cls = 'soon-member-status-pill--active';
var text = '生效中';
if (st === 'expiring') { cls = 'soon-member-status-pill--expiring'; text = '即将到期'; }
return '<span class="soon-member-status-pill ' + cls + '">' + text + '</span>';
}
function storageRing(metrics, warn) {
var r = 52;
var c = 2 * Math.PI * r;
var offset = c - (c * Math.min(metrics.pctNum, 100) / 100);
var fillCls = warn ? ' soon-member-storage-ring__fill--warn' : '';
return '<div class="soon-member-storage-ring">' +
'<svg viewBox="0 0 120 120" aria-hidden="true">' +
'<defs>' +
'<linearGradient id="memberRingGrad" x1="0%" y1="0%" x2="100%" y2="0%">' +
'<stop offset="0%" stop-color="#00897b"/><stop offset="100%" stop-color="#4db6ac"/>' +
'</linearGradient>' +
'<linearGradient id="memberRingWarn" x1="0%" y1="0%" x2="100%" y2="0%">' +
'<stop offset="0%" stop-color="#f57c00"/><stop offset="100%" stop-color="#ffb74d"/>' +
'</linearGradient></defs>' +
'<circle class="soon-member-storage-ring__track" cx="60" cy="60" r="' + r + '"/>' +
'<circle class="soon-member-storage-ring__fill' + fillCls + '" cx="60" cy="60" r="' + r + '" ' +
'stroke-dasharray="' + c + '" stroke-dashoffset="' + offset + '"/></svg>' +
'<div class="soon-member-storage-ring__center">' +
'<span class="soon-member-storage-ring__pct">' + metrics.pctLabel + '%</span>' +
'<span class="soon-member-storage-ring__label">已使用</span></div></div>';
}
function renderMyPlan(m) {
var el = document.getElementById('myPlan');
if (!el) return;
if (!m) {
el.innerHTML = '<div class="soon-member-dash"><div class="soon-member-dash__body">' +
'<p class="soon-member-dash__desc">加载失败,请重试</p>' +
'<button type="button" class="soon-btn soon-btn--sm" id="myPlanRetry">重试</button></div></div>';
var retry = document.getElementById('myPlanRetry');
if (retry) retry.onclick = loadMembership;
return;
}
currentMembership = m;
var isMember = isPaidMember(m);
if (typeof window.soonApplyMembership === 'function') {
window.soonApplyMembership(m);
} else if (typeof window.soonLoadMembership === 'function') {
window.soonLoadMembership(true);
}
var usage = m.usage || {};
var sub = m.subscription || {};
if (!isMember) {
el.innerHTML =
'<div class="soon-member-dash">' +
'<div class="soon-member-dash__body soon-member-dash__body--guest">' +
'<div class="soon-member-dash__badge-row">' +
'<span class="soon-member-tier soon-member-tier--free">免费版</span>' + statusPill(sub, false) + '</div>' +
'<h2 class="soon-member-dash__title">' + esc(m.name || '免费版') + '</h2>' +
'<p class="soon-member-dash__desc">' + esc(m.description || '免费体验设计与编辑') + '</p>' +
'<p class="soon-member-dash__hint">订阅后可预览、打印、保存并导出作品</p>' +
'<div class="soon-member-dash__actions">' +
'<button type="button" class="soon-btn soon-btn--sm" id="myPlanGoPlans">查看订阅方案</button>' +
'</div></div></div>';
var goPlans = document.getElementById('myPlanGoPlans');
if (goPlans) {
goPlans.onclick = function () {
var sec = document.getElementById('plans');
if (sec) sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
}
return;
}
var quota = m.quota_mb || 2048;
var metrics = usageMetrics(usage, quota);
var warn = metrics.pctNum >= 90;
var barCls = warn ? 'soon-member-progress__bar soon-member-progress__bar--warn' : 'soon-member-progress__bar';
var expires = sub.expires_at
? esc(String(sub.expires_at).slice(0, 16).replace('T', ' '))
: '—';
var days = sub.days_remaining != null ? sub.days_remaining + ' 天' : '—';
el.innerHTML =
'<div class="soon-member-dash">' +
'<div class="soon-member-dash__body">' +
'<div class="soon-member-dash__top">' +
'<div><div class="soon-member-dash__badge-row">' +
'<span class="soon-member-tier">订阅版</span>' + statusPill(sub, true) + '</div>' +
'<h2 class="soon-member-dash__title">' + esc(m.name) + '</h2>' +
'<p class="soon-member-dash__desc">' + esc(m.description || '已解锁完整交付能力') + '</p>' +
'<p class="soon-member-dash__expire">有效期至 <strong>' + expires + '</strong> · 剩余 ' + esc(days) + '</p></div>' +
storageRing(metrics, warn) + '</div>' +
'<div class="soon-member-dash__usage">' +
'<div class="soon-member-dash__usage-head"><span>云端存储占用</span><strong>' +
esc(metrics.usedDisplay) + ' / ' + esc(m.quota_display || quota + ' MB') +
'' + metrics.pctLabel + '%</strong></div>' +
'<div class="soon-member-progress"><div class="' + barCls + '" style="width:' + metrics.barWidth + '%"></div></div>' +
'</div></div>' +
'<div class="soon-member-dash__stats">' +
'<div class="soon-member-dash__stat"><div class="soon-member-dash__stat-value">' + (usage.files_count || 0) +
'</div><div class="soon-member-dash__stat-label">云端文件</div></div>' +
'<div class="soon-member-dash__stat"><div class="soon-member-dash__stat-value">' + esc(days) +
'</div><div class="soon-member-dash__stat-label">剩余天数</div></div>' +
'</div></div>';
}
function orderBadge(status, refundStatus) {
if (refundStatus === 'pending') {
return '<span class="soon-member-order-badge soon-member-order-badge--pending">退款审核</span>';
}
var cls = 'soon-member-order-badge--' + (status || 'pending');
return '<span class="soon-member-order-badge ' + cls + '">' + esc(STATUS_LABEL[status] || status) + '</span>';
}
function orderPagerHtml() {
var page = orderState.page;
var size = orderState.size;
var total = orderState.total;
var pages = Math.max(1, Math.ceil(total / size));
if (total === 0) return '';
return '<div class="soon-member-pager">' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="orderPrev"' +
(page <= 1 ? ' disabled' : '') + '>上一页</button>' +
'<span>第 ' + page + ' / ' + pages + ' 页(共 ' + total + ' 条)</span>' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="orderNext"' +
(page >= pages ? ' disabled' : '') + '>下一页</button></div>';
}
function bindOrderPager() {
var prev = document.getElementById('orderPrev');
var next = document.getElementById('orderNext');
if (prev && !prev.disabled) {
prev.onclick = function () {
orderState.page = Math.max(1, orderState.page - 1);
loadOrders();
};
}
if (next && !next.disabled) {
next.onclick = function () {
var pages = Math.max(1, Math.ceil(orderState.total / orderState.size));
orderState.page = Math.min(pages, orderState.page + 1);
loadOrders();
};
}
}
function orderActionCell(o) {
var actions = [];
if (o.status === 'pending') {
actions.push('<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-member-order-act" ' +
'data-act="pay" data-no="' + esc(o.order_no) + '" data-plan="' + esc(o.plan_name) + '" data-price="' +
(o.amount_cents / 100).toFixed(2) + '" data-channel="' + esc(o.channel) + '">继续支付</button>');
actions.push('<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-member-order-act soon-member-order-act--muted" ' +
'data-act="cancel" data-no="' + esc(o.order_no) + '">取消</button>');
} else if (o.status === 'paid' && (!o.refund_status || o.refund_status === 'none')) {
actions.push('<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-member-order-act" ' +
'data-act="refund" data-no="' + esc(o.order_no) + '">申请退款</button>');
}
if (!actions.length) return '<span class="soon-member-order-act--empty">—</span>';
return '<div class="soon-member-order-actions">' + actions.join('') + '</div>';
}
function bindOrderActions() {
document.querySelectorAll('.soon-member-order-act[data-act]').forEach(function (btn) {
btn.onclick = function () {
var act = btn.dataset.act;
var no = btn.dataset.no;
if (act === 'pay') {
openPayModal(null, btn.dataset.plan, btn.dataset.price, {
orderNo: no,
channel: btn.dataset.channel || 'wechat',
});
return;
}
if (act === 'cancel') {
layer.confirm('确定取消该待支付订单?', { skin: 'soon-layer', title: '取消订单' }, function (idx) {
apiPost('/pay/orders/' + encodeURIComponent(no) + '/cancel', {}).then(function (r) {
layer.close(idx);
if (r.ok) {
soonToast('订单已取消', 'success');
loadOrders();
} else {
soonToast(r.message || '取消失败', 'warn');
}
});
});
return;
}
if (act === 'refund') {
layer.prompt({
skin: 'soon-layer',
title: '申请退款',
formType: 2,
value: '',
maxlength: 200,
}, function (reason, idx) {
reason = (reason || '').trim();
if (!reason) {
soonToast('请填写退款原因', 'warn');
return;
}
apiPost('/pay/orders/' + encodeURIComponent(no) + '/refund-request', { reason: reason }).then(function (r) {
layer.close(idx);
if (r.ok) {
soonToast('退款申请已提交', 'success');
loadOrders();
} else {
soonToast(r.message || '提交失败', 'warn');
}
});
});
}
};
});
}
function renderOrders(orders) {
var el = document.getElementById('orderHistory');
if (!el) return;
if (!orders || !orders.length) {
el.innerHTML = '<div class="soon-member-orders-empty">暂无订单,订阅后将显示在这里</div>' +
orderPagerHtml();
bindOrderPager();
return;
}
var rows = orders.map(function (o) {
return '<tr><td>' + esc(o.order_no) + '</td><td>' + esc(o.plan_name) + '</td><td class="soon-member-orders__amount">¥' +
(o.amount_cents / 100).toFixed(2) + '</td><td>' + esc(CHANNEL_LABEL[o.channel] || o.channel) +
'</td><td>' + orderBadge(o.status, o.refund_status) + '</td><td>' +
esc(String(o.paid_at || o.created_at || '').slice(0, 16).replace('T', ' ')) + '</td><td>' +
orderActionCell(o) + '</td></tr>';
}).join('');
el.innerHTML = '<table><thead><tr><th>订单号</th><th>方案</th><th>金额</th><th>渠道</th><th>状态</th><th>时间</th><th>操作</th></tr></thead><tbody>' +
rows + '</tbody></table>' + orderPagerHtml();
bindOrderPager();
bindOrderActions();
}
function loadOrders() {
apiGet('/pay/orders?page=' + orderState.page + '&size=' + orderState.size).then(function (res) {
if (!res.ok) {
renderOrders([]);
return;
}
var data = res.data || {};
orderState.total = data.total || 0;
orderState.page = data.page || orderState.page;
orderState.size = data.size || orderState.size;
renderOrders(data.items || []);
}).catch(function () {
renderOrders([]);
});
}
function renderPlans(items) {
var grid = document.getElementById('plans');
if (!grid || !window.SoonMemberPlan) return;
var paid = items.filter(function (p) { return p.code !== 'free' && p.price_cents > 0; });
var currentCode = currentMembership && isPaidMember(currentMembership) ? currentMembership.code : '';
if (!paid.length) {
grid.innerHTML = '<div class="soon-member-orders-empty" style="grid-column:1/-1">暂无可用订阅方案</div>';
return;
}
grid.innerHTML = window.SoonMemberPlan.plansGridHtml(paid, {
allPlans: paid,
currentCode: currentCode,
isMember: isPaidMember(currentMembership),
mode: 'page',
});
grid.querySelectorAll('.soon-plan-card__cta[data-id]').forEach(function (btn) {
btn.onclick = function () {
openPayModal(Number(btn.dataset.id), btn.dataset.name, btn.dataset.price);
};
});
}
function renderPlansError(message, onRetry) {
var grid = document.getElementById('plans');
if (!grid) return;
grid.innerHTML = '<div class="soon-member-orders-empty" style="grid-column:1/-1">' +
esc(message || '方案加载失败') +
'<br><button type="button" class="soon-btn soon-btn--sm" style="margin-top:14px" id="plansRetry">重试</button></div>';
var btn = document.getElementById('plansRetry');
if (btn && onRetry) btn.onclick = onRetry;
}
function loadPlans() {
apiGet('/plans').then(function (ps) {
if (!ps.ok) {
renderPlansError(ps.message || '方案加载失败', loadPlans);
return;
}
renderPlans((ps.data && ps.data.items) || []);
}).catch(function () {
renderPlansError('网络错误,请稍后重试', loadPlans);
});
}
function openPayModal(planId, planName, priceDisplay, opts) {
opts = opts || {};
var payCore = window.SoonMemberPay;
if (!payCore || !payCore.openPayModal) {
soonToast('支付模块未加载,请刷新页面', 'warn');
return;
}
payCore.openPayModal({
planId: planId,
planName: planName,
priceDisplay: priceDisplay,
orderNo: opts.orderNo,
channel: opts.channel || 'wechat',
onPaid: function () {
showPayBanner();
loadMembership();
},
});
}
function loadMembership() {
apiGet('/plans/me').then(function (my) {
if (!my.ok || !my.data) {
renderMyPlan(null);
return;
}
renderMyPlan(my.data.membership || my.data);
loadOrders();
loadPlans();
}).catch(function () {
renderMyPlan(null);
});
}
function init() {
if (!localStorage.getItem('soon_access')) {
location.href = 'login.web.html?redirect=' + encodeURIComponent('member.web.html');
return;
}
if (window.SoonPortalTopbar) {
SoonPortalTopbar.mount('#portal-topbar', { active: 'member' });
} else {
initLanguageSelect();
}
soonPortalAuth.init({ logoutReload: false });
apiGet('/auth/me').then(function (me) {
if (!me.ok) {
location.href = 'login.web.html?redirect=' + encodeURIComponent('member.web.html');
return;
}
if (me.data && me.data.role === 'admin') {
var adminEl = document.getElementById('auth_admin');
if (adminEl) adminEl.style.display = 'inline-flex';
}
loadMembership();
if (new URLSearchParams(location.search).get('paid') || sessionStorage.getItem('soon_pay_return')) {
sessionStorage.removeItem('soon_pay_return');
showPayBanner();
loadMembership();
}
}).catch(function () {
renderMyPlan(null);
renderPlansError('网络错误,请稍后重试', function () { location.reload(); });
var ordersEl = document.getElementById('orderHistory');
if (ordersEl) {
ordersEl.innerHTML = '<div class="soon-member-orders-empty">加载失败,<button type="button" class="soon-btn soon-btn--sm" id="ordersRetry">重试</button></div>';
var ordersRetry = document.getElementById('ordersRetry');
if (ordersRetry) ordersRetry.onclick = function () { location.reload(); };
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+433 -47
View File
@@ -47,6 +47,57 @@
'Accept': 'application/json' '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) { function parseCloudRef(p) {
return typeof window.soonParseFileKey === 'function' ? window.soonParseFileKey(p) : null; return typeof window.soonParseFileKey === 'function' ? window.soonParseFileKey(p) : null;
} }
@@ -83,13 +134,12 @@
var err = new Error(info.message || 'cloud_error'); var err = new Error(info.message || 'cloud_error');
err.status = info.status; err.status = info.status;
err.code = info.code; err.code = info.code;
err.apiNotified = true;
return Promise.reject(err); return Promise.reject(err);
} }
function requireCloudAuth(actionLabel) { function requireCloudAuth(actionLabel) {
if (getAccessToken()) return true; return !!getAccessToken();
if (typeof window.soonRequireLogin === 'function') return window.soonRequireLogin(actionLabel);
return false;
} }
function navigateToPage(pathWithQuery) { function navigateToPage(pathWithQuery) {
@@ -106,25 +156,52 @@
return 'en'; return 'en';
} }
function getSystemFonts() { var WEB_FONT_FALLBACK = [
var list = [
'Arial', 'Arial Black', 'Comic Sans MS', 'Courier New', 'Georgia', 'Arial', 'Arial Black', 'Comic Sans MS', 'Courier New', 'Georgia',
'Impact', 'Microsoft YaHei', 'SimHei', 'SimSun', 'KaiTi', 'FangSong', 'Impact', 'Microsoft YaHei', 'SimHei', 'SimSun', 'KaiTi', 'FangSong',
'Times New Roman', 'Trebuchet MS', 'Verdana', 'PingFang SC', 'Hiragino Sans GB' 'Times New Roman', 'Trebuchet MS', 'Verdana', 'PingFang SC', 'Hiragino Sans GB',
'Helvetica Neue', 'Segoe UI', 'Tahoma', 'STHeiti', 'STSong', 'STKaiti', 'STFangsong'
]; ];
if (typeof document !== 'undefined' && document.fonts && document.fonts.forEach) {
function mergeFontNames() {
var set = {}; var set = {};
WEB_FONT_FALLBACK.forEach(function (name) { set[name] = 1; });
if (typeof document !== 'undefined' && document.fonts && document.fonts.forEach) {
document.fonts.forEach(function (f) { document.fonts.forEach(function (f) {
var name = (f.family || '').replace(/^["']|["']$/g, ''); var name = (f.family || '').replace(/^["']|["']$/g, '');
if (name) set[name] = 1; 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) { function listCloudFiles(pageOrLimit, sizeOrOffset) {
if (!requireCloudAuth('查看文件')) return Promise.reject(new Error('unauthorized')); if (!getAccessToken()) {
var empty = { items: [], total: 0 };
if (typeof pageOrLimit === 'object' && pageOrLimit) {
empty.page = pageOrLimit.page || 1;
empty.size = pageOrLimit.size || 12;
}
return Promise.resolve(empty);
}
var url; var url;
var fallback = { items: [], total: 0 }; var fallback = { items: [], total: 0 };
if (typeof pageOrLimit === 'object' && pageOrLimit) { if (typeof pageOrLimit === 'object' && pageOrLimit) {
@@ -145,8 +222,10 @@
function createCloudFile(name, jsonStr) { function createCloudFile(name, jsonStr) {
if (!requireCloudAuth('保存')) return Promise.reject(new Error('unauthorized')); if (!requireCloudAuth('保存')) return Promise.reject(new Error('unauthorized'));
if (typeof window.soonRequireMember === 'function' && !window.soonRequireMember('保存')) { try {
return Promise.reject(new Error('membership_required')); jsonStr = prepareCloudJsonStr(jsonStr);
} catch (e) {
return Promise.reject(e);
} }
return authedFetch('files', { return authedFetch('files', {
method: 'POST', method: 'POST',
@@ -157,10 +236,46 @@
.then(packCloudResult); .then(packCloudResult);
} }
function syncCloudFileAfterConflict(id) {
var oldKey = (typeof window !== 'undefined' && window.openAs && window.openAs.name) ? window.openAs.name : '';
return authedFetch('files/' + id, { headers: { Accept: 'application/json' } })
.then(function (response) {
if (!response.ok) return null;
return response.json();
})
.then(function (payload) {
if (!payload || !payload.ok || !payload.data) return null;
var d = payload.data;
var newKey = typeof window.soonApplyCloudMeta === 'function'
? window.soonApplyCloudMeta(d)
: ('soondesign_file:' + d.id + ':v' + d.version);
if (oldKey && oldKey !== newKey && typeof window.soonLocalRemove === 'function') {
window.soonLocalRemove(oldKey);
}
if (typeof window.soonRecentOnCloudSave === 'function') {
window.soonRecentOnCloudSave({
fileKey: newKey,
fileId: d.id,
name: d.name,
version: d.version
}, oldKey, null);
}
if (typeof window !== 'undefined' && window.openAs && newKey) {
window.openAs.name = newKey;
}
if (typeof window.soonToast === 'function') {
window.soonToast('文件已在其他端更新,已同步版本,请再次保存', 'warn');
}
return newKey;
});
}
function updateCloudFile(id, name, jsonStr, version) { function updateCloudFile(id, name, jsonStr, version) {
if (!requireCloudAuth('保存')) return Promise.reject(new Error('unauthorized')); if (!requireCloudAuth('保存')) return Promise.reject(new Error('unauthorized'));
if (typeof window.soonRequireMember === 'function' && !window.soonRequireMember('保存')) { try {
return Promise.reject(new Error('membership_required')); jsonStr = prepareCloudJsonStr(jsonStr);
} catch (e) {
return Promise.reject(e);
} }
var body = { name: normalizeSoonName(name), json: jsonStr }; var body = { name: normalizeSoonName(name), json: jsonStr };
if (version != null) body.version = version; if (version != null) body.version = version;
@@ -169,7 +284,19 @@
headers: JSON_HEADERS, headers: JSON_HEADERS,
body: JSON.stringify(body) body: JSON.stringify(body)
}) })
.then(function (r) { return r.text().then(function (t) { return handleCloudResponse(r, t); }); }) .then(function (r) {
return r.text().then(function (t) {
if (r.status === 409) {
return syncCloudFileAfterConflict(id).then(function () {
var err = new Error('version_conflict');
err.status = 409;
err.conflictSynced = true;
return Promise.reject(err);
});
}
return handleCloudResponse(r, t);
});
})
.then(packCloudResult); .then(packCloudResult);
} }
@@ -185,9 +312,8 @@
} }
function downloadCloudFile(id, fileName) { function downloadCloudFile(id, fileName) {
if (!requireCloudAuth('下载')) return Promise.reject(new Error('unauthorized')); if (!getAccessToken()) {
if (typeof window.soonRequireMember === 'function' && !window.soonRequireMember('下载')) { return Promise.reject(new Error('unauthorized'));
return Promise.reject(new Error('membership_required'));
} }
return authedFetch('files/' + id + '/download', { headers: { Accept: 'application/octet-stream' } }) return authedFetch('files/' + id + '/download', { headers: { Accept: 'application/octet-stream' } })
.then(function (response) { .then(function (response) {
@@ -204,6 +330,52 @@
}); });
} }
function isSoonSaveDialog(options) {
if (options && options.soonFile === true) return true;
if (options && options.soonFile === false) return false;
var raw = (options && options.defaultPath) ? String(options.defaultPath) : '';
if (!raw) return true;
if (typeof window.soonIsTemplateKey === 'function' && window.soonIsTemplateKey(raw)) return true;
if (raw.indexOf('soondesign_file:') === 0 || raw.indexOf('soondesign_session:') === 0) return true;
var filters = options && options.filters;
if (filters && Array.isArray(filters)) {
for (var i = 0; i < filters.length; i++) {
var exts = filters[i].extensions;
if (exts && exts.indexOf('soon') >= 0) return true;
}
return false;
}
return /\.soon$/i.test(raw.split(/[/\\]/).pop() || '');
}
function genericSaveDialogName(raw) {
var n = String(raw || 'file').trim();
return n.split(/[/\\]/).pop() || n || 'file';
}
function resolveSaveDialogName(name, options, isSoon) {
var n = String(name || '').trim();
if (!n) n = isSoon ? 'design.soon' : 'file';
if (isSoon) {
return typeof window.soonEnsureSoonExt === 'function'
? window.soonEnsureSoonExt(n)
: normalizeSoonName(n);
}
return genericSaveDialogName(n);
}
function defaultSoonSaveDialogName(raw) {
if (!raw || (typeof window.soonIsTemplateKey === 'function' && window.soonIsTemplateKey(raw))) {
return typeof window.soonDefaultNewSoonName === 'function'
? window.soonDefaultNewSoonName()
: normalizeSoonName('design.soon');
}
if (raw.indexOf('soondesign_file:') === 0 && typeof window.soonDisplayFileName === 'function') {
return window.soonDisplayFileName(raw);
}
return normalizeSoonName(raw);
}
var bridge = { var bridge = {
readHistory: function () { readHistory: function () {
try { try {
@@ -236,27 +408,121 @@
}); });
} }
var key = typeof pathOrHandle === 'string' ? pathOrHandle : ''; var key = typeof pathOrHandle === 'string' ? pathOrHandle : '';
var cacheKey = key;
function cacheHitThen(hit) {
if (!hit || !hit.json) return null;
if (typeof window.soonLocalIsTemplateStale === 'function' &&
window.soonLocalIsTemplateStale(cacheKey, hit.meta)) {
if (typeof window.soonLocalRemove === 'function') {
return window.soonLocalRemove(cacheKey).then(function () { return null; });
}
return Promise.resolve(null);
}
if (cacheKey.indexOf('soondesign_file:') === 0 && hit.meta && hit.meta.name &&
typeof window.soonBindFileMeta === 'function') {
window.soonBindFileMeta(cacheKey, { name: hit.meta.name });
}
return Promise.resolve(hit.json);
}
function afterNetworkJson(json, meta) {
if (!json) return null;
if (typeof window.soonLocalCacheAndThumb === 'function') {
return window.soonLocalCacheAndThumb(cacheKey, json, meta || {}).then(function () { return json; });
}
return json;
}
if (key && typeof window.soonLocalGet === 'function') {
return window.soonLocalGet(cacheKey).then(function (hit) {
return cacheHitThen(hit).then(function (cached) {
if (cached) return cached;
return fetchFromNetwork(key, cacheKey);
});
}).catch(function () { return fetchFromNetwork(key, cacheKey); });
}
return fetchFromNetwork(key, cacheKey);
function fetchFromNetwork(key, cacheKey) {
if (key.indexOf('soondesign_session:') === 0) { if (key.indexOf('soondesign_session:') === 0) {
if (typeof window.soonLocalGet === 'function') {
return window.soonLocalGet(cacheKey).then(function (hit) {
if (hit && hit.json) return hit.json;
try { try {
var j = sessionStorage.getItem(key); var j = sessionStorage.getItem(key);
if (!j && typeof localStorage !== 'undefined') j = localStorage.getItem(key); if (!j && typeof localStorage !== 'undefined') j = localStorage.getItem(key);
return Promise.resolve(j ? JSON.parse(j) : null); if (!j || j === 'idb') return null;
var parsed = JSON.parse(j);
return afterNetworkJson(parsed, { source: 'session', name: key });
} catch (e) { return null; }
});
}
try {
var j = sessionStorage.getItem(key);
if (!j && typeof localStorage !== 'undefined') j = localStorage.getItem(key);
if (!j || j === 'idb') return Promise.resolve(null);
return Promise.resolve(JSON.parse(j));
} catch (e) { return Promise.resolve(null); } } catch (e) { return Promise.resolve(null); }
} }
if (key && key.indexOf('soondesign_file:') === 0 && typeof fetch !== 'undefined') { if (key && key.indexOf('soondesign_file:') === 0 && typeof fetch !== 'undefined') {
var fileMatch = key.match(/^soondesign_file:(\d+)/); var fileMatch = key.match(/^soondesign_file:(\d+)/);
if (fileMatch && getAccessToken()) { if (fileMatch && getAccessToken()) {
return authedFetch('files/' + fileMatch[1] + '/download', { headers: { Accept: 'application/json' } }) return authedFetch('files/' + fileMatch[1], { headers: { Accept: 'application/json' } })
.then(function (response) { .then(function (response) {
if (!response.ok) return null; if (!response.ok) return null;
return response.text().then(function (text) { return response.json();
try { return JSON.parse(text); } catch (e) { return null; } })
.then(function (payload) {
if (!payload || !payload.ok || !payload.data) return null;
var raw = payload.data.json;
var parsed;
if (typeof raw === 'string') {
try { parsed = JSON.parse(raw); } catch (e) { return null; }
} else {
parsed = raw && typeof raw === 'object' ? raw : null;
}
if (!parsed) return null;
if (typeof window.soonApplyCloudMeta === 'function') {
window.soonApplyCloudMeta(payload.data);
}
return afterNetworkJson(parsed, {
source: 'cloud',
name: payload.data.name || '',
type: parsed.soonType || 1,
updatedAt: payload.data.updated_at || ''
});
})
.catch(function () { return null; });
}
}
if (key && key.indexOf('soondesign_template:') === 0 && typeof fetch !== 'undefined') {
var tplMatch = key.match(/^soondesign_template:(\d+)/);
if (tplMatch) {
var apiBase = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || '';
if (!apiBase) return Promise.resolve(null);
return fetch(apiBase + '/templates/' + tplMatch[1] + '/file', { headers: { Accept: 'application/json' } })
.then(function (response) {
if (!response.ok) return null;
return response.text();
})
.then(function (text) {
if (!text) return null;
var parsed;
try { parsed = JSON.parse(text); } catch (e) { return null; }
var tplMeta = window._soonTemplateMeta || {};
return afterNetworkJson(parsed, {
source: 'template',
name: tplMeta.name || '',
type: tplMeta.type || (parsed.soonType || 1),
updatedAt: tplMeta.updated_at || ''
}); });
}) })
.catch(function () { return null; }); .catch(function () { return null; });
} }
} }
return Promise.resolve(null); return Promise.resolve(null);
}
}, },
showOpenDialog: function (options) { showOpenDialog: function (options) {
return new Promise(function (resolve) { return new Promise(function (resolve) {
@@ -295,19 +561,56 @@
}, },
showSaveDialog: function (options) { showSaveDialog: function (options) {
var raw = (options && options.defaultPath) ? options.defaultPath : ''; var raw = (options && options.defaultPath) ? options.defaultPath : '';
var defaultName = normalizeSoonName(raw || 'design.soon'); var isSoon = isSoonSaveDialog(options);
if (typeof window.soonDisplayFileName === 'function' && raw.indexOf('soondesign_file:') === 0) { var defaultName = isSoon ? defaultSoonSaveDialogName(raw) : genericSaveDialogName(raw);
defaultName = window.soonDisplayFileName(raw); var title = (options && options.title) ? options.title : (isSoon ? '保存到云端' : '保存文件');
}
return new Promise(function (resolve) { return new Promise(function (resolve) {
var name = typeof prompt === 'function' ? prompt('保存为文件名(如 xxx.soon', defaultName) : defaultName; if (typeof layer === 'undefined' || !layer.open) {
if (name === null) { resolve({ canceled: false, filePath: defaultName, useCloud: !!isSoon });
resolve({ canceled: true });
return; return;
} }
var fp = (name && String(name).trim()) ? String(name).trim() : defaultName; var esc = function (s) {
fp = normalizeSoonName(fp); return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
resolve({ canceled: false, filePath: fp, useCloud: true }); };
var html = '<div class="soon-dialog-body" style="padding:16px 20px;">' +
'<label style="display:block;margin-bottom:8px;color:#b8c0c8;font-size:13px;">文件名</label>' +
'<input type="text" id="soonSaveDialogInput" class="layui-input" value="' + esc(defaultName) + '" ' +
'style="background:#2b3036;border-color:rgba(255,255,255,0.08);color:#eef1f4;">' +
'</div>';
layer.open({
type: 1,
skin: 'soon-layer',
title: title,
area: ['360px', 'auto'],
shadeClose: false,
content: html,
btn: ['确定', '取消'],
btnAlign: 'r',
yes: function (index) {
var input = document.getElementById('soonSaveDialogInput');
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 });
}
});
}); });
}, },
writeFile: function (pathOrHandle, content) { writeFile: function (pathOrHandle, content) {
@@ -328,23 +631,97 @@
pathOrHandle = typeof pathOrHandle.name === 'string' ? pathOrHandle.name : 'design.soon'; pathOrHandle = typeof pathOrHandle.name === 'string' ? pathOrHandle.name : 'design.soon';
} }
if (!requireCloudAuth('保存')) {
return Promise.reject(new Error('unauthorized'));
}
var name = typeof pathOrHandle === 'string' ? pathOrHandle : 'design.soon'; var name = typeof pathOrHandle === 'string' ? pathOrHandle : 'design.soon';
var cloudRef = parseCloudRef(name); var cloudRef = parseCloudRef(name);
var fileName = normalizeSoonName(name); var fileName = normalizeSoonName(name);
var parsedJson;
try { parsedJson = JSON.parse(str); } catch (e) { parsedJson = null; }
function cacheWriteForKey(targetKey, meta) {
if (!parsedJson || typeof window.soonLocalCacheAndThumb !== 'function') return Promise.resolve();
return window.soonLocalCacheAndThumb(targetKey, parsedJson, meta || {});
}
function afterCloudSave(res, prevKey) {
if (!res || !res.fileKey || !parsedJson) return res;
var meta = { source: 'cloud', name: res.name || fileName, type: parsedJson.soonType || 1 };
if (prevKey && prevKey !== res.fileKey && typeof window.soonLocalRenameKey === 'function') {
return window.soonLocalRenameKey(prevKey, res.fileKey, meta).then(function () { return res; });
}
return cacheWriteForKey(res.fileKey, meta).then(function () { return res; });
}
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);
}
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 };
}
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(localKey, meta).then(function () {
return finishLocalSave(localKey);
});
}
function syncCloudIfAuthed(localResult) {
if (!getAccessToken()) return Promise.resolve(localResult);
if (cloudRef && cloudRef.id) { if (cloudRef && cloudRef.id) {
var ver = cloudRef.version; var ver = cloudRef.version;
if (ver == null && window._soonFileMeta && window._soonFileMeta.id === cloudRef.id) { if (ver == null && window._soonFileMeta && window._soonFileMeta.id === cloudRef.id) {
ver = window._soonFileMeta.version; ver = window._soonFileMeta.version;
} }
return updateCloudFile(cloudRef.id, fileName, str, ver); 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 () {
localResult.syncFailed = true;
return localResult;
});
} }
return createCloudFile(fileName, str); return saveLocalFirst().then(syncCloudIfAuthed).catch(function () {
return Promise.reject(new Error('save_failed'));
});
}, },
readFile: function (pathOrHandle) { readFile: function (pathOrHandle) {
if (pathOrHandle && pathOrHandle.getFile) { if (pathOrHandle && pathOrHandle.getFile) {
@@ -368,16 +745,11 @@
getSystemFonts: getSystemFonts, getSystemFonts: getSystemFonts,
openDesignPage: function (file, type) { openDesignPage: function (file, type) {
var t = type || 1; var t = type || 1;
if (file && typeof sessionStorage !== 'undefined') { var key = file || '';
try { if (typeof window.soonSyncOpenNavigation === 'function') {
sessionStorage.setItem('soondesign_open_file', file); window.soonSyncOpenNavigation(key, t);
sessionStorage.setItem('soondesign_open_type', String(t));
if (file.indexOf('soondesign_file:') === 0 && window._soonFileMeta) {
sessionStorage.setItem('soondesign_open_meta', JSON.stringify(window._soonFileMeta));
} }
} catch (e) {} var fileParam = key ? encodeURIComponent(key) : '';
}
var fileParam = file ? encodeURIComponent(file) : '';
navigateToPage('design' + t + '.web.html?file=' + fileParam + '&type=' + t); navigateToPage('design' + t + '.web.html?file=' + fileParam + '&type=' + t);
}, },
openFirstPage: function () { openFirstPage: function () {
@@ -438,6 +810,7 @@
showOpenDialog: bridge.showOpenDialog, showOpenDialog: bridge.showOpenDialog,
showSaveDialog: bridge.showSaveDialog showSaveDialog: bridge.showSaveDialog
}; };
window.soonShowSaveDialog = bridge.showSaveDialog;
window.path = pathStub; window.path = pathStub;
window.fs = null; window.fs = null;
window.remote = null; window.remote = null;
@@ -445,6 +818,11 @@
window.ipcRenderer = { window.ipcRenderer = {
send: function (ch, a1, a2) { 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-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-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-design-page' && bridge.openDesignPage && (a1 !== undefined || a2 !== undefined)) { bridge.openDesignPage(a1 || '', a2 || 1); }
if (ch === 'open-help-file' && bridge.openHelp) { bridge.openHelp(); } if (ch === 'open-help-file' && bridge.openHelp) { bridge.openHelp(); }
@@ -452,6 +830,14 @@
}, },
on: function (ch, cb) { 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 === '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; if (ch === 'close') window._closeCb = cb;
} }
}; };
+311 -16
View File
@@ -486,12 +486,45 @@ body.soon-admin-page .soon-admin-date-wrap input[type="date"].soon-input::-webki
border-radius: 4px; border-radius: 4px;
} }
.soon-admin-form { padding: var(--soon-space-md); } .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 { .soon-admin-form--plan {
padding: var(--soon-space-lg); padding: var(--soon-space-lg);
} }
.soon-admin-form--plan .soon-admin-form-panel:last-of-type { .soon-admin-form--plan .soon-admin-form-panel:last-of-type {
margin-bottom: 0; margin-bottom: 0;
} }
.soon-admin-form--modal-shell .soon-admin-form-panel:last-of-type {
margin-bottom: 0;
}
.soon-admin-form-grid { .soon-admin-form-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; 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); padding-top: var(--soon-space-md);
border-top: 1px solid var(--soon-border); 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 { .soon-admin-filter-bar {
display: flex; display: flex;
@@ -719,26 +768,117 @@ body.soon-admin-page .soon-admin-date-wrap input[type="date"].soon-input::-webki
white-space: nowrap; white-space: nowrap;
} }
.soon-admin-key-form-row { .soon-admin-form--pay { padding: 0; }
display: grid; .soon-admin-form-panel--pay {
grid-template-columns: 140px 1fr auto; padding: var(--soon-space-md) var(--soon-space-lg);
gap: var(--soon-space-sm);
align-items: start;
padding: var(--soon-space-md) 0;
border-bottom: 1px solid var(--soon-border);
} }
.soon-admin-key-form-row:last-child { border-bottom: none; } .soon-admin-form--pay .soon-admin-tabs {
.soon-admin-key-form-row__label { margin-top: var(--soon-space-md);
font-size: var(--soon-font-sm); padding-top: var(--soon-space-md);
color: var(--soon-text); border-top: 1px solid var(--soon-border);
padding-top: 8px;
} }
.soon-admin-key-form-row__status { .soon-admin-form--pay .soon-admin-form__actions {
margin-top: var(--soon-space-sm);
padding: var(--soon-space-sm) 0 0;
background: none;
border: none;
border-radius: 0;
}
.soon-admin-pay-status {
display: flex;
gap: 8px;
margin-bottom: var(--soon-space-md);
}
.soon-admin-pay-status__item {
padding: 2px 10px;
border-radius: 999px;
font-size: var(--soon-font-xs); font-size: var(--soon-font-xs);
margin-top: 4px; border: 1px solid var(--soon-border);
color: var(--soon-text-muted);
}
.soon-admin-pay-status__item.is-ok {
color: var(--soon-accent);
border-color: rgba(0, 150, 136, 0.35);
background: rgba(0, 150, 136, 0.08);
}
.soon-admin-pay-status__item.is-miss {
color: var(--soon-danger);
border-color: rgba(244, 67, 54, 0.35);
background: rgba(244, 67, 54, 0.08);
}
.soon-admin-pay-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 20px;
}
@media (max-width: 768px) {
.soon-admin-pay-grid { grid-template-columns: 1fr; }
}
.soon-admin-pay-field { min-width: 0; }
.soon-admin-pay-field--wide { grid-column: 1 / -1; }
.soon-admin-pay-field__label {
display: block;
margin-bottom: 4px;
font-size: var(--soon-font-xs);
color: var(--soon-text-muted);
}
.soon-admin-pay-field__control { min-width: 0; }
.soon-admin-pay-field__control > .soon-input { width: 100%; }
.soon-admin-pay-checks {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
min-height: 32px;
align-items: center;
}
.soon-input--readonly {
opacity: 0.8;
cursor: default;
background: rgba(255, 255, 255, 0.03);
font-size: var(--soon-font-xs);
}
.soon-admin-pay-key {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.soon-admin-pay-key__badge {
min-width: 48px;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
text-align: center;
}
.soon-admin-pay-key__badge.is-ok {
color: var(--soon-accent);
background: rgba(0, 150, 136, 0.12);
}
.soon-admin-pay-key__badge.is-miss {
color: var(--soon-danger);
background: rgba(244, 67, 54, 0.1);
}
.soon-admin-pay-key__text { flex: 1; min-width: 0; }
.soon-admin-pay-key__file { cursor: pointer; margin: 0; }
.soon-admin-pay-key__paste {
display: none;
width: 100%;
margin-top: 6px;
}
.soon-admin-pay-key__paste.is-open { display: block; }
.soon-admin-pay-key__paste .soon-input { width: 100%; margin-bottom: 4px; }
.soon-admin-form-checks--inline {
margin-top: 0;
padding-top: 0;
border-top: none;
} }
.soon-admin-key-form-row__status--ok { color: var(--soon-accent); }
.soon-admin-key-form-row__status--miss { color: var(--soon-danger); }
.soon-admin-btn-group { .soon-admin-btn-group {
display: flex; display: flex;
@@ -847,6 +987,148 @@ body.soon-admin-page .soon-admin-date-wrap input[type="date"].soon-input::-webki
line-height: 1.5; line-height: 1.5;
} }
.soon-admin-form--tpl { padding: var(--soon-space-md) var(--soon-space-lg); }
.soon-admin-tpl-modal {
padding: var(--soon-space-lg);
box-sizing: border-box;
}
.soon-admin-tpl-card {
display: flex;
align-items: stretch;
gap: var(--soon-space-md);
width: 100%;
margin: 0 0 var(--soon-space-md);
padding: var(--soon-space-md);
text-align: left;
background: var(--soon-bg-elevated);
border: 1px solid var(--soon-border);
border-radius: var(--soon-radius-md);
cursor: default;
color: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.soon-admin-tpl-card.is-ready {
cursor: zoom-in;
}
.soon-admin-tpl-card.is-ready:hover {
border-color: rgba(0, 150, 136, 0.45);
box-shadow: 0 0 0 1px rgba(0, 150, 136, 0.12);
}
.soon-admin-tpl-card__media {
position: relative;
flex-shrink: 0;
width: 96px;
height: 120px;
border-radius: var(--soon-radius-sm);
background: var(--soon-bg-base);
border: 1px solid var(--soon-border);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.soon-admin-tpl-card__media img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.soon-admin-tpl-card__placeholder {
padding: 0 8px;
font-size: 11px;
line-height: 1.4;
text-align: center;
color: var(--soon-text-muted);
}
.soon-admin-tpl-card__hint {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 4px 6px;
font-size: 10px;
text-align: center;
color: #fff;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.72));
}
.soon-admin-tpl-card__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
}
.soon-admin-tpl-card__name {
font-size: var(--soon-font-md);
font-weight: 600;
color: var(--soon-text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.soon-admin-tpl-card__type {
display: inline-flex;
align-self: flex-start;
padding: 2px 10px;
font-size: var(--soon-font-xs);
color: var(--soon-text-muted);
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--soon-border);
border-radius: 999px;
}
.soon-admin-tpl-picker {
display: flex;
align-items: center;
gap: var(--soon-space-sm);
margin-bottom: var(--soon-space-md);
cursor: pointer;
}
.soon-admin-tpl-picker__input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
overflow: hidden;
}
.soon-admin-tpl-picker__name {
flex: 1;
min-width: 0;
font-size: var(--soon-font-sm);
color: var(--soon-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.soon-admin-tpl-options {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--soon-space-md);
margin-bottom: var(--soon-space-md);
}
.soon-admin-tpl-option {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: var(--soon-font-sm);
color: var(--soon-text-muted);
}
.soon-admin-tpl-option .soon-input {
width: 88px;
}
.soon-admin-tpl-option--check {
margin-left: auto;
color: var(--soon-text);
}
.soon-admin-tpl-modal__actions {
display: flex;
justify-content: flex-end;
padding-top: var(--soon-space-md);
border-top: 1px solid var(--soon-border);
}
.soon-admin-detail-empty { .soon-admin-detail-empty {
margin: 0; margin: 0;
font-size: var(--soon-font-sm); font-size: var(--soon-font-sm);
@@ -897,3 +1179,16 @@ body.soon-admin-page .layui-layer-soon-layer .layui-layer-ico {
filter: brightness(0) invert(1); filter: brightness(0) invert(1);
opacity: 0.92; opacity: 0.92;
} }
.layui-layer.soon-layer.soon-layer--tpl,
.layui-layer-soon-layer.soon-layer--tpl {
border-radius: var(--soon-radius-md) !important;
}
.layui-layer.soon-layer.soon-layer--tpl .layui-layer-content,
.layui-layer-soon-layer.soon-layer--tpl .layui-layer-content {
overflow: hidden !important;
max-height: none !important;
height: auto !important;
padding: 0 !important;
}
@@ -11,6 +11,7 @@
'/users': 'users', '/users': 'users',
'/orders': 'orders', '/orders': 'orders',
'/plans': 'plans', '/plans': 'plans',
'/templates': 'templates',
'/settings': 'settings', '/settings': 'settings',
'/audits': 'audits', '/audits': 'audits',
'/payment': 'payment', '/payment': 'payment',
@@ -21,6 +22,7 @@
users: '/users', users: '/users',
orders: '/orders', orders: '/orders',
plans: '/plans', plans: '/plans',
templates: '/templates',
settings: '/settings', settings: '/settings',
audits: '/audits', audits: '/audits',
payment: '/payment', payment: '/payment',
+55 -11
View File
@@ -141,31 +141,75 @@
setBtnLoading(btn, true, opts.loadingLabel); setBtnLoading(btn, true, opts.loadingLabel);
ret.finally(function () { ret.finally(function () {
setBtnLoading(btn, false); 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) { function openFormModal(title, formHtml, onSave, opts) {
if (typeof layer === 'undefined') return; if (typeof layer === 'undefined') return;
opts = opts || {}; opts = opts || {};
layer.open({ layer.open({
type: 1, type: 1,
skin: 'soon-layer', skin: opts.skin || 'soon-layer',
title: title, title: title,
area: opts.area || ['420px', 'auto'], area: opts.area || ['420px', 'auto'],
maxWidth: opts.maxWidth || '96vw', maxWidth: opts.maxWidth || '96vw',
content: formHtml, content: formHtml,
success: function (layero) { success: function (layero) {
var area = opts.area || ['420px', 'auto']; var contentEl = layerContentEl(layero);
if (area[1] && area[1] !== 'auto') { layoutAdminFormModal(contentEl, opts);
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 btn = document.getElementById('adminModalSave'); var btn = document.getElementById('adminModalSave');
if (btn) { if (btn) {
btn.onclick = function () { btn.onclick = function () {
@@ -33,6 +33,7 @@
{ href: '#/users', label: '用户管理' }, { href: '#/users', label: '用户管理' },
{ href: '#/orders', label: '订单管理' }, { href: '#/orders', label: '订单管理' },
{ href: '#/plans', label: '套餐配置' }, { href: '#/plans', label: '套餐配置' },
{ href: '#/templates', label: '模板库' },
{ href: '#/payment', label: '支付密钥' }, { href: '#/payment', label: '支付密钥' },
{ href: '#/settings', label: '系统设置' }, { href: '#/settings', label: '系统设置' },
{ href: '#/audits', label: '审计日志' }, { href: '#/audits', label: '审计日志' },
@@ -96,7 +97,7 @@
statCard('订单总数', d.orders, { href: '#/orders', hint: '含全部状态' }) + statCard('订单总数', d.orders, { href: '#/orders', hint: '含全部状态' }) +
statCard('今日订单', d.orders_today, { href: '#/orders', hint: '按创建日统计' }) + statCard('今日订单', d.orders_today, { href: '#/orders', hint: '按创建日统计' }) +
statCard('待支付', d.orders_pending || 0, { href: '#/orders?status=pending', hint: '需关注超时未付' }) + statCard('待支付', d.orders_pending || 0, { href: '#/orders?status=pending', hint: '需关注超时未付' }) +
statCard('活跃订阅', d.active_subscriptions, { href: '#/users', hint: '有效会员数' }) + statCard('活跃会员', d.active_subscriptions, { href: '#/users', hint: '有效会员数' }) +
statCard('今日收入', I().formatCents(d.revenue_today_cents), { href: '#/orders', hint: '已支付订单' }) + statCard('今日收入', I().formatCents(d.revenue_today_cents), { href: '#/orders', hint: '已支付订单' }) +
statCard('云端文件', d.files_total, { hint: '未删除的设计文件' }) + statCard('云端文件', d.files_total, { hint: '未删除的设计文件' }) +
statCard('退款待审', d.refunds_pending || 0, { href: '#/orders?refund_status=pending', hint: '需在订单页处理' }); statCard('退款待审', d.refunds_pending || 0, { href: '#/orders?refund_status=pending', hint: '需在订单页处理' });
@@ -4,6 +4,7 @@
var Api = function () { return window.AdminApi; }; var Api = function () { return window.AdminApi; };
var Ui = function () { return window.AdminUi; }; var Ui = function () { return window.AdminUi; };
var I = function () { return window.AdminI18n; }; var I = function () { return window.AdminI18n; };
var Form = function () { return window.AdminForm; };
var State = function () { return window.AdminState; }; var State = function () { return window.AdminState; };
var Filter = function () { return window.AdminFilter; }; var Filter = function () { return window.AdminFilter; };
var Menu = function () { return window.AdminMenu; }; var Menu = function () { return window.AdminMenu; };
@@ -162,7 +163,7 @@
'<div class="soon-form-field"><label class="soon-form-label">备注</label>' + '<div class="soon-form-field"><label class="soon-form-label">备注</label>' +
'<textarea id="orderNote" class="soon-input soon-input--sm" rows="3" placeholder="' + I().esc(placeholder) + '<textarea id="orderNote" class="soon-input soon-input--sm" rows="3" placeholder="' + I().esc(placeholder) +
'"></textarea></div>' + '"></textarea></div>' +
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">确认</button></div>'; Form().actions('确认', 'adminModalSave') + '</div>';
Ui().openFormModal(title, form, function () { Ui().openFormModal(title, form, function () {
return onSave(Ui().el('orderNote').value.trim()); return onSave(Ui().el('orderNote').value.trim());
}); });
@@ -202,11 +203,11 @@
var subHtml; var subHtml;
if (sub) { if (sub) {
subHtml = Ui().detailRow('订阅状态', I().badgeStatus(sub.status)) + subHtml = Ui().detailRow('会员状态', I().badgeStatus(sub.status)) +
Ui().detailRow('开始', I().esc(I().formatDateTime(sub.started_at))) + Ui().detailRow('开始', I().esc(I().formatDateTime(sub.started_at))) +
Ui().detailRow('到期', I().esc(I().formatDateTime(sub.expires_at))); Ui().detailRow('到期', I().esc(I().formatDateTime(sub.expires_at)));
} else { } else {
subHtml = '<p class="soon-admin-detail-empty">无关联订阅</p>'; subHtml = '<p class="soon-admin-detail-empty">无关联会员记录</p>';
} }
var drawerActions = rowActions(o).replace(/<button[^>]*order-detail-btn[^>]*>[\s\S]*?<\/button>/, ''); var drawerActions = rowActions(o).replace(/<button[^>]*order-detail-btn[^>]*>[\s\S]*?<\/button>/, '');
@@ -246,7 +247,7 @@
openNoteModal(title, '可选:填写审核备注', function (note) { openNoteModal(title, '可选:填写审核备注', function (note) {
return Api().postJson('orders/' + btn.dataset.id + '/refund/approve', { note: note }).then(function (r) { return Api().postJson('orders/' + btn.dataset.id + '/refund/approve', { note: note }).then(function (r) {
if (r) { if (r) {
layer.closeAll(); soonLayerDismiss();
var gw = r.data && r.data.gateway; var gw = r.data && r.data.gateway;
var msg = '退款已完成'; var msg = '退款已完成';
if (gw && gw.indexOf('skipped') === 0) 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) { return Api().postJson('orders/' + btn.dataset.id + '/refund/reject', { note: note }).then(function (r) {
if (r) { if (r) {
layer.closeAll(); soonLayerDismiss();
soonToast('已拒绝退款申请', 'success'); soonToast('已拒绝退款申请', 'success');
Ui().closeDrawer(); Ui().closeDrawer();
render(); render();
@@ -1,290 +1,272 @@
(function () { (function () {
'use strict'; 'use strict';
var Api = function () { return window.AdminApi; }; var Api = function () { return window.AdminApi; };
var Ui = function () { return window.AdminUi; }; var Ui = function () { return window.AdminUi; };
var I = function () { return window.AdminI18n; }; var I = function () { return window.AdminI18n; };
var Form = function () { return window.AdminForm; }; var Form = function () { return window.AdminForm; };
var Tabs = function () { return window.AdminTabs; }; var Tabs = function () { return window.AdminTabs; };
var PARAM_KEYS = [
'site.base_url',
'site.front_base_url',
'alipay.app_id',
'wechat.app_id',
'wechat.mch_id',
'wechat.mch_serial_no',
];
var KEY_FIELDS = {
var CHANNEL_CONFIG = { alipay: [
{ kind: 'private_key', file: 'alipay_private_key.pem', label: '应用私钥' },
alipay: { { kind: 'public_key', file: 'alipay_public_key.pem', label: '支付宝公钥' },
label: '支付宝',
kinds: [
{ kind: 'private_key', file: 'alipay_private_key.pem', label: '应用私钥 private_key' },
{ kind: 'public_key', file: 'alipay_public_key.pem', label: '支付宝公钥 public_key' },
], ],
wechat: [
}, { kind: 'mch_private_key', file: 'wechat_mch_private_key.pem', label: '商户私钥' },
{ kind: 'api_v3_key', file: 'wechat_api_v3_key.pem', label: 'APIv3 密钥', text: true },
wechat: { { kind: 'platform_cert', file: 'wechat_platform_cert.pem', label: '平台证书' },
label: '微信支付',
kinds: [
{ kind: 'mch_private_key', file: 'wechat_mch_private_key.pem', label: '商户私钥 mch_private_key' },
{ kind: 'api_v3_key', file: 'wechat_api_v3_key.pem', label: 'APIv3 密钥 api_v3_key' },
{ kind: 'platform_cert', file: 'wechat_platform_cert.pem', label: '平台证书 platform_cert' },
], ],
},
}; };
function cfgId(key) {
return 'pay_' + key.replace(/\./g, '_');
}
function fileSet(items) { function fileSet(items) {
var set = {}; var set = {};
(items || []).forEach(function (p) { set[p.name] = p; }); (items || []).forEach(function (p) { set[p.name] = p; });
return set; return set;
} }
function deployDefaults() {
var cfg = window.SOON_DEPLOY_CONFIG || {};
function channelFormHtml(channel, files) { return {
apiBase: String(cfg.api_v1_base || '').replace(/\/api\/v1\/?$/, ''),
var cfg = CHANNEL_CONFIG[channel]; frontBase: cfg.front_base_url || cfg.web_base || '',
var rows = cfg.kinds.map(function (k) {
var ok = !!files[k.file];
var statusCls = ok ? 'soon-admin-key-form-row__status--ok' : 'soon-admin-key-form-row__status--miss';
var statusText = ok ? '已就绪' : '缺失';
var id = channel + '_' + k.kind;
return '<div class="soon-admin-key-form-row">' +
'<div><div class="soon-admin-key-form-row__label">' + I().esc(k.label) + '</div>' +
'<div class="soon-admin-key-form-row__status ' + statusCls + '">' + statusText + '</div></div>' +
'<textarea id="' + id + '" class="soon-input soon-input--sm soon-input--textarea" rows="4" ' +
'placeholder="粘贴 PEM 内容或拖入 .pem 文件"></textarea>' +
'<button type="button" class="soon-btn soon-btn--sm pay-upload-btn" data-channel="' + channel +
'" data-kind="' + k.kind + '" data-target="' + id + '">上传</button></div>';
}).join('');
return '<div class="soon-admin-form">' + Form().panel(cfg.label + ' 密钥', rows) +
'<details class="soon-admin-hint"><summary>回调说明</summary>' +
'<p>notify<code>/api/v1/pay/' + channel + '/notify</code>(须配置 site.base_url 为 HTTPS 外网地址)</p></details>' +
'<div class="soon-admin-upload" id="payDrop_' + channel + '">拖拽 .pem 到此处自动填入对应字段</div></div>';
}
function filesTabHtml(items) {
var rows = (items || []).map(function (p) {
return '<tr><td>' + I().esc(p.name) + '</td><td>' + p.bytes + ' B</td><td>' +
I().formatDateTime(p.mtime) + '</td></tr>';
}).join('');
return Ui().dataTable(['文件名', '大小', '修改时间'], rows);
}
function uploadPem(channel, kind, content, onDone) {
return Api().fetch('payment/upload', {
method: 'POST',
body: JSON.stringify({ channel: channel, kind: kind, content: content }),
}).then(function (j) {
if (j) {
soonToast('密钥已上传', 'success');
if (onDone) onDone();
}
return j;
});
}
function bindChannel(channel, panel) {
panel.querySelectorAll('.pay-upload-btn').forEach(function (btn) {
btn.onclick = function () {
var target = Ui().el(btn.dataset.target);
var content = target ? target.value.trim() : '';
if (!content) {
soonToast('请先粘贴或拖入 PEM 内容', 'warn');
return;
}
Ui().runFormSubmit(btn, function () {
return uploadPem(btn.dataset.channel, btn.dataset.kind, content, render);
});
}; };
});
var drop = Ui().el('payDrop_' + channel);
if (!drop) return;
drop.ondragover = function (e) { e.preventDefault(); drop.classList.add('is-dragover'); };
drop.ondragleave = function () { drop.classList.remove('is-dragover'); };
drop.ondrop = function (e) {
e.preventDefault();
drop.classList.remove('is-dragover');
var file = e.dataTransfer.files[0];
if (!file) return;
file.text().then(function (text) {
var name = file.name.toLowerCase();
var cfg = CHANNEL_CONFIG[channel];
var matched = cfg.kinds.find(function (k) { return name.indexOf(k.kind.replace(/_/g, '')) >= 0 || name === k.file; });
var targetId = matched ? channel + '_' + matched.kind : channel + '_' + cfg.kinds[0].kind;
var ta = Ui().el(targetId);
if (ta) ta.value = text;
soonToast('已填入 ' + (matched ? matched.label : '首个字段') + ',请确认后点击上传', 'success');
});
};
} }
function parseDisplayChannels(values) {
var raw = values && values['payment.display_channels'] != null
? values['payment.display_channels'] : 'alipay';
return String(raw).split(/[\s,]+/).filter(function (v) { return v === 'alipay' || v === 'wechat'; });
}
function statusLine(readiness) {
readiness = readiness || {};
var a = readiness.alipay || {};
var w = readiness.wechat || {};
return '<div class="soon-admin-pay-status">' +
'<span class="soon-admin-pay-status__item' + (a.ready ? ' is-ok' : ' is-miss') + '">支付宝</span>' +
'<span class="soon-admin-pay-status__item' + (w.ready ? ' is-ok' : ' is-miss') + '">微信支付</span></div>';
}
function render() { function field(label, control, wide) {
return '<div class="soon-admin-pay-field' + (wide ? ' soon-admin-pay-field--wide' : '') + '">' +
'<label class="soon-admin-pay-field__label">' + I().esc(label) + '</label>' +
'<div class="soon-admin-pay-field__control">' + control + '</div></div>';
}
var panel = Ui().el('panel-payment'); function grid(cells) {
return '<div class="soon-admin-pay-grid">' + cells.join('') + '</div>';
}
if (!panel) return; function textInput(key, values, placeholder) {
var val = values && values[key] != null ? values[key] : '';
return Form().input(cfgId(key), { value: val, placeholder: placeholder || '' });
}
panel.innerHTML = '<div class="soon-admin-page">' + Ui().pageHeader('支付配置', '按渠道管理密钥文件,上传后立即热加载') + function readonlyInput(value) {
return '<input class="soon-input soon-input--sm soon-input--readonly" readonly value="' +
I().esc(value || '') + '">';
}
Ui().skeletonTable() + '</div>'; function displayChannelsField(selected) {
if (!selected.length) selected = ['alipay'];
var mk = function (ch, label) {
var on = selected.indexOf(ch) >= 0;
return '<label class="soon-admin-check"><input type="checkbox" class="pay-disp-ch" value="' + ch + '"' +
(on ? ' checked' : '') + '> ' + label + '</label>';
};
return field('用户端展示',
'<div class="soon-admin-pay-checks">' + mk('alipay', '支付宝') + mk('wechat', '微信') + '</div>', true);
}
function siteGrid(values, notifyUrls) {
var defs = deployDefaults();
return grid([
displayChannelsField(parseDisplayChannels(values)),
field('API 外网', textInput('site.base_url', values, defs.apiBase)),
field('前端站点', textInput('site.front_base_url', values, defs.frontBase)),
field('支付宝回调', readonlyInput(notifyUrls.alipay)),
field('微信回调', readonlyInput(notifyUrls.wechat)),
]);
}
function keyField(channel, keyDef, files) {
var ok = !!files[keyDef.file];
var id = channel + '_' + keyDef.kind;
var pasteId = id + '_paste';
var badge = '<span class="soon-admin-pay-key__badge ' + (ok ? 'is-ok' : 'is-miss') + '">' +
(ok ? '已配置' : '未配置') + '</span>';
var inner;
if (keyDef.text) {
inner = badge +
Form().input(id + '_text', { placeholder: 'APIv3 密钥', className: 'soon-admin-pay-key__text' }) +
'<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost pay-key-upload" data-channel="' +
channel + '" data-kind="' + keyDef.kind + '" data-source="' + id + '_text">写入</button>';
} else {
inner = badge +
'<label class="soon-btn soon-btn--xs soon-btn--ghost soon-admin-pay-key__file">上传' +
'<input type="file" class="pay-key-file" accept=".pem,.txt,.key" data-channel="' +
channel + '" data-kind="' + keyDef.kind + '" hidden></label>' +
'<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost pay-key-paste" data-target="' +
pasteId + '">粘贴</button>' +
'<div id="' + pasteId + '_box" class="soon-admin-pay-key__paste">' +
'<textarea id="' + pasteId + '" class="soon-input soon-input--sm" rows="2" placeholder="PEM"></textarea>' +
'<button type="button" class="soon-btn soon-btn--xs pay-key-commit" data-channel="' + channel +
'" data-kind="' + keyDef.kind + '" data-target="' + pasteId + '">确认</button></div>';
}
return field(keyDef.label, '<div class="soon-admin-pay-key">' + inner + '</div>');
}
Api().get('payment/status').then(function (j) { function channelGrid(channel, files, values) {
var cells = [];
if (!j) return; if (channel === 'alipay') {
cells.push(field('APPID', textInput('alipay.app_id', values, '开放平台 APPID')));
var items = j.data.items || []; KEY_FIELDS.alipay.forEach(function (f) { cells.push(keyField('alipay', f, files)); });
} else {
var files = fileSet(items); cells.push(field('AppID', textInput('wechat.app_id', values, 'AppID')));
cells.push(field('商户号', textInput('wechat.mch_id', values, 'mch_id')));
cells.push(field('证书序列号', textInput('wechat.mch_serial_no', values, 'serial_no')));
KEY_FIELDS.wechat.forEach(function (f) { cells.push(keyField('wechat', f, files)); });
var tabsHtml = Tabs().html('paymentTabs', [ }
return grid(cells);
}
function formHtml(values, files, notifyUrls, readiness) {
var tabs = Tabs().html('paymentTabs', [
{ id: 'alipay', label: '支付宝' }, { id: 'alipay', label: '支付宝' },
{ id: 'wechat', label: '微信' },
{ id: 'wechat', label: '微信支付' },
{ id: 'files', label: '文件清单' },
], { ], {
alipay: channelGrid('alipay', files, values),
alipay: channelFormHtml('alipay', files), wechat: channelGrid('wechat', files, values),
wechat: channelFormHtml('wechat', files),
files: filesTabHtml(items),
}, 'alipay'); }, 'alipay');
return '<div class="soon-admin-form soon-admin-form--pay">' +
'<div class="soon-admin-form-panel soon-admin-form-panel--pay">' +
panel.innerHTML = '<div class="soon-admin-page">' + statusLine(readiness) + siteGrid(values, notifyUrls) + tabs +
'</div>' + Form().actions('保存', 'payFormSave') + '</div>';
Ui().pageHeader('支付配置', '按渠道管理密钥文件,上传后立即热加载') +
tabsHtml + '</div>';
var tabsRoot = Ui().el('paymentTabs');
Tabs().bind(tabsRoot);
bindChannel('alipay', panel);
bindChannel('wechat', panel);
});
} }
function collectParams() {
var items = PARAM_KEYS.map(function (key) {
var el = Ui().el(cfgId(key));
return { key: key, value: el ? el.value.trim() : '' };
});
var disp = [];
document.querySelectorAll('.pay-disp-ch:checked').forEach(function (el) { disp.push(el.value); });
items.push({ key: 'payment.display_channels', value: disp.join(',') });
return items;
}
function uploadKey(channel, kind, content) {
return Api().fetch('payment/upload', {
method: 'POST',
body: JSON.stringify({ channel: channel, kind: kind, content: content }),
});
}
function bindKeys(panel) {
panel.querySelectorAll('.pay-key-file').forEach(function (input) {
input.onchange = function () {
var file = input.files && input.files[0];
if (!file) return;
file.text().then(function (text) {
var content = text.trim();
if (!content) {
soonToast('文件为空', 'warn');
return;
}
uploadKey(input.dataset.channel, input.dataset.kind, content).then(function (j) {
if (j) {
soonToast('已保存', 'success');
render();
}
});
}).finally(function () { input.value = ''; });
};
});
panel.querySelectorAll('.pay-key-paste').forEach(function (btn) {
btn.onclick = function () {
var box = Ui().el(btn.dataset.target + '_box');
if (!box) return;
var open = box.classList.toggle('is-open');
var wrap = box.closest('.soon-admin-pay-field');
if (wrap) wrap.classList.toggle('soon-admin-pay-field--wide', open);
if (open) {
var ta = Ui().el(btn.dataset.target);
if (ta) ta.focus();
}
};
});
panel.querySelectorAll('.pay-key-upload, .pay-key-commit').forEach(function (btn) {
btn.onclick = function () {
var content = '';
if (btn.classList.contains('pay-key-upload')) {
var src = Ui().el(btn.dataset.source);
content = src ? src.value.trim() : '';
} else {
var ta = Ui().el(btn.dataset.target);
content = ta ? ta.value.trim() : '';
}
if (!content) {
soonToast('内容为空', 'warn');
return;
}
Ui().runFormSubmit(btn, function () {
return uploadKey(btn.dataset.channel, btn.dataset.kind, content).then(function (j) {
if (j) {
soonToast('已保存', 'success');
render();
}
});
});
};
});
}
function bindSave() {
Ui().bindFormSubmit('payFormSave', function () {
return Api().postJson('payment/config', { items: collectParams() }).then(function (j) {
if (j) {
soonToast('已保存', 'success');
render();
}
});
});
}
function render() {
var panel = Ui().el('panel-payment');
if (!panel) return;
var header = Ui().pageHeader('支付配置');
panel.innerHTML = '<div class="soon-admin-page">' + header + Ui().skeletonTable() + '</div>';
Api().get('payment/status').then(function (j) {
if (!j) return;
var data = j.data || {};
panel.innerHTML = '<div class="soon-admin-page">' + header +
formHtml(data.params || {}, fileSet(data.items), data.notify_urls || {}, data.readiness) +
'</div>';
Tabs().bind(Ui().el('paymentTabs'));
bindSave();
bindKeys(panel);
});
}
window.AdminViews = window.AdminViews || {}; window.AdminViews = window.AdminViews || {};
window.AdminViews.payment = { render: render }; window.AdminViews.payment = { render: render };
})(); })();
@@ -24,6 +24,10 @@
return plan && plan.code !== 'free' && plan.price_cents > 0; return plan && plan.code !== 'free' && plan.price_cents > 0;
} }
function isLifetimePlan(plan) {
return plan && plan.code === 'member_lifetime';
}
function editableFeatures(plan) { function editableFeatures(plan) {
if (plan.features_editable && plan.features_editable.length) { if (plan.features_editable && plan.features_editable.length) {
return plan.features_editable.slice(); return plan.features_editable.slice();
@@ -96,9 +100,10 @@
if (isFreePlan(plan)) return ''; if (isFreePlan(plan)) return '';
var days = parseInt(Ui().el('peDays') && Ui().el('peDays').value, 10); var days = parseInt(Ui().el('peDays') && Ui().el('peDays').value, 10);
if (!days || days <= 0) days = plan.duration_days || 0; if (!days || days <= 0) days = plan.duration_days || 0;
if (days <= 0) return '付费方案保存后将自动追加订阅周期说明。'; if (isLifetimePlan(plan)) return '永久激活方案,duration_days 固定为 0。';
if (days <= 0) return '付费方案保存后将自动追加周期说明。';
var label = days >= 365 ? '1 年' : (days === 90 ? '1 季' : (days >= 30 && days % 30 === 0 ? (days / 30) + ' 个月' : days + ' 天')); var label = days >= 365 ? '1 年' : (days === 90 ? '1 季' : (days >= 30 && days % 30 === 0 ? (days / 30) + ' 个月' : days + ' 天'));
return '保存后将自动追加订阅周期说明(' + label + '),用户端卡片中不展示该行'; return '保存后将自动追加周期说明(' + label + '),用户端不展示该行';
} }
function openEditModal(plan) { function openEditModal(plan) {
@@ -112,6 +117,7 @@
Form().field('简介', Form().input('peDesc', { value: plan.description || '' })) Form().field('简介', Form().input('peDesc', { value: plan.description || '' }))
); );
var lifetime = isLifetimePlan(plan);
var pricePanel = Form().panel('价格与周期', var pricePanel = Form().panel('价格与周期',
'<div class="soon-admin-form-grid">' + '<div class="soon-admin-form-grid">' +
Form().field('价格(元)', Form().field('价格(元)',
@@ -121,8 +127,8 @@
disabled: free, disabled: free,
})) + })) +
Form().field('有效天数', Form().field('有效天数',
Form().input('peDays', { type: 'number', value: plan.duration_days }), Form().input('peDays', { type: 'number', value: plan.duration_days, disabled: lifetime }),
free ? '免费版填 0' : '月付 30 / 季付 90 / 年付 365') + free ? '免费版填 0' : (lifetime ? '永久激活固定为 0' : '月付 30 / 季付 90 / 年付 365')) +
Form().field('排序', Form().field('排序',
Form().input('peSort', { type: 'number', value: plan.sort_order })) + Form().input('peSort', { type: 'number', value: plan.sort_order })) +
'</div>' + '</div>' +
@@ -140,12 +146,12 @@
Form().field('存储(MB', Form().input('peQuota', { type: 'number', value: plan.quota_mb })) + Form().field('存储(MB', Form().input('peQuota', { type: 'number', value: plan.quota_mb })) +
Form().field('文件数上限', Form().input('peMaxFiles', { type: 'number', value: plan.max_files })) + Form().field('文件数上限', Form().input('peMaxFiles', { type: 'number', value: plan.max_files })) +
'</div>', '</div>',
'云端存储与文件数量上限;预览/保存/导出仍受订阅状态约束。' '云端存储与文件数量上限。'
); );
var benefitTitle = free ? '免费版展示' : '订阅包含'; var benefitTitle = free ? '免费版展示' : '会员权益';
var benefitHint = free var benefitHint = free
? '展示在会员中心免费版区域;补充说明写在简介中。' ? '展示在用户端身份说明中。'
: periodHint(plan); : periodHint(plan);
var benefitPanel = Form().panel(benefitTitle, var benefitPanel = Form().panel(benefitTitle,
'<div class="soon-admin-feature-list" id="peFeatures">' + featureRowsHtml(features) + '</div>' + '<div class="soon-admin-feature-list" id="peFeatures">' + featureRowsHtml(features) + '</div>' +
@@ -158,7 +164,7 @@
Form().actions('保存', 'adminModalSave') + Form().actions('保存', 'adminModalSave') +
'</div>'; '</div>';
Ui().openFormModal((free ? '免费版' : '订阅方案') + ' · ' + plan.name, form, function () { Ui().openFormModal((free ? '免费版' : '会员激活') + ' · ' + plan.name, form, function () {
var name = Ui().el('peName').value.trim(); var name = Ui().el('peName').value.trim();
if (!name) { if (!name) {
soonToast('名称不能为空', 'warn'); soonToast('名称不能为空', 'warn');
@@ -177,7 +183,7 @@
price_cents: Math.round(yuan * 100), price_cents: Math.round(yuan * 100),
quota_mb: parseInt(Ui().el('peQuota').value, 10) || 0, quota_mb: parseInt(Ui().el('peQuota').value, 10) || 0,
max_files: parseInt(Ui().el('peMaxFiles').value, 10) || 0, max_files: parseInt(Ui().el('peMaxFiles').value, 10) || 0,
duration_days: parseInt(Ui().el('peDays').value, 10) || 0, duration_days: lifetime ? 0 : (parseInt(Ui().el('peDays').value, 10) || 0),
sort_order: parseInt(Ui().el('peSort').value, 10) || 0, sort_order: parseInt(Ui().el('peSort').value, 10) || 0,
is_recommended: free ? 0 : (Ui().el('peRec').checked ? 1 : 0), is_recommended: free ? 0 : (Ui().el('peRec').checked ? 1 : 0),
is_active: Ui().el('peActive').checked ? 1 : 0, is_active: Ui().el('peActive').checked ? 1 : 0,
@@ -186,7 +192,7 @@
}; };
return Api().postJson('plans', body).then(function (r) { return Api().postJson('plans', body).then(function (r) {
if (r) { if (r) {
layer.closeAll(); soonLayerDismiss();
soonToast('已保存', 'success'); soonToast('已保存', 'success');
render(); render();
} }
@@ -215,7 +221,7 @@
var panel = Ui().el('panel-plans'); var panel = Ui().el('panel-plans');
if (!panel) return; if (!panel) return;
panel.innerHTML = '<div class="soon-admin-page">' + panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('订阅方案', '配置价格、周期、配额与用户端展示文案') + Ui().pageHeader('会员激活', '配置激活价格、配额与用户端展示文案') +
Ui().skeletonTable() + '</div>'; Ui().skeletonTable() + '</div>';
Api().get('plans').then(function (j) { Api().get('plans').then(function (j) {
@@ -230,7 +236,7 @@
}).join(''); }).join('');
panel.innerHTML = '<div class="soon-admin-page">' + panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('订阅方案', '配置价格、周期、配额与用户端展示文案') + Ui().pageHeader('会员激活', '配置激活价格、配额与用户端展示文案') +
Ui().dataTable( Ui().dataTable(
['代码', '名称', '价格', '权益', '存储', '有效期', '推荐', '状态', '操作'], ['代码', '名称', '价格', '权益', '存储', '有效期', '推荐', '状态', '操作'],
rows rows
@@ -5,39 +5,22 @@
var Ui = function () { return window.AdminUi; }; var Ui = function () { return window.AdminUi; };
var I = function () { return window.AdminI18n; }; var I = function () { return window.AdminI18n; };
var Form = function () { return window.AdminForm; }; var Form = function () { return window.AdminForm; };
var Tabs = function () { return window.AdminTabs; };
var Filter = function () { return window.AdminFilter; }; var Filter = function () { return window.AdminFilter; };
var State = function () { return window.AdminState; }; var State = function () { return window.AdminState; };
var Router = function () { return window.AdminRouter; }; var Router = function () { return window.AdminRouter; };
var SETTING_LABELS = { var SETTING_LABELS = {
'site.base_url': 'API 站点外网地址', 'site.base_url': 'API 站点外网地址(见支付配置)',
'site.front_base_url': '前端站点地址', 'payment.display_channels': '前端展示渠道(见支付配置)',
'alipay.app_id': '支付宝 APPID(见支付配置)',
'wechat.app_id': '微信 AppID(见支付配置)',
'wechat.mch_id': '微信商户号(见支付配置)',
'wechat.mch_serial_no': '商户证书序列号(见支付配置)',
}; };
var valuesByKey = {}; var valuesByKey = {};
var allItems = []; var allItems = [];
function siteFields() {
var cfg = window.SOON_DEPLOY_CONFIG || {};
var apiBase = String(cfg.api_v1_base || '').replace(/\/api\/v1\/?$/, '');
var frontBase = cfg.front_base_url || cfg.web_base || '';
return [
{
key: 'site.base_url',
label: 'API 站点外网地址',
hint: '须 HTTPS 外网可达;支付宝/微信 notify 回调依赖此地址',
placeholder: apiBase,
},
{
key: 'site.front_base_url',
label: '前端站点地址',
hint: '支付完成 return_url 跳转至会员页',
placeholder: frontBase,
},
];
}
function settingLabel(key) { function settingLabel(key) {
if (SETTING_LABELS[key]) { if (SETTING_LABELS[key]) {
return I().esc(key) + '<br><span class="soon-admin-setting-hint">' + I().esc(SETTING_LABELS[key]) + '</span>'; return I().esc(key) + '<br><span class="soon-admin-setting-hint">' + I().esc(SETTING_LABELS[key]) + '</span>';
@@ -50,17 +33,6 @@
return I().esc(val); return I().esc(val);
} }
function siteFormHtml() {
var fields = siteFields().map(function (f) {
var val = valuesByKey[f.key] != null ? valuesByKey[f.key] : '';
return Form().field(f.label,
Form().input('cfg_' + f.key.replace(/\./g, '_'), { value: val, placeholder: f.placeholder }),
f.hint);
}).join('');
return '<div class="soon-admin-form">' + Form().panel('站点与回调地址', fields) +
Form().actions('保存站点配置', 'settingsSiteSave') + '</div>';
}
function allSettingsHtml(filterQ) { function allSettingsHtml(filterQ) {
var q = (filterQ || '').toLowerCase(); var q = (filterQ || '').toLowerCase();
var items = allItems.filter(function (s) { var items = allItems.filter(function (s) {
@@ -82,21 +54,6 @@
return filter + Ui().dataTable(['配置项', '当前值', '更新时间', '操作'], rows); return filter + Ui().dataTable(['配置项', '当前值', '更新时间', '操作'], rows);
} }
function bindSiteForm() {
Ui().bindFormSubmit('settingsSiteSave', function () {
var items = siteFields().map(function (f) {
var el = Ui().el('cfg_' + f.key.replace(/\./g, '_'));
return { key: f.key, value: el ? el.value.trim() : '' };
});
return Api().postJson('settings/batch', { items: items }).then(function (r) {
if (r) {
soonToast('站点配置已保存', 'success');
render();
}
});
});
}
function bindAllSettings() { function bindAllSettings() {
var st = State().settings; var st = State().settings;
var searchBtn = Ui().el('settingsSearchBtn'); var searchBtn = Ui().el('settingsSearchBtn');
@@ -130,19 +87,20 @@
var form = '<div class="soon-admin-form">' + var form = '<div class="soon-admin-form">' +
Form().field('配置键', '<input class="soon-input soon-input--sm" value="' + I().esc(key) + '" disabled>') + Form().field('配置键', '<input class="soon-input soon-input--sm" value="' + I().esc(key) + '" disabled>') +
Form().field('配置值', Form().input('settingVal', { value: val, type: 'textarea', rows: 5 })) + Form().field('配置值', Form().input('settingVal', { value: val, type: 'textarea', rows: 5 })) +
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">保存</button></div>'; Form().actions('保存', 'adminModalSave') + '</div>';
Ui().openFormModal('修改 ' + key, form, function () { Ui().openFormModal('修改 ' + key, form, function () {
Ui().confirm('确认保存该配置?', function () { Ui().confirm('确认保存该配置?', function () {
var saveBtn = Ui().el('adminModalSave'); var saveBtn = Ui().el('adminModalSave');
Ui().setBtnLoading(saveBtn, true); Ui().setBtnLoading(saveBtn, true);
Api().postJson('settings', { key: key, value: Ui().el('settingVal').value }).then(function (r) { Api().postJson('settings', { key: key, value: Ui().el('settingVal').value }).then(function (r) {
if (r) { if (r) {
layer.closeAll(); soonLayerDismiss();
soonToast('配置已保存', 'success'); soonToast('配置已保存', 'success');
render(); render();
} }
}).finally(function () { }).finally(function () {
Ui().setBtnLoading(saveBtn, false); Ui().setBtnLoading(saveBtn, false);
if (typeof window.soonCleanupLayerMask === 'function') window.soonCleanupLayerMask();
}); });
}); });
}); });
@@ -154,7 +112,7 @@
var panel = Ui().el('panel-settings'); var panel = Ui().el('panel-settings');
if (!panel) return; if (!panel) return;
var st = State().settings; var st = State().settings;
var header = Ui().pageHeader('系统设置', '站点地址与运行时参数;修改后即时生效'); var header = Ui().pageHeader('系统设置', '运行时参数;支付相关请在「支付配置」维护');
panel.innerHTML = '<div class="soon-admin-page">' + header + Ui().skeletonTable() + '</div>'; panel.innerHTML = '<div class="soon-admin-page">' + header + Ui().skeletonTable() + '</div>';
Api().get('settings').then(function (j) { Api().get('settings').then(function (j) {
@@ -168,20 +126,7 @@
allItems = j.data.items || []; allItems = j.data.items || [];
allItems.forEach(function (s) { valuesByKey[s.key] = s.value; }); allItems.forEach(function (s) { valuesByKey[s.key] = s.value; });
var tabsHtml = Tabs().html('settingsTabs', [ panel.innerHTML = '<div class="soon-admin-page">' + header + allSettingsHtml(st.q) + '</div>';
{ id: 'site', label: '站点配置' },
{ id: 'all', label: '全部配置' },
], {
site: siteFormHtml(),
all: allSettingsHtml(st.q),
}, st.tab);
panel.innerHTML = '<div class="soon-admin-page">' + header + tabsHtml + '</div>';
Tabs().bind(Ui().el('settingsTabs'), function (tabId) {
st.tab = tabId;
Router().syncQuery();
});
bindSiteForm();
bindAllSettings(); bindAllSettings();
}); });
} }
@@ -0,0 +1,380 @@
(function () {
'use strict';
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 MODAL_OPTS = { area: ['480px', 'auto'], skin: 'soon-layer soon-layer--tpl', maxWidth: '94vw' };
function activeBadge(v) {
var on = v === true || parseInt(v, 10) === 1;
return I().badgeStatus(on ? 'active' : 'disabled');
}
function typeLabel(type) {
return Number(type) === 2 ? '双面' : '单面';
}
function formatBytes(n) {
n = parseInt(n, 10) || 0;
if (!n) return '—';
if (n < 1024) return n + ' B';
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
return (n / 1048576).toFixed(1) + ' MB';
}
function getAdminBase() {
return ((window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_admin_base) || '').replace(/\/+$/, '');
}
function revokeBlob(state) {
if (state && state._blobUrl) {
try { URL.revokeObjectURL(state._blobUrl); } catch (e) { /* ignore */ }
state._blobUrl = '';
}
}
function nameFromFileName(fileName) {
return String(fileName || '').replace(/\.soon$/i, '').replace(/[_-]+/g, ' ').trim();
}
function parseSoonMeta(text, fileName) {
var data;
try {
data = JSON.parse(text);
} catch (e) {
throw new Error('invalid_json');
}
if (!data || typeof data !== 'object') throw new Error('invalid_json');
var type = 1;
if (Number(data.soonType) === 2 || data.backBlackPic) type = 2;
var thumbRaw = '';
if (data.frontDisplayPic && typeof data.frontDisplayPic === 'string') {
var candidate = data.frontDisplayPic.trim();
if (candidate.indexOf('data:image/') === 0) thumbRaw = candidate;
}
var name = '';
if (typeof data.name === 'string' && data.name.trim()) name = data.name.trim();
else if (typeof data.title === 'string' && data.title.trim()) name = data.title.trim();
else name = nameFromFileName(fileName);
if (!name) name = '未命名模板';
return { name: name, type: type, thumbRaw: thumbRaw, content: text };
}
function shrinkDataUrl(dataUrl, maxW, quality, done) {
if (!dataUrl) {
done('');
return;
}
var img = new Image();
img.onload = function () {
var w = img.naturalWidth || img.width;
var h = img.naturalHeight || img.height;
if (!w || !h) {
done(dataUrl);
return;
}
if (w <= maxW) {
done(dataUrl);
return;
}
var nh = Math.max(1, Math.round(h * (maxW / w)));
var canvas = document.createElement('canvas');
canvas.width = maxW;
canvas.height = nh;
canvas.getContext('2d').drawImage(img, 0, 0, maxW, nh);
try {
done(canvas.toDataURL('image/jpeg', quality));
} catch (e) {
done(dataUrl);
}
};
img.onerror = function () { done(''); };
img.src = dataUrl;
}
function setPreviewImage(modalState, src, renderPreview) {
modalState.thumbView = src || '';
renderPreview();
}
function applyThumbRaw(modalState, thumbRaw, renderPreview) {
if (!thumbRaw) {
setPreviewImage(modalState, '', renderPreview);
return;
}
if (thumbRaw.length < 700000) {
setPreviewImage(modalState, thumbRaw, renderPreview);
return;
}
shrinkDataUrl(thumbRaw, 360, 0.82, function (small) {
setPreviewImage(modalState, small || thumbRaw, renderPreview);
});
}
function readFileText(input) {
return new Promise(function (resolve, reject) {
var file = input && input.files && input.files[0];
if (!file) {
reject(new Error('no_file'));
return;
}
var reader = new FileReader();
reader.onload = function () { resolve({ text: String(reader.result || ''), fileName: file.name }); };
reader.onerror = function () { reject(new Error('read_failed')); };
reader.readAsText(file, 'UTF-8');
});
}
function fetchAdminThumb(plan, modalState, renderPreview) {
if (!plan || !plan.id || !Api().getToken()) {
renderPreview();
return;
}
revokeBlob(modalState);
window.fetch(getAdminBase() + '/templates/' + plan.id + '/thumb?_=' + Date.now(), {
headers: { Authorization: 'Bearer ' + Api().getToken() },
}).then(function (r) {
if (!r.ok) throw new Error('thumb');
return r.blob();
}).then(function (blob) {
modalState._blobUrl = URL.createObjectURL(blob);
setPreviewImage(modalState, modalState._blobUrl, renderPreview);
}).catch(function () {
renderPreview();
});
}
function openThumbPreview(src, title) {
if (!src || typeof layer === 'undefined') return;
layer.photos({
photos: {
title: title || '模板预览',
start: 0,
data: [{ alt: title || '模板预览', src: src }],
},
anim: 5,
shade: 0.88,
});
}
function modalHtml(opts) {
opts = opts || {};
var isEdit = opts.mode === 'edit';
var plan = opts.plan || {};
var pickLabel = isEdit ? '替换文件' : '选择文件';
return '<div class="soon-admin-tpl-modal">' +
'<button type="button" id="tplThumbBtn" class="soon-admin-tpl-card" disabled aria-label="预览缩略图">' +
'<span class="soon-admin-tpl-card__media">' +
'<img id="tplThumb" alt="" hidden>' +
'<span id="tplThumbEmpty" class="soon-admin-tpl-card__placeholder">选择 .soon 后显示预览</span>' +
'<span id="tplThumbHint" class="soon-admin-tpl-card__hint" hidden>点击放大</span></span>' +
'<span class="soon-admin-tpl-card__info">' +
'<span id="tplNameDisp" class="soon-admin-tpl-card__name">' + I().esc(isEdit ? (plan.name || '—') : '—') + '</span>' +
'<span id="tplTypeDisp" class="soon-admin-tpl-card__type">' +
(isEdit ? I().esc(typeLabel(plan.type)) : '—') + '</span></span></button>' +
'<label class="soon-admin-tpl-picker">' +
'<input type="file" id="tplFile" class="soon-admin-tpl-picker__input" accept=".soon,application/json">' +
'<span class="soon-btn soon-btn--sm soon-btn--ghost">' + I().esc(pickLabel) + '</span>' +
'<span id="tplFileLabel" class="soon-admin-tpl-picker__name">' +
(isEdit ? I().esc(plan.name + '.soon') : '未选择') + '</span></label>' +
'<div class="soon-admin-tpl-options">' +
'<label class="soon-admin-tpl-option"><span>排序</span>' +
Form().input('tplSort', { type: 'number', value: plan.sort_order != null ? plan.sort_order : 0 }) + '</label>' +
'<label class="soon-admin-tpl-option soon-admin-tpl-option--check">' +
'<input type="checkbox" id="tplActive"' + (plan.is_active !== false ? ' checked' : '') + '>' +
'<span>首页上架</span></label></div>' +
'<div class="soon-admin-tpl-modal__actions">' +
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">' +
(isEdit ? '保存' : '上传') + '</button></div></div>';
}
function bindTplModal(opts, modalState) {
var fileEl = Ui().el('tplFile');
var thumbBtn = Ui().el('tplThumbBtn');
var thumbEl = Ui().el('tplThumb');
var thumbEmptyEl = Ui().el('tplThumbEmpty');
var thumbHintEl = Ui().el('tplThumbHint');
var nameEl = Ui().el('tplNameDisp');
var typeEl = Ui().el('tplTypeDisp');
var labelEl = Ui().el('tplFileLabel');
var plan = opts.plan;
function renderPreview() {
var hasMeta = !!(modalState.name || modalState.type);
var src = modalState.thumbView || '';
nameEl.textContent = modalState.name || '—';
typeEl.textContent = hasMeta ? typeLabel(modalState.type) : '—';
if (src) {
thumbEl.src = src;
thumbEl.hidden = false;
thumbEmptyEl.hidden = true;
thumbHintEl.hidden = false;
thumbBtn.disabled = false;
thumbBtn.classList.add('is-ready');
} else {
thumbEl.hidden = true;
thumbEl.removeAttribute('src');
thumbEmptyEl.hidden = false;
thumbEmptyEl.textContent = hasMeta ? '无缩略图' : '选择 .soon 后显示预览';
thumbHintEl.hidden = true;
thumbBtn.disabled = true;
thumbBtn.classList.remove('is-ready');
}
}
if (thumbBtn) {
thumbBtn.onclick = function () {
if (!modalState.thumbView) return;
openThumbPreview(modalState.thumbView, modalState.name);
};
}
if (opts.mode === 'edit' && plan) {
modalState.name = plan.name;
modalState.type = plan.type;
modalState.thumbView = '';
modalState.content = null;
renderPreview();
fetchAdminThumb(plan, modalState, renderPreview);
}
if (!fileEl) return;
fileEl.onchange = function () {
var file = fileEl.files && fileEl.files[0];
if (!file) return;
labelEl.textContent = file.name;
readFileText(fileEl).then(function (payload) {
var meta = parseSoonMeta(payload.text, payload.fileName);
modalState.name = meta.name;
modalState.type = meta.type;
modalState.content = meta.content;
applyThumbRaw(modalState, meta.thumbRaw, renderPreview);
}).catch(function (e) {
fileEl.value = '';
labelEl.textContent = opts.mode === 'edit' ? (plan.name + '.soon') : '未选择';
if (e && e.message === 'invalid_json') soonToast('.soon 须为有效 JSON', 'warn');
else soonToast('文件读取失败', 'error');
});
};
}
function openCreateModal() {
var modalState = { content: null, name: '', type: 1, thumbView: '', _blobUrl: '' };
Ui().openFormModal('上传模板', modalHtml({ mode: 'create' }), function () {
if (!modalState.content) {
soonToast('请先选择 .soon 文件', 'warn');
return false;
}
return Api().postJson('templates', {
name: modalState.name,
content: modalState.content,
sort_order: parseInt(Ui().el('tplSort').value, 10) || 0,
is_active: Ui().el('tplActive').checked ? 1 : 0,
}).then(function (r) {
if (r) {
revokeBlob(modalState);
soonLayerDismiss();
soonToast('已上传', 'success');
render();
}
});
}, Object.assign({}, MODAL_OPTS, {
success: function () { bindTplModal({ mode: 'create' }, modalState); },
}));
}
function openEditModal(plan) {
var modalState = { content: null, name: plan.name, type: plan.type, thumbView: '', _blobUrl: '' };
Ui().openFormModal('编辑模板', modalHtml({ mode: 'edit', plan: plan }), function () {
var body = {
name: modalState.content ? modalState.name : plan.name,
sort_order: parseInt(Ui().el('tplSort').value, 10) || 0,
is_active: Ui().el('tplActive').checked ? 1 : 0,
};
if (modalState.content) body.content = modalState.content;
return Api().putJson('templates/' + plan.id, body).then(function (r) {
if (r) {
revokeBlob(modalState);
soonLayerDismiss();
soonToast('已保存', 'success');
render();
}
});
}, Object.assign({}, MODAL_OPTS, {
success: function () { bindTplModal({ mode: 'edit', plan: plan }, modalState); },
}));
}
function deleteTemplate(plan) {
Ui().confirm('确定删除模板「' + plan.name + '」?', function () {
Api().del('templates/' + plan.id).then(function (r) {
if (r) {
soonToast('已删除', 'success');
render();
}
});
});
}
function render() {
var panel = Ui().el('panel-templates');
if (!panel) return;
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('模板库', '上传 .soon 后自动识别名称、类型与缩略图',
'<button type="button" class="soon-btn soon-btn--sm" id="tplCreate">上传模板</button>') +
Ui().skeletonTable() + '</div>';
Api().get('templates').then(function (j) {
if (!j) return;
var items = j.data.items || [];
var rows = items.map(function (p) {
return '<tr data-id="' + p.id + '"><td>' + p.id + '</td><td>' + I().esc(p.name) +
'</td><td>' + typeLabel(p.type) + '</td><td>' + formatBytes(p.file_size) + '</td><td>' +
p.sort_order + '</td><td>' + (p.has_thumb ? '有' : '—') + '</td><td>' +
activeBadge(p.is_active) + '</td><td>' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost tpl-edit">编辑</button> ' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost tpl-del">删除</button></td></tr>';
}).join('');
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('模板库', '上传 .soon 后自动识别名称、类型与缩略图',
'<button type="button" class="soon-btn soon-btn--sm" id="tplCreate">上传模板</button>') +
Ui().dataTable(
['ID', '名称', '类型', '大小', '排序', '缩略图', '状态', '操作'],
rows
) + '</div>';
var planMap = {};
items.forEach(function (p) { planMap[p.id] = p; });
panel.querySelector('#tplCreate').onclick = openCreateModal;
panel.querySelectorAll('.tpl-edit').forEach(function (btn) {
btn.onclick = function () {
var id = parseInt(btn.closest('tr').dataset.id, 10);
if (planMap[id]) openEditModal(planMap[id]);
};
});
panel.querySelectorAll('.tpl-del').forEach(function (btn) {
btn.onclick = function () {
var id = parseInt(btn.closest('tr').dataset.id, 10);
if (planMap[id]) deleteTemplate(planMap[id]);
};
});
});
}
window.AdminViews = window.AdminViews || {};
window.AdminViews.templates = { render: render };
})();
@@ -8,6 +8,7 @@
var Filter = function () { return window.AdminFilter; }; var Filter = function () { return window.AdminFilter; };
var Menu = function () { return window.AdminMenu; }; var Menu = function () { return window.AdminMenu; };
var Router = function () { return window.AdminRouter; }; var Router = function () { return window.AdminRouter; };
var Form = function () { return window.AdminForm; };
function buildQuery() { function buildQuery() {
var st = State().users; var st = State().users;
@@ -57,7 +58,7 @@
? formField('管理级别', '<div id="adminLevelField" style="display:none"><select id="ucAdminLevel" class="soon-input soon-input--sm">' + ? formField('管理级别', '<div id="adminLevelField" style="display:none"><select id="ucAdminLevel" class="soon-input soon-input--sm">' +
adminLevelOptions('ops') + '</select></div>', '运营管理员不可访问设置、审计与支付密钥') adminLevelOptions('ops') + '</select></div>', '运营管理员不可访问设置、审计与支付密钥')
: '') + : '') +
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">创建</button></div>'; Form().actions('创建', 'adminModalSave') + '</div>';
Ui().openFormModal('新增用户', form, function () { Ui().openFormModal('新增用户', form, function () {
var email = Ui().el('ucEmail').value.trim(); var email = Ui().el('ucEmail').value.trim();
var pwd = Ui().el('ucPwd').value; var pwd = Ui().el('ucPwd').value;
@@ -76,7 +77,7 @@
} }
return Api().postJson('users', body).then(function (r) { return Api().postJson('users', body).then(function (r) {
if (r) { if (r) {
layer.closeAll(); soonLayerDismiss();
soonToast('用户已创建', 'success'); soonToast('用户已创建', 'success');
render(); render();
} }
@@ -95,7 +96,7 @@
? formField('管理级别', '<div id="adminLevelField"><select id="ueAdminLevel" class="soon-input soon-input--sm">' + ? formField('管理级别', '<div id="adminLevelField"><select id="ueAdminLevel" class="soon-input soon-input--sm">' +
adminLevelOptions(level) + '</select></div>', '仅对管理员账号生效') adminLevelOptions(level) + '</select></div>', '仅对管理员账号生效')
: '') + : '') +
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">保存</button></div>'; Form().actions('保存', 'adminModalSave') + '</div>';
Ui().openFormModal('编辑用户 #' + user.id, form, function () { Ui().openFormModal('编辑用户 #' + user.id, form, function () {
var body = { var body = {
email: Ui().el('ueEmail').value.trim(), email: Ui().el('ueEmail').value.trim(),
@@ -107,7 +108,7 @@
} }
return Api().putJson('users/' + user.id, body).then(function (r) { return Api().putJson('users/' + user.id, body).then(function (r) {
if (r) { if (r) {
layer.closeAll(); soonLayerDismiss();
soonToast('用户信息已更新', 'success'); soonToast('用户信息已更新', 'success');
render(); render();
} }
@@ -121,7 +122,7 @@
var form = '<div class="soon-admin-form">' + var form = '<div class="soon-admin-form">' +
formField('新密码', '<input id="upPwd" type="password" class="soon-input soon-input--sm" autocomplete="new-password" placeholder="至少 8 位">') + formField('新密码', '<input id="upPwd" type="password" class="soon-input soon-input--sm" autocomplete="new-password" placeholder="至少 8 位">') +
formField('确认密码', '<input id="upPwd2" type="password" class="soon-input soon-input--sm" autocomplete="new-password">') + formField('确认密码', '<input id="upPwd2" type="password" class="soon-input soon-input--sm" autocomplete="new-password">') +
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">重置密码</button></div>'; Form().actions('重置密码', 'adminModalSave') + '</div>';
Ui().openFormModal('重置密码', form, function () { Ui().openFormModal('重置密码', form, function () {
var p1 = Ui().el('upPwd').value; var p1 = Ui().el('upPwd').value;
var p2 = Ui().el('upPwd2').value; var p2 = Ui().el('upPwd2').value;
@@ -135,7 +136,7 @@
} }
return Api().postJson('users/' + userId + '/password', { password: p1 }).then(function (r) { return Api().postJson('users/' + userId + '/password', { password: p1 }).then(function (r) {
if (r) { if (r) {
layer.closeAll(); soonLayerDismiss();
soonToast('密码已重置', 'success'); soonToast('密码已重置', 'success');
} }
}); });
@@ -153,7 +154,7 @@
formField('套餐', '<select id="usPlan" class="soon-input soon-input--sm">' + opts + '</select>') + formField('套餐', '<select id="usPlan" class="soon-input soon-input--sm">' + opts + '</select>') +
formField('有效天数', '<input id="usDays" type="number" min="0" class="soon-input soon-input--sm" placeholder="留空则用套餐默认天数">') + formField('有效天数', '<input id="usDays" type="number" min="0" class="soon-input soon-input--sm" placeholder="留空则用套餐默认天数">') +
'<p class="soon-admin-form-hint">选择免费版将取消当前有效会员。</p>' + '<p class="soon-admin-form-hint">选择免费版将取消当前有效会员。</p>' +
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">保存</button></div>'; Form().actions('保存', 'adminModalSave') + '</div>';
Ui().openFormModal('设置会员', form, function () { Ui().openFormModal('设置会员', form, function () {
var planSel = Ui().el('usPlan'); var planSel = Ui().el('usPlan');
var daysVal = Ui().el('usDays').value.trim(); var daysVal = Ui().el('usDays').value.trim();
@@ -161,7 +162,7 @@
if (daysVal) body.days = parseInt(daysVal, 10); if (daysVal) body.days = parseInt(daysVal, 10);
return Api().postJson('users/' + userId + '/subscription', body).then(function (r) { return Api().postJson('users/' + userId + '/subscription', body).then(function (r) {
if (r) { if (r) {
layer.closeAll(); soonLayerDismiss();
soonToast('会员已更新', 'success'); soonToast('会员已更新', 'success');
render(); render();
} }
@@ -205,7 +206,7 @@
Ui().detailRow('开始时间', I().esc(I().formatDateTime(sub.started_at))) + Ui().detailRow('开始时间', I().esc(I().formatDateTime(sub.started_at))) +
Ui().detailRow('到期时间', I().esc(I().formatDateTime(sub.expires_at))); Ui().detailRow('到期时间', I().esc(I().formatDateTime(sub.expires_at)));
} else { } else {
subHtml = '<p class="soon-admin-detail-empty">免费版 / 无有效订阅</p>'; subHtml = '<p class="soon-admin-detail-empty">普通用户 / 未激活</p>';
} }
var usageHtml = Ui().detailRow('文件数', I().esc(usage.files_count || 0)) + var usageHtml = Ui().detailRow('文件数', I().esc(usage.files_count || 0)) +
@@ -263,7 +264,7 @@
var inline = [ var inline = [
{ label: '详情', action: 'detail', dataset: ds }, { label: '详情', action: 'detail', dataset: ds },
{ label: '编辑', action: 'edit', dataset: editDs, disabled: editDisabled }, { label: '编辑', action: 'edit', dataset: editDs, disabled: editDisabled },
{ label: '订阅', action: 'sub', dataset: ds }, { label: '会员', action: 'sub', dataset: ds },
]; ];
var more = [ var more = [
{ label: '改密', action: 'pwd', dataset: ds }, { label: '改密', action: 'pwd', dataset: ds },
+4 -1
View File
@@ -19,6 +19,7 @@
<div class="soon-admin-nav-item" data-tab="users"><img src="../../assets/images/web/admin/users.svg" alt=""><span>用户</span></div> <div class="soon-admin-nav-item" data-tab="users"><img src="../../assets/images/web/admin/users.svg" alt=""><span>用户</span></div>
<div class="soon-admin-nav-item" data-tab="orders"><img src="../../assets/images/web/admin/orders.svg" alt=""><span>订单</span></div> <div class="soon-admin-nav-item" data-tab="orders"><img src="../../assets/images/web/admin/orders.svg" alt=""><span>订单</span></div>
<div class="soon-admin-nav-item" data-tab="plans"><img src="../../assets/images/web/admin/plans.svg" alt=""><span>套餐</span></div> <div class="soon-admin-nav-item" data-tab="plans"><img src="../../assets/images/web/admin/plans.svg" alt=""><span>套餐</span></div>
<div class="soon-admin-nav-item" data-tab="templates"><img src="../../assets/images/web/admin/plans.svg" alt=""><span>模板库</span></div>
<div class="soon-admin-nav-item" data-tab="settings" data-require="full"><img src="../../assets/images/web/admin/settings.svg" alt=""><span>设置</span></div> <div class="soon-admin-nav-item" data-tab="settings" data-require="full"><img src="../../assets/images/web/admin/settings.svg" alt=""><span>设置</span></div>
<div class="soon-admin-nav-item" data-tab="audits" data-require="full"><img src="../../assets/images/web/admin/audits.svg" alt=""><span>审计</span></div> <div class="soon-admin-nav-item" data-tab="audits" data-require="full"><img src="../../assets/images/web/admin/audits.svg" alt=""><span>审计</span></div>
<div class="soon-admin-nav-item" data-tab="payment" data-require="full"><img src="../../assets/images/web/admin/payment.svg" alt=""><span>支付密钥</span></div> <div class="soon-admin-nav-item" data-tab="payment" data-require="full"><img src="../../assets/images/web/admin/payment.svg" alt=""><span>支付密钥</span></div>
@@ -28,6 +29,7 @@
<div id="panel-users" class="panel"></div> <div id="panel-users" class="panel"></div>
<div id="panel-orders" class="panel"></div> <div id="panel-orders" class="panel"></div>
<div id="panel-plans" class="panel"></div> <div id="panel-plans" class="panel"></div>
<div id="panel-templates" class="panel"></div>
<div id="panel-settings" class="panel"></div> <div id="panel-settings" class="panel"></div>
<div id="panel-audits" class="panel"></div> <div id="panel-audits" class="panel"></div>
<div id="panel-payment" class="panel"></div> <div id="panel-payment" class="panel"></div>
@@ -35,7 +37,7 @@
</div> </div>
</div> </div>
<script src="../../assets/deploy-config.js"></script> <script src="../../config/local.js"></script>
<script src="../../js/common/auth-redirect.js"></script> <script src="../../js/common/auth-redirect.js"></script>
<script src="../../vendor/layui/layui.js"></script> <script src="../../vendor/layui/layui.js"></script>
<script src="../../js/common/soon-toast.js"></script> <script src="../../js/common/soon-toast.js"></script>
@@ -55,6 +57,7 @@
<script src="assets/js/views/users.js"></script> <script src="assets/js/views/users.js"></script>
<script src="assets/js/views/orders.js"></script> <script src="assets/js/views/orders.js"></script>
<script src="assets/js/views/plans.js"></script> <script src="assets/js/views/plans.js"></script>
<script src="assets/js/views/templates.js"></script>
<script src="assets/js/views/settings.js"></script> <script src="assets/js/views/settings.js"></script>
<script src="assets/js/views/audits.js"></script> <script src="assets/js/views/audits.js"></script>
<script src="assets/js/views/payment.js"></script> <script src="assets/js/views/payment.js"></script>
+1 -1
View File
@@ -41,7 +41,7 @@
</div> </div>
</div> </div>
<script src="../../assets/deploy-config.js"></script> <script src="../../config/local.js"></script>
<script src="../../js/common/auth-redirect.js"></script> <script src="../../js/common/auth-redirect.js"></script>
<script src="../../vendor/layui/layui.js"></script> <script src="../../vendor/layui/layui.js"></script>
<script src="../../js/common/soon-toast.js"></script> <script src="../../js/common/soon-toast.js"></script>
+6 -11
View File
@@ -12,7 +12,6 @@
<link rel="stylesheet" href="../assets/css/portal.css"> <link rel="stylesheet" href="../assets/css/portal.css">
<link rel="stylesheet" href="../assets/css/design.css"> <link rel="stylesheet" href="../assets/css/design.css">
<link rel="stylesheet" href="../assets/css/subscribe-gate.css"> <link rel="stylesheet" href="../assets/css/subscribe-gate.css">
<link rel="stylesheet" href="../assets/css/member.css">
<style> <style>
/* ================================================== /* ==================================================
1. 全局布局 (Flex 自适应:完美兼容 Windows/Mac) 1. 全局布局 (Flex 自适应:完美兼容 Windows/Mac)
@@ -279,7 +278,7 @@
</style> </style>
</head> </head>
<body class="soon-design-page"> <body class="soon-design-page soon-design--type1">
<div id="portal-topbar"></div> <div id="portal-topbar"></div>
@@ -874,20 +873,21 @@
<script src="../vendor/js/guideLines1.js"></script> <script src="../vendor/js/guideLines1.js"></script>
<script src="../vendor/js/print.min.js"></script> <script src="../vendor/js/print.min.js"></script>
<script src="../vendor/js/crypto-js.min.js"></script> <script src="../vendor/js/crypto-js.min.js"></script>
<script src="../vendor/js/jspdf.umd.min.js"></script> <script src="../config/local.js"></script>
<script src="../assets/deploy-config.js"></script>
<script src="../js/common/auth-redirect.js"></script> <script src="../js/common/auth-redirect.js"></script>
<script src="../js/common/auth-session.js"></script> <script src="../js/common/auth-session.js"></script>
<script src="../js/common/member-pay-core.js"></script> <script src="../js/common/member-pay-core.js"></script>
<script src="../js/common/member-plan-core.js"></script>
<script src="../js/common/cloud-files.js"></script> <script src="../js/common/cloud-files.js"></script>
<script src="../js/common/soon-local-store.js"></script>
<script src="../js/common/soon-recent.js"></script>
<script src="../js/common/asset-base.js"></script> <script src="../js/common/asset-base.js"></script>
<script src="../js/common/portal-topbar.js"></script> <script src="../js/common/portal-topbar.js"></script>
<script src="../js/common/portal-auth.js"></script> <script src="../js/common/portal-auth.js"></script>
<script src="../vendor/layui/layui.js"></script> <script src="../vendor/layui/layui.js"></script>
<script src="../vendor/js/qrcode.min.js"></script> <script src="../vendor/js/qrcode.min.js"></script>
<script src="../js/common/soon-toast.js"></script> <script src="../js/common/soon-toast.js"></script>
<script src="../js/common/member-subscribe.js"></script> <script src="../js/common/member-login-gate.js"></script>
<script src="../js/common/member-activate.js"></script>
<script src="../js/platform/web.js?v=basepath2"></script> <script src="../js/platform/web.js?v=basepath2"></script>
<script src="../vendor/js/jr-qrcode.js"></script> <script src="../vendor/js/jr-qrcode.js"></script>
<script src="../vendor/js/JsBarcode.all.min.js"></script> <script src="../vendor/js/JsBarcode.all.min.js"></script>
@@ -896,11 +896,6 @@
<script> <script>
if (window.SoonPortalTopbar) SoonPortalTopbar.mount('#portal-topbar', { variant: 'design' }); if (window.SoonPortalTopbar) SoonPortalTopbar.mount('#portal-topbar', { variant: 'design' });
if (window.soonPortalAuth) soonPortalAuth.init({ logoutReload: false }); if (window.soonPortalAuth) soonPortalAuth.init({ logoutReload: false });
if (window.soonLoadMembership) {
window.soonLoadMembership().then(function () {
if (window.soonRefreshPortalIdentity) window.soonRefreshPortalIdentity();
});
}
</script> </script>
</body> </body>
+11 -13
View File
@@ -12,7 +12,6 @@
<link rel="stylesheet" href="../assets/css/portal.css"> <link rel="stylesheet" href="../assets/css/portal.css">
<link rel="stylesheet" href="../assets/css/design.css"> <link rel="stylesheet" href="../assets/css/design.css">
<link rel="stylesheet" href="../assets/css/subscribe-gate.css"> <link rel="stylesheet" href="../assets/css/subscribe-gate.css">
<link rel="stylesheet" href="../assets/css/member.css">
<style> <style>
/* 标尺样式 */ /* 标尺样式 */
.ruler-container { .ruler-container {
@@ -79,7 +78,7 @@
</style> </style>
</head> </head>
<body class="soon-design-page"> <body class="soon-design-page soon-design--type2">
<div id="portal-topbar"></div> <div id="portal-topbar"></div>
@@ -560,15 +559,17 @@
<div class="line"></div> <div class="line"></div>
<div class="ui-button ui-button-active img-warp" id="front_side"> <div class="soon-side-tabs">
<div class="ui-button ui-button-active" id="front_side">
<span language="m" zh="正面" ozh="正面" en="Front"></span> <span language="m" zh="正面" ozh="正面" en="Front"></span>
</div> </div>
<div class="ui-button img-warp" id="back_side"> <div class="ui-button" id="back_side">
<span language="m" zh="背面" ozh="背面" en="Back"></span> <span language="m" zh="背面" ozh="背面" en="Back"></span>
</div> </div>
</div>
<div class="line"></div> <div class="line"></div>
<div class="ui-button img-warp" id="version_change"> <div class="ui-button soon-tool-text-btn" id="version_change">
<span language="m" zh="切换" ozh="切换" en="Swap"></span> <span language="m" zh="切换" ozh="切换" en="Swap"></span>
</div> </div>
@@ -665,19 +666,21 @@
<script src="../vendor/js/print.min.js"></script> <script src="../vendor/js/print.min.js"></script>
<script src="../vendor/js/crypto-js.min.js"></script> <script src="../vendor/js/crypto-js.min.js"></script>
<script src="../vendor/js/jspdf.umd.min.js"></script> <script src="../vendor/js/jspdf.umd.min.js"></script>
<script src="../assets/deploy-config.js"></script> <script src="../config/local.js"></script>
<script src="../js/common/auth-redirect.js"></script> <script src="../js/common/auth-redirect.js"></script>
<script src="../js/common/auth-session.js"></script> <script src="../js/common/auth-session.js"></script>
<script src="../js/common/member-pay-core.js"></script> <script src="../js/common/member-pay-core.js"></script>
<script src="../js/common/member-plan-core.js"></script>
<script src="../js/common/cloud-files.js"></script> <script src="../js/common/cloud-files.js"></script>
<script src="../js/common/soon-local-store.js"></script>
<script src="../js/common/soon-recent.js"></script>
<script src="../js/common/asset-base.js"></script> <script src="../js/common/asset-base.js"></script>
<script src="../js/common/portal-topbar.js"></script> <script src="../js/common/portal-topbar.js"></script>
<script src="../js/common/portal-auth.js"></script> <script src="../js/common/portal-auth.js"></script>
<script src="../vendor/layui/layui.js"></script> <script src="../vendor/layui/layui.js"></script>
<script src="../vendor/js/qrcode.min.js"></script> <script src="../vendor/js/qrcode.min.js"></script>
<script src="../js/common/soon-toast.js"></script> <script src="../js/common/soon-toast.js"></script>
<script src="../js/common/member-subscribe.js"></script> <script src="../js/common/member-login-gate.js"></script>
<script src="../js/common/member-activate.js"></script>
<script src="../js/platform/web.js?v=basepath2"></script> <script src="../js/platform/web.js?v=basepath2"></script>
<script src="../vendor/js/jr-qrcode.js"></script> <script src="../vendor/js/jr-qrcode.js"></script>
<script src="../vendor/js/JsBarcode.all.min.js"></script> <script src="../vendor/js/JsBarcode.all.min.js"></script>
@@ -686,11 +689,6 @@
<script> <script>
if (window.SoonPortalTopbar) SoonPortalTopbar.mount('#portal-topbar', { variant: 'design' }); if (window.SoonPortalTopbar) SoonPortalTopbar.mount('#portal-topbar', { variant: 'design' });
if (window.soonPortalAuth) soonPortalAuth.init({ logoutReload: false }); if (window.soonPortalAuth) soonPortalAuth.init({ logoutReload: false });
if (window.soonLoadMembership) {
window.soonLoadMembership().then(function () {
if (window.soonRefreshPortalIdentity) window.soonRefreshPortalIdentity();
});
}
</script> </script>
</body> </body>
+8 -13
View File
@@ -19,8 +19,6 @@
<link rel="stylesheet" href="../assets/css/layer-soon.css"> <link rel="stylesheet" href="../assets/css/layer-soon.css">
<link rel="stylesheet" href="../assets/css/index.css"> <link rel="stylesheet" href="../assets/css/index.css">
<link rel="stylesheet" href="../assets/css/subscribe-gate.css">
<link rel="stylesheet" href="../assets/css/member.css">
</head> </head>
@@ -136,19 +134,17 @@
<script src="../assets/deploy-config.js"></script> <script src="../config/local.js"></script>
<script src="../js/common/auth-redirect.js"></script> <script src="../js/common/auth-redirect.js"></script>
<script src="../js/common/auth-session.js"></script> <script src="../js/common/auth-session.js"></script>
<script src="../js/common/soon-toast.js"></script>
<script src="../js/common/member-pay-core.js"></script>
<script src="../js/common/member-plan-core.js"></script>
<script src="../js/common/cloud-files.js"></script> <script src="../js/common/cloud-files.js"></script>
<script src="../js/common/soon-local-store.js"></script>
<script src="../js/common/soon-recent.js"></script>
<script src="../vendor/layui/layui.js"></script> <script src="../vendor/layui/layui.js"></script>
<script src="../vendor/js/qrcode.min.js"></script> <script src="../vendor/js/qrcode.min.js"></script>
<script src="../js/common/soon-toast.js"></script>
<script src="../js/common/member-subscribe.js"></script>
<script src="../js/common/asset-base.js"></script> <script src="../js/common/asset-base.js"></script>
@@ -162,12 +158,11 @@
<script> <script>
if (window.SoonPortalTopbar) SoonPortalTopbar.mount('#portal-topbar', { active: 'home' }); if (window.SoonPortalTopbar) SoonPortalTopbar.mount('#portal-topbar', { active: 'home' });
soonPortalAuth.init(); soonPortalAuth.init({
if (window.soonLoadMembership) { onAuthed: function () {
window.soonLoadMembership().then(function () { if (window.soonReloadRecentFiles) window.soonReloadRecentFiles();
if (window.soonRefreshPortalIdentity) window.soonRefreshPortalIdentity();
});
} }
});
</script> </script>
</body> </body>
+1 -1
View File
@@ -39,7 +39,7 @@
</div> </div>
</div> </div>
<script src="../assets/deploy-config.js"></script> <script src="../config/local.js"></script>
<script src="../js/common/portal-topbar.js"></script> <script src="../js/common/portal-topbar.js"></script>
<script>SoonPortalTopbar.mount('#portal-topbar', { variant: 'auth' });</script> <script>SoonPortalTopbar.mount('#portal-topbar', { variant: 'auth' });</script>
<script src="../js/common/auth-redirect.js"></script> <script src="../js/common/auth-redirect.js"></script>
-71
View File
@@ -1,71 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="SoonDesign 订阅与账户">
<link rel="icon" href="../assets/images/favicon.ico">
<title>订阅中心 · SoonDesign</title>
<link rel="stylesheet" href="../vendor/layui/css/layui.css">
<link rel="stylesheet" href="../assets/css/layer-soon.css">
<link rel="stylesheet" href="../assets/css/portal.css">
<link rel="stylesheet" href="../assets/css/member.css">
</head>
<body class="soon-member-page">
<div class="soon-pay-banner" id="payBanner">支付成功,订阅已生效</div>
<div id="portal-topbar"></div>
<div class="soon-member-wrap">
<header class="soon-member-pagehead">
<div>
<p class="soon-member-pagehead__eyebrow">订阅服务</p>
<h1 class="soon-member-pagehead__title">订阅中心</h1>
<p class="soon-member-pagehead__sub">免费体验设计工具;订阅后解锁预览、打印与云端交付</p>
</div>
</header>
<section class="soon-member-dashboard" id="myPlan">
<div class="soon-skeleton soon-skeleton--h160"></div>
</section>
<section class="soon-member-section">
<div class="soon-member-section__head">
<h2><span class="soon-member-section__mark"></span>订阅方案</h2>
<p>选择适合您的订阅周期,功能相同,随时续订</p>
</div>
<div class="soon-plans-grid" id="plans">
<div class="soon-skeleton soon-skeleton--h320" style="grid-column:1/-1"></div>
</div>
</section>
<section class="soon-member-section soon-member-section--last">
<div class="soon-member-section__head">
<h2><span class="soon-member-section__mark"></span>订单记录</h2>
<p>订阅与支付记录,支持分页浏览</p>
</div>
<div class="soon-member-orders" id="orderHistory">
<div class="soon-member-orders-empty">加载中...</div>
</div>
</section>
</div>
<script src="../assets/deploy-config.js"></script>
<script src="../js/common/auth-redirect.js"></script>
<script src="../js/common/auth-session.js"></script>
<script src="../js/common/member-pay-core.js"></script>
<script src="../js/common/member-plan-core.js"></script>
<script src="../js/common/cloud-files.js"></script>
<script src="../js/common/portal-topbar.js"></script>
<script src="../js/common/portal-auth.js"></script>
<script src="../vendor/js/qrcode.min.js"></script>
<script src="../vendor/layui/layui.js"></script>
<script>
layui.use(['layer'], function () {
window.layer = layui.layer;
layer.config({ skin: 'soon-layer' });
});
</script>
<script src="../js/member.js"></script>
</body>
</html>
+1 -1
View File
@@ -39,7 +39,7 @@
</div> </div>
</div> </div>
<script src="../assets/deploy-config.js"></script> <script src="../config/local.js"></script>
<script src="../js/common/portal-topbar.js"></script> <script src="../js/common/portal-topbar.js"></script>
<script>SoonPortalTopbar.mount('#portal-topbar', { variant: 'auth' });</script> <script>SoonPortalTopbar.mount('#portal-topbar', { variant: 'auth' });</script>
<script src="../js/common/auth-redirect.js"></script> <script src="../js/common/auth-redirect.js"></script>

Some files were not shown because too many files have changed in this diff Show More