diff --git a/backend-web/public/index.php b/backend-web/public/index.php index e2cce49..62821bf 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}/thumb', [FileController::class, 'thumb']); 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']); diff --git a/backend-web/schema.sql b/backend-web/schema.sql index 1af6948..318e2af 100644 --- a/backend-web/schema.sql +++ b/backend-web/schema.sql @@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS `soon_files` ( `json` LONGTEXT NOT NULL, `size` INT UNSIGNED NOT NULL DEFAULT 0, `version` INT UNSIGNED NOT NULL DEFAULT 1, + `thumb` MEDIUMTEXT DEFAULT NULL, `created_at` DATETIME NOT NULL, `updated_at` DATETIME NOT NULL, `deleted_at` DATETIME DEFAULT NULL, diff --git a/backend-web/src/Controllers/FileController.php b/backend-web/src/Controllers/FileController.php index dac3f8a..6c56e29 100644 --- a/backend-web/src/Controllers/FileController.php +++ b/backend-web/src/Controllers/FileController.php @@ -75,6 +75,12 @@ final class FileController Json::ok(['id' => $id]); } + public function thumb(int $id): void + { + $u = Auth::require(); + FileService::outputThumb($u['id'], $id); + } + public function download(int $id): void { $u = Auth::require(); diff --git a/backend-web/src/Services/FileService.php b/backend-web/src/Services/FileService.php index e457172..22ca3e5 100644 --- a/backend-web/src/Services/FileService.php +++ b/backend-web/src/Services/FileService.php @@ -55,7 +55,8 @@ final class FileService public static function list(int $userId, int $limit, int $offset): array { $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 ' . 'ORDER BY updated_at DESC LIMIT :lim OFFSET :off' ); @@ -63,7 +64,12 @@ final class FileService $stmt->bindValue('lim', $limit, \PDO::PARAM_INT); $stmt->bindValue('off', $offset, \PDO::PARAM_INT); $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 @@ -79,11 +85,15 @@ final class FileService Json::fail('quota_exceeded', '存储空间已满,请清理文件或续订', 413); } $now = date('Y-m-d H:i:s'); + $thumb = TemplateService::thumbFromSoonJson($json); $stmt = Db::pdo()->prepare( - 'INSERT INTO soon_files (user_id, name, json, size, version, created_at, updated_at) ' - . 'VALUES (:u, :n, :j, :s, 1, :created_at, :updated_at)' + 'INSERT INTO soon_files (user_id, name, json, size, version, thumb, created_at, updated_at) ' + . 'VALUES (:u, :n, :j, :s, 1, :th, :created_at, :updated_at)' ); - $stmt->execute(['u' => $userId, 'n' => $name, 'j' => $json, 's' => $size, 'created_at' => $now, 'updated_at' => $now]); + $stmt->execute([ + 'u' => $userId, 'n' => $name, 'j' => $json, 's' => $size, 'th' => $thumb !== '' ? $thumb : null, + 'created_at' => $now, 'updated_at' => $now, + ]); $id = (int)Db::pdo()->lastInsertId(); return ['id' => $id, 'name' => $name, 'size' => $size, 'version' => 1, 'updated_at' => $now]; } @@ -111,12 +121,14 @@ final class FileService } $now = date('Y-m-d H:i:s'); $newVersion = (int)$row['version'] + 1; + $thumb = TemplateService::thumbFromSoonJson($json); $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' ); $upd->execute([ 'n' => $name, 'j' => $json, 's' => $newSize, 'v' => $newVersion, + 'th' => $thumb !== '' ? $thumb : null, 'ts' => $now, 'id' => $id, 'cv' => (int)$row['version'], ]); if ($upd->rowCount() === 0) { @@ -152,4 +164,22 @@ final class FileService } 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']); + } } diff --git a/backend-web/src/Services/MembershipService.php b/backend-web/src/Services/MembershipService.php index d8d87bf..868f06a 100644 --- a/backend-web/src/Services/MembershipService.php +++ b/backend-web/src/Services/MembershipService.php @@ -372,12 +372,12 @@ final class MembershipService $fileStmt->execute(['u' => $userId]); $row = $fileStmt->fetch() ?: ['files_count' => 0, 'storage_bytes' => 0]; $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 [ 'files_count' => (int)$row['files_count'], 'storage_bytes' => $bytes, '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', ]; } diff --git a/backend-web/src/Services/TemplateService.php b/backend-web/src/Services/TemplateService.php index 7c70d1f..9dc8dfe 100644 --- a/backend-web/src/Services/TemplateService.php +++ b/backend-web/src/Services/TemplateService.php @@ -434,12 +434,22 @@ 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=60, must-revalidate'); + 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'; diff --git a/docker/php/migrate-dev-schema.php b/docker/php/migrate-dev-schema.php index a3504e9..8ffee79 100644 --- a/docker/php/migrate-dev-schema.php +++ b/docker/php/migrate-dev-schema.php @@ -55,6 +55,10 @@ if (!columnExists($pdo, 'users', 'admin_level')) { $alters[] = "ALTER TABLE users ADD COLUMN admin_level ENUM('full','ops') DEFAULT NULL AFTER role"; } +if (!columnExists($pdo, 'soon_files', 'thumb')) { + $alters[] = 'ALTER TABLE soon_files ADD COLUMN thumb MEDIUMTEXT DEFAULT NULL AFTER version'; +} + foreach ($alters as $sql) { $pdo->exec($sql); fwrite(STDOUT, "migrate-dev-schema: {$sql}" . PHP_EOL); diff --git a/docs/API-PAGINATION.md b/docs/API-PAGINATION.md index 20b2153..3485e6e 100644 --- a/docs/API-PAGINATION.md +++ b/docs/API-PAGINATION.md @@ -168,7 +168,8 @@ GET /api/v1/pay/orders?page=1&size=8 | `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` | **legacy**:裸 `.soon` 流;门户禁止调用,仅供兼容 | +| `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}`(门户读取模板数据) @@ -196,10 +197,11 @@ GET /api/v1/pay/orders?page=1&size=8 | 前缀 | 含义 | readJsonFile | 保存 writeFile | |------|------|--------------|----------------| | `soondesign_file:{id}:v{ver}` | 云端已登记文件 | `GET /files/{id}` | `PUT /files/{id}` | -| `soondesign_template:{id}` | 云端模板(只读源) | `GET /templates/{id}` | `POST /files`(首次保存新建) | +| `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}` 二次包装) --- @@ -236,3 +238,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 | +| 2026-06-08 | 新增 GET /files/{id}/thumb;files list 增 `has_thumb`;模板 /file 长缓存 | diff --git a/docs/WEB-LOCAL-CACHE.md b/docs/WEB-LOCAL-CACHE.md new file mode 100644 index 0000000..427aacb --- /dev/null +++ b/docs/WEB-LOCAL-CACHE.md @@ -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 验收后再进入下一阶段。 diff --git a/frontend-web/assets/css/design.css b/frontend-web/assets/css/design.css index e43672a..645ccf3 100644 --- a/frontend-web/assets/css/design.css +++ b/frontend-web/assets/css/design.css @@ -424,6 +424,7 @@ 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) { @@ -431,15 +432,22 @@ html:has(body.soon-design-page) { 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 { height: 100vh; max-height: 100vh; + max-width: 100vw; overflow: hidden; margin: 0; padding: 0 !important; display: flex; flex-direction: column; - box-sizing: border-box; background-color: #1e1e1e; -webkit-font-smoothing: antialiased; } @@ -448,7 +456,21 @@ body.soon-design-page .soon-design-portal-bar, body.soon-design-page .soon-portal-topbar { position: static !important; width: 100%; + max-width: 100%; + min-width: 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 { @@ -457,6 +479,7 @@ body.soon-design-page #portal-topbar { body.soon-design-page .layui-fluid.main { flex: 1; + min-width: 0; min-height: 0; height: auto !important; overflow: hidden; @@ -467,6 +490,7 @@ body.soon-design-page .layui-fluid.main { body.soon-design-page .layui-row { flex: 1; + min-width: 0; min-height: 0; display: flex; flex-direction: row-reverse; @@ -477,7 +501,7 @@ body.soon-design-page .layui-row { } body.soon-design-page .col-left { - flex: 1; + flex: 1 1 0; min-width: 0; margin-right: 0; float: none; @@ -509,15 +533,116 @@ body.soon-design-page .tools-bar { 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 { white-space: nowrap; flex-shrink: 0; min-width: 60px; - display: flex; + display: inline-flex; align-items: 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 { flex-shrink: 0; } @@ -567,15 +692,18 @@ body.soon-design-page .container .right { body.soon-design-page #canvas-div { flex: 1; + min-width: 0; min-height: 0; position: relative; overflow: hidden; width: 100%; + max-width: 100%; display: flex; } body.soon-design-page .canvas-wrapper { width: 100%; + max-width: 100%; height: 100%; overflow: auto; position: relative; diff --git a/frontend-web/assets/css/layer-soon.css b/frontend-web/assets/css/layer-soon.css index ea41708..742d848 100644 --- a/frontend-web/assets/css/layer-soon.css +++ b/frontend-web/assets/css/layer-soon.css @@ -45,15 +45,23 @@ color: inherit !important; } +.layui-layer.soon-layer.soon-layer--pay, +.layui-layer-soon-layer.soon-layer--pay { + 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; - max-height: none !important; -} - -.layui-layer.soon-layer.soon-layer--pay, -.layui-layer-soon-layer.soon-layer--pay { - max-height: calc(100vh - 32px); + 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, @@ -77,6 +85,136 @@ 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--scroll, .layui-layer-soon-layer .layui-layer-content.soon-layer-content--scroll { overflow-x: hidden !important; @@ -149,12 +287,12 @@ 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 { +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 .layui-layer-setwin .layui-layer-ico:hover { +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; } diff --git a/frontend-web/assets/css/member.css b/frontend-web/assets/css/member.css index e8ba3a2..422bd5a 100644 --- a/frontend-web/assets/css/member.css +++ b/frontend-web/assets/css/member.css @@ -803,461 +803,7 @@ opacity: 0.85; } -/* ── 支付弹窗 ── */ -.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-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; -} - -.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; -} +@import url('pay-sheet.css'); .soon-member-page ::-webkit-scrollbar { width: 8px; height: 8px; } .soon-member-page ::-webkit-scrollbar-thumb { diff --git a/frontend-web/assets/css/pay-sheet.css b/frontend-web/assets/css/pay-sheet.css new file mode 100644 index 0000000..8ca462e --- /dev/null +++ b/frontend-web/assets/css/pay-sheet.css @@ -0,0 +1,472 @@ +/* 支付确认弹窗(设计页 / 会员页共用) */ +.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 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; +} diff --git a/frontend-web/assets/css/subscribe-gate.css b/frontend-web/assets/css/subscribe-gate.css index c1b0d19..2d7918d 100644 --- a/frontend-web/assets/css/subscribe-gate.css +++ b/frontend-web/assets/css/subscribe-gate.css @@ -1,3 +1,5 @@ +@import url('pay-sheet.css'); + .soon-subscribe-modal { text-align: left; background: var(--soon-bg-panel); @@ -223,31 +225,149 @@ 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: 0 24px 8px; + padding: 18px 22px 8px; display: flex; flex-direction: column; gap: 12px; } -.soon-login-gate__form .soon-input-wrap { - width: 100%; +.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 { - color: #f87171; - font-size: 13px; 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 { - padding: 0 24px 16px; + margin: 0; + padding: 0 0 4px; font-size: 13px; - color: rgba(255, 255, 255, 0.65); + text-align: center; + color: var(--soon-text-muted); } .soon-login-gate__links a { - color: #7dd3fc; + 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 { @@ -265,12 +385,6 @@ color: rgba(255, 255, 255, 0.55); } -.soon-activate-price__value { - font-size: 28px; - font-weight: 700; - color: #fff; -} - .soon-activate-price__hint { font-size: 12px; color: rgba(255, 255, 255, 0.5); @@ -283,25 +397,69 @@ margin-top: 16px; } -.soon-activate-channel { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 8px 14px; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.12); - font-size: 13px; - cursor: pointer; -} - -.soon-activate-channel.is-active { - border-color: #38bdf8; - background: rgba(56, 189, 248, 0.12); -} - .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; +} diff --git a/frontend-web/js/common/asset-base.js b/frontend-web/js/common/asset-base.js index aeec558..d17cb2e 100644 --- a/frontend-web/js/common/asset-base.js +++ b/frontend-web/js/common/asset-base.js @@ -38,4 +38,122 @@ var n = String(rel || '').replace(/^\/+/, ''); 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)); + }; })(); diff --git a/frontend-web/js/common/cloud-files.js b/frontend-web/js/common/cloud-files.js index e527055..29b5a2b 100644 --- a/frontend-web/js/common/cloud-files.js +++ b/frontend-web/js/common/cloud-files.js @@ -39,18 +39,243 @@ 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(); + var mult = 0.18; + var quality = 0.72; + var url = ''; + for (var i = 0; i < 3; i++) { + try { + var candidate = canvas.toDataURL({ format: 'jpeg', quality: quality, multiplier: mult }); + if (candidate && candidate.indexOf('data:image/') === 0 && candidate.length <= THUMB_MAX) { + url = candidate; + break; + } + } catch (e) { /* ignore */ } + quality -= 0.18; + mult -= 0.04; + } + hidden.forEach(function (obj) { obj.visible = true; }); + if (hidden.length) canvas.renderAll(); + 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 soonReportSaveError(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) || '保存失败', 'error'); + } + } + + function soonWriteSoonContentWeb(fp, content, prevOpenKey, onDone, onError) { + if (!window.platformBridge || !window.platformBridge.writeFile) return false; + window.platformBridge.writeFile(fp, content).then(function (res) { + if (typeof onDone === 'function') onDone(res, prevOpenKey); + }).catch(function (err) { + if (typeof onError === 'function') onError(err); + else soonReportSaveError(err); + }); + 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) { var n = String(pathOrName || 'design.soon'); if (n.indexOf(FILE_PREFIX) === 0) { - var meta = window._soonFileMeta; - return (meta && meta.name) ? meta.name : 'design.soon'; + var resolved = soonResolveCloudFileName(n); + if (resolved) return resolved; + return 'design.soon'; } if (n.indexOf(TEMPLATE_PREFIX) === 0) { - var tpl = soonParseTemplateKey(n); - return tpl ? ('template-' + tpl.id + '.soon') : 'template.soon'; + return soonDefaultNewSoonName(); } if (n.indexOf('soondesign_session:') === 0) { - n = n.replace(/^soondesign_session:/, ''); + return soonSessionDisplayName(n); } n = n.split(/[/\\]/).pop(); if (!/\.soon$/i.test(n)) n = (n.replace(/\.soon$/i, '') || 'design') + '.soon'; @@ -128,10 +353,12 @@ var parsed = soonParseFileKey(fileKey); if (!parsed) return; var prev = window._soonFileMeta || {}; + var name = (opts && opts.name) || (prev.id === parsed.id ? prev.name : '') || ''; + if (!name) name = soonLookupRecentCloudName(parsed.id); window._soonFileMeta = { id: parsed.id, version: parsed.version != null ? parsed.version : prev.version, - name: (opts && opts.name) || (prev.id === parsed.id ? prev.name : '') || '' + name: name }; } @@ -301,11 +528,15 @@ function soonPutSoonSession(j, fileName) { if (!j) return ''; - var hint = (fileName || 'design').replace(/\.soon$/i, ''); - var key = 'soondesign_session:' + hint + '-' + Date.now(); + 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)); - try { localStorage.setItem(key, JSON.stringify(j)); } catch (e2) { /* ignore quota */ } return key; } catch (e) { return ''; @@ -318,6 +549,9 @@ 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) { @@ -369,25 +603,55 @@ 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; + 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); } - 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; - } + 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 (err && err.status) return; - soonToast((err && err.message) ? err.message : '云端登记失败,可稍后保存重试', 'warn'); + 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'); + } }); } @@ -462,13 +726,13 @@ return tplParsed ? ('模板 #' + tplParsed.id) : pathOrKey; } if (pathOrKey.indexOf(FILE_PREFIX) === 0) { - var meta = window._soonFileMeta; - if (meta && meta.name) return meta.name; + var resolved = soonResolveCloudFileName(pathOrKey); + if (resolved) return resolved; var parsed = soonParseFileKey(pathOrKey); return parsed ? ('文件 #' + parsed.id) : pathOrKey; } if (pathOrKey.indexOf('soondesign_session:') === 0) { - return pathOrKey.replace(/^soondesign_session:/, ''); + return soonSessionDisplayName(pathOrKey); } return String(pathOrKey).split(/[/\\]/).pop(); } @@ -503,6 +767,7 @@ window.soonParseFileKey = soonParseFileKey; window.soonMakeFileKey = soonMakeFileKey; window.soonNormalizeSoonName = soonNormalizeSoonName; + window.soonResolveCloudFileName = soonResolveCloudFileName; window.soonApplyCloudMeta = soonApplyCloudMeta; window.soonBindFileMeta = soonBindFileMeta; window.soonSyncOpenNavigation = soonSyncOpenNavigation; @@ -529,6 +794,21 @@ window.soonParseApiError = soonParseApiError; window.soonShowApiError = soonShowApiError; 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); diff --git a/frontend-web/js/common/member-activate.js b/frontend-web/js/common/member-activate.js index 8853b95..8b4e06e 100644 --- a/frontend-web/js/common/member-activate.js +++ b/frontend-web/js/common/member-activate.js @@ -141,6 +141,8 @@ } _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 = '网络错误,请稍后重试'; @@ -205,24 +207,36 @@ try { layer.close(_gate.payIndex); } catch (e) { /* ignore */ } } _gate = { index: null, payIndex: null, plan: null, gateOpts: opts }; - var width = Math.min(520, window.innerWidth - 24); + 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--subscribe'); + 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'; + 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; diff --git a/frontend-web/js/common/member-login-gate.js b/frontend-web/js/common/member-login-gate.js index a0b2e40..56c1a80 100644 --- a/frontend-web/js/common/member-login-gate.js +++ b/frontend-web/js/common/member-login-gate.js @@ -37,21 +37,29 @@ } function shellHtml(reason) { + var mailIcon = ''; + var lockIcon = ''; return '
' + - '
' + - '登录' + - '

登录后继续

' + - '

预览内' + esc(reason || '导出或打印') + '需先登录账号。

' + + '' + '' + + '' + + '' + + '' + + '' + '' + - '
'; } @@ -77,7 +85,7 @@ form.addEventListener('submit', function (e) { e.preventDefault(); if (errEl) { - errEl.style.display = 'none'; + errEl.hidden = true; errEl.textContent = ''; } var email = (form.email && form.email.value || '').trim(); @@ -90,7 +98,7 @@ if (!j.ok || !j.data || !j.data.access_token) { if (errEl) { errEl.textContent = (j && j.message) || '登录失败'; - errEl.style.display = 'block'; + errEl.hidden = false; } return; } @@ -109,7 +117,7 @@ }).catch(function () { if (errEl) { errEl.textContent = '网络错误,请稍后重试'; - errEl.style.display = 'block'; + errEl.hidden = false; } }); }); @@ -122,20 +130,26 @@ try { layer.close(_gate.index); } catch (e) { /* ignore */ } } _gate.opts = opts; - var width = Math.min(420, window.innerWidth - 24); + 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(opts.reason), success: function (layero, index) { var layerEl = layero && layero[0] ? layero[0] : layero; - if (layerEl && layerEl.classList) layerEl.classList.add('soon-layer--subscribe'); + 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'; + if (content) { + content.style.padding = '0'; + content.style.overflow = 'visible'; + content.style.background = 'transparent'; + } var modal = layerEl.querySelector('.soon-login-gate'); _gate.index = index; _gate.opts = opts; diff --git a/frontend-web/js/common/member-pay-core.js b/frontend-web/js/common/member-pay-core.js index 6a695be..383fd40 100644 --- a/frontend-web/js/common/member-pay-core.js +++ b/frontend-web/js/common/member-pay-core.js @@ -473,22 +473,29 @@ displayChannels: channels, }); var payCtrl = null; + var width = Math.min(480, window.innerWidth - 32); var idx = layer.open({ type: 1, skin: 'soon-layer', title: false, closeBtn: 1, + fixed: true, + offset: 'auto', + shade: [0.72, '#000'], shadeClose: !!opts.shadeClose, - area: ['480px'], + maxWidth: width, + area: [width + 'px', 'auto'], content: html, - success: function (layero) { + success: function (layero, index) { var layerEl = layero && layero[0] ? layero[0] : layero; if (layerEl && layerEl.classList) { layerEl.classList.add('soon-layer--pay'); var layerContent = layerEl.querySelector('.layui-layer-content'); if (layerContent) { + layerContent.style.padding = '0'; layerContent.style.overflow = 'visible'; layerContent.style.maxHeight = 'none'; + layerContent.style.background = 'transparent'; } } var sheet = layerEl.querySelector('.soon-pay-sheet'); @@ -505,6 +512,9 @@ }, onBack: opts.onBack, }); + requestAnimationFrame(function () { + if (typeof layer.style === 'function') layer.style(index); + }); }, end: function () { if (payCtrl) payCtrl.destroy(); diff --git a/frontend-web/js/common/portal-auth.js b/frontend-web/js/common/portal-auth.js index 5ef213a..dc1e35a 100644 --- a/frontend-web/js/common/portal-auth.js +++ b/frontend-web/js/common/portal-auth.js @@ -19,8 +19,43 @@ } } + function initClearCache() { + var btn = document.getElementById('auth_clear_cache'); + if (!btn) return; + 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: '
将清除最近文件与本地缓存,不会退出登录。未上传的本地编辑将丢失。
', + 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); + }); + } + }); + }); + }); + } + function initTopbar(options) { options = options || {}; + initClearCache(); + var tok = localStorage.getItem('soon_access') || ''; var login = document.getElementById('auth_login'); var reg = document.getElementById('auth_register'); diff --git a/frontend-web/js/common/portal-topbar.js b/frontend-web/js/common/portal-topbar.js index 455547d..c70d880 100644 --- a/frontend-web/js/common/portal-topbar.js +++ b/frontend-web/js/common/portal-topbar.js @@ -37,7 +37,8 @@ function toolsBlock(lang, linksHtml) { return '
' + lang + - (linksHtml ? '' : '') + + (linksHtml ? '' : '') + '
'; } diff --git a/frontend-web/js/common/soon-local-store.js b/frontend-web/js/common/soon-local-store.js new file mode 100644 index 0000000..81bf89a --- /dev/null +++ b/frontend-web/js/common/soon-local-store.js @@ -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; +})(); diff --git a/frontend-web/js/common/soon-recent.js b/frontend-web/js/common/soon-recent.js new file mode 100644 index 0000000..6b5e4ec --- /dev/null +++ b/frontend-web/js/common/soon-recent.js @@ -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; +})(); diff --git a/frontend-web/js/design1/core.js b/frontend-web/js/design1/core.js index 34a00cc..11ad56f 100644 --- a/frontend-web/js/design1/core.js +++ b/frontend-web/js/design1/core.js @@ -150,6 +150,7 @@ function applySysLan(data) { localStorage.setItem("lang", "zh"); break; case 'zh-TW': + case 'ozh': s_lan = "ozh"; // 同步到全局作用域(用于 .jsc 文件加载) if (typeof window !== 'undefined') { @@ -375,64 +376,73 @@ window.rotate = function (id) { $(`#${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() { - // 类型改变 - fabric.Image.fromURL(soonAsset('bg_front_1.png'), function (image) { - let width = $("#canvas1").width() + if (is_bgi_add || window.is_bgi_add) return; + var loadBg = typeof window.soonFabricImageFromAsset === 'function' + ? 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; - image.top = ($("#canvas1").height() - image.height * image.get("scaleY")) / 2; + function finishBack(backImg) { + if (backImg) { + soonPlaceBgOnCanvas(backImg, '#canvas2'); + background_image2 = backImg; + window.background_image2 = backImg; + canvas2.add(backImg); + canvas2.renderAll(); + recordObjs2.push(JSON.stringify(objs2)); + recordJson2.push(canvas2.toJSON(TO_JSON_PROPERTIES)); + } + is_bgi_add = true; + window.is_bgi_add = true; + soonOpenPendingDesignFile(); + } - image.selectable = false; - image.hoverable = false; - image.hoverCursor = "default" - image.setSrc(image.toDataURL(), function (image) { - image.scale(1, 1); - background_image1 = image; - background_image = background_image1; - window.background_image1 = background_image1; - window.background_image = background_image; - canvas.add(image); + function startLoad() { + if (!loadBg) return; + loadBg('bg_front_1.png', function (frontImg) { + if (!frontImg) return; + frontImg.scale(1, 1); + soonPlaceBgOnCanvas(frontImg, '#canvas1'); + background_image1 = frontImg; + background_image = frontImg; + window.background_image1 = frontImg; + window.background_image = frontImg; + canvas.add(frontImg); canvas.renderAll(); - //pre_objs1.push(JSON.stringify(objs1)); - //pre_json1.push(canvas1.toJSON(["selectable","hoverable","hoverCursor"])); - recordObjs1.push(JSON.stringify(objs1)); recordJson1.push(canvas1.toJSON(TO_JSON_PROPERTIES)); + loadBg('bg_back.png', finishBack, function () { loadBg('bg_back.png', finishBack); }); + }, function () { + setTimeout(startLoad, 200); }); - // 类型改变 - fabric.Image.fromURL(soonAsset('bg_back.png'), function (image) { - image.left = ($("#canvas2").width() - image.width) / 2; - image.top = ($("#canvas2").height() - image.height) / 2; + } - image.selectable = false; - image.hoverable = false; - image.hoverCursor = "default" - - image.setSrc(image.toDataURL(), function (image) { - background_image2 = image; - window.background_image2 = background_image2; - canvas2.add(image); - canvas2.renderAll(); - //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)); - - 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); - } - }); - }); - is_bgi_add = true; - }); + whenReady('#canvas1', startLoad); } // 将 updateControls 附加到 window 对象,确保在 eval() 加载 .jsc 文件时也能访问 diff --git a/frontend-web/js/design1/output.js b/frontend-web/js/design1/output.js index b689e60..4c05886 100644 --- a/frontend-web/js/design1/output.js +++ b/frontend-web/js/design1/output.js @@ -1181,6 +1181,7 @@ window.openFile = function open(file, jAlready) { recordJson2.push(j2); }, 0); if (typeof window.soonTryConsumeCloudImport === 'function') window.soonTryConsumeCloudImport(file); + if (typeof window.soonRecentUpsertFromOpen === 'function') window.soonRecentUpsertFromOpen(file, j); } }); } @@ -1263,11 +1264,23 @@ function cloudSaveSuccessLabel(res, 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) { applyCloudWriteResult(res, fallback); if (res && res.fileKey && typeof window.soonClearPendingImport === 'function') { window.soonClearPendingImport(); } + if (typeof window.soonRecentOnCloudSave === 'function') { + window.soonRecentOnCloudSave(res, fallback, soonPageDesignType()); + } layer.msg(language_str("saveSucc") + cloudSaveSuccessLabel(res, fallback)); if (callback && typeof callback === 'function') callback(); } @@ -1276,15 +1289,6 @@ 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 = []; @@ -1319,26 +1323,26 @@ function saveAs(op1, callback) { let o = { f: j, b: {}, fo: objs1, bo: objs2 }; let con_o = Object.assign(o, op1); if (!guardWebSave()) return; + var prevOpenKey = openAs.name || ''; + if (typeof window.soonApplySavePreview === 'function') window.soonApplySavePreview(con_o, canvas1); + con_o.soonType = 1; var dialogApi = (typeof dialog !== 'undefined' && dialog) ? dialog : (window.platformBridge && window.platformBridge.showSaveDialog ? { showSaveDialog: function(opts) { return window.platformBridge.showSaveDialog(opts); } } : null); if (!dialogApi) return; dialogApi.showSaveDialog({ title: language_str("saveFile"), 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) { 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; - 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); - con_o.soonType = 1; - if (window.platformBridge && window.platformBridge.writeFile) { - window.platformBridge.writeFile(fp, content).then(function (res) { - onCloudWriteDone(res, fp, callback); - }).catch(reportSaveError); - return; - } + if (typeof window.soonWriteSoonContentWeb === 'function' && + window.soonWriteSoonContentWeb(fp, content, prevOpenKey, function (res, pk) { + onCloudWriteDone(res, pk, callback); + })) return; var fs = typeof require !== 'undefined' ? require('fs') : null; if (fs) { fs.writeFileSync(result.filePath || fp, content, 'utf8'); @@ -1347,7 +1351,9 @@ function saveAs(op1, callback) { saveHistory(); if (callback && typeof callback === 'function') callback(); } - }).catch(reportSaveError); + }).catch(function (err) { + if (typeof window.soonReportSaveError === 'function') window.soonReportSaveError(err); + }); } function save(op1, callback) { @@ -1384,15 +1390,18 @@ function save(op1, callback) { let o = { f: j, b: {}, fo: objs1, bo: objs2 }; let con_o = Object.assign(o, op1); if (!guardWebSave()) return; - if (openAs.name != "") { - con_o.soonType = 1; - var content = JSON.stringify(con_o); - if (window.platformBridge && window.platformBridge.writeFile) { - window.platformBridge.writeFile(openAs.name, content).then(function (res) { - onCloudWriteDone(res, openAs.name, callback); - }).catch(reportSaveError); - return; - } + var prevOpenKey = openAs.name || ''; + if (typeof window.soonApplySavePreview === 'function') window.soonApplySavePreview(con_o, canvas1); + con_o.soonType = 1; + var content = JSON.stringify(con_o); + 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; if (fs) { fs.writeFileSync(openAs.name, content, 'utf8'); @@ -1407,21 +1416,17 @@ function save(op1, callback) { dialogApi.showSaveDialog({ title: language_str("saveFile"), filters: [{ name: 'Soon File Type', extensions: ['soon'] }], - defaultPath: openAs.name || undefined - }).then(async function(result) { + defaultPath: typeof window.soonSaveDialogDefaultPath === 'function' ? window.soonSaveDialogDefaultPath(openAs.name) : openAs.name + }).then(function(result) { 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; - 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); - con_o.soonType = 1; - if (window.platformBridge && window.platformBridge.writeFile) { - window.platformBridge.writeFile(fp, content).then(function (res) { - onCloudWriteDone(res, fp, callback); - }).catch(reportSaveError); - return; - } + if (typeof window.soonWriteSoonContentWeb === 'function' && + window.soonWriteSoonContentWeb(fp, content, prevOpenKey, function (res, pk) { + onCloudWriteDone(res, pk, callback); + })) return; var fs = typeof require !== 'undefined' ? require('fs') : null; if (fs) { fs.writeFileSync(result.filePath || fp, content, 'utf8'); @@ -1430,12 +1435,27 @@ function save(op1, callback) { saveHistory(); if (callback && typeof callback === 'function') callback(); } - }).catch(reportSaveError); + }).catch(function (err) { + if (typeof window.soonReportSaveError === 'function') window.soonReportSaveError(err); + }); } window.saveHistory = function saveHistory() { + 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) { - var currentPath = openAs.name; if (!currentPath) return; var pathForHistory = currentPath; if (window.platformBridge && currentPath.indexOf('soondesign_session:') !== 0 && currentPath.indexOf('soondesign_file:') !== 0) { diff --git a/frontend-web/js/design2/core.js b/frontend-web/js/design2/core.js index 1f92694..2aeba60 100644 --- a/frontend-web/js/design2/core.js +++ b/frontend-web/js/design2/core.js @@ -9,12 +9,27 @@ function soonDesign2MainHeight() { return Math.max(480, window.innerHeight - (tb ? tb.offsetHeight : 0)); } +function soonDesign2SidebarWidth() { + var colRight = document.querySelector('body.soon-design-page .col-right'); + 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 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); + 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() { @@ -80,6 +95,7 @@ zoom = (function () { var z = divW / 1200 > 1 ? 1 : divW / 1200; return z > 0 ? z : 0.5; })() +window.zoom = zoom; // 初始化标尺 function initRulers() { @@ -518,29 +534,110 @@ 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(); - }); - }); +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(); + } + if (window.is_bgi_add) { + canvas1.requestRenderAll(); + canvas2.requestRenderAll(); + } } -$('#source_front').width(zoom * $('#source_front').width()) -$('#source_back').width(zoom * $('#source_back').width()) + +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 soonDesign2BgOverlayMetrics(imgEl) { + if (!imgEl || !imgEl.complete || imgEl.naturalWidth <= 0) return null; + var z = zoom || window.zoom || 0.5; + var w = parseFloat(imgEl.style.width); + if (!w || w <= 0) w = imgEl.naturalWidth * z; + var h = parseFloat(imgEl.style.height); + if (!h || h <= 0) h = imgEl.naturalHeight * z; + return { w: w, h: h }; +} + +function soonDesign2PrepareBgSources(done) { + var sf = document.getElementById('source_front'); + var sb = document.getElementById('source_back'); + 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.soonDesign2BgOverlayMetrics = soonDesign2BgOverlayMetrics; +window.soonDesign2Kickoff = soonDesign2Kickoff; + +initRulers(); $('.canvas-container:eq(1)').hide() canvas = canvas1 window.canvas = canvas @@ -1643,58 +1740,71 @@ function getCoordsMaxY(acoords) { 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() { - // 类型改变 - fabric.Image.fromURL(soonAsset('bg_front_2.png'), function (image) { - image.left = ($('#canvas1').width() - image.width * image.get('scaleX')) / 2 - image.top = ($('#canvas1').height() - image.height * image.get('scaleY')) / 2 + if (background_image1 && background_image2) return; + var loadBg = typeof window.soonFabricImageFromAsset === 'function' + ? window.soonFabricImageFromAsset + : null; + var whenReady = typeof window.soonRunWhenCanvasReady === 'function' + ? window.soonRunWhenCanvasReady + : function (_sel, fn) { fn(); }; + var props = window.TO_JSON_PROPERTIES || ['selectable', 'hoverable', 'hoverCursor', 'text', 'fontStyle', 'fontWeight', 'underline', 'evented', 'linethrough', 'textBackgroundColor', 'diameter', 'flipped']; - image.selectable = false - image.hoverable = false - image.hoverCursor = 'default' - image.setSrc(image.toDataURL(), function (image) { - background_image1 = image - background_image = background_image1 - window.background_image1 = background_image1; - window.background_image = background_image; - canvas1.add(image) - canvas1.renderAll() + function finishBack(backImg) { + if (backImg) { + soonPlaceBgOnCanvas(backImg, '#canvas2'); + background_image2 = backImg; + window.background_image2 = backImg; + canvas2.add(backImg); + canvas2.renderAll(); + recordObjs2.push(JSON.stringify(objs2)); + recordJson2.push(canvas2.toJSON(TO_JSON_PROPERTIES)); + } + soonOpenPendingDesignFile(); + } - 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 + function startLoad() { + if (!loadBg) return; + loadBg('bg_front_2.png', function (frontImg) { + if (!frontImg) return; + soonPlaceBgOnCanvas(frontImg, '#canvas1'); + background_image1 = frontImg; + background_image = frontImg; + window.background_image1 = frontImg; + window.background_image = frontImg; + canvas1.add(frontImg); + canvas1.renderAll(); + recordObjs1.push(JSON.stringify(objs1)); + recordJson1.push(canvas1.toJSON(props)); + loadBg('bg_back_2.png', finishBack, function () { loadBg('bg_back_2.png', finishBack, null, true); }, true); + }, function () { + setTimeout(startLoad, 200); + }, null, true); + } - image.selectable = false - image.hoverable = false - image.hoverCursor = 'default' - - image.setSrc(image.toDataURL(), function (image) { - background_image2 = image - window.background_image2 = background_image2; - canvas2.add(image) - canvas2.renderAll() - - recordObjs2.push(JSON.stringify(objs2)) - recordJson2.push(canvas2.toJSON(TO_JSON_PROPERTIES)) - - 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); - } - }) - }) - is_bgi_add = true - }) + whenReady('#canvas1', startLoad); } function addPic(pointer) { @@ -1975,6 +2085,7 @@ function applySysLan(data) { localStorage.setItem('lang', 'zh') break case 'zh-TW': + case 'ozh': s_lan = 'ozh' // 同步到全局作用域(用于 .jsc 文件加载) if (typeof window !== 'undefined') { @@ -2112,8 +2223,6 @@ if (typeof ipcRenderer !== 'undefined' && ipcRenderer) { window.platformBridge.onClose(onCloseConfirm) } -addBackground() //添加背景 白色卡片 - initAligningGuidelines(canvas1) initAligningGuidelines(canvas2) diff --git a/frontend-web/js/design2/output.js b/frontend-web/js/design2/output.js index 20ac7b7..844a7f2 100644 --- a/frontend-web/js/design2/output.js +++ b/frontend-web/js/design2/output.js @@ -1086,11 +1086,23 @@ function cloudSaveSuccessLabel(res, 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) { applyCloudWriteResult(res, fallback); if (res && res.fileKey && typeof window.soonClearPendingImport === 'function') { window.soonClearPendingImport(); } + if (typeof window.soonRecentOnCloudSave === 'function') { + window.soonRecentOnCloudSave(res, fallback, soonPageDesignType()); + } layer.msg(language_str('saveSucc') + cloudSaveSuccessLabel(res, fallback)); if (callback && typeof callback === 'function') callback(); } @@ -1099,15 +1111,6 @@ 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 = [] @@ -1142,23 +1145,23 @@ function saveAs(op1, callback) { let o = { f: j, b: b, fo: objs1, bo: objs2 } let con_o = Object.assign(o, op1) if (!guardWebSave()) return; + var prevOpenKey = openAs.name || ''; + if (typeof window.soonApplySavePreview === 'function') window.soonApplySavePreview(con_o, canvas1); + con_o.soonType = 2; var dialogApi = (typeof dialog !== 'undefined' && dialog) ? dialog : (window.platformBridge && window.platformBridge.showSaveDialog ? { showSaveDialog: function(opts) { return window.platformBridge.showSaveDialog(opts); } } : null); if (!dialogApi) return; - 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) { 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; - 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); - if (window.platformBridge && window.platformBridge.writeFile) { - window.platformBridge.writeFile(fp, content).then(function (res) { - onCloudWriteDone(res, fp, callback); - }).catch(reportSaveError); - return; - } + if (typeof window.soonWriteSoonContentWeb === 'function' && + window.soonWriteSoonContentWeb(fp, content, prevOpenKey, function (res, pk) { + onCloudWriteDone(res, pk, callback); + })) return; var fs = typeof require !== 'undefined' ? require('fs') : null; if (fs) { fs.writeFileSync(fp, content, 'utf8'); @@ -1168,7 +1171,9 @@ function saveAs(op1, callback) { if (callback && typeof callback === 'function') callback(); } }) - .catch(reportSaveError); + .catch(function (err) { + if (typeof window.soonReportSaveError === 'function') window.soonReportSaveError(err); + }); } function save(op1, callback) { @@ -1204,16 +1209,19 @@ function save(op1, callback) { let o = { f: j, b: b, fo: objs1, bo: objs2 } let con_o = Object.assign(o, op1) - con_o.soonType = 2 - var content = JSON.stringify(con_o) if (!guardWebSave()) return; - if (openAs.name != '') { - if (window.platformBridge && window.platformBridge.writeFile) { - window.platformBridge.writeFile(openAs.name, content).then(function (res) { - onCloudWriteDone(res, openAs.name, callback); - }).catch(reportSaveError); - return; - } + var prevOpenKey = openAs.name || ''; + if (typeof window.soonApplySavePreview === 'function') window.soonApplySavePreview(con_o, canvas1); + con_o.soonType = 2; + var content = JSON.stringify(con_o); + 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; if (fs) { fs.writeFileSync(openAs.name, content, 'utf8'); @@ -1225,19 +1233,17 @@ 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); 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) { 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; - 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'; - if (window.platformBridge && window.platformBridge.writeFile) { - window.platformBridge.writeFile(fp, content).then(function (res) { - onCloudWriteDone(res, fp, callback); - }).catch(reportSaveError); - return; - } + if (typeof window.soonWriteSoonContentWeb === 'function' && + window.soonWriteSoonContentWeb(fp, content, prevOpenKey, function (res, pk) { + onCloudWriteDone(res, pk, callback); + })) return; var fs = typeof require !== 'undefined' ? require('fs') : null; if (fs) { fs.writeFileSync(fp, content, 'utf8'); @@ -1247,12 +1253,27 @@ function save(op1, callback) { if (callback && typeof callback === 'function') callback(); } }) - .catch(reportSaveError); + .catch(function (err) { + if (typeof window.soonReportSaveError === 'function') window.soonReportSaveError(err); + }); } window.saveHistory = function saveHistory() { + 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) { - var currentPath = openAs.name; if (!currentPath) return; var pathForHistory = currentPath; if (window.platformBridge && currentPath.indexOf('soondesign_session:') !== 0 && currentPath.indexOf('soondesign_file:') !== 0) { @@ -1455,6 +1476,7 @@ window.openFile = function open(file, jAlready) { step = step2 } if (typeof window.soonTryConsumeCloudImport === 'function') window.soonTryConsumeCloudImport(file); + if (typeof window.soonRecentUpsertFromOpen === 'function') window.soonRecentUpsertFromOpen(file, j); } canvas1.loadFromJSON(j.f, function () { diff --git a/frontend-web/js/design2/ui.js b/frontend-web/js/design2/ui.js index bce12bc..a64e1a1 100644 --- a/frontend-web/js/design2/ui.js +++ b/frontend-web/js/design2/ui.js @@ -359,9 +359,17 @@ $('#version_change').click(function () { } else { bg_version = 1 } - // 类型改变 - $('#source_front').attr('src', soonAsset('front_bg') + bg_version + '_2.png') - canvas.renderAll() + var sf = document.getElementById('source_front') + if (!sf) return + sf.onload = function () { + if (typeof window.soonDesign2ScaleBgSources === 'function') { + window.soonDesign2ScaleBgSources() + } + canvas1.requestRenderAll() + canvas2.requestRenderAll() + } + sf.onerror = sf.onload + sf.src = soonAsset('front_bg' + bg_version + '_2.png') }) $('#front_side').click(function () { $(this).addClass('ui-button-active') @@ -1373,58 +1381,6 @@ $('#new').click(function () { ) }) 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) { updateControls() @@ -2749,10 +2705,19 @@ if (typeof window._design2CanvasEventsInitialized === 'undefined') { // 保留 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()) - } + if (!window.is_bgi_add) return + var image = document.getElementById('source_front') + var metrics = typeof window.soonDesign2BgOverlayMetrics === 'function' + ? window.soonDesign2BgOverlayMetrics(image) + : null + if (!metrics) return + ctx1.drawImage( + image, + (canvas1.width - metrics.w) / 2, + (canvas1.height - metrics.h) / 2, + metrics.w, + metrics.h + ) }) // 保留 canvas1 特定的 text:changed 处理 canvas1.on('text:changed', (e) => { @@ -2760,11 +2725,27 @@ if (typeof window._design2CanvasEventsInitialized === 'undefined') { }) // 保留 canvas2 特定的 after:render 处理 canvas2.on('after:render', function () { - if (is_bgi_add) { - var image = document.getElementById('source_back') - ctx2.drawImage(image, (canvas.width - $('#source_back').width()) / 2, (canvas1.height - $('#source_back').height()) / 2, $('#source_back').width(), $('#source_back').height()) - } + if (!window.is_bgi_add) return + var image = document.getElementById('source_back') + var metrics = typeof window.soonDesign2BgOverlayMetrics === 'function' + ? window.soonDesign2BgOverlayMetrics(image) + : null + if (!metrics) return + ctx2.drawImage( + image, + (canvas2.width - metrics.w) / 2, + (canvas2.height - metrics.h) / 2, + metrics.w, + metrics.h + ) }) + requestAnimationFrame(function () { + requestAnimationFrame(function () { + if (typeof window.soonDesign2Kickoff === 'function') { + window.soonDesign2Kickoff(); + } + }); + }); // 注意:以下事件已在 core.js 的 initCanvasEvents() 中统一处理,移除重复监听以避免冲突: // - mouse:down (通过 handleObjectSelected 处理) // - mouse:up (已在 core.js 中处理) diff --git a/frontend-web/js/index.js b/frontend-web/js/index.js index a15903d..e37e160 100644 --- a/frontend-web/js/index.js +++ b/frontend-web/js/index.js @@ -144,39 +144,17 @@ layui.use(['layer', 'form', 'jquery'], function () { - var lang = localStorage.getItem("lang"); + window.soonResolveLocale(function (code) { - if (lang) { - - s_lan = lang; + s_lan = code; langua_ge(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,19 +177,20 @@ layui.use(['layer', 'form', 'jquery'], function () { try { return !!localStorage.getItem('soon_access'); } catch (e) { return false; } } - function makeFileKey(id, version) { - return typeof window.soonMakeFileKey === 'function' - ? window.soonMakeFileKey(id, version) - : ('soondesign_file:' + id + ':v' + version); - } - var fileListState = { page: 1, size: 12, total: 0, items: [] }; var templateListState = { page: 1, size: 8, total: 0, items: [] }; 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) return path.substring(sessionPrefix.length); + 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; @@ -220,74 +199,6 @@ layui.use(['layer', 'form', 'jquery'], function () { return get_filename(path) || 'design.soon'; } - function parseItemTime(value) { - if (!value) return 0; - var d = new Date(String(value).replace(' ', 'T')); - return isNaN(d.getTime()) ? 0 : d.getTime(); - } - - function mergeRecentItems(localHistory, cloudItems) { - var items = []; - var cloudById = {}; - var seenCloudIds = {}; - (cloudItems || []).forEach(function (c) { - if (c && c.id != null) cloudById[c.id] = c; - }); - - (localHistory || []).forEach(function (h) { - if (!h || !h.path) return; - var cloudMatch = String(h.path).match(/^soondesign_file:(\d+)/); - if (cloudMatch) { - var id = parseInt(cloudMatch[1], 10); - var cloud = cloudById[id]; - if (cloud) { - seenCloudIds[id] = true; - items.push({ - kind: 'cloud', - filePath: makeFileKey(cloud.id, cloud.version), - fileId: cloud.id, - name: cloud.name || ('文件 #' + cloud.id), - type: h.type, - sortTime: parseItemTime(cloud.updated_at || cloud.created_at || h.time), - }); - return; - } - } - items.push({ - kind: 'local', - filePath: h.path, - name: displayNameFromPath(h.path), - type: h.type, - sortTime: parseItemTime(h.time), - }); - }); - - (cloudItems || []).forEach(function (c) { - if (!c || c.id == null || seenCloudIds[c.id]) return; - items.push({ - kind: 'cloud', - filePath: makeFileKey(c.id, c.version), - fileId: c.id, - name: c.name || ('文件 #' + c.id), - type: null, - sortTime: parseItemTime(c.updated_at || c.created_at), - }); - }); - - items.sort(function (a, b) { return (b.sortTime || 0) - (a.sortTime || 0); }); - return items; - } - - async function fetchLocalHistory() { - if (!window.sysAPI || typeof window.sysAPI.readHistory !== 'function') return []; - try { - var j = await window.sysAPI.readHistory(); - return (j && Array.isArray(j.history)) ? j.history : []; - } catch (e) { - return []; - } - } - async function fetchCloudItems() { if (!hasCloudAuth() || !window.platformBridge || !window.platformBridge.listCloudFiles) return []; try { @@ -299,15 +210,60 @@ layui.use(['layer', 'form', 'jquery'], function () { } function removeLocalHistoryPath(path) { - if (!path || !window.sysAPI || typeof window.sysAPI.readHistory !== 'function') { - return Promise.resolve(); + if (!path) return Promise.resolve(); + if (typeof window.soonRecentRemove === 'function') window.soonRecentRemove(path); + if (typeof window.soonLocalRemove === 'function') { + return window.soonLocalRemove(path); } - return window.sysAPI.readHistory().then(function (j) { - var list = (j && Array.isArray(j.history)) ? j.history : []; - var next = list.filter(function (item) { return item.path !== path; }); - if (next.length === list.length) return null; - return window.sysAPI.writeHistory({ history: next }); - }).catch(function () { return null; }); + 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 ''; + } + + function recentDeleteBtnHtml() { + return ''; } function renderFilePager() { @@ -344,22 +300,48 @@ layui.use(['layer', 'form', 'jquery'], function () { } } - async function loadHistory() { + var _recentLoadedAt = 0; + + async function loadHistory(force) { try { + if (!force && _recentLoadedAt && (Date.now() - _recentLoadedAt) < 60000) { + return; + } + + if (typeof window.soonRecentMigrateFromHistory === 'function') { + window.soonRecentMigrateFromHistory(); + } + var recentCountEl = document.getElementById('recentCount'); - var localHistory = await fetchLocalHistory(); - var cloudItems = await fetchCloudItems(); - var merged = mergeRecentItems(localHistory, cloudItems); - fileListState.items = merged; - fileListState.total = merged.length; + var items = typeof window.soonRecentList === 'function' ? window.soonRecentList() : []; + var mapped = items.map(function (it) { + return { + kind: it.kind || (typeof window.soonRecentKindFromKey === 'function' ? window.soonRecentKindFromKey(it.key) : 'local'), + filePath: it.key, + fileId: it.fileId, + name: it.name, + type: it.type, + thumbRef: it.thumbRef || it.key, + updated_at: it.updated_at || '' + }; + }); + + if (hasCloudAuth() && typeof window.soonRecentEnrichFromCloud === 'function') { + var cloudItems = await fetchCloudItems(); + 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 (!merged.length) { + if (!mapped.length) { $(".card-list").html(soonEmptyBlock('暂无文件', '点击「打开文件」导入,或新建模板开始设计')); renderFilePager(); @@ -368,89 +350,30 @@ layui.use(['layer', 'form', 'jquery'], function () { } var start = (fileListState.page - 1) * fileListState.size; - var slice = merged.slice(start, start + fileListState.size); + var slice = mapped.slice(start, start + fileListState.size); let h = ""; for (let item of slice) { let filePath = item.filePath; - let src = ""; - let fileExists = true; - let imgStyle = ""; let realType = item.type || 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((realType == 2 || realType == "2") ? 'bg_2.png' : 'bg_1.png'); - - imgStyle = "opacity: 0.6; filter: grayscale(100%);"; - - } - + let src = await resolveRecentThumb(item); + let fallbackSrc = recentFallbackThumb(realType); let displayName = item.name || displayNameFromPath(filePath); - let cardTitle = displayName; - var cardClass = 'card'; - if (!fileExists) { + var actions = recentDownloadBtnHtml() + recentDeleteBtnHtml(); - cardTitle = language_str("clickToDelete"); - - cardClass += ' soon-card--lost'; - - } - - var lostBadge = !fileExists - - ? ' ' + language_str("lost") + '' - - : ''; - - var actions = ''; - if (item.kind === 'cloud' && item.fileId) { - actions = '' - + ''; - } else { - actions = ''; - } - - h += `
+ h += `
${actions}
- + -
${escapeAttr(displayName)}${lostBadge}
+
${escapeAttr(displayName)}
@@ -468,17 +391,13 @@ layui.use(['layer', 'form', 'jquery'], function () { $(".card-list").html(soonEmptyBlock('加载失败', '请刷新页面重试', '')); 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 = ''; } } - window.soonReloadRecentFiles = loadHistory; - - - function renderTemplatesError(message) { var grid = document.getElementById('templatesGrid'); @@ -517,6 +436,7 @@ layui.use(['layer', 'form', 'jquery'], function () { id: id, name: m.name || m.title || '模板', type: type, + updated_at: m.updated_at || '', thumbSrc: id ? (base + '/templates/' + id + '/thumb' + v) : templateFallbackThumb(type) }; } @@ -528,6 +448,12 @@ layui.use(['layer', 'form', 'jquery'], function () { 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); @@ -658,16 +584,23 @@ layui.use(['layer', 'form', 'jquery'], function () { - loadHistory(); + loadHistory(true); loadTemplates(); + window.soonReloadRecentFiles = function () { loadHistory(true); }; window.soonReloadTemplates = function () { loadTemplates(true); }; document.addEventListener('visibilitychange', function () { - if (document.visibilityState === 'visible') loadTemplates(false); + if (document.visibilityState === 'visible') { + loadTemplates(false); + loadHistory(false); + } }); window.addEventListener('pageshow', function (e) { - if (e.persisted) loadTemplates(false); + if (e.persisted) { + loadTemplates(false); + loadHistory(false); + } }); var templatePrevNav = document.getElementById('templatePrev'); @@ -713,6 +646,14 @@ layui.use(['layer', 'form', 'jquery'], function () { await window.platformBridge.deleteCloudFile(fileId); } 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 }); if (typeof onDone === 'function') onDone(); layer.close(index); @@ -726,14 +667,27 @@ layui.use(['layer', 'form', 'jquery'], function () { 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'; - if (id && window.platformBridge && window.platformBridge.downloadCloudFile) { + var type = parseInt(card.attr('data-type'), 10) || 1; - window.platformBridge.downloadCloudFile(id, name).catch(function () {}); + var item = { + kind: kind, + filePath: filePath, + key: filePath, + fileId: fileId || undefined, + name: name, + type: type + }; + if (typeof window.soonDownloadRecentItem === 'function') { + window.soonDownloadRecentItem(item); } }); @@ -771,7 +725,7 @@ layui.use(['layer', 'form', 'jquery'], function () { } catch (err) { /* ignore */ } } layer.msg(language_str("deleted"), { icon: 1, time: 1000 }); - loadHistory(); + loadHistory(true); layer.close(index); }); } @@ -781,52 +735,21 @@ layui.use(['layer', 'form', 'jquery'], function () { } - confirmDeleteCloudFile(fileId, language_str('deleteFileConfirm'), loadHistory); + 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 type = Number($(this).attr("data-type")) || 1; - const soonData = await window.sysAPI.readJsonFile(filePath); - - if (soonData) { - - let type = soonData.soonType ? soonData.soonType : (soonData.backBlackPic ? 2 : 1); - - if (typeof window.soonBindFileMeta === 'function') { - window.soonBindFileMeta(filePath, { name: $(this).attr('data-name') || '' }); - } - - openDesign(type, filePath); - - } else { - - var kind = $(this).attr('data-kind') || 'cloud'; - - var fileId = parseInt($(this).attr('data-id'), 10); - - if (kind === 'local' || !fileId) { - - removeLocalHistoryPath(filePath).then(function () { - if (filePath.indexOf('soondesign_session:') === 0) { - try { - sessionStorage.removeItem(filePath); - localStorage.removeItem(filePath); - } catch (err) { /* ignore */ } - } - loadHistory(); - }); - - return; - - } - - confirmDeleteCloudFile(fileId, language_str("delContent"), loadHistory); - + if (typeof window.soonBindFileMeta === 'function') { + window.soonBindFileMeta(filePath, { name: $(this).attr('data-name') || '' }); } + openDesign(type, filePath); + }); diff --git a/frontend-web/js/platform/web.js b/frontend-web/js/platform/web.js index 9a645dd..e01563b 100644 --- a/frontend-web/js/platform/web.js +++ b/frontend-web/js/platform/web.js @@ -186,7 +186,9 @@ } function downloadCloudFile(id, fileName) { - if (!requireCloudAuth('下载')) return Promise.reject(new Error('unauthorized')); + if (!getAccessToken()) { + return Promise.reject(new Error('unauthorized')); + } return authedFetch('files/' + id + '/download', { headers: { Accept: 'application/octet-stream' } }) .then(function (response) { if (!response.ok) { @@ -202,6 +204,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 = { readHistory: function () { try { @@ -234,61 +282,121 @@ }); } var key = typeof pathOrHandle === 'string' ? pathOrHandle : ''; - if (key.indexOf('soondesign_session:') === 0) { - try { - var j = sessionStorage.getItem(key); - if (!j && typeof localStorage !== 'undefined') j = localStorage.getItem(key); - return Promise.resolve(j ? JSON.parse(j) : null); - } 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; - if (typeof raw === 'string') { - try { return JSON.parse(raw); } catch (e) { return null; } - } - return raw && typeof raw === 'object' ? raw : null; - }) - .catch(function () { return null; }); + var cacheKey = key; + + function cacheHitThen(hit) { + if (!hit || !hit.json) return null; + if (typeof window.soonLocalIsTemplateStale === 'function' && + window.soonLocalIsTemplateStale(cacheKey, hit.meta)) { + if (typeof window.soonLocalRemove === 'function') { + return window.soonLocalRemove(cacheKey).then(function () { return null; }); + } + return Promise.resolve(null); } + if (cacheKey.indexOf('soondesign_file:') === 0 && hit.meta && hit.meta.name && + typeof window.soonBindFileMeta === 'function') { + window.soonBindFileMeta(cacheKey, { name: hit.meta.name }); + } + return Promise.resolve(hit.json); } - 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, + + 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: 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; }); + 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) { return new Promise(function (resolve) { @@ -327,16 +435,12 @@ }, 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 || raw.indexOf('soondesign_template:') === 0)) { - defaultName = window.soonDisplayFileName(raw); - } - var title = (options && options.title) ? options.title : '保存到云端'; + var isSoon = isSoonSaveDialog(options); + var defaultName = isSoon ? defaultSoonSaveDialogName(raw) : genericSaveDialogName(raw); + var title = (options && options.title) ? options.title : (isSoon ? '保存到云端' : '保存文件'); return new Promise(function (resolve) { if (typeof layer === 'undefined' || !layer.open) { - var fpFallback = defaultName; - resolve({ canceled: false, filePath: fpFallback, useCloud: true }); + resolve({ canceled: false, filePath: defaultName, useCloud: !!isSoon }); return; } var esc = function (s) { @@ -359,10 +463,9 @@ 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); + name = resolveSaveDialogName(name, options, isSoon); layer.close(index); - resolve({ canceled: false, filePath: name, useCloud: true }); + resolve({ canceled: false, filePath: name, useCloud: !!isSoon }); }, btn2: function (index) { layer.close(index); @@ -397,21 +500,52 @@ var cloudRef = parseCloudRef(name); var fileName = normalizeSoonName(name); + var parsedJson; + try { parsedJson = JSON.parse(str); } catch (e) { parsedJson = null; } + + function cacheWriteForKey(targetKey, meta) { + if (!parsedJson || typeof window.soonLocalCacheAndThumb !== 'function') return Promise.resolve(); + return window.soonLocalCacheAndThumb(targetKey, parsedJson, meta || {}); + } + + function afterCloudSave(res, prevKey) { + if (!res || !res.fileKey || !parsedJson) return res; + var meta = { source: 'cloud', name: res.name || fileName, type: parsedJson.soonType || 1 }; + if (prevKey && prevKey !== res.fileKey && typeof window.soonLocalRenameKey === 'function') { + return window.soonLocalRenameKey(prevKey, res.fileKey, meta).then(function () { return res; }); + } + return cacheWriteForKey(res.fileKey, meta).then(function () { return res; }); + } + if (!getAccessToken()) { if (cloudRef && cloudRef.id) { requireCloudAuth('保存'); return Promise.reject(new Error('unauthorized')); } - var sessionKey = name.indexOf('soondesign_session:') === 0 - ? name - : 'soondesign_session:' + fileName.replace(/\.soon$/i, '') + '-' + Date.now(); - try { - sessionStorage.setItem(sessionKey, str); - try { localStorage.setItem(sessionKey, str); } catch (e2) { /* ignore quota */ } - return Promise.resolve({ fileKey: sessionKey, name: fileName, version: 0 }); - } catch (e) { - return Promise.reject(new Error('save_failed')); + var sessionKey = typeof window.soonMakeSessionKey === 'function' + ? window.soonMakeSessionKey(name) + : (name.indexOf('soondesign_session:') === 0 + ? name + : 'soondesign_session:' + fileName.replace(/\.soon$/i, '')); + var prevSessionKey = name.indexOf('soondesign_session:') === 0 ? name : ''; + function finishSessionSave() { + try { sessionStorage.setItem(sessionKey, 'idb'); } catch (e) { /* ignore */ } + return { fileKey: sessionKey, name: fileName, version: 0 }; } + if (prevSessionKey && prevSessionKey !== sessionKey && + typeof window.soonLocalRenameKey === 'function') { + return cacheWriteForKey(sessionKey, { source: 'session', name: fileName }).then(function () { + return window.soonLocalRenameKey(prevSessionKey, sessionKey, { source: 'session', name: fileName }) + .then(finishSessionSave); + }).catch(function () { + return Promise.reject(new Error('save_failed')); + }); + } + return cacheWriteForKey(sessionKey, { source: 'session', name: fileName }).then(function () { + return finishSessionSave(); + }).catch(function () { + return Promise.reject(new Error('save_failed')); + }); } if (!requireCloudAuth('保存')) { @@ -423,10 +557,20 @@ if (ver == null && window._soonFileMeta && window._soonFileMeta.id === cloudRef.id) { ver = window._soonFileMeta.version; } - return updateCloudFile(cloudRef.id, fileName, str, ver); + if (typeof window.soonResolveCloudFileName === 'function') { + var cloudSaveName = window.soonResolveCloudFileName(name); + if (cloudSaveName) fileName = cloudSaveName; + } + var prevCloudKey = name; + return updateCloudFile(cloudRef.id, fileName, str, ver).then(function (res) { + return afterCloudSave(res, prevCloudKey); + }); } - return createCloudFile(fileName, str); + var prevKey = name.indexOf('soondesign_session:') === 0 ? name : ''; + return createCloudFile(fileName, str).then(function (res) { + return afterCloudSave(res, prevKey); + }); }, readFile: function (pathOrHandle) { if (pathOrHandle && pathOrHandle.getFile) { diff --git a/frontend-web/pages/design1.web.html b/frontend-web/pages/design1.web.html index d54a2bc..f7e6e02 100644 --- a/frontend-web/pages/design1.web.html +++ b/frontend-web/pages/design1.web.html @@ -879,6 +879,8 @@ + + diff --git a/frontend-web/pages/design2.web.html b/frontend-web/pages/design2.web.html index 8f47fb8..d5072e0 100644 --- a/frontend-web/pages/design2.web.html +++ b/frontend-web/pages/design2.web.html @@ -559,15 +559,17 @@
-
- -
-
- +
+
+ +
+
+ +
-
+
@@ -669,6 +671,8 @@ + + diff --git a/frontend-web/pages/index.web.html b/frontend-web/pages/index.web.html index 2d6a6ad..9943657 100644 --- a/frontend-web/pages/index.web.html +++ b/frontend-web/pages/index.web.html @@ -140,6 +140,8 @@ + +