diff --git a/backend-web/public/admin.php b/backend-web/public/admin.php index 32f91f3..75fd85f 100644 --- a/backend-web/public/admin.php +++ b/backend-web/public/admin.php @@ -60,6 +60,7 @@ Router::get('/api/admin/plans', [PlansController::class, 'list']); 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']); diff --git a/backend-web/public/index.php b/backend-web/public/index.php index 88104a2..e2cce49 100644 --- a/backend-web/public/index.php +++ b/backend-web/public/index.php @@ -46,6 +46,7 @@ Router::get('/api/v1/auth/me', [AuthController::class, 'me']); Router::get('/api/v1/files', [FileController::class, 'index']); Router::post('/api/v1/files', [FileController::class, 'create']); +Router::get('/api/v1/files/{id}', [FileController::class, 'show']); Router::put('/api/v1/files/{id}', [FileController::class, 'update']); Router::delete('/api/v1/files/{id}', [FileController::class, 'delete']); Router::get('/api/v1/files/{id}/download', [FileController::class, 'download']); @@ -65,6 +66,7 @@ Router::post('/api/v1/pay/wechat/notify', [PayController::class, 'wechatNotify'] 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']); diff --git a/backend-web/src/Admin/Controllers/TemplatesController.php b/backend-web/src/Admin/Controllers/TemplatesController.php index e3c68d3..1f27a29 100644 --- a/backend-web/src/Admin/Controllers/TemplatesController.php +++ b/backend-web/src/Admin/Controllers/TemplatesController.php @@ -51,4 +51,10 @@ final class TemplatesController 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); + } } diff --git a/backend-web/src/Controllers/AuthController.php b/backend-web/src/Controllers/AuthController.php index 629217a..70d6d7e 100644 --- a/backend-web/src/Controllers/AuthController.php +++ b/backend-web/src/Controllers/AuthController.php @@ -6,6 +6,7 @@ namespace Soon\Api\Controllers; use Soon\Api\Core\Json; use Soon\Api\Middleware\Auth; use Soon\Api\Services\AuthService; +use Soon\Api\Services\MembershipService; final class AuthController { @@ -38,7 +39,6 @@ final class AuthController public function me(): void { $user = Auth::require(); - unset($user['password_hash']); - Json::ok($user); + Json::ok(array_merge($user, MembershipService::membershipSummary((int)$user['id']))); } } diff --git a/backend-web/src/Controllers/FileController.php b/backend-web/src/Controllers/FileController.php index f527523..dac3f8a 100644 --- a/backend-web/src/Controllers/FileController.php +++ b/backend-web/src/Controllers/FileController.php @@ -44,6 +44,20 @@ final class FileController 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 { $u = Auth::require(); diff --git a/backend-web/src/Controllers/PlanController.php b/backend-web/src/Controllers/PlanController.php index bb02f2d..6b2e6a2 100644 --- a/backend-web/src/Controllers/PlanController.php +++ b/backend-web/src/Controllers/PlanController.php @@ -17,9 +17,6 @@ final class PlanController public function myPlan(): void { $u = Auth::require(); - Json::ok([ - 'membership' => MembershipService::currentPlan($u['id']), - 'recent_orders' => MembershipService::recentOrders($u['id']), - ]); + Json::ok(['membership' => MembershipService::currentPlan($u['id'])]); } } diff --git a/backend-web/src/Controllers/TemplateController.php b/backend-web/src/Controllers/TemplateController.php index fa32dba..c4132d0 100644 --- a/backend-web/src/Controllers/TemplateController.php +++ b/backend-web/src/Controllers/TemplateController.php @@ -10,10 +10,17 @@ 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); diff --git a/backend-web/src/Services/MembershipService.php b/backend-web/src/Services/MembershipService.php index e4ab7d6..d8d87bf 100644 --- a/backend-web/src/Services/MembershipService.php +++ b/backend-web/src/Services/MembershipService.php @@ -16,7 +16,7 @@ final class MembershipService $stmt = Db::pdo()->query( '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 AND code = "member_lifetime" AND price_cents > 0 ' + . 'WHERE is_active = 1 AND code = \'member_lifetime\' AND price_cents > 0 ' . 'ORDER BY sort_order ASC, price_cents ASC' ); $items = []; @@ -34,7 +34,7 @@ final class MembershipService $stmt = $pdo->prepare( '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 ' - . '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' ); $stmt->execute(['u' => $userId]); @@ -70,7 +70,7 @@ final class MembershipService $memberQuota = (int)Config::get('limits.member_quota_mb', 2048); $memberFiles = (int)Config::get('limits.member_max_files', 200); $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(); $freeRow = $freeStmt->fetch(); if ($freeRow) { @@ -108,13 +108,32 @@ final class MembershipService { $stmt = Db::pdo()->prepare( '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() ' - . 'AND p.code <> "free" AND p.price_cents > 0 LIMIT 1' + . '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' ); $stmt->execute(['u' => $userId]); return (bool)$stmt->fetchColumn(); } + /** @return array{is_member:bool,tier:string,name:string,subscription:array} */ + public static function membershipSummary(int $userId): array + { + if (self::isActiveMember($userId)) { + return [ + 'is_member' => true, + 'tier' => 'member', + 'name' => '会员', + 'subscription' => ['status' => 'active'], + ]; + } + return [ + 'is_member' => false, + 'tier' => 'free', + 'name' => '普通用户', + 'subscription' => ['status' => 'free'], + ]; + } + /** @return array> */ public static function recentOrders(int $userId, int $limit = 8): array { diff --git a/backend-web/src/Services/TemplateService.php b/backend-web/src/Services/TemplateService.php index 8debd2c..7c70d1f 100644 --- a/backend-web/src/Services/TemplateService.php +++ b/backend-web/src/Services/TemplateService.php @@ -28,7 +28,7 @@ final class TemplateService public static function listPublic(): array { $stmt = Db::pdo()->query( - 'SELECT id, name, type FROM soon_templates WHERE is_active = 1 ' + 'SELECT id, name, type, updated_at FROM soon_templates WHERE is_active = 1 ' . 'ORDER BY sort_order ASC, id ASC' ); $items = []; @@ -37,6 +37,7 @@ final class TemplateService 'id' => (int)$row['id'], 'name' => (string)$row['name'], 'type' => (int)$row['type'], + 'updated_at' => (string)$row['updated_at'], ]; } return $items; @@ -93,23 +94,141 @@ final class TemplateService if (!is_array($data)) { Json::fail('bad_request', '模板须为有效的 JSON(.soon)', 400); } + return self::metaFromSoonData($data); + } + /** @param array $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)]; + } - $thumb = ''; - if (!empty($data['frontDisplayPic']) && is_string($data['frontDisplayPic'])) { - $candidate = trim($data['frontDisplayPic']); - if (str_starts_with($candidate, 'data:image/') && strlen($candidate) <= self::THUMB_MAX_BYTES) { - $thumb = $candidate; + /** @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 $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; + } - return ['type' => $type, 'thumb' => $thumb]; + 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( @@ -186,6 +305,8 @@ final class TemplateService $thumb = $meta['thumb']; $size = strlen($json); self::writeFile($id, $json); + } elseif ($thumb === '') { + $thumb = self::ensureThumbStored($id); } Db::pdo()->prepare( @@ -232,6 +353,33 @@ final class TemplateService } } + /** @return array */ + 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); @@ -239,25 +387,35 @@ final class TemplateService http_response_code(404); exit; } - $thumb = (string)($row['thumb'] ?? ''); + $thumb = trim((string)($row['thumb'] ?? '')); if ($thumb === '' || !str_starts_with($thumb, 'data:image/')) { + $thumb = self::ensureThumbStored($id); + } + if ($thumb === '') { http_response_code(404); exit; } - if (!preg_match('#^data:(image/[a-zA-Z0-9.+-]+);base64,(.+)$#', $thumb, $m)) { + $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; } - $bin = base64_decode($m[2], true); - if ($bin === false) { + $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; } - header('Content-Type: ' . $m[1]); - header('Cache-Control: public, max-age=86400'); - header('Content-Length: ' . strlen($bin)); - echo $bin; - exit; + self::emitThumbBinary($thumb, (string)$row['updated_at']); } public static function outputFile(int $id): void @@ -276,7 +434,8 @@ final class TemplateService } header('Content-Type: application/json; charset=utf-8'); header('Content-Disposition: inline; filename="' . str_replace('"', '', $name) . '.soon"'); - header('Cache-Control: public, max-age=300'); + header('Cache-Control: public, max-age=60, must-revalidate'); + header('ETag: "' . md5((string)$row['updated_at'] . ':' . (int)$row['file_size']) . '"'); readfile($path); exit; } diff --git a/docs/API-PAGINATION.md b/docs/API-PAGINATION.md index f550d7b..20b2153 100644 --- a/docs/API-PAGINATION.md +++ b/docs/API-PAGINATION.md @@ -162,13 +162,45 @@ GET /api/v1/pay/orders?page=1&size=8 | 路径 | 形态 | |------|------| | `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/templates` | 首页模板列表(仅 `id/name/type`;缩略图与文件按需拉取) | +| `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` JSON(点击使用时再下载) | +| `GET /api/v1/templates/{id}/file` | **legacy**:裸 `.soon` 流;门户禁止调用,仅供兼容 | | `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}` | `POST /files`(首次保存新建) | +| `soondesign_session:...` | 本地/临时会话 | sessionStorage | 已登录 `POST /files`;未登录写 session | + +- **保存/打开**走 JSON API;**仅**首页「下载」走 `GET /files/{id}/download` 落盘 `.soon` + --- ## 前端约定 @@ -203,3 +235,4 @@ GET /api/v1/pay/orders?page=1&size=8 | 日期 | 说明 | |------|------| | 2026-06-08 | 初版:统一 page/size/total;files 兼容 limit/offset;pay/orders 分页 | +| 2026-06-08 | 新增 GET /templates/{id} JSON 契约;Web 虚拟 key 前缀;/file 标 legacy | diff --git a/frontend-web/assets/css/design.css b/frontend-web/assets/css/design.css index d815487..e43672a 100644 --- a/frontend-web/assets/css/design.css +++ b/frontend-web/assets/css/design.css @@ -13,10 +13,10 @@ img { padding: 0 !important; height: 100%; } -.col-left{ +body:not(.soon-design-page) .col-left{ margin-right: 320px; } -.col-right{ +body:not(.soon-design-page) .col-right{ float: right; height: 100%; width: 320px; @@ -419,6 +419,13 @@ textarea:hover{ } /* ── 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%; +} + html:has(body.soon-design-page) { height: 100%; overflow: hidden; @@ -437,8 +444,9 @@ body.soon-design-page { -webkit-font-smoothing: antialiased; } +body.soon-design-page .soon-design-portal-bar, body.soon-design-page .soon-portal-topbar { - position: static; + position: static !important; width: 100%; flex-shrink: 0; } @@ -453,7 +461,7 @@ body.soon-design-page .layui-fluid.main { height: auto !important; overflow: hidden; padding: 0 !important; - display: flex; + display: flex !important; flex-direction: column; } @@ -706,10 +714,19 @@ body.soon-design-page .guide-line-drag { opacity: 0.9; } -body.soon-design-page #source_front, -body.soon-design-page #source_front2, -body.soon-design-page #source_back { +body.soon-design-page.soon-design--type1 #source_front, +body.soon-design-page.soon-design--type1 #source_front2, +body.soon-design-page.soon-design--type1 #source_back { width: 2787px; height: 2787px; 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; } \ No newline at end of file diff --git a/frontend-web/assets/css/layer-soon.css b/frontend-web/assets/css/layer-soon.css index 639aa01..ea41708 100644 --- a/frontend-web/assets/css/layer-soon.css +++ b/frontend-web/assets/css/layer-soon.css @@ -20,6 +20,31 @@ color: var(--soon-text) !important; } +/* layui 关闭/状态图标为深色 sprite,深底上需反色 */ +.layui-layer.soon-layer .layui-layer-setwin .layui-layer-ico, +.layui-layer-soon-layer .layui-layer-setwin .layui-layer-ico, +.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-content, .layui-layer-soon-layer.soon-layer--pay .layui-layer-content { overflow: visible !important; @@ -104,13 +129,33 @@ } .layui-layer-dialog .layui-layer-content { - background-color: var(--soon-bg-panel); - color: var(--soon-text); + background-color: var(--soon-bg-panel) !important; + color: var(--soon-text) !important; } + .layui-layer-dialog .layui-layer-title { - background-color: var(--soon-bg-elevated); - color: var(--soon-text-strong); - border-bottom: 1px solid var(--soon-border-subtle); + 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-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 .layui-layer-setwin .layui-layer-ico { + filter: brightness(0) invert(1); + opacity: 0.88; +} + +body.soon-design-page .layui-layer .layui-layer-setwin .layui-layer-ico:hover { + opacity: 1; } .layui-layer.soon-toast, @@ -188,22 +233,3 @@ color: var(--soon-text-strong) !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; -} diff --git a/frontend-web/js/common/cloud-files.js b/frontend-web/js/common/cloud-files.js index 652eb3f..e527055 100644 --- a/frontend-web/js/common/cloud-files.js +++ b/frontend-web/js/common/cloud-files.js @@ -2,9 +2,23 @@ 'use strict'; 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() { - 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() { @@ -31,6 +45,10 @@ var meta = window._soonFileMeta; return (meta && meta.name) ? meta.name : 'design.soon'; } + if (n.indexOf(TEMPLATE_PREFIX) === 0) { + var tpl = soonParseTemplateKey(n); + return tpl ? ('template-' + tpl.id + '.soon') : 'template.soon'; + } if (n.indexOf('soondesign_session:') === 0) { n = n.replace(/^soondesign_session:/, ''); } @@ -45,8 +63,68 @@ 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) { - if (!fileKey || fileKey.indexOf(FILE_PREFIX) !== 0) return; + if (!fileKey || fileKey.indexOf(FILE_PREFIX) !== 0) { + window._soonFileMeta = null; + return; + } var parsed = soonParseFileKey(fileKey); if (!parsed) return; var prev = window._soonFileMeta || {}; @@ -137,16 +215,22 @@ soonApplyMembership(null); return Promise.resolve(_membership); } - var url = soonApiBase() + '/plans/me'; + var url = soonApiBase() + '/auth/me'; var fetchFn = typeof window.soonAuthedFetch === 'function' ? window.soonAuthedFetch(url, { headers: { Accept: 'application/json' } }) : fetch(url, { headers: { Authorization: 'Bearer ' + soonGetAccessToken(), Accept: 'application/json' } }); _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) { if (j && j.ok && j.data) { - soonApplyMembership(j.data.membership || j.data); - } else { + soonApplyMembership(j.data); + } else if (j !== null) { soonApplyMembership(null); } return _membership; @@ -184,20 +268,27 @@ } return; } - if (soonIsMember()) { - onAllowed(); + 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; } - if (typeof window.soonShowActivateGate === 'function') { - window.soonShowActivateGate({ - reason: actionLabel || '导出或打印', - onSuccess: function () { - onAllowed(); - }, - }); - } else { - soonToast('请先激活会员后再' + (actionLabel || '操作'), 'warn'); - } + allowOrActivate(); } function soonGuardCloudSave() { @@ -223,7 +314,11 @@ function soonOpenSoonJsonLocally(j, fileName) { var key = soonPutSoonSession(j, fileName); - if (!key) return ''; + if (!key) { + soonToast('无法打开文件(存储空间不足)', 'error'); + return ''; + } + soonScheduleCloudImport(key, fileName); var type = soonSoonTypeFromJson(j); if (window.platformBridge && window.platformBridge.openDesignPage) { window.platformBridge.openDesignPage(key, type); @@ -231,6 +326,75 @@ 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 */ } + + var jsonRaw; + try { + jsonRaw = sessionStorage.getItem(currentFileKey); + if (!jsonRaw && typeof localStorage !== 'undefined') jsonRaw = localStorage.getItem(currentFileKey); + } catch (e) { + return; + } + if (!jsonRaw) return; + + var bridge = window.platformBridge; + if (!bridge || typeof bridge.importSoonFile !== 'function') return; + + bridge.importSoonFile(pending.name || 'design.soon', jsonRaw).then(function (res) { + if (res && res.fileKey && typeof window.openAs !== 'undefined' && window.openAs) { + window.openAs.name = res.fileKey; + } + }).catch(function (err) { + if (err && err.status) return; + soonToast((err && err.message) ? err.message : '云端登记失败,可稍后保存重试', 'warn'); + }); + } + + function soonReportOpenLoadError(message) { + soonToast(message || '文件加载失败', 'error'); + } + function soonToast(message, type) { if (typeof window.soonToast === 'function') window.soonToast(message, type); } @@ -289,6 +453,14 @@ function soonDisplayFileName(pathOrKey) { 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) { var meta = window._soonFileMeta; if (meta && meta.name) return meta.name; @@ -333,12 +505,21 @@ window.soonNormalizeSoonName = soonNormalizeSoonName; window.soonApplyCloudMeta = soonApplyCloudMeta; window.soonBindFileMeta = soonBindFileMeta; + window.soonSyncOpenNavigation = soonSyncOpenNavigation; + window.soonResolveOpenFileKey = soonResolveOpenFileKey; + window.soonConsumeOpenMeta = soonConsumeOpenMeta; window.soonFormatOpenTitle = soonFormatOpenTitle; window.soonGuardCloudSave = soonGuardCloudSave; window.soonGuardPreviewDeliver = soonGuardPreviewDeliver; window.soonHandlePayReturn = soonHandlePayReturn; window.soonOpenSoonJsonLocally = soonOpenSoonJsonLocally; window.soonPutSoonSession = soonPutSoonSession; + window.soonScheduleCloudImport = soonScheduleCloudImport; + window.soonTryConsumeCloudImport = soonTryConsumeCloudImport; + window.soonClearPendingImport = soonClearPendingImport; + window.soonReportOpenLoadError = soonReportOpenLoadError; + window.soonParseTemplateKey = soonParseTemplateKey; + window.soonMakeTemplateKey = soonMakeTemplateKey; window.soonSoonTypeFromJson = soonSoonTypeFromJson; window.soonLoadMembership = soonLoadMembership; window.soonApplyMembership = soonApplyMembership; diff --git a/frontend-web/js/common/member-pay-core.js b/frontend-web/js/common/member-pay-core.js index 8b9d399..6a695be 100644 --- a/frontend-web/js/common/member-pay-core.js +++ b/frontend-web/js/common/member-pay-core.js @@ -260,7 +260,7 @@ return '未获取到支付信息'; } - function payEmptyStateHtml(channel) { + function paySheetHtml(opts) { opts = opts || {}; var resumeOrderNo = opts.orderNo || null; var channels = opts.displayChannels || _displayChannels; diff --git a/frontend-web/js/design1/core.js b/frontend-web/js/design1/core.js index 10fc9de..34a00cc 100644 --- a/frontend-web/js/design1/core.js +++ b/frontend-web/js/design1/core.js @@ -6,7 +6,9 @@ $("#object_attribute").css("height", "470px"); $("#obj_list").css("height", document.body.clientHeight - 520); $(".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); zoom = $(".canvas_bg_img").width() / 2787 $("#canvas1").attr("width", $(".canvas_box").width() / zoom); @@ -259,7 +261,7 @@ if (typeof ipcRenderer !== 'undefined' && ipcRenderer) { applySysLan(window.platformBridge.getLocale()); } function onCloseConfirm() { - layer.confirm('' + language_str("whetherSave") + '', { + layer.confirm(language_str("whetherSave"), { btn: [language_str("save"), language_str("noSave"), language_str("cancel")] , btn3: function (index, layero) {} }, function (index, layero) { @@ -418,28 +420,14 @@ function addBackground() { 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) {} - } + var file = typeof window.soonResolveOpenFileKey === 'function' + ? window.soonResolveOpenFileKey(GetFile) + : (GetFile().get('file') || ''); 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) {} + if (typeof window.soonConsumeOpenMeta === 'function') { + window.soonConsumeOpenMeta(file); } - var p = window.openFile(file) - if (p && p.then) p.then(function() {}, function() {}) + window.openFile(file); } }); }); diff --git a/frontend-web/js/design1/output.js b/frontend-web/js/design1/output.js index 7ae2711..b689e60 100644 --- a/frontend-web/js/design1/output.js +++ b/frontend-web/js/design1/output.js @@ -401,6 +401,7 @@ window.display_func = function display_func(img1, img2, img3) { layer.open({ type: 1, + skin: 'soon-layer', area: [w, h], title: language_str("display"), //不显示标题栏"预览" shadeClose: true, //点击遮罩关闭 @@ -1097,8 +1098,14 @@ window.openFile = function open(file, jAlready) { if (!file) return; if (typeof window.soonBindFileMeta === 'function') window.soonBindFileMeta(file); function doOpenWithJson(j) { - if (!j || !background_image) return; - if (!j.f || !j.f.objects || !Array.isArray(j.f.objects) || j.f.objects.length === 0) return; + if (!j || !background_image) { + 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) { j.b = { version: '4.6.0', objects: [], hoverCursor: 'move' }; } @@ -1173,12 +1180,14 @@ window.openFile = function open(file, jAlready) { var j2 = canvas2.toJSON(props); recordJson2.push(j2); }, 0); + if (typeof window.soonTryConsumeCloudImport === 'function') window.soonTryConsumeCloudImport(file); } }); } if (arguments.length >= 2 && jAlready) { doOpenWithJson(jAlready); openAs.name = file; + if (typeof window.soonTryConsumeCloudImport === 'function') window.soonTryConsumeCloudImport(file); return; } if (window.platformBridge && window.platformBridge.readJsonFile) { @@ -1186,8 +1195,12 @@ window.openFile = function open(file, jAlready) { if (j) { doOpenWithJson(j); openAs.name = file; + } else if (typeof window.soonReportOpenLoadError === 'function') { + window.soonReportOpenLoadError('模板/文件加载失败'); } - }).catch(function() {}); + }).catch(function () { + if (typeof window.soonReportOpenLoadError === 'function') window.soonReportOpenLoadError('模板/文件加载失败'); + }); } try { var fs = typeof require !== 'undefined' ? require('fs') : null; @@ -1252,6 +1265,9 @@ function cloudSaveSuccessLabel(res, fallback) { function onCloudWriteDone(res, fallback, callback) { applyCloudWriteResult(res, fallback); + if (res && res.fileKey && typeof window.soonClearPendingImport === 'function') { + window.soonClearPendingImport(); + } layer.msg(language_str("saveSucc") + cloudSaveSuccessLabel(res, fallback)); if (callback && typeof callback === 'function') callback(); } @@ -1260,6 +1276,15 @@ function guardWebSave() { return typeof window.soonGuardCloudSave === 'function' ? window.soonGuardCloudSave() : true; } +function reportSaveError(err) { + if (err && err.status) return; + if (typeof window.soonShowApiError === 'function') { + window.soonShowApiError({ status: err && err.status, message: (err && err.message) || '保存失败' }); + } else if (typeof window.soonToast === 'function') { + window.soonToast('保存失败', 'error'); + } +} + function saveAs(op1, callback) { // 临时移除辅助线,保存后再恢复 let guideLines1 = []; @@ -1311,7 +1336,7 @@ function saveAs(op1, callback) { if (window.platformBridge && window.platformBridge.writeFile) { window.platformBridge.writeFile(fp, content).then(function (res) { onCloudWriteDone(res, fp, callback); - }).catch(function () {}); + }).catch(reportSaveError); return; } var fs = typeof require !== 'undefined' ? require('fs') : null; @@ -1322,7 +1347,7 @@ function saveAs(op1, callback) { saveHistory(); if (callback && typeof callback === 'function') callback(); } - }).catch(function(err) {}); + }).catch(reportSaveError); } function save(op1, callback) { @@ -1365,7 +1390,7 @@ function save(op1, callback) { if (window.platformBridge && window.platformBridge.writeFile) { window.platformBridge.writeFile(openAs.name, content).then(function (res) { onCloudWriteDone(res, openAs.name, callback); - }).catch(function () {}); + }).catch(reportSaveError); return; } var fs = typeof require !== 'undefined' ? require('fs') : null; @@ -1394,7 +1419,7 @@ function save(op1, callback) { if (window.platformBridge && window.platformBridge.writeFile) { window.platformBridge.writeFile(fp, content).then(function (res) { onCloudWriteDone(res, fp, callback); - }).catch(function () {}); + }).catch(reportSaveError); return; } var fs = typeof require !== 'undefined' ? require('fs') : null; @@ -1405,7 +1430,7 @@ function save(op1, callback) { saveHistory(); if (callback && typeof callback === 'function') callback(); } - }).catch(function(err) {}); + }).catch(reportSaveError); } window.saveHistory = function saveHistory() { diff --git a/frontend-web/js/design1/ui.js b/frontend-web/js/design1/ui.js index 34f9929..4dabd89 100644 --- a/frontend-web/js/design1/ui.js +++ b/frontend-web/js/design1/ui.js @@ -1345,7 +1345,7 @@ $("#open").click(function () { OpenDialog(); return; } - layer.confirm('' + language_str("whetherSave") + '', function (index) {//confirm 是否保存当前文件�? + layer.confirm(language_str("whetherSave"), function (index) {//confirm 是否保存当前文件�? if (typeof window.output === 'function') { window.output(OpenDialog); } @@ -1375,18 +1375,14 @@ $("#open").click(function () { } } function importAndOpen(j, fileName) { - var tok = typeof window.soonGetAccessToken === 'function' - ? window.soonGetAccessToken() - : (localStorage.getItem('soon_access') || ''); - if (!tok && typeof window.soonPutSoonSession === 'function') { + if (typeof window.soonPutSoonSession === 'function') { var localKey = window.soonPutSoonSession(j, fileName || 'design.soon'); - if (localKey) openWithKey(localKey, j); - return; + if (!localKey) return; + if (typeof window.soonScheduleCloudImport === 'function') { + window.soonScheduleCloudImport(localKey, fileName || 'design.soon'); + } + openWithKey(localKey, j); } - if (!window.platformBridge || !window.platformBridge.importSoonFile) return; - window.platformBridge.importSoonFile(fileName || 'design.soon', j).then(function(res) { - if (res && res.fileKey) openWithKey(res.fileKey, j); - }).catch(function () {}); } if (file && file.text) { file.text().then(function(t) { @@ -1445,7 +1441,7 @@ $("#new").click(function () { else if (window.platformBridge) window.platformBridge.openFirstPage(); return; } - layer.confirm('' + language_str("whetherSave") + '', function (index) {//confirm 是否保存当前文件�? + layer.confirm(language_str("whetherSave"), function (index) {//confirm 是否保存当前文件�? if (typeof window.output === 'function') { window.output(function() { if (ipcRenderer) ipcRenderer.send('open-first-page'); else if (window.platformBridge) window.platformBridge.openFirstPage(); }); } diff --git a/frontend-web/js/design2/core.js b/frontend-web/js/design2/core.js index aab9f56..1f92694 100644 --- a/frontend-web/js/design2/core.js +++ b/frontend-web/js/design2/core.js @@ -2,15 +2,40 @@ // 注意:此代码在layui.use回调函数内部执行 // 全局变量已在主入口文件中定义,这里只需要初始化 -$('#canvas1').attr('height', document.body.clientHeight - 100) -$('#canvas2').attr('height', document.body.clientHeight - 100) -$('#object_attribute').css('height', '470px') -$('#obj_list').css('height', document.body.clientHeight - 520) -$('.obj_list').css('height', $('#obj_list').height() - 32) +function soonDesign2MainHeight() { + var main = document.querySelector('.layui-fluid.main'); + if (main && main.clientHeight > 40) return main.clientHeight; + var tb = document.getElementById('portal-topbar'); + return Math.max(480, window.innerHeight - (tb ? tb.offsetHeight : 0)); +} -$('.main').show() -$('#canvas1').attr('width', $('#canvas-div').width()) -$('#canvas2').attr('width', $('#canvas-div').width()) +function soonDesign2CanvasWidth() { + var w = $('#canvas-div').width(); + if (w > 0) return w; + w = $('.col-left').width(); + if (w > 0) return Math.max(320, w - 70); + return Math.max(320, window.innerWidth - 390); +} + +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对象 // 优化性能:在创建 Fabric Canvas 之前,先设置 canvas context 的 willReadFrequently @@ -42,11 +67,19 @@ is_bgi_add = false window.ctx1 = ctx1; window.ctx2 = ctx2; 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) -//console.log(canvas1.width / 2, canvas1.height / 2); -//console.log($("#canvas-div").width() / 1200 > 1 ? 1 : $("#canvas-div").width() / 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 +canvas1.zoomToPoint(new fabric.Point(canvas1.width / 2, canvas1.height / 2), (function () { + var divW = $('#canvas-div').width() || _design2Layout.canvasW || 800; + return divW / 1200 > 1 ? 1 : divW / 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; +})() // 初始化标尺 function initRulers() { @@ -486,6 +519,26 @@ function initRulers() { } initRulers(); +if (window.SOON_DEPLOY_CONFIG && ($('#canvas-div').width() || 0) < 100) { + requestAnimationFrame(function () { + requestAnimationFrame(function () { + var layout = layoutDesign2Shell(); + var divW = layout.canvasW; + zoom = divW / 1200 > 1 ? 1 : divW / 1200; + if (zoom <= 0) zoom = 0.5; + 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); + window.zoom = zoom; + canvas1.renderAll(); + canvas2.renderAll(); + initRulers(); + }); + }); +} $('#source_front').width(zoom * $('#source_front').width()) $('#source_back').width(zoom * $('#source_back').width()) $('.canvas-container:eq(1)').hide() @@ -1629,28 +1682,14 @@ function addBackground() { 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) {} - } + var file = typeof window.soonResolveOpenFileKey === 'function' + ? window.soonResolveOpenFileKey(GetFile) + : (GetFile().get('file') || ''); 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) {} + if (typeof window.soonConsumeOpenMeta === 'function') { + window.soonConsumeOpenMeta(file); } - var p = window.openFile(file); - if (p && p.then) p.then(function() {}, function() {}); + window.openFile(file); } }) }) @@ -2048,7 +2087,7 @@ if (typeof ipcRenderer !== 'undefined' && ipcRenderer) { } function onCloseConfirm() { layer.confirm( - '' + language_str('whetherSave') + '', + language_str('whetherSave'), { btn: [language_str('save'), language_str('noSave'), language_str('cancel')], btn3: function (index, layero) { } diff --git a/frontend-web/js/design2/output.js b/frontend-web/js/design2/output.js index 71e2598..20ac7b7 100644 --- a/frontend-web/js/design2/output.js +++ b/frontend-web/js/design2/output.js @@ -333,6 +333,7 @@ window.display_func = function display_func(img1, img2, img3) { layer.open({ type: 1, + skin: 'soon-layer', area: [w, h], title: language_str('display'), //不显示标题栏"预览" shadeClose: true, //点击遮罩关闭 @@ -1087,6 +1088,9 @@ function cloudSaveSuccessLabel(res, fallback) { function onCloudWriteDone(res, fallback, callback) { applyCloudWriteResult(res, fallback); + if (res && res.fileKey && typeof window.soonClearPendingImport === 'function') { + window.soonClearPendingImport(); + } layer.msg(language_str('saveSucc') + cloudSaveSuccessLabel(res, fallback)); if (callback && typeof callback === 'function') callback(); } @@ -1095,6 +1099,15 @@ function guardWebSave() { return typeof window.soonGuardCloudSave === 'function' ? window.soonGuardCloudSave() : true; } +function reportSaveError(err) { + if (err && err.status) return; + if (typeof window.soonShowApiError === 'function') { + window.soonShowApiError({ status: err && err.status, message: (err && err.message) || '保存失败' }); + } else if (typeof window.soonToast === 'function') { + window.soonToast('保存失败', 'error'); + } +} + function saveAs(op1, callback) { // 临时移除辅助线,保存后再恢复 let guideLines1 = [] @@ -1143,7 +1156,7 @@ function saveAs(op1, callback) { if (window.platformBridge && window.platformBridge.writeFile) { window.platformBridge.writeFile(fp, content).then(function (res) { onCloudWriteDone(res, fp, callback); - }).catch(function () {}); + }).catch(reportSaveError); return; } var fs = typeof require !== 'undefined' ? require('fs') : null; @@ -1155,7 +1168,7 @@ function saveAs(op1, callback) { if (callback && typeof callback === 'function') callback(); } }) - .catch(function(err) {}); + .catch(reportSaveError); } function save(op1, callback) { @@ -1198,7 +1211,7 @@ function save(op1, callback) { if (window.platformBridge && window.platformBridge.writeFile) { window.platformBridge.writeFile(openAs.name, content).then(function (res) { onCloudWriteDone(res, openAs.name, callback); - }).catch(function () {}); + }).catch(reportSaveError); return; } var fs = typeof require !== 'undefined' ? require('fs') : null; @@ -1222,7 +1235,7 @@ function save(op1, callback) { if (window.platformBridge && window.platformBridge.writeFile) { window.platformBridge.writeFile(fp, content).then(function (res) { onCloudWriteDone(res, fp, callback); - }).catch(function () {}); + }).catch(reportSaveError); return; } var fs = typeof require !== 'undefined' ? require('fs') : null; @@ -1234,7 +1247,7 @@ function save(op1, callback) { if (callback && typeof callback === 'function') callback(); } }) - .catch(function(err) {}); + .catch(reportSaveError); } window.saveHistory = function saveHistory() { @@ -1287,7 +1300,10 @@ window.openFile = function open(file, jAlready) { if (!file) return; if (typeof window.soonBindFileMeta === 'function') window.soonBindFileMeta(file); function doOpenWithJson(j) { - if (!j || !background_image) return; + if (!j || !background_image) { + if (typeof window.soonReportOpenLoadError === 'function') window.soonReportOpenLoadError('模板/文件加载失败'); + return; + } openAs.name = file; var left = background_image.left let top = background_image.top @@ -1438,6 +1454,7 @@ window.openFile = function open(file, jAlready) { recordJson = recordJson2 step = step2 } + if (typeof window.soonTryConsumeCloudImport === 'function') window.soonTryConsumeCloudImport(file); } canvas1.loadFromJSON(j.f, function () { @@ -1482,7 +1499,11 @@ window.openFile = function open(file, jAlready) { if (j) { doOpenWithJson(j); openAs.name = file; + } else if (typeof window.soonReportOpenLoadError === 'function') { + window.soonReportOpenLoadError('模板/文件加载失败'); } + }).catch(function () { + if (typeof window.soonReportOpenLoadError === 'function') window.soonReportOpenLoadError('模板/文件加载失败'); }); } try { diff --git a/frontend-web/js/design2/ui.js b/frontend-web/js/design2/ui.js index a0eb885..bce12bc 100644 --- a/frontend-web/js/design2/ui.js +++ b/frontend-web/js/design2/ui.js @@ -1299,7 +1299,7 @@ $('#open').click(function () { return; } layer.confirm( - '' + language_str('whetherSave') + '', + language_str('whetherSave'), function (index) { if (typeof window.output === 'function') { window.output(OpenDialog); @@ -1326,18 +1326,14 @@ $('#open').click(function () { if (typeof window.openFile === 'function') window.openFile(fileKey, j); } function importAndOpen(j, fileName) { - var tok = typeof window.soonGetAccessToken === 'function' - ? window.soonGetAccessToken() - : (localStorage.getItem('soon_access') || ''); - if (!tok && typeof window.soonPutSoonSession === 'function') { + if (typeof window.soonPutSoonSession === 'function') { var localKey = window.soonPutSoonSession(j, fileName || 'design.soon'); - if (localKey) openWithKey(localKey, j); - return; + if (!localKey) return; + if (typeof window.soonScheduleCloudImport === 'function') { + window.soonScheduleCloudImport(localKey, fileName || 'design.soon'); + } + openWithKey(localKey, j); } - if (!window.platformBridge || !window.platformBridge.importSoonFile) return; - window.platformBridge.importSoonFile(fileName || 'design.soon', j).then(function(res) { - if (res && res.fileKey) openWithKey(res.fileKey, j); - }).catch(function () {}); } if (file && file.text) { file.text().then(function(t) { @@ -1364,7 +1360,7 @@ $('#new').click(function () { return; } layer.confirm( - '' + language_str('whetherSave') + '', + language_str('whetherSave'), function (index) { if (typeof window.output === 'function') { window.output(goFirstPage); diff --git a/frontend-web/js/index.js b/frontend-web/js/index.js index 53617be..a15903d 100644 --- a/frontend-web/js/index.js +++ b/frontend-web/js/index.js @@ -512,41 +512,26 @@ layui.use(['layer', 'form', 'jquery'], function () { 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, - thumbSrc: id ? (base + '/templates/' + id + '/thumb') : templateFallbackThumb(type), - file_url: id ? (base + '/templates/' + id + '/file') : (m.file_url || '') + thumbSrc: id ? (base + '/templates/' + id + '/thumb' + v) : templateFallbackThumb(type) }; } function openTemplateItem(m) { var type = Number(m.type) || 1; - function openWithJson(j) { - if (!j) { - openDesign(type, ''); - return; - } - var key = 'soondesign_session:tpl-' + Date.now(); - try { - sessionStorage.setItem(key, JSON.stringify(j)); - openDesign(type, key); - } catch (e) { - openDesign(type, ''); - } - } - var fileUrl = m.file_url || ''; - if (!fileUrl) { - openDesign(type, ''); + var id = m && m.id; + if (!id) { + if (typeof window.soonToast === 'function') window.soonToast('模板不可用', 'error'); return; } - fetch(fileUrl).then(function (r) { - if (!r.ok) throw new Error('fetch'); - return r.json(); - }).then(openWithJson).catch(function () { - openDesign(type, ''); - }); + var templateKey = typeof window.soonMakeTemplateKey === 'function' + ? window.soonMakeTemplateKey(id) + : ('soondesign_template:' + id); + openDesign(type, templateKey); } function templateCardHtml(m, itemIndex) { @@ -614,7 +599,9 @@ layui.use(['layer', 'form', 'jquery'], function () { renderTemplatePager(); } - async function loadTemplates() { + var _templatesLoadedAt = 0; + + async function loadTemplates(force) { var grid = document.getElementById('templatesGrid'); @@ -622,6 +609,10 @@ layui.use(['layer', 'form', 'jquery'], function () { if (!grid) return; + if (!force && _templatesLoadedAt && (Date.now() - _templatesLoadedAt) < 60000) { + return; + } + var base = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || ''; if (!base) { @@ -655,6 +646,7 @@ layui.use(['layer', 'form', 'jquery'], function () { templateListState.total = templateListState.items.length; templateListState.page = 1; renderTemplatesPage(); + _templatesLoadedAt = Date.now(); } catch (e) { @@ -670,6 +662,14 @@ layui.use(['layer', 'form', 'jquery'], function () { loadTemplates(); + window.soonReloadTemplates = function () { loadTemplates(true); }; + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') loadTemplates(false); + }); + window.addEventListener('pageshow', function (e) { + if (e.persisted) loadTemplates(false); + }); + var templatePrevNav = document.getElementById('templatePrev'); var templateNextNav = document.getElementById('templateNext'); if (templatePrevNav) { @@ -879,30 +879,7 @@ layui.use(['layer', 'form', 'jquery'], function () { } - var tok = typeof window.soonGetAccessToken === 'function' - ? window.soonGetAccessToken() - : (localStorage.getItem('soon_access') || ''); - - if (!tok && typeof window.soonOpenSoonJsonLocally === 'function') { - window.soonOpenSoonJsonLocally(soonData, fileName); - return; - } - - if (!window.platformBridge || !window.platformBridge.importSoonFile) return; - - 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 */ } + window.soonOpenSoonJsonLocally(soonData, fileName); }).catch(function (err) { console.log(err); }); diff --git a/frontend-web/js/platform/web.js b/frontend-web/js/platform/web.js index 29bb357..9a645dd 100644 --- a/frontend-web/js/platform/web.js +++ b/frontend-web/js/platform/web.js @@ -244,12 +244,46 @@ 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] + '/download', { headers: { Accept: 'application/json' } }) + return authedFetch('files/' + fileMatch[1], { 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; } - }); + return response.json(); + }) + .then(function (payload) { + if (!payload || !payload.ok || !payload.data) return null; + var raw = payload.data.json; + if (typeof raw === 'string') { + try { return JSON.parse(raw); } catch (e) { return null; } + } + return raw && typeof raw === 'object' ? raw : null; + }) + .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], { 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; + if (payload.data.id) { + window._soonTemplateMeta = { + id: payload.data.id, + name: payload.data.name || '', + type: payload.data.type + }; + } + var raw = payload.data.json; + if (typeof raw === 'string') { + try { return JSON.parse(raw); } catch (e) { return null; } + } + return raw && typeof raw === 'object' ? raw : null; }) .catch(function () { return null; }); } @@ -294,18 +328,51 @@ showSaveDialog: function (options) { var raw = (options && options.defaultPath) ? options.defaultPath : ''; var defaultName = normalizeSoonName(raw || 'design.soon'); - if (typeof window.soonDisplayFileName === 'function' && raw.indexOf('soondesign_file:') === 0) { + if (typeof window.soonDisplayFileName === 'function' && + (raw.indexOf('soondesign_file:') === 0 || raw.indexOf('soondesign_template:') === 0)) { defaultName = window.soonDisplayFileName(raw); } + var title = (options && options.title) ? options.title : '保存到云端'; return new Promise(function (resolve) { - var name = typeof prompt === 'function' ? prompt('保存为文件名(如 xxx.soon)', defaultName) : defaultName; - if (name === null) { - resolve({ canceled: true }); + if (typeof layer === 'undefined' || !layer.open) { + var fpFallback = defaultName; + resolve({ canceled: false, filePath: fpFallback, useCloud: true }); return; } - var fp = (name && String(name).trim()) ? String(name).trim() : defaultName; - fp = normalizeSoonName(fp); - resolve({ canceled: false, filePath: fp, useCloud: true }); + var esc = function (s) { + return String(s || '').replace(/&/g, '&').replace(/文件名' + + '' + + ''; + 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; + if (!name) name = defaultName; + name = normalizeSoonName(name); + layer.close(index); + resolve({ canceled: false, filePath: name, useCloud: true }); + }, + btn2: function (index) { + layer.close(index); + resolve({ canceled: true }); + }, + cancel: function (index) { + layer.close(index); + resolve({ canceled: true }); + } + }); }); }, writeFile: function (pathOrHandle, content) { @@ -383,16 +450,11 @@ getSystemFonts: getSystemFonts, openDesignPage: function (file, type) { var t = type || 1; - if (file && typeof sessionStorage !== 'undefined') { - try { - sessionStorage.setItem('soondesign_open_file', file); - 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 key = file || ''; + if (typeof window.soonSyncOpenNavigation === 'function') { + window.soonSyncOpenNavigation(key, t); } - var fileParam = file ? encodeURIComponent(file) : ''; + var fileParam = key ? encodeURIComponent(key) : ''; navigateToPage('design' + t + '.web.html?file=' + fileParam + '&type=' + t); }, openFirstPage: function () { @@ -453,6 +515,7 @@ showOpenDialog: bridge.showOpenDialog, showSaveDialog: bridge.showSaveDialog }; + window.soonShowSaveDialog = bridge.showSaveDialog; window.path = pathStub; window.fs = null; window.remote = null; diff --git a/frontend-web/pages/admin/assets/admin-shell.css b/frontend-web/pages/admin/assets/admin-shell.css index a54d63a..db209d7 100644 --- a/frontend-web/pages/admin/assets/admin-shell.css +++ b/frontend-web/pages/admin/assets/admin-shell.css @@ -938,6 +938,148 @@ body.soon-admin-page .soon-admin-date-wrap input[type="date"].soon-input::-webki 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 { margin: 0; font-size: var(--soon-font-sm); @@ -988,3 +1130,16 @@ body.soon-admin-page .layui-layer-soon-layer .layui-layer-ico { filter: brightness(0) invert(1); 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; +} diff --git a/frontend-web/pages/admin/assets/js/core/ui.js b/frontend-web/pages/admin/assets/js/core/ui.js index 87cdfe0..5065573 100644 --- a/frontend-web/pages/admin/assets/js/core/ui.js +++ b/frontend-web/pages/admin/assets/js/core/ui.js @@ -150,7 +150,7 @@ opts = opts || {}; layer.open({ type: 1, - skin: 'soon-layer', + skin: opts.skin || 'soon-layer', title: title, area: opts.area || ['420px', 'auto'], maxWidth: opts.maxWidth || '96vw', diff --git a/frontend-web/pages/admin/assets/js/views/templates.js b/frontend-web/pages/admin/assets/js/views/templates.js index 11ded4d..3c0f0d9 100644 --- a/frontend-web/pages/admin/assets/js/views/templates.js +++ b/frontend-web/pages/admin/assets/js/views/templates.js @@ -6,6 +6,8 @@ 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'); @@ -23,6 +25,99 @@ 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]; @@ -31,106 +126,195 @@ return; } var reader = new FileReader(); - reader.onload = function () { resolve(String(reader.result || '')); }; + 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 defaultNameFromFile(input) { - var file = input && input.files && input.files[0]; - if (!file) return ''; - return file.name.replace(/\.soon$/i, '').replace(/[_-]+/g, ' ').trim(); + 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 uploadFields(required) { - return Form().panel('模板文件', - Form().field('选择 .soon 文件', - '' + - (required ? '' : '

留空则不替换已有文件

'), - required ? '须为有效的 .soon JSON 文件' : '') - ); + 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 metaFields(plan) { - plan = plan || {}; - return Form().panel('展示设置', - Form().field('名称', Form().input('tplName', { value: plan.name || '' })) + - '
' + - Form().field('排序', Form().input('tplSort', { type: 'number', value: plan.sort_order != null ? plan.sort_order : 0 })) + - Form().field('类型', '') + - '
' + - '' - ); + function modalHtml(opts) { + opts = opts || {}; + var isEdit = opts.mode === 'edit'; + var plan = opts.plan || {}; + var pickLabel = isEdit ? '替换文件' : '选择文件'; + + return '
' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
'; + } + + 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 form = '
' + uploadFields(true) + metaFields() + - Form().actions('上传', 'adminModalSave') + '
'; + var modalState = { content: null, name: '', type: 1, thumbView: '', _blobUrl: '' }; - Ui().openFormModal('上传模板', form, function () { - var nameEl = Ui().el('tplName'); - var fileEl = Ui().el('tplFile'); - var name = nameEl.value.trim() || defaultNameFromFile(fileEl); - if (!name) { - soonToast('请填写名称或选择文件', 'warn'); + Ui().openFormModal('上传模板', modalHtml({ mode: 'create' }), function () { + if (!modalState.content) { + soonToast('请先选择 .soon 文件', 'warn'); return false; } - return readFileText(fileEl).then(function (content) { - return Api().postJson('templates', { - name: name, - content: content, - sort_order: parseInt(Ui().el('tplSort').value, 10) || 0, - is_active: Ui().el('tplActive').checked ? 1 : 0, - }).then(function (r) { - if (r) { - layer.closeAll(); - soonToast('已上传', 'success'); - render(); - } - }); - }).catch(function (e) { - if (e && e.message === 'no_file') soonToast('请选择 .soon 文件', 'warn'); - else soonToast('文件读取失败', 'error'); + 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); + layer.closeAll(); + soonToast('已上传', 'success'); + render(); + } }); - }, { area: ['480px', 'auto'] }); + }, Object.assign({}, MODAL_OPTS, { + success: function () { bindTplModal({ mode: 'create' }, modalState); }, + })); } function openEditModal(plan) { - var form = '
' + metaFields(plan) + uploadFields(false) + - Form().actions('保存', 'adminModalSave') + '
'; + var modalState = { content: null, name: plan.name, type: plan.type, thumbView: '', _blobUrl: '' }; - Ui().openFormModal('编辑模板 · ' + plan.name, form, function () { - var name = Ui().el('tplName').value.trim(); - if (!name) { - soonToast('名称不能为空', 'warn'); - return false; - } + Ui().openFormModal('编辑模板', modalHtml({ mode: 'edit', plan: plan }), function () { var body = { - name: name, + name: modalState.content ? modalState.name : plan.name, sort_order: parseInt(Ui().el('tplSort').value, 10) || 0, is_active: Ui().el('tplActive').checked ? 1 : 0, }; - var fileEl = Ui().el('tplFile'); - var hasFile = fileEl && fileEl.files && fileEl.files[0]; - var save = function (content) { - if (content) body.content = content; - return Api().putJson('templates/' + plan.id, body).then(function (r) { - if (r) { - layer.closeAll(); - soonToast('已保存', 'success'); - render(); - } - }); - }; - if (hasFile) { - return readFileText(fileEl).then(save).catch(function () { - soonToast('文件读取失败', 'error'); - }); - } - return save(null); - }, { area: ['480px', 'auto'] }); + if (modalState.content) body.content = modalState.content; + return Api().putJson('templates/' + plan.id, body).then(function (r) { + if (r) { + revokeBlob(modalState); + layer.closeAll(); + soonToast('已保存', 'success'); + render(); + } + }); + }, Object.assign({}, MODAL_OPTS, { + success: function () { bindTplModal({ mode: 'edit', plan: plan }, modalState); }, + })); } function deleteTemplate(plan) { @@ -148,7 +332,7 @@ var panel = Ui().el('panel-templates'); if (!panel) return; panel.innerHTML = '
' + - Ui().pageHeader('模板库', '管理首页展示的 .soon 模板(缩略图与名称轻量加载)', + Ui().pageHeader('模板库', '上传 .soon 后自动识别名称、类型与缩略图', '') + Ui().skeletonTable() + '
'; @@ -165,7 +349,7 @@ }).join(''); panel.innerHTML = '
' + - Ui().pageHeader('模板库', '管理首页展示的 .soon 模板(缩略图与名称轻量加载)', + Ui().pageHeader('模板库', '上传 .soon 后自动识别名称、类型与缩略图', '') + Ui().dataTable( ['ID', '名称', '类型', '大小', '排序', '缩略图', '状态', '操作'], diff --git a/frontend-web/pages/design1.web.html b/frontend-web/pages/design1.web.html index 40a7e3b..d54a2bc 100644 --- a/frontend-web/pages/design1.web.html +++ b/frontend-web/pages/design1.web.html @@ -278,7 +278,7 @@ - +
diff --git a/frontend-web/pages/design2.web.html b/frontend-web/pages/design2.web.html index 4893178..8f47fb8 100644 --- a/frontend-web/pages/design2.web.html +++ b/frontend-web/pages/design2.web.html @@ -78,7 +78,7 @@ - +