Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f196bdec8 | |||
| 7dadc41bb1 | |||
| 5fb269cc0a | |||
| 4d19d7e814 | |||
| 23b958753d | |||
| 55a6029779 | |||
| 4ce4486b26 | |||
| 877fd278c2 | |||
| ebe191b06d | |||
| 152228d41f |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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,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/')) {
|
||||||
|
|||||||
@@ -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 粘贴块。
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
|
public function download(string $name): void
|
||||||
|
{
|
||||||
|
$path = SoonModelService::resolvePath($name);
|
||||||
|
if ($path === null) {
|
||||||
|
Json::fail('not_found', '模板不存在', 404);
|
||||||
}
|
}
|
||||||
$data = json_decode((string)file_get_contents($manifest), true);
|
$base = basename($path);
|
||||||
if (!is_array($data)) {
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
Json::fail('manifest_invalid', '模型清单已损坏', 500);
|
header('Content-Disposition: inline; filename="' . str_replace('"', '', $base) . '"');
|
||||||
}
|
header('Cache-Control: public, max-age=300');
|
||||||
Json::ok($data);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,33 +68,50 @@ 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
|
||||||
{
|
{
|
||||||
$maxFiles = MembershipService::maxFilesLimit($userId);
|
$name = self::normalizeName($name);
|
||||||
if (self::fileCount($userId) >= $maxFiles) {
|
self::assertJsonPayload($json);
|
||||||
Json::fail('file_limit_exceeded', '已达文件数量上限(' . $maxFiles . ' 个),请清理文件或续订', 413);
|
try {
|
||||||
|
$maxFiles = MembershipService::maxFilesLimit($userId);
|
||||||
|
if (self::fileCount($userId) >= $maxFiles) {
|
||||||
|
Json::fail('file_limit_exceeded', '已达文件数量上限(' . $maxFiles . ' 个),请清理文件或续订', 413);
|
||||||
|
}
|
||||||
|
$size = strlen($json);
|
||||||
|
$quota = self::quotaBytes($userId);
|
||||||
|
$used = self::usedBytes($userId);
|
||||||
|
if ($quota > 0 && $used + $size > $quota) {
|
||||||
|
Json::fail('quota_exceeded', '存储空间已满,请清理文件或续订', 413);
|
||||||
|
}
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$thumb = self::extractThumb($json);
|
||||||
|
$stmt = Db::pdo()->prepare(
|
||||||
|
'INSERT INTO soon_files (user_id, name, json, size, version, thumb, created_at, updated_at) '
|
||||||
|
. 'VALUES (:u, :n, :j, :s, 1, :th, :created_at, :updated_at)'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
'u' => $userId, 'n' => $name, 'j' => $json, 's' => $size, 'th' => $thumb !== '' ? $thumb : null,
|
||||||
|
'created_at' => $now, 'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
$id = (int)Db::pdo()->lastInsertId();
|
||||||
|
return ['id' => $id, 'name' => $name, 'size' => $size, 'version' => 1, 'updated_at' => $now];
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
self::failDb($e);
|
||||||
}
|
}
|
||||||
$size = strlen($json);
|
|
||||||
$quota = self::quotaBytes($userId);
|
|
||||||
$used = self::usedBytes($userId);
|
|
||||||
if ($quota > 0 && $used + $size > $quota) {
|
|
||||||
Json::fail('quota_exceeded', '存储空间已满,请清理文件或续订', 413);
|
|
||||||
}
|
|
||||||
$now = date('Y-m-d H:i:s');
|
|
||||||
$stmt = Db::pdo()->prepare(
|
|
||||||
'INSERT INTO soon_files (user_id, name, json, size, version, created_at, updated_at) '
|
|
||||||
. 'VALUES (:u, :n, :j, :s, 1, :created_at, :updated_at)'
|
|
||||||
);
|
|
||||||
$stmt->execute(['u' => $userId, 'n' => $name, 'j' => $json, 's' => $size, 'created_at' => $now, 'updated_at' => $now]);
|
|
||||||
$id = (int)Db::pdo()->lastInsertId();
|
|
||||||
return ['id' => $id, 'name' => $name, 'size' => $size, 'version' => 1, 'updated_at' => $now];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function update(int $userId, int $id, string $name, string $json, ?int $expectedVersion): array
|
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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
$expiresAt = (string)($row['subscription_expires_at'] ?? '');
|
$durationDays = (int)($row['duration_days'] ?? 0);
|
||||||
$daysRemaining = self::daysUntil($expiresAt);
|
$startedAt = $row['subscription_started_at'] ?? null;
|
||||||
$plan['subscription'] = [
|
if ($durationDays <= 0 || (string)($row['code'] ?? '') === 'member_lifetime') {
|
||||||
'id' => (int)$row['subscription_id'],
|
$plan['subscription'] = [
|
||||||
'started_at' => $row['subscription_started_at'] ?? null,
|
'type' => 'lifetime',
|
||||||
'expires_at' => $expiresAt,
|
'activated_at' => $startedAt,
|
||||||
'days_remaining' => $daysRemaining,
|
'status' => 'active',
|
||||||
'status' => $daysRemaining <= 7 ? 'expiring' : 'active',
|
];
|
||||||
];
|
} else {
|
||||||
|
$expiresAt = (string)($row['subscription_expires_at'] ?? '');
|
||||||
|
$daysRemaining = self::daysUntil($expiresAt);
|
||||||
|
$plan['subscription'] = [
|
||||||
|
'id' => (int)$row['subscription_id'],
|
||||||
|
'started_at' => $startedAt,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'days_remaining' => $daysRemaining,
|
||||||
|
'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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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/*
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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/total;files 兼容 limit/offset;pay/orders 分页 |
|
| 2026-06-08 | 初版:统一 page/size/total;files 兼容 limit/offset;pay/orders 分页 |
|
||||||
|
| 2026-06-08 | 新增 GET /templates/{id} JSON 契约;Web 虚拟 key 前缀;/file 标 legacy |
|
||||||
|
| 2026-06-08 | 新增 GET /files/{id}/thumb;files list 增 `has_thumb`;模板 /file 长缓存 |
|
||||||
|
|||||||
+16
-3
@@ -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 路径
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
# Web 端本地缓存与最近文件优化(开发计划 · 细化版)
|
||||||
|
|
||||||
|
> 状态:P0~P7 已实施。与 [API-PAGINATION.md](API-PAGINATION.md)「Web 虚拟 key」交叉引用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 背景与约束
|
||||||
|
|
||||||
|
### 0.1 问题(已核实代码)
|
||||||
|
|
||||||
|
| 现象 | 根因 | 代码位置 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `GET /templates/{id}` 20~30s | 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 L2:IndexedDB `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 1:IndexedDB 模块(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 2:web.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.4;miss 走 `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 3:L1 最近列表 + 保存/打开写 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 分页 |
|
||||||
|
|
||||||
|
**验收**
|
||||||
|
|
||||||
|
- [ ] 首页 Network:0× 全量 `files/{id}` / `templates/{id}`
|
||||||
|
- [ ] 12 卡仅 thumb 图或 0 额外请求(IDB 命中)
|
||||||
|
- [ ] 从设计页返回可见新保存项
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5:session 迁移与清理(1h)
|
||||||
|
|
||||||
|
| 任务 ID | 任务 | 细节 |
|
||||||
|
|---------|------|------|
|
||||||
|
| P5.1 | `soonPutSoonSession` | 正文 IDB;sessionStorage 仅存 `{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` 元数据 | 不变 |
|
||||||
|
|
||||||
|
**部署**:前端 P1~P5 同批;强刷 `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 → P7;P6 与 P3/P4 并行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|------|------|
|
||||||
|
| IDB 配额 | LRU 20;thumb ≤100KB;evict 失败 toast |
|
||||||
|
| 多设备 | 云 version 为准;409 提示刷新 |
|
||||||
|
| 模板 stale 漏检 | 首页列表带 `updated_at`;§1.4 |
|
||||||
|
| PUT 后双 version 缓存 | `onCloudWriteDone` rename + 删旧 key |
|
||||||
|
| 隐私模式 | 降级 session+网络,与现网一致 |
|
||||||
|
| 旧用户无 recent | `soonRecentMigrateFromHistory` 一次 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 不在 scope
|
||||||
|
|
||||||
|
- CDN;`.soon` 剥离 base64;Electron IDB;CRDT 离线合并。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**确认后**按 P1→P7 实施;每阶段勾选任务 ID 验收后再进入下一阶段。
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'
|
|
||||||
};
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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'
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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));
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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 () {
|
||||||
clearSession();
|
if (hadSession) {
|
||||||
redirectToLogin();
|
clearSession();
|
||||||
|
redirectToLogin();
|
||||||
|
}
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,50 +512,231 @@
|
|||||||
return !!_membership.isMember;
|
return !!_membership.isMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
function soonMemberPageUrl() {
|
function soonGuardPreviewDeliver(actionLabel, onAllowed) {
|
||||||
return 'member.web.html';
|
if (typeof onAllowed !== 'function') return;
|
||||||
}
|
if (!soonIsWebPortal()) {
|
||||||
|
onAllowed();
|
||||||
function soonRequireMember(actionLabel) {
|
return;
|
||||||
if (!soonGetAccessToken()) return soonRequireLogin(actionLabel);
|
|
||||||
if (soonIsMember()) return true;
|
|
||||||
if (typeof window.soonShowSubscribeGate === 'function') {
|
|
||||||
window.soonShowSubscribeGate({ action: actionLabel || '使用' });
|
|
||||||
} else {
|
|
||||||
soonToast('订阅后可' + (actionLabel || '使用'), 'warn');
|
|
||||||
}
|
}
|
||||||
return false;
|
if (!soonGetAccessToken()) {
|
||||||
|
if (typeof window.soonShowLoginGate === 'function') {
|
||||||
|
window.soonShowLoginGate({
|
||||||
|
reason: actionLabel || '导出或打印',
|
||||||
|
onSuccess: function () {
|
||||||
|
soonGuardPreviewDeliver(actionLabel, onAllowed);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
soonRequireLogin(actionLabel);
|
||||||
|
}
|
||||||
|
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 (typeof window.soonRedirectToLogin === 'function') {
|
if (options.redirect) {
|
||||||
setTimeout(function () { window.soonRedirectToLogin(); }, 600);
|
if (typeof window.soonRedirectToLogin === 'function') {
|
||||||
} else if (typeof window.redirectToLogin === 'function') {
|
setTimeout(function () { window.soonRedirectToLogin(); }, 600);
|
||||||
setTimeout(function () { window.redirectToLogin(); }, 600);
|
} else if (typeof window.redirectToLogin === 'function') {
|
||||||
} else {
|
setTimeout(function () { window.redirectToLogin(); }, 600);
|
||||||
window.location.href = 'login.web.html';
|
} else {
|
||||||
|
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();
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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'); };
|
||||||
|
})();
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -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 clearSession() {
|
function showGuestNav() {
|
||||||
if (typeof window.soonClearAuthSession === 'function') {
|
var guest = document.getElementById('auth_guest');
|
||||||
window.soonClearAuthSession();
|
var account = document.getElementById('auth_account');
|
||||||
} else {
|
var session = document.getElementById('auth_session');
|
||||||
localStorage.removeItem('soon_access');
|
var admin = document.getElementById('auth_admin');
|
||||||
localStorage.removeItem('soon_refresh');
|
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 initTopbar(options) {
|
function clearSession() {
|
||||||
options = options || {};
|
if (typeof window.soonClearAuthSession === 'function') window.soonClearAuthSession();
|
||||||
var tok = localStorage.getItem('soon_access') || '';
|
else {
|
||||||
var login = document.getElementById('auth_login');
|
localStorage.removeItem('soon_access');
|
||||||
var reg = document.getElementById('auth_register');
|
localStorage.removeItem('soon_refresh');
|
||||||
var member = document.getElementById('auth_member');
|
}
|
||||||
|
if (typeof window.soonApplyMembership === 'function') window.soonApplyMembership(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUserUi(data) {
|
||||||
|
if (typeof window.soonApplyMembership === 'function') window.soonApplyMembership(data);
|
||||||
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 (avatar && data.email) {
|
||||||
|
avatar.style.display = 'inline-flex';
|
||||||
|
avatar.textContent = data.email.charAt(0).toUpperCase();
|
||||||
|
avatar.title = data.email;
|
||||||
|
}
|
||||||
|
if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity();
|
||||||
|
}
|
||||||
|
|
||||||
if (!tok) return;
|
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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
showAuthedNav();
|
function bindLogoutOnce(options) {
|
||||||
if (login) login.style.display = 'none';
|
if (_logoutBound) return;
|
||||||
if (reg) reg.style.display = 'none';
|
var logout = document.getElementById('auth_logout');
|
||||||
if (member) member.style.display = 'inline-flex';
|
if (!logout) return;
|
||||||
if (logout) logout.style.display = 'inline-flex';
|
_logoutBound = true;
|
||||||
|
logout.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearSession();
|
||||||
|
showGuestNav();
|
||||||
|
if (options.logoutReload !== false) location.reload();
|
||||||
|
else location.href = 'index.web.html';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var base = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || '';
|
function bindPopupLink(id, fn) {
|
||||||
if (!base) return;
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var meFetch = typeof window.soonAuthedFetch === 'function'
|
function bindPopupAuthLinks() {
|
||||||
? window.soonAuthedFetch(base + '/auth/me', { headers: { Authorization: 'Bearer ' + tok } })
|
if (typeof window.soonShowLoginGate !== 'function') return;
|
||||||
: fetch(base + '/auth/me', { headers: { Authorization: 'Bearer ' + tok } });
|
bindPopupLink('auth_login', function () {
|
||||||
meFetch
|
window.soonShowLoginGate({ reason: '登录账号' });
|
||||||
.then(function (r) { return r.json(); })
|
});
|
||||||
.then(function (j) {
|
if (typeof window.soonShowRegisterGate === 'function') {
|
||||||
if (!j.ok || !j.data) {
|
bindPopupLink('auth_register', function () {
|
||||||
clearSession();
|
window.soonShowRegisterGate({ reason: '注册账号' });
|
||||||
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.textContent = j.data.email.charAt(0).toUpperCase();
|
|
||||||
avatar.title = j.data.email;
|
|
||||||
}
|
|
||||||
if (typeof window.soonLoadMembership === 'function') {
|
|
||||||
window.soonLoadMembership().then(function () {
|
|
||||||
if (typeof window.soonRefreshPortalIdentity === 'function') {
|
|
||||||
window.soonRefreshPortalIdentity();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function () { /* keep authed shell */ });
|
|
||||||
|
|
||||||
if (logout) {
|
|
||||||
logout.addEventListener('click', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
clearSession();
|
|
||||||
if (options.logoutReload !== false) location.reload();
|
|
||||||
else location.href = 'index.web.html';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.soonPortalAuth = { init: initTopbar };
|
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 };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
})();
|
})();
|
||||||
|
|||||||
+147
-110
@@ -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;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
is_bgi_add = true;
|
||||||
|
window.is_bgi_add = true;
|
||||||
|
soonOpenPendingDesignFile();
|
||||||
|
}
|
||||||
|
|
||||||
image.selectable = false;
|
function startLoad() {
|
||||||
image.hoverable = false;
|
if (!loadBg) return;
|
||||||
image.hoverCursor = "default"
|
loadBg('bg_front_1.png', function (frontImg) {
|
||||||
image.setSrc(image.toDataURL(), function (image) {
|
if (!frontImg) return;
|
||||||
image.scale(1, 1);
|
frontImg.objectCaching = true;
|
||||||
background_image1 = image;
|
frontImg.scale(1, 1);
|
||||||
background_image = background_image1;
|
soonPlaceBgOnCanvas(frontImg, '#canvas1');
|
||||||
window.background_image1 = background_image1;
|
background_image1 = frontImg;
|
||||||
window.background_image = background_image;
|
background_image = frontImg;
|
||||||
canvas.add(image);
|
window.background_image1 = frontImg;
|
||||||
|
window.background_image = frontImg;
|
||||||
|
canvas.add(frontImg);
|
||||||
canvas.renderAll();
|
canvas.renderAll();
|
||||||
//pre_objs1.push(JSON.stringify(objs1));
|
|
||||||
//pre_json1.push(canvas1.toJSON(["selectable","hoverable","hoverCursor"]));
|
|
||||||
|
|
||||||
recordObjs1.push(JSON.stringify(objs1));
|
recordObjs1.push(JSON.stringify(objs1));
|
||||||
recordJson1.push(canvas1.toJSON(TO_JSON_PROPERTIES));
|
recordJson1.push(canvas1.toJSON(TO_JSON_PROPERTIES));
|
||||||
});
|
loadBg('bg_back.png', finishBack, function () { loadBg('bg_back.png', finishBack, null, 2, true); }, 2, true);
|
||||||
// 类型改变
|
}, function () {
|
||||||
fabric.Image.fromURL(soonAsset('bg_back.png'), function (image) {
|
setTimeout(startLoad, 200);
|
||||||
image.left = ($("#canvas2").width() - image.width) / 2;
|
}, 2, true);
|
||||||
image.top = ($("#canvas2").height() - image.height) / 2;
|
}
|
||||||
|
|
||||||
image.selectable = false;
|
whenReady('#canvas1', startLoad);
|
||||||
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();
|
|
||||||
//pre_objs2.push(JSON.stringify(objs2));
|
|
||||||
//pre_json2.push(canvas2.toJSON(["selectable","hoverable","hoverCursor"]));
|
|
||||||
|
|
||||||
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) {}
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 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) => {
|
||||||
let offsetX = 0, offsetY = 0;
|
_mouseMovePending = { e: e, canvas: currentCanvas };
|
||||||
if (currentCanvas === canvas2 && typeof background_image !== 'undefined') {
|
if (_mouseMoveRaf) return;
|
||||||
offsetX = background_image.left;
|
_mouseMoveRaf = requestAnimationFrame(function () {
|
||||||
offsetY = background_image.top;
|
_mouseMoveRaf = 0;
|
||||||
}
|
var payload = _mouseMovePending;
|
||||||
if (e.pointer) {
|
if (!payload) return;
|
||||||
$("#posText").text("X: " + (e.pointer.x - offsetX).toFixed(0) + " Y: " + (e.pointer.y - offsetY).toFixed(0));
|
var moveEvent = payload.e;
|
||||||
}
|
var moveCanvas = payload.canvas;
|
||||||
updateRulerGuides(e.e);
|
let offsetX = 0, offsetY = 0;
|
||||||
|
if (moveCanvas === canvas2 && typeof background_image !== 'undefined') {
|
||||||
|
offsetX = background_image.left;
|
||||||
|
offsetY = background_image.top;
|
||||||
|
}
|
||||||
|
if (moveEvent.pointer) {
|
||||||
|
$("#posText").text("X: " + (moveEvent.pointer.x - offsetX).toFixed(0) + " Y: " + (moveEvent.pointer.y - offsetY).toFixed(0));
|
||||||
|
}
|
||||||
|
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,38 +1772,41 @@ function initCanvasEvents() {
|
|||||||
|
|
||||||
// 7. 对象修改后
|
// 7. 对象修改后
|
||||||
currentCanvas.on('object:modified', function (event) {
|
currentCanvas.on('object:modified', function (event) {
|
||||||
if (currentCanvas.getActiveObjects().length > 1) return;
|
try {
|
||||||
let idx = getIndex(event.target);
|
if (currentCanvas.getActiveObjects().length > 1) return;
|
||||||
if (idx <= 0) return;
|
let idx = getIndex(event.target);
|
||||||
let objData = objs[idx - 1];
|
if (idx <= 0) return;
|
||||||
// 安全检查:确保 objData 存在且有 type 属性
|
let objData = objs[idx - 1];
|
||||||
if (!objData || typeof objData.type === 'undefined') {
|
// 安全检查:确保 objData 存在且有 type 属性
|
||||||
|
if (!objData || typeof objData.type === 'undefined') {
|
||||||
|
// 更新对象列表以同步锁状态
|
||||||
|
if (typeof window.updateList === 'function') {
|
||||||
|
window.updateList();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let target = event.target;
|
||||||
|
|
||||||
|
if ([3, 4, 10].includes(objData.type)) {
|
||||||
|
target.fontSize = (target.fontSize * target.scaleX).toFixed(0);
|
||||||
|
target.scaleX = 1; target.scaleY = 1;
|
||||||
|
target._clearCache();
|
||||||
|
if (objData.type == 3) $("#text_size").val(target.fontSize);
|
||||||
|
if (objData.type == 4) $("#out_text_size").val(target.fontSize);
|
||||||
|
if (objData.type == 10) $("#count_size").val(target.fontSize);
|
||||||
|
currentCanvas.renderAll();
|
||||||
|
} else if (objData.type == 5) {
|
||||||
|
target.width *= target.scaleX; target.height *= target.scaleY;
|
||||||
|
target.scaleX = 1; target.scaleY = 1;
|
||||||
|
}
|
||||||
|
window.recordState();
|
||||||
// 更新对象列表以同步锁状态
|
// 更新对象列表以同步锁状态
|
||||||
if (typeof window.updateList === 'function') {
|
if (typeof window.updateList === 'function') {
|
||||||
window.updateList();
|
window.updateList();
|
||||||
}
|
}
|
||||||
return;
|
} finally {
|
||||||
}
|
soonDesign1SetInteracting(false);
|
||||||
let target = event.target;
|
|
||||||
|
|
||||||
if ([3, 4, 10].includes(objData.type)) {
|
|
||||||
target.fontSize = (target.fontSize * target.scaleX).toFixed(0);
|
|
||||||
target.scaleX = 1; target.scaleY = 1;
|
|
||||||
target._clearCache();
|
|
||||||
if (objData.type == 3) $("#text_size").val(target.fontSize);
|
|
||||||
if (objData.type == 4) $("#out_text_size").val(target.fontSize);
|
|
||||||
if (objData.type == 10) $("#count_size").val(target.fontSize);
|
|
||||||
currentCanvas.renderAll();
|
|
||||||
updateControls();
|
updateControls();
|
||||||
} else if (objData.type == 5) {
|
|
||||||
target.width *= target.scaleX; target.height *= target.scaleY;
|
|
||||||
target.scaleX = 1; target.scaleY = 1;
|
|
||||||
updateControls();
|
|
||||||
}
|
|
||||||
window.recordState();
|
|
||||||
// 更新对象列表以同步锁状态
|
|
||||||
if (typeof window.updateList === 'function') {
|
|
||||||
window.updateList();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// ==================================================
|
// ==================================================
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 () {
|
||||||
saveImageAsPNG(buffer);
|
if (typeof window.soonGuardPreviewDeliver === 'function') {
|
||||||
|
window.soonGuardPreviewDeliver(language_str('output') || '导出', function () {
|
||||||
|
saveImageAsPNG(buffer);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
saveImageAsPNG(buffer);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
btn2: function () {
|
btn2: function () {
|
||||||
if (window.platformBridge && window.platformBridge.printPdf) {
|
if (typeof window.soonGuardPreviewDeliver === 'function') {
|
||||||
|
window.soonGuardPreviewDeliver('打印', function () {
|
||||||
|
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; }' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else 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; }' });
|
||||||
}
|
}
|
||||||
|
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 || '';
|
||||||
con_o.soonType = 1;
|
if (typeof window.soonApplySavePreview === 'function') window.soonApplySavePreview(con_o, canvas1);
|
||||||
var content = JSON.stringify(con_o);
|
if (typeof window.soonRefreshDesignCanvases === 'function') window.soonRefreshDesignCanvases();
|
||||||
if (window.platformBridge && window.platformBridge.writeFile) {
|
con_o.soonType = 1;
|
||||||
window.platformBridge.writeFile(openAs.name, content).then(function (res) {
|
var content = JSON.stringify(con_o);
|
||||||
onCloudWriteDone(res, openAs.name, callback);
|
var needsDialog = typeof window.soonNeedsSaveDialog === 'function'
|
||||||
}).catch(function () {});
|
? window.soonNeedsSaveDialog(openAs.name)
|
||||||
return;
|
: !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');
|
||||||
@@ -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;
|
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) {
|
function doWrite(j) {
|
||||||
var currentPath = openAs.name;
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+216
-83
@@ -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 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 (background_image1 && background_image2) return;
|
||||||
fabric.Image.fromURL(soonAsset('bg_front_2.png'), function (image) {
|
var loadBg = typeof window.soonFabricImageFromAsset === 'function'
|
||||||
image.left = ($('#canvas1').width() - image.width * image.get('scaleX')) / 2
|
? window.soonFabricImageFromAsset
|
||||||
image.top = ($('#canvas1').height() - image.height * image.get('scaleY')) / 2
|
: 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'];
|
||||||
|
|
||||||
image.selectable = false
|
function finishBack(backImg) {
|
||||||
image.hoverable = false
|
if (backImg) {
|
||||||
image.hoverCursor = 'default'
|
soonPlaceBgOnCanvas(backImg, '#canvas2');
|
||||||
image.setSrc(image.toDataURL(), function (image) {
|
background_image2 = backImg;
|
||||||
background_image1 = image
|
window.background_image2 = backImg;
|
||||||
background_image = background_image1
|
canvas2.add(backImg);
|
||||||
window.background_image1 = background_image1;
|
canvas2.renderAll();
|
||||||
window.background_image = background_image;
|
recordObjs2.push(JSON.stringify(objs2));
|
||||||
canvas1.add(image)
|
recordJson2.push(canvas2.toJSON(TO_JSON_PROPERTIES));
|
||||||
canvas1.renderAll()
|
}
|
||||||
|
soonOpenPendingDesignFile();
|
||||||
|
}
|
||||||
|
|
||||||
recordObjs1.push(JSON.stringify(objs1))
|
function startLoad() {
|
||||||
const props = window.TO_JSON_PROPERTIES || ['selectable', 'hoverable', 'hoverCursor', 'text', 'fontStyle', 'fontWeight', 'underline', 'evented', 'linethrough', 'textBackgroundColor', 'diameter', 'flipped'];
|
if (!loadBg) return;
|
||||||
recordJson1.push(canvas1.toJSON(props))
|
loadBg('bg_front_2.png', function (frontImg) {
|
||||||
})
|
if (!frontImg) return;
|
||||||
// 类型改变
|
soonPlaceBgOnCanvas(frontImg, '#canvas1');
|
||||||
fabric.Image.fromURL(soonAsset('bg_back_2.png'), function (image) {
|
background_image1 = frontImg;
|
||||||
image.left = ($('#canvas2').width() - image.width) / 2
|
background_image = frontImg;
|
||||||
image.top = ($('#canvas2').height() - image.height) / 2
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
image.selectable = false
|
whenReady('#canvas1', startLoad);
|
||||||
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) {}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,29 +355,55 @@ 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 (window.platformBridge && window.platformBridge.printPdf && (printPath3.indexOf('data:') === 0 || printPath4.indexOf('data:') === 0)) {
|
if (typeof window.soonGuardPreviewDeliver === 'function') {
|
||||||
|
window.soonGuardPreviewDeliver('打印', function () {
|
||||||
|
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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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; }' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else 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);
|
||||||
return;
|
} else if (btns[1] === '打印正面') {
|
||||||
}
|
|
||||||
if (btns[1] === '打印正面') {
|
|
||||||
printJS({ printable: printPath3, type: 'image', style: 'img { width: 100%; height: auto; }' });
|
printJS({ printable: printPath3, type: 'image', style: 'img { width: 100%; height: auto; }' });
|
||||||
} 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; }' });
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
btn3: function () {
|
btn3: function () {
|
||||||
if (window.platformBridge && window.platformBridge.printPdf) {
|
if (typeof window.soonGuardPreviewDeliver === 'function') {
|
||||||
|
window.soonGuardPreviewDeliver('打印', function () {
|
||||||
|
if (window.platformBridge && window.platformBridge.printPdf) {
|
||||||
|
window.platformBridge.printPdf(pdfBlob);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printJS({ printable: printPath4, type: 'image', style: 'img { width: 100%; height: auto; }' });
|
||||||
|
});
|
||||||
|
} else if (window.platformBridge && window.platformBridge.printPdf) {
|
||||||
window.platformBridge.printPdf(pdfBlob);
|
window.platformBridge.printPdf(pdfBlob);
|
||||||
return;
|
} else {
|
||||||
|
printJS({ printable: printPath4, type: 'image', style: 'img { width: 100%; height: auto; }' });
|
||||||
}
|
}
|
||||||
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;
|
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) {
|
function doWrite(j) {
|
||||||
var currentPath = openAs.name;
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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 中处理)
|
||||||
|
|||||||
+291
-208
@@ -144,39 +144,17 @@ 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);
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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,46 +706,64 @@ 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 (typeof window.soonBindFileMeta === 'function') {
|
||||||
|
window.soonBindFileMeta(filePath, { name: $(this).attr('data-name') || '' });
|
||||||
if (soonData) {
|
|
||||||
|
|
||||||
let type = soonData.soonType ? soonData.soonType : (soonData.backBlackPic ? 2 : 1);
|
|
||||||
|
|
||||||
if (typeof window.soonBindFileMeta === 'function') {
|
|
||||||
window.soonBindFileMeta(filePath, { name: $(this).attr('data-name') || '' });
|
|
||||||
}
|
|
||||||
|
|
||||||
openDesign(type, filePath);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
|
|
||||||
var fileId = parseInt($(this).attr('data-id'), 10);
|
|
||||||
|
|
||||||
confirmDeleteCloudFile(fileId, language_str("delContent"), loadHistory);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openDesign(type, filePath);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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); });
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
+456
-70
@@ -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'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function mergeFontNames() {
|
||||||
|
var set = {};
|
||||||
|
WEB_FONT_FALLBACK.forEach(function (name) { set[name] = 1; });
|
||||||
if (typeof document !== 'undefined' && document.fonts && document.fonts.forEach) {
|
if (typeof document !== 'undefined' && document.fonts && document.fonts.forEach) {
|
||||||
var set = {};
|
|
||||||
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 : '';
|
||||||
if (key.indexOf('soondesign_session:') === 0) {
|
var cacheKey = key;
|
||||||
try {
|
|
||||||
var j = sessionStorage.getItem(key);
|
function cacheHitThen(hit) {
|
||||||
if (!j && typeof localStorage !== 'undefined') j = localStorage.getItem(key);
|
if (!hit || !hit.json) return null;
|
||||||
return Promise.resolve(j ? JSON.parse(j) : null);
|
if (typeof window.soonLocalIsTemplateStale === 'function' &&
|
||||||
} catch (e) { return Promise.resolve(null); }
|
window.soonLocalIsTemplateStale(cacheKey, hit.meta)) {
|
||||||
}
|
if (typeof window.soonLocalRemove === 'function') {
|
||||||
if (key && key.indexOf('soondesign_file:') === 0 && typeof fetch !== 'undefined') {
|
return window.soonLocalRemove(cacheKey).then(function () { return null; });
|
||||||
var fileMatch = key.match(/^soondesign_file:(\d+)/);
|
}
|
||||||
if (fileMatch && getAccessToken()) {
|
return Promise.resolve(null);
|
||||||
return authedFetch('files/' + fileMatch[1] + '/download', { headers: { Accept: 'application/json' } })
|
|
||||||
.then(function (response) {
|
|
||||||
if (!response.ok) return null;
|
|
||||||
return response.text().then(function (text) {
|
|
||||||
try { return JSON.parse(text); } catch (e) { return null; }
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(function () { return 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 (typeof window.soonLocalGet === 'function') {
|
||||||
|
return window.soonLocalGet(cacheKey).then(function (hit) {
|
||||||
|
if (hit && hit.json) return hit.json;
|
||||||
|
try {
|
||||||
|
var j = sessionStorage.getItem(key);
|
||||||
|
if (!j && typeof localStorage !== 'undefined') j = localStorage.getItem(key);
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
if (key && key.indexOf('soondesign_file:') === 0 && typeof fetch !== 'undefined') {
|
||||||
|
var fileMatch = key.match(/^soondesign_file:(\d+)/);
|
||||||
|
if (fileMatch && getAccessToken()) {
|
||||||
|
return authedFetch('files/' + fileMatch[1], { 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 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; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||||
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);
|
||||||
|
|
||||||
if (cloudRef && cloudRef.id) {
|
var parsedJson;
|
||||||
var ver = cloudRef.version;
|
try { parsedJson = JSON.parse(str); } catch (e) { parsedJson = null; }
|
||||||
if (ver == null && window._soonFileMeta && window._soonFileMeta.id === cloudRef.id) {
|
|
||||||
ver = window._soonFileMeta.version;
|
function cacheWriteForKey(targetKey, meta) {
|
||||||
}
|
if (!parsedJson || typeof window.soonLocalCacheAndThumb !== 'function') return Promise.resolve();
|
||||||
return updateCloudFile(cloudRef.id, fileName, str, ver);
|
return window.soonLocalCacheAndThumb(targetKey, parsedJson, meta || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
return createCloudFile(fileName, str);
|
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) {
|
||||||
|
var ver = cloudRef.version;
|
||||||
|
if (ver == null && window._soonFileMeta && window._soonFileMeta.id === cloudRef.id) {
|
||||||
|
ver = window._soonFileMeta.version;
|
||||||
|
}
|
||||||
|
var cloudSaveName = fileName;
|
||||||
|
if (typeof window.soonResolveCloudFileName === 'function') {
|
||||||
|
var resolved = window.soonResolveCloudFileName(name);
|
||||||
|
if (resolved) cloudSaveName = resolved;
|
||||||
|
}
|
||||||
|
return updateCloudFile(cloudRef.id, cloudSaveName, str, ver).then(function (res) {
|
||||||
|
return afterCloudSave(res, localResult.fileKey || name);
|
||||||
|
}).catch(function () {
|
||||||
|
localResult.syncFailed = true;
|
||||||
|
return localResult;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var prevKey = localResult.fileKey || name;
|
||||||
|
return createCloudFile(fileName, str).then(function (res) {
|
||||||
|
return afterCloudSave(res, prevKey);
|
||||||
|
}).catch(function () {
|
||||||
|
localResult.syncFailed = true;
|
||||||
|
return localResult;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = file ? encodeURIComponent(file) : '';
|
var fileParam = key ? encodeURIComponent(key) : '';
|
||||||
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '支付宝',
|
wechat: [
|
||||||
|
{ kind: 'mch_private_key', file: 'wechat_mch_private_key.pem', label: '商户私钥' },
|
||||||
kinds: [
|
{ kind: 'api_v3_key', file: 'wechat_api_v3_key.pem', label: 'APIv3 密钥', text: true },
|
||||||
|
{ kind: 'platform_cert', file: 'wechat_platform_cert.pem', label: '平台证书' },
|
||||||
{ kind: 'private_key', file: 'alipay_private_key.pem', label: '应用私钥 private_key' },
|
],
|
||||||
|
|
||||||
{ kind: 'public_key', file: 'alipay_public_key.pem', label: '支付宝公钥 public_key' },
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
wechat: {
|
|
||||||
|
|
||||||
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 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>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function grid(cells) {
|
||||||
|
return '<div class="soon-admin-pay-grid">' + cells.join('') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function textInput(key, values, placeholder) {
|
||||||
|
var val = values && values[key] != null ? values[key] : '';
|
||||||
|
return Form().input(cfgId(key), { value: val, placeholder: placeholder || '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function readonlyInput(value) {
|
||||||
|
return '<input class="soon-input soon-input--sm soon-input--readonly" readonly value="' +
|
||||||
|
I().esc(value || '') + '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
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>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelGrid(channel, files, values) {
|
||||||
|
var cells = [];
|
||||||
|
if (channel === 'alipay') {
|
||||||
|
cells.push(field('APPID', textInput('alipay.app_id', values, '开放平台 APPID')));
|
||||||
|
KEY_FIELDS.alipay.forEach(function (f) { cells.push(keyField('alipay', f, files)); });
|
||||||
|
} else {
|
||||||
|
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)); });
|
||||||
|
}
|
||||||
|
return grid(cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formHtml(values, files, notifyUrls, readiness) {
|
||||||
|
var tabs = Tabs().html('paymentTabs', [
|
||||||
|
{ id: 'alipay', label: '支付宝' },
|
||||||
|
{ id: 'wechat', label: '微信' },
|
||||||
|
], {
|
||||||
|
alipay: channelGrid('alipay', files, values),
|
||||||
|
wechat: channelGrid('wechat', files, values),
|
||||||
|
}, 'alipay');
|
||||||
|
|
||||||
|
return '<div class="soon-admin-form soon-admin-form--pay">' +
|
||||||
|
'<div class="soon-admin-form-panel soon-admin-form-panel--pay">' +
|
||||||
|
statusLine(readiness) + siteGrid(values, notifyUrls) + tabs +
|
||||||
|
'</div>' + Form().actions('保存', 'payFormSave') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function render() {
|
||||||
|
|
||||||
var panel = Ui().el('panel-payment');
|
var panel = Ui().el('panel-payment');
|
||||||
|
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
|
var header = Ui().pageHeader('支付配置');
|
||||||
panel.innerHTML = '<div class="soon-admin-page">' + Ui().pageHeader('支付配置', '按渠道管理密钥文件,上传后立即热加载') +
|
panel.innerHTML = '<div class="soon-admin-page">' + header + Ui().skeletonTable() + '</div>';
|
||||||
|
|
||||||
Ui().skeletonTable() + '</div>';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Api().get('payment/status').then(function (j) {
|
Api().get('payment/status').then(function (j) {
|
||||||
|
|
||||||
if (!j) return;
|
if (!j) return;
|
||||||
|
var data = j.data || {};
|
||||||
var items = j.data.items || [];
|
panel.innerHTML = '<div class="soon-admin-page">' + header +
|
||||||
|
formHtml(data.params || {}, fileSet(data.items), data.notify_urls || {}, data.readiness) +
|
||||||
var files = fileSet(items);
|
'</div>';
|
||||||
|
Tabs().bind(Ui().el('paymentTabs'));
|
||||||
|
bindSave();
|
||||||
|
bindKeys(panel);
|
||||||
var tabsHtml = Tabs().html('paymentTabs', [
|
|
||||||
|
|
||||||
{ id: 'alipay', label: '支付宝' },
|
|
||||||
|
|
||||||
{ id: 'wechat', label: '微信支付' },
|
|
||||||
|
|
||||||
{ id: 'files', label: '文件清单' },
|
|
||||||
|
|
||||||
], {
|
|
||||||
|
|
||||||
alipay: channelFormHtml('alipay', files),
|
|
||||||
|
|
||||||
wechat: channelFormHtml('wechat', files),
|
|
||||||
|
|
||||||
files: filesTabHtml(items),
|
|
||||||
|
|
||||||
}, 'alipay');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
|
||||||
|
|
||||||
Ui().pageHeader('支付配置', '按渠道管理密钥文件,上传后立即热加载') +
|
|
||||||
|
|
||||||
tabsHtml + '</div>';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
var tabsRoot = Ui().el('paymentTabs');
|
|
||||||
|
|
||||||
Tabs().bind(tabsRoot);
|
|
||||||
|
|
||||||
bindChannel('alipay', panel);
|
|
||||||
|
|
||||||
bindChannel('wechat', 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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
<span language="m" zh="正面" ozh="正面" en="Front"></span>
|
<div class="ui-button ui-button-active" id="front_side">
|
||||||
</div>
|
<span language="m" zh="正面" ozh="正面" en="Front"></span>
|
||||||
<div class="ui-button img-warp" id="back_side">
|
</div>
|
||||||
<span language="m" zh="背面" ozh="背面" en="Back"></span>
|
<div class="ui-button" id="back_side">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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
Reference in New Issue
Block a user