From 72bc6502eb1ac8845261a430d022fb3d5027b006 Mon Sep 17 00:00:00 2001 From: LQ Date: Wed, 19 Aug 2026 08:16:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=8B=A5=E5=B9=B2=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/BaseApp/BaseWxService.php | 37 + .../Controllers/Api/MaterialController.php | 2 + .../Controllers/Api/PackageController.php | 52 ++ app/Http/Controllers/Api/WxAppController.php | 2 +- .../Api/WxCardSchemeController.php | 61 ++ .../Controllers/Api/WxTemplateController.php | 9 +- app/Http/Controllers/Wx/PackageController.php | 51 ++ app/Http/Middleware/ApiOpLogMiddleware.php | 3 +- .../Middleware/WxHttpsRewriteMiddleware.php | 63 ++ app/Models/FileModel.php | 25 +- app/Models/business/PackageItemModel.php | 33 + app/Models/business/PackageModel.php | 24 + app/Models/business/WxCardSchemeModel.php | 21 + app/Service/LoginService.php | 4 +- app/Service/MaterialService.php | 59 +- app/Service/WxAppConfigService.php | 2 +- app/Service/business/ListService.php | 31 +- app/Service/business/OrderCoreService.php | 25 + app/Service/business/PackageQuoteService.php | 98 +++ app/Service/business/PackageService.php | 317 +++++++++ app/Service/business/PriceService.php | 13 + app/Service/business/WxCardSchemeService.php | 233 +++++++ .../business/WxTemplatePresetService.php | 4 +- .../business/WxTemplateSchemaService.php | 632 +++++++++++++++++- app/Service/business/WxTemplateService.php | 4 +- app/Service/common/JWTService.php | 71 +- app/Service/common/MediaUrlService.php | 9 +- .../common/oss/OssRuntimeConfigService.php | 21 +- .../common/oss/OssStorageInterface.php | 4 +- .../common/upload/AliyunStorageService.php | 2 +- .../common/upload/LocalhostStorageService.php | 4 +- .../common/upload/ObjectKeyNormalizeTrait.php | 35 +- .../common/upload/ObjectListXmlTrait.php | 2 +- .../common/upload/QcloudStorageService.php | 2 +- .../common/upload/QiniuStorageService.php | 82 ++- .../upload/S3CompatibleStorageService.php | 2 +- app/Service/wx/WxAppService.php | 13 + app/Service/wx/WxAuthService.php | 2 +- app/Service/wx/WxListService.php | 43 ++ app/Service/wx/WxPackageService.php | 274 ++++++++ app/Service/wx/WxProductService.php | 31 - app/Service/wx/WxThemeService.php | 89 ++- bootstrap/app.php | 2 + config/lgp_menu.php | 1 + config/wx_card_schemes.php | 222 ++++++ config/wx_page_presets.php | 419 ++++++++++++ config/wx_templates.php | 540 +++++++++------ .../sql/2026-08-17/01_cull_wx_templates.sql | 49 ++ .../sql/2026-08-17/02_cc_wx_card_scheme.sql | 29 + .../01_qiniu_clear_default_prefix.sql | 13 + database/sql/2026-08-18/02_cc_package.sql | 58 ++ .../03_cc_list_package_snapshot.sql | 66 ++ .../04_nl_wx_app_package_enabled.sql | 14 + public/nl_admin.sql | 3 +- routes/api.php | 6 +- themes/_template.example.json | 57 ++ 56 files changed, 3627 insertions(+), 343 deletions(-) create mode 100644 app/Http/Controllers/Api/PackageController.php create mode 100644 app/Http/Controllers/Api/WxCardSchemeController.php create mode 100644 app/Http/Controllers/Wx/PackageController.php create mode 100644 app/Http/Middleware/WxHttpsRewriteMiddleware.php create mode 100644 app/Models/business/PackageItemModel.php create mode 100644 app/Models/business/PackageModel.php create mode 100644 app/Models/business/WxCardSchemeModel.php create mode 100644 app/Service/business/PackageQuoteService.php create mode 100644 app/Service/business/PackageService.php create mode 100644 app/Service/business/WxCardSchemeService.php create mode 100644 app/Service/wx/WxPackageService.php create mode 100644 config/wx_card_schemes.php create mode 100644 config/wx_page_presets.php create mode 100644 database/sql/2026-08-17/01_cull_wx_templates.sql create mode 100644 database/sql/2026-08-17/02_cc_wx_card_scheme.sql create mode 100644 database/sql/2026-08-18/01_qiniu_clear_default_prefix.sql create mode 100644 database/sql/2026-08-18/02_cc_package.sql create mode 100644 database/sql/2026-08-18/03_cc_list_package_snapshot.sql create mode 100644 database/sql/2026-08-18/04_nl_wx_app_package_enabled.sql create mode 100644 themes/_template.example.json diff --git a/app/BaseApp/BaseWxService.php b/app/BaseApp/BaseWxService.php index 25384606..058b0548 100644 --- a/app/BaseApp/BaseWxService.php +++ b/app/BaseApp/BaseWxService.php @@ -2,6 +2,7 @@ namespace App\BaseApp; +use App\Models\business\WxUserModel; use App\Service\common\UtilsService; use App\Service\wx\WxTokenService; @@ -45,4 +46,40 @@ class BaseWxService extends BaseNotAuthService { return $this->userInfo['price_number'] ?? 1; } + + /** + * 打开分享链接时继承代理商倍率 + * + * 商品详情、套餐详情共用:只有「还没有上级、自己也不是代理商」的已登录用户会被绑定; + * 已绑同一上级时同步最新倍率。未登录不写库,只影响本次会话价。 + */ + protected function inheritAgentPrice(int $shareUserId): void + { + if ($shareUserId <= 0) { + return; + } + $isAgent = (int) ($this->userInfo['is_p'] ?? 0) === 1; + $pid = (int) ($this->userInfo['pid'] ?? 0); + if ($isAgent) { + return; + } + if ($pid !== 0 && $pid !== $shareUserId) { + return; + } + $agent = WxUserModel::where('id', $shareUserId)->where('deleted_at', 0)->first(); + if (empty($agent) || (int) $agent['is_p'] !== 1) { + return; + } + $this->userInfo['show_price'] = 1; + $this->userInfo['price_number'] = $agent['price_number']; + if ($this->userId <= 0) { + return; + } + WxUserModel::where('id', $this->userId)->update([ + 'pid' => $shareUserId, + 'show_price' => 1, + 'price_number' => $agent['price_number'], + 'updated_at' => time(), + ]); + } } diff --git a/app/Http/Controllers/Api/MaterialController.php b/app/Http/Controllers/Api/MaterialController.php index 7e7c8707..3ef0c8c4 100644 --- a/app/Http/Controllers/Api/MaterialController.php +++ b/app/Http/Controllers/Api/MaterialController.php @@ -46,6 +46,8 @@ class MaterialController extends BaseController */ public function syncFromOss(): JsonResponse { + // 七牛要先查区域再列举,再加一批 insert,默认 30s 很容易被网关/PHP 掐死 + cc_set_time_limit(); $this->insertField = ['oss_config_id']; $this->notRequest = ['prefix', 'marker', 'limit']; $params = $this->checkRequiredFields(request()->post()); diff --git a/app/Http/Controllers/Api/PackageController.php b/app/Http/Controllers/Api/PackageController.php new file mode 100644 index 00000000..58210919 --- /dev/null +++ b/app/Http/Controllers/Api/PackageController.php @@ -0,0 +1,52 @@ +service = PackageService::getInstance(); + $this->insertField = ['name']; + $this->updateField = ['id', 'name']; + $this->notRequest = ['cover', 'subtitle', 'remark', 'is_hot', 'sort', 'status', 'package_amount']; + } + + /** + * 上架 / 下架 + * @Method POST + */ + public function status(): JsonResponse + { + return jok( + $this->service->status((int) request()->post('id'), request()->post('status')), + '操作成功' + ); + } + + /** + * 保存搭配明细并重算原价 / 补差价 + * @Method POST + */ + public function saveItems(): JsonResponse + { + return jok($this->service->saveItems(request()->post()), '搭配已保存'); + } + + /** + * 搭配搜索:带回封面和规格,无有效报价的规格 selectable=false + * @Method GET + */ + public function searchCatalog(): JsonResponse + { + return jok($this->service->searchCatalog()); + } +} diff --git a/app/Http/Controllers/Api/WxAppController.php b/app/Http/Controllers/Api/WxAppController.php index bcbf0738..4c295368 100644 --- a/app/Http/Controllers/Api/WxAppController.php +++ b/app/Http/Controllers/Api/WxAppController.php @@ -19,7 +19,7 @@ class WxAppController extends BaseController $this->updateField = ['id']; $this->notRequest = [ 'app_secret', 'mch_id', 'mch_key', 'mch_serial_no', 'mch_private_key', - 'platform_public_key', 'notify_url', 'template_code', 'remark', 'name', 'app_id', + 'platform_public_key', 'notify_url', 'template_code', 'package_enabled', 'remark', 'name', 'app_id', ]; } diff --git a/app/Http/Controllers/Api/WxCardSchemeController.php b/app/Http/Controllers/Api/WxCardSchemeController.php new file mode 100644 index 00000000..64935115 --- /dev/null +++ b/app/Http/Controllers/Api/WxCardSchemeController.php @@ -0,0 +1,61 @@ +service = WxCardSchemeService::getInstance(); + $this->insertField = ['name', 'code']; + $this->updateField = ['id']; + $this->notRequest = [ + 'preview', 'layers', 'is_preset', 'app_code', 'sort', 'name', 'code', 'status', + ]; + } + + /** + * 另存为自定义方案 + * @Method POST + */ + public function saveAs(): JsonResponse + { + return jok($this->service->saveAs(request()->post()), '已另存为方案'); + } + + /** + * 灌入 15 套内置预设 + * @Method POST + */ + public function initPresets(): JsonResponse + { + return jok( + $this->service->initPresets((bool) request()->post('overwrite', false)), + '卡片预设初始化完成' + ); + } + + /** + * 图层白名单,画布编辑器渲染控件用 + * @Method GET + */ + public function schema(): JsonResponse + { + return jok([ + 'types' => WxTemplateSchemaService::LAYER_TYPES, + 'binds' => WxTemplateSchemaService::LAYER_BINDS, + 'fonts' => WxTemplateSchemaService::LAYER_FONTS, + 'faces' => WxTemplateSchemaService::LAYER_FACES, + 'assets' => WxTemplateSchemaService::DECO_ASSETS, + ]); + } +} diff --git a/app/Http/Controllers/Api/WxTemplateController.php b/app/Http/Controllers/Api/WxTemplateController.php index 0fcf1276..79a0ac14 100644 --- a/app/Http/Controllers/Api/WxTemplateController.php +++ b/app/Http/Controllers/Api/WxTemplateController.php @@ -66,7 +66,7 @@ class WxTemplateController extends BaseController } /** - * 用内置 20 套预设初始化模板库 + * 用内置 4 套轻奢预设初始化模板库,并顺带灌 15 套卡片方案 * @Method POST */ public function initPresets(): JsonResponse @@ -78,14 +78,11 @@ class WxTemplateController extends BaseController } /** - * 令牌与布局的可选项,前端渲染编辑表单用 + * 令牌与布局的可选项,装修工作室渲染表单用 * @Method GET */ public function schema(): JsonResponse { - return jok([ - 'tokens' => WxTemplateSchemaService::TOKEN_SCHEMA, - 'layout' => WxTemplateSchemaService::LAYOUT_SCHEMA, - ]); + return jok(WxTemplateSchemaService::getInstance()->describe()); } } diff --git a/app/Http/Controllers/Wx/PackageController.php b/app/Http/Controllers/Wx/PackageController.php new file mode 100644 index 00000000..1a2636ee --- /dev/null +++ b/app/Http/Controllers/Wx/PackageController.php @@ -0,0 +1,51 @@ +service = WxPackageService::getInstance(); + } + + /** + * 首页热门套餐 + * @Method GET + */ + public function hot(): JsonResponse + { + return jok($this->service->hot()); + } + + /** + * 套餐详情,p_user_id 为代理商分享来源(与商品详情同一套) + * @Method GET + */ + public function detail(): JsonResponse + { + return jok($this->service->detail( + (int) request()->get('id', 0), + (int) request()->get('p_user_id', 0) + )); + } + + /** + * 一件加入清单 + * @Method POST + */ + public function toList(): JsonResponse + { + return jok($this->service->toList(request()->post()), '已加入清单'); + } +} diff --git a/app/Http/Middleware/ApiOpLogMiddleware.php b/app/Http/Middleware/ApiOpLogMiddleware.php index d543f88d..ab629eb7 100644 --- a/app/Http/Middleware/ApiOpLogMiddleware.php +++ b/app/Http/Middleware/ApiOpLogMiddleware.php @@ -128,7 +128,8 @@ class ApiOpLogMiddleware $secret = hash('sha256', $secret !== '' ? $secret : 'nl_admin_jwt_fallback_secret'); } $decoded = JWT::decode($token, new Key($secret, 'HS256')); - return (int) ($decoded->data->id ?? 0); + $data = \App\Service\common\JWTService::getInstance()->extractUserData($decoded); + return (int) ($data['id'] ?? 0); } catch (\Throwable $e) { return 0; } diff --git a/app/Http/Middleware/WxHttpsRewriteMiddleware.php b/app/Http/Middleware/WxHttpsRewriteMiddleware.php new file mode 100644 index 00000000..c5521629 --- /dev/null +++ b/app/Http/Middleware/WxHttpsRewriteMiddleware.php @@ -0,0 +1,63 @@ +getData(true); + if (!is_array($payload)) { + return $response; + } + // 与 jok 约定一致:成功 code 为 0;字符串 "0" 一并兼容 + if (($payload['code'] ?? null) != 0 || !array_key_exists('result', $payload)) { + return $response; + } + $payload['result'] = $this->checkHttp($payload['result']); + $response->setData($payload); + return $response; + } + + /** + * 递归把字符串/数组/对象中的 http:// 换成 https:// + * 不用 PHPUnit 的 isObject,对象先转数组再走 + */ + private function checkHttp(mixed $data): mixed + { + if (is_object($data)) { + $data = json_decode(json_encode($data), true); + } + if (is_string($data)) { + return str_replace('http://', 'https://', $data); + } + if (is_array($data)) { + array_walk_recursive($data, function (&$value) { + if (is_string($value)) { + $value = str_replace('http://', 'https://', $value); + } + }); + return $data; + } + return $data; + } +} diff --git a/app/Models/FileModel.php b/app/Models/FileModel.php index cadb51ed..d8c5ec7c 100644 --- a/app/Models/FileModel.php +++ b/app/Models/FileModel.php @@ -54,6 +54,29 @@ class FileModel extends BaseModel 'source' => 'integer', ]; + /** + * 从对象键取出能落库的扩展名(小写、不带点、最长 20) + * + * 七牛历史上会把处理参数写进文件名: + * b_xxx.jpeg~tplv-a9rns2rl98-downsize_watermark_1_5 + * pathinfo 会把整段 ~ 后面都当成扩展名,varchar(20) 直接截断报错, + * 整批 insert 跟着失败。这里先剥查询串和 ~ 后缀,再取真正的后缀。 + * 微信保存的 .pic / .pic_hd 按 jpg 认,否则会被打成「其他」。 + */ + public static function extOfKey(string $key): string + { + $name = basename(str_replace('\\', '/', $key)); + $cut = strpbrk($name, '?~#'); + if ($cut !== false) { + $name = substr($name, 0, strlen($name) - strlen($cut)); + } + $ext = strtolower(trim((string) pathinfo($name, PATHINFO_EXTENSION), " \t\n\r\0\x0B.")); + if ($ext === 'pic' || $ext === 'pic_hd') { + return 'jpg'; + } + return $ext === '' ? '' : substr($ext, 0, 20); + } + /** * 扩展名归类到 type * @@ -62,7 +85,7 @@ class FileModel extends BaseModel public static function typeOfExt(string $ext): int { return match (strtolower(trim($ext, " \t\n\r\0\x0B."))) { - 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico', 'avif', 'heic' => self::TYPE_IMAGE, + 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico', 'avif', 'heic', 'pic', 'pic_hd' => self::TYPE_IMAGE, 'mp4', 'mov', 'avi', 'mkv', 'flv', 'wmv', 'webm', 'm3u8', 'ts' => self::TYPE_VIDEO, 'mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'amr' => self::TYPE_AUDIO, 'xls', 'xlsx', 'csv' => self::TYPE_EXCEL, diff --git a/app/Models/business/PackageItemModel.php b/app/Models/business/PackageItemModel.php new file mode 100644 index 00000000..f05ee7ea --- /dev/null +++ b/app/Models/business/PackageItemModel.php @@ -0,0 +1,33 @@ +belongsTo(PackageModel::class, 'package_id', 'id'); + } + + public function catalogue(): BelongsTo + { + return $this->belongsTo(CatalogueModel::class, 'catalogue_id', 'id'); + } + + public function priceSheet(): BelongsTo + { + return $this->belongsTo(PriceSheetModel::class, 'price_sheet_id', 'id'); + } +} diff --git a/app/Models/business/PackageModel.php b/app/Models/business/PackageModel.php new file mode 100644 index 00000000..b6b37b36 --- /dev/null +++ b/app/Models/business/PackageModel.php @@ -0,0 +1,24 @@ +hasMany(PackageItemModel::class, 'package_id', 'id'); + } +} diff --git a/app/Models/business/WxCardSchemeModel.php b/app/Models/business/WxCardSchemeModel.php new file mode 100644 index 00000000..d192da8b --- /dev/null +++ b/app/Models/business/WxCardSchemeModel.php @@ -0,0 +1,21 @@ + 'array', + ]; +} diff --git a/app/Service/LoginService.php b/app/Service/LoginService.php index ff492786..14881512 100644 --- a/app/Service/LoginService.php +++ b/app/Service/LoginService.php @@ -124,7 +124,7 @@ class LoginService extends BaseNotAuthService public function logout(): array { $decoded = JWTService::getInstance()->parseExpiringToken($this->refreshTtl()); - $userId = (int) ($decoded->data->id ?? 0); + $userId = (int) (JWTService::getInstance()->extractUserData($decoded)['id'] ?? 0); if ($userId > 0) { JWTService::getInstance()->revoke($userId); } @@ -141,7 +141,7 @@ class LoginService extends BaseNotAuthService public function refresh(): array { $decoded = JWTService::getInstance()->parseExpiringToken($this->refreshTtl()); - $data = isset($decoded->data) ? (array) $decoded->data : []; + $data = JWTService::getInstance()->extractUserData($decoded); $userId = (int) ($data['id'] ?? 0); if ($userId <= 0) { UtilsService::getInstance()->notAuth('登录状态已失效,请重新登录'); diff --git a/app/Service/MaterialService.php b/app/Service/MaterialService.php index c1e39c27..78ac5e47 100644 --- a/app/Service/MaterialService.php +++ b/app/Service/MaterialService.php @@ -28,11 +28,11 @@ use Throwable; */ class MaterialService extends BaseService { - /** 单次同步默认条数:一屏够看,又不至于把请求跑到超时 */ - private const SYNC_DEFAULT_LIMIT = 200; + /** 单次同步默认条数:七牛列举 + 入库要压在网关超时之前 */ + private const SYNC_DEFAULT_LIMIT = 80; - /** 单次同步上限,防止前端把 limit 传成十万 */ - private const SYNC_MAX_LIMIT = 1000; + /** 单次同步上限;再大前端默认 10s、1panel 60s 都容易掐断 */ + private const SYNC_MAX_LIMIT = 200; /** 分批取值 / 分批回写的批大小 */ private const CHUNK_SIZE = 1000; @@ -198,6 +198,7 @@ class MaterialService extends BaseService $result = [ 'inserted' => 0, 'updated' => 0, + 'listed' => count($items), 'next_marker' => (string) ($page['next_marker'] ?? ''), 'finished' => (bool) ($page['finished'] ?? true), ]; @@ -217,7 +218,7 @@ class MaterialService extends BaseService $seen[$key] = true; $url = (string) ($item['url'] ?? ''); $hash = (string) ($item['hash'] ?? ''); - $ext = strtolower((string) pathinfo($key, PATHINFO_EXTENSION)); + $ext = FileModel::extOfKey($key); $row = $byPath[$key] ?? $byUrl[$url] ?? ($hash !== '' ? ($byHash[$hash] ?? null) : null); if ($row !== null) { @@ -252,7 +253,10 @@ class MaterialService extends BaseService ]; } if (!empty($pending)) { - FileModel::insert($pending); + // 上百行一条 INSERT 容易把 SQL 撑到数兆,拆开更稳也更快提交 + foreach (array_chunk($pending, 50) as $slice) { + FileModel::insert($slice); + } $result['inserted'] = count($pending); } return $result; @@ -473,12 +477,33 @@ class MaterialService extends BaseService private function existingIndexOf(array $items): array { $paths = array_values(array_unique(array_column($items, 'key'))); - $urls = array_values(array_unique(array_filter(array_column($items, 'url')))); - $hashes = array_values(array_unique(array_filter(array_column($items, 'hash')))); - + $select = ['id', 'name', 'url', 'path', 'ext', 'type', 'hash', 'oss_config_id']; + $byPath = []; + $byUrl = []; + $byHash = []; + // 先只走 path 索引:OSS 拉取的对象都有真实 key,一条 whereIn 比 path/url/hash 三路 OR 快得多 + if (!empty($paths)) { + foreach (FileModel::where('deleted_at', 0)->whereIn('path', $paths)->get($select) as $row) { + $path = (string) $row->path; + if ($path !== '') { + $byPath[$path] = $row; + } + } + } + $missed = array_values(array_filter( + $items, + static fn ($item) => !isset($byPath[(string) ($item['key'] ?? '')]) + )); + if ($missed === []) { + return [$byPath, $byUrl, $byHash]; + } + $urls = array_values(array_unique(array_filter(array_column($missed, 'url')))); + $hashes = array_values(array_unique(array_filter(array_column($missed, 'hash')))); + if ($urls === [] && $hashes === []) { + return [$byPath, $byUrl, $byHash]; + } $rows = FileModel::where('deleted_at', 0) - ->where(function ($q) use ($paths, $urls, $hashes) { - $q->whereIn('path', $paths); + ->where(function ($q) use ($urls, $hashes) { if (!empty($urls)) { $q->orWhereIn('url', $urls); } @@ -488,22 +513,14 @@ class MaterialService extends BaseService }); } }) - ->get(['id', 'name', 'url', 'path', 'ext', 'type', 'hash', 'oss_config_id']); - - $byPath = []; - $byUrl = []; - $byHash = []; + ->get($select); foreach ($rows as $row) { - $path = (string) $row->path; $url = (string) $row->url; $hash = (string) $row->hash; - if ($path !== '') { - $byPath[$path] = $row; - } if ($url !== '' && !isset($byUrl[$url])) { $byUrl[$url] = $row; } - if ($path === '' && $hash !== '' && !isset($byHash[$hash])) { + if ((string) $row->path === '' && $hash !== '' && !isset($byHash[$hash])) { $byHash[$hash] = $row; } } diff --git a/app/Service/WxAppConfigService.php b/app/Service/WxAppConfigService.php index e37555a7..a00d08f7 100644 --- a/app/Service/WxAppConfigService.php +++ b/app/Service/WxAppConfigService.php @@ -25,7 +25,7 @@ class WxAppConfigService extends BaseService $this->model = WxAppModel::class; $this->selectField = [ 'id', 'code', 'name', 'app_id', 'mch_id', 'mch_serial_no', 'notify_url', - 'template_code', 'status', 'remark', 'created_at', 'updated_at', + 'template_code', 'package_enabled', 'status', 'remark', 'created_at', 'updated_at', ]; $this->queryField = ['code' => '=', 'name' => 'like', 'status' => '=']; $this->orderBy = ['name' => 'id', 'sort' => 'asc']; diff --git a/app/Service/business/ListService.php b/app/Service/business/ListService.php index 8f605d28..d9162ad4 100644 --- a/app/Service/business/ListService.php +++ b/app/Service/business/ListService.php @@ -6,6 +6,7 @@ use App\BaseApp\BaseService; use App\Models\business\ListItemModel; use App\Models\business\ListModel; use App\Models\business\OrderModel; +use App\Models\business\PriceSheetModel; use App\Models\business\WxUserModel; /** @@ -22,7 +23,8 @@ class ListService extends BaseService $this->model = ListModel::class; $this->selectField = [ 'id', 'list_no', 'name', 'user_id', 'enterprise_id', 'remark', - 'status', 'created_at', 'updated_at', + 'status', 'package_id', 'package_amount', 'original_amount', + 'created_at', 'updated_at', ]; $this->queryField = [ 'list_no' => 'like', @@ -113,6 +115,13 @@ class ListService extends BaseService unset($item); $info['total_amount'] = $total; $info['total_amount_text'] = $price->centsToYuan($total); + $payable = PackageQuoteService::getInstance()->listPayable( + (int) ($info['package_id'] ?? 0), + (int) ($info['package_amount'] ?? 0), + (int) ($info['original_amount'] ?? 0), + $info['items'] + ); + $info = array_merge($info, PackageQuoteService::getInstance()->withText($payable)); return $info; } @@ -171,8 +180,12 @@ class ListService extends BaseService if ($itemId <= 0) { $this->utils->errorThrow('参数错误'); } + $item = ListItemModel::with('list.user')->where('id', $itemId)->where('deleted_at', 0)->first(); + if (empty($item)) { + $this->utils->errorThrow('明细不存在'); + } $update = ['updated_at' => time()]; - foreach (['price_sheet_id', 'quantity', 'unit_price'] as $field) { + foreach (['price_sheet_id', 'quantity'] as $field) { if (array_key_exists($field, $params)) { $update[$field] = (int) $params[$field]; } @@ -182,6 +195,20 @@ class ListService extends BaseService $update[$field] = (string) $params[$field]; } } + // 改规格后按该客户倍率重算当前单价,套餐清单才能按差额补差价 + $sheetId = (int) ($update['price_sheet_id'] ?? $item['price_sheet_id']); + $material = (string) ($update['material_key'] ?? $item['material_key'] ?? 'routine'); + if ($sheetId > 0) { + $sheet = PriceSheetModel::where('id', $sheetId)->where('deleted_at', 0)->first(); + if (!empty($sheet)) { + $multiplier = $item['list']['user']['price_number'] ?? 1; + $update['unit_price'] = PriceService::getInstance()->resolveUnitPrice( + (string) ($sheet['routine'] ?? ''), + $material, + $multiplier + ); + } + } return ListItemModel::where('id', $itemId)->update($update); } diff --git a/app/Service/business/OrderCoreService.php b/app/Service/business/OrderCoreService.php index f2949a07..6cfe2092 100644 --- a/app/Service/business/OrderCoreService.php +++ b/app/Service/business/OrderCoreService.php @@ -4,6 +4,7 @@ namespace App\Service\business; use App\Models\business\ListItemModel; use App\Models\business\ListModel; +use App\Service\business\PackageQuoteService; use App\Models\business\OrderDeliveryModel; use App\Models\business\OrderItemModel; use App\Models\business\OrderModel; @@ -118,6 +119,16 @@ class OrderCoreService if (empty($rows)) { UtilsService::getInstance()->errorThrow('清单里没有可下单的商品'); } + // 套餐清单:总额用套餐价 + 变更差价,明细行仍按当前规格单价快照 + if ((int) ($list['package_id'] ?? 0) > 0) { + $payable = PackageQuoteService::getInstance()->listPayable( + (int) $list['package_id'], + (int) ($list['package_amount'] ?? 0), + (int) ($list['original_amount'] ?? 0), + $rows + ); + $total = (int) $payable['payable_amount']; + } $orderId = 0; DB::connection('business')->transaction(function () use (&$orderId, $list, $userId, $user, $params, $rows, $total) { @@ -163,6 +174,20 @@ class OrderCoreService UtilsService::getInstance()->errorThrow('订单不存在'); } $order = $order->toArray(); + $sourceList = ListModel::where('id', (int) ($order['list_id'] ?? 0))->first(); + if (!empty($sourceList) && (int) ($sourceList['package_id'] ?? 0) > 0) { + $quote = PackageQuoteService::getInstance(); + $order['package_id'] = (int) $sourceList['package_id']; + $order['package_amount'] = (int) $sourceList['package_amount']; + $order['original_amount'] = (int) $sourceList['original_amount']; + $order['diff_amount'] = (int) $order['total_amount'] - (int) $sourceList['package_amount']; + $order = array_merge($order, $quote->withText([ + 'package_amount' => $order['package_amount'], + 'original_amount' => $order['original_amount'], + 'diff_amount' => $order['diff_amount'], + 'payable_amount' => (int) $order['total_amount'], + ])); + } $order['voucher_list'] = []; foreach ($order['payments'] ?? [] as $payment) { foreach (array_filter(explode(',', (string) $payment['voucher'])) as $image) { diff --git a/app/Service/business/PackageQuoteService.php b/app/Service/business/PackageQuoteService.php new file mode 100644 index 00000000..4f4bf8a0 --- /dev/null +++ b/app/Service/business/PackageQuoteService.php @@ -0,0 +1,98 @@ +centsToYuan($cents); + return $price->yuanToCents($yuan, $multiplier); + } + + /** + * 套餐清单应付 + * + * 未绑套餐:应付 = 当前明细合计。 + * 已绑套餐:应付 = 套餐价快照 + 当前合计 − 原价快照(删除的套餐行会从原价里扣掉)。 + * + * @param array $items + * @return array{package_amount:int,original_amount:int,diff_amount:int,payable_amount:int,items_amount:int} + */ + public function listPayable(int $packageId, int $packageAmount, int $originalAmount, array $items): array + { + $itemsAmount = 0; + foreach ($items as $item) { + $qty = max(1, (int) ($item['quantity'] ?? 1)); + $itemsAmount += (int) ($item['unit_price'] ?? 0) * $qty; + } + if ($packageId <= 0) { + return [ + 'package_amount' => 0, + 'original_amount' => $itemsAmount, + 'diff_amount' => 0, + 'payable_amount' => $itemsAmount, + 'items_amount' => $itemsAmount, + ]; + } + $payable = max(0, $packageAmount + $itemsAmount - $originalAmount); + return [ + 'package_amount' => $packageAmount, + 'original_amount' => $originalAmount, + 'diff_amount' => $payable - $packageAmount, + 'payable_amount' => $payable, + 'items_amount' => $itemsAmount, + ]; + } + + /** + * 给金额数组补 *_text 元字段,方便前端直接渲染 + * + * @param array $amounts + * @return array + */ + public function withText(array $amounts, bool $showPrice = true): array + { + $price = PriceService::getInstance(); + foreach (['package_amount', 'original_amount', 'diff_amount', 'payable_amount', 'items_amount', 'discount_amount'] as $key) { + if (!array_key_exists($key, $amounts)) { + continue; + } + $cents = (int) $amounts[$key]; + $amounts[$key . '_text'] = $showPrice ? $price->centsToYuan($cents) : PriceService::MASK; + } + return $amounts; + } +} diff --git a/app/Service/business/PackageService.php b/app/Service/business/PackageService.php new file mode 100644 index 00000000..d15fd129 --- /dev/null +++ b/app/Service/business/PackageService.php @@ -0,0 +1,317 @@ +model = PackageModel::class; + $this->selectField = [ + 'id', 'name', 'cover', 'subtitle', 'remark', + 'original_amount', 'package_amount', 'is_hot', 'sort', 'status', + 'created_at', 'updated_at', + ]; + $this->queryField = [ + 'name' => 'like', + 'status' => '=', + 'is_hot' => '=', + ]; + $this->orderBy = ['name' => 'sort', 'sort' => 'asc']; + $this->media = MediaUrlService::getInstance(); + $this->quote = PackageQuoteService::getInstance(); + $this->price = PriceService::getInstance(); + } + + public function list(): array + { + $result = $this->getPageList(); + $ids = array_column($result['items'], 'id'); + $counts = PackageItemModel::whereIn('package_id', $ids) + ->where('deleted_at', 0) + ->selectRaw('package_id, count(*) as total') + ->groupBy('package_id') + ->get() + ->keyBy('package_id'); + foreach ($result['items'] as &$item) { + $item['cover'] = $this->media->toPublic($item['cover'] ?? ''); + $item['item_count'] = (int) ($counts[$item['id']]['total'] ?? 0); + $item = $this->withAmountText($item); + } + unset($item); + return $result; + } + + public function option(): mixed + { + $this->optionField = ['id', 'name']; + return $this->getOption(); + } + + public function detail($id): mixed + { + $info = PackageModel::with([ + 'items' => fn ($query) => $query->where('deleted_at', 0)->orderBy('sort')->orderBy('id'), + 'items.catalogue', + 'items.priceSheet', + ])->where('id', $id)->where('deleted_at', 0)->first(); + if (empty($info)) { + $this->utils->errorThrow('套餐不存在'); + } + $info = $info->toArray(); + $info['cover'] = $this->media->toPublic($info['cover'] ?? ''); + foreach ($info['items'] as &$item) { + $item['title'] = $item['catalogue']['title'] ?? ''; + $item['cover'] = $this->media->toPublic($item['catalogue']['cover'] ?? ''); + $item['specification'] = $item['price_sheet']['specification'] ?? ''; + $item['dimension'] = $item['price_sheet']['dimension'] ?? ''; + $item['unit_price_text'] = $this->price->centsToYuan((int) ($item['unit_price'] ?? 0)); + } + unset($item); + return $this->withAmountText($info); + } + + public function create($params): mixed + { + $row = $this->normalizeMain($params); + $row['created_at'] = time(); + $id = PackageModel::insertGetId($row); + if (!empty($params['items']) && is_array($params['items'])) { + $this->replaceItems((int) $id, $params['items'], $params); + } + return $this->detail($id); + } + + public function update($id, $params): mixed + { + $row = $this->normalizeMain($params, (int) $id); + $this->save($id, $row); + return $this->detail($id); + } + + public function delete($ids): mixed + { + $ids = (array) $ids; + $now = time(); + PackageModel::whereIn('id', $ids)->update(['deleted_at' => $now, 'updated_at' => $now]); + PackageItemModel::whereIn('package_id', $ids)->update(['deleted_at' => $now, 'updated_at' => $now]); + return true; + } + + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } + + /** + * 搭配搜索:商品封面 + 规格报价。 + * 单价用和保存时同一套 resolveUnitPrice,没有数字报价的规格标成不可选,避免搜到却存不进去。 + */ + public function searchCatalog(): array + { + $keyword = trim((string) request()->input('keyword', '')); + $limit = max(1, min(50, (int) request()->input('limit', 20))); + + $query = CatalogueModel::query() + ->where('deleted_at', 0) + ->with([ + 'priceSheet' => fn ($q) => $q->where('deleted_at', 0)->orderBy('id'), + ]); + if ($keyword !== '') { + $like = '%' . $keyword . '%'; + $query->where(function ($inner) use ($like) { + $inner->where('title', 'like', $like) + ->orWhere('identifier', 'like', $like) + ->orWhere('alias', 'like', $like); + }); + } + + $rows = $query->orderByDesc('id')->limit($limit)->get()->toArray(); + $items = []; + foreach ($rows as $row) { + $sheets = []; + foreach ($row['price_sheet'] ?? [] as $sheet) { + $unit = $this->price->resolveUnitPrice((string) ($sheet['routine'] ?? ''), 'routine', 1); + $sheets[] = [ + 'id' => (int) ($sheet['id'] ?? 0), + 'specification' => (string) ($sheet['specification'] ?? ''), + 'dimension' => (string) ($sheet['dimension'] ?? ''), + 'unit_price' => $unit, + 'unit_price_text' => $this->price->centsToYuan($unit), + 'selectable' => $unit > 0, + ]; + } + $items[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'title' => (string) ($row['title'] ?? ''), + 'identifier' => (string) ($row['identifier'] ?? ''), + 'cover' => $this->media->toPublic($row['cover'] ?? ''), + 'sheets' => $sheets, + ]; + } + return ['items' => $items]; + } + + /** + * 整表替换搭配明细,并按原价差额补套餐报价 + */ + public function saveItems(array $params): mixed + { + $id = (int) ($params['id'] ?? 0); + if ($id <= 0) { + $this->utils->errorThrow('请选择套餐'); + } + $pkg = PackageModel::where('id', $id)->where('deleted_at', 0)->first(); + if (empty($pkg)) { + $this->utils->errorThrow('套餐不存在'); + } + $this->replaceItems($id, $params['items'] ?? [], $params); + return $this->detail($id); + } + + /** + * 清洗主表字段。package_amount 按元进来,转成分;没传报价时新建默认等于原价。 + */ + private function normalizeMain(array $params, int $id = 0): array + { + $row = [ + 'updated_at' => time(), + ]; + if (array_key_exists('name', $params)) { + $name = trim((string) $params['name']); + if ($name === '') { + $this->utils->errorThrow('请填写套餐名称'); + } + $row['name'] = $name; + } elseif ($id === 0) { + $this->utils->errorThrow('请填写套餐名称'); + } + if (array_key_exists('cover', $params)) { + $row['cover'] = $this->media->firstOf($params['cover']); + } + foreach (['subtitle', 'remark'] as $field) { + if (array_key_exists($field, $params)) { + $row[$field] = (string) $params[$field]; + } + } + foreach (['is_hot', 'sort', 'status'] as $field) { + if (array_key_exists($field, $params)) { + $row[$field] = (int) $params[$field]; + } + } + if (array_key_exists('package_amount', $params)) { + $row['package_amount'] = max(0, $this->price->yuanToCents($params['package_amount'])); + } + if ($id === 0 && !isset($row['package_amount'])) { + $row['package_amount'] = 0; + $row['original_amount'] = 0; + } + return $row; + } + + /** + * 校验规格、写入明细、重算原价。前端传了 package_amount(元)就用它,否则按差额补。 + */ + private function replaceItems(int $packageId, array $items, array $params): void + { + $pkg = PackageModel::where('id', $packageId)->where('deleted_at', 0)->first(); + $oldOriginal = (int) ($pkg['original_amount'] ?? 0); + $oldQuote = (int) ($pkg['package_amount'] ?? 0); + + $rows = []; + $now = time(); + $sort = 0; + $newOriginal = 0; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $catalogueId = (int) ($item['catalogue_id'] ?? 0); + $sheetId = (int) ($item['price_sheet_id'] ?? 0); + $qty = max(1, (int) ($item['quantity'] ?? 1)); + if ($catalogueId <= 0 || $sheetId <= 0) { + $this->utils->errorThrow('每件商品都必须选择规格'); + } + $sheet = PriceSheetModel::where('id', $sheetId) + ->where('catalogue_id', $catalogueId) + ->where('deleted_at', 0) + ->first(); + if (empty($sheet)) { + $this->utils->errorThrow('规格不存在或不属于该商品'); + } + $unit = $this->price->resolveUnitPrice( + (string) ($sheet['routine'] ?? ''), + (string) ($item['material_key'] ?? 'routine'), + 1 + ); + if ($unit <= 0) { + $this->utils->errorThrow('规格缺少有效报价'); + } + $newOriginal += $unit * $qty; + $rows[] = [ + 'package_id' => $packageId, + 'catalogue_id' => $catalogueId, + 'price_sheet_id' => $sheetId, + 'material_key' => (string) ($item['material_key'] ?? 'routine'), + 'quantity' => $qty, + 'unit_price' => $unit, + 'sort' => (int) ($item['sort'] ?? $sort), + 'remark' => (string) ($item['remark'] ?? ''), + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => 0, + ]; + $sort++; + } + + $quote = array_key_exists('package_amount', $params) + ? max(0, $this->price->yuanToCents($params['package_amount'])) + : $this->quote->applyOriginalDiff($oldOriginal, $newOriginal, $oldQuote); + + DB::connection('business')->transaction(function () use ($packageId, $rows, $now, $newOriginal, $quote) { + PackageItemModel::where('package_id', $packageId)->where('deleted_at', 0)->update([ + 'deleted_at' => $now, + 'updated_at' => $now, + ]); + if (!empty($rows)) { + PackageItemModel::insert($rows); + } + PackageModel::where('id', $packageId)->update([ + 'original_amount' => $newOriginal, + 'package_amount' => $quote, + 'updated_at' => $now, + ]); + }); + } + + /** + * 补原价/套餐价/优惠的元文字段 + */ + private function withAmountText(array $row): array + { + $original = (int) ($row['original_amount'] ?? 0); + $quote = (int) ($row['package_amount'] ?? 0); + $row['discount_amount'] = max(0, $original - $quote); + return $this->quote->withText($row); + } +} diff --git a/app/Service/business/PriceService.php b/app/Service/business/PriceService.php index 081a7dbd..a439ff08 100644 --- a/app/Service/business/PriceService.php +++ b/app/Service/business/PriceService.php @@ -139,6 +139,19 @@ class PriceService return number_format($cents / 100, 2, '.', ''); } + /** + * 元转分。套餐报价后台按元录入,入库前走这里,避免前端自己乘 100 对不齐。 + * 可选倍率:小程序把基准套餐价乘用户 price_number 时复用。 + */ + public function yuanToCents(mixed $yuan, mixed $multiplier = 1): int + { + if (!is_numeric($yuan)) { + return 0; + } + $scaled = bcmul((string) $yuan, $this->normalizeMultiplier($multiplier), 2); + return (int) bcmul($scaled, '100', 0); + } + /** * 倍率兜底:库里可能是空串、0 或负数,直接拿去 bcmul 会把价格清零 */ diff --git a/app/Service/business/WxCardSchemeService.php b/app/Service/business/WxCardSchemeService.php new file mode 100644 index 00000000..b139c195 --- /dev/null +++ b/app/Service/business/WxCardSchemeService.php @@ -0,0 +1,233 @@ +model = WxCardSchemeModel::class; + $this->selectField = [ + 'id', 'name', 'code', 'preview', 'is_preset', 'app_code', + 'sort', 'status', 'created_at', 'updated_at', + ]; + $this->queryField = [ + 'name' => 'like', + 'code' => 'like', + 'app_code' => '=', + 'is_preset' => '=', + 'status' => '=', + ]; + $this->orderBy = ['name' => 'sort', 'sort' => 'asc']; + } + + public function list(): array + { + $result = $this->getPageList(); + $ids = array_values(array_filter(array_map( + static fn ($row) => (int) ($row['id'] ?? 0), + $result['items'] ?? [] + ))); + if ($ids === []) { + return $result; + } + $extras = WxCardSchemeModel::whereIn('id', $ids)->get(['id', 'layers'])->keyBy('id'); + foreach ($result['items'] as &$item) { + $extra = $extras[(int) $item['id']] ?? null; + $item['layers'] = is_array($extra?->layers) ? $extra->layers : []; + } + unset($item); + return $result; + } + + public function option(): mixed + { + $this->optionField = ['id', 'name', 'code', 'is_preset']; + return $this->getOption(); + } + + public function detail($id): mixed + { + $saved = $this->selectField; + $this->selectField = ['*']; + try { + return $this->getDetail($id); + } finally { + $this->selectField = $saved; + } + } + + public function create($params): mixed + { + return $this->insert($this->normalize($params)); + } + + public function update($id, $params): mixed + { + $row = WxCardSchemeModel::where('id', $id)->where('deleted_at', 0)->first(); + if (empty($row)) { + $this->utils->errorThrow('方案不存在'); + } + // 预设只允许改 layers / name / preview,不许改 code 以免小程序引用断裂 + if ((int) $row['is_preset'] === 1) { + $params['code'] = $row['code']; + $params['is_preset'] = 1; + } + return $this->save($id, $this->normalize($params, (int) $id)); + } + + public function delete($ids): mixed + { + $hasPreset = WxCardSchemeModel::whereIn('id', (array) $ids)->where('is_preset', 1)->exists(); + if ($hasPreset) { + $this->utils->errorThrow('内置预设不能删除,请另存为方案后再改'); + } + return $this->del($ids); + } + + /** + * 把当前图层另存为新方案(运营自由设计后的落点) + */ + public function saveAs(array $params): array + { + $name = trim((string) ($params['name'] ?? '')); + if ($name === '') { + $this->utils->errorThrow('请填写方案名称'); + } + $code = trim((string) ($params['code'] ?? '')); + if ($code === '') { + $code = 'custom-' . substr(md5($name . microtime(true)), 0, 8); + } + $id = $this->insert($this->normalize([ + 'name' => $name, + 'code' => $code, + 'preview' => (string) ($params['preview'] ?? ''), + 'layers' => $params['layers'] ?? [], + 'is_preset' => 0, + 'app_code' => (string) ($params['app_code'] ?? ''), + 'sort' => (int) ($params['sort'] ?? 100), + 'status' => 0, + ])); + return ['id' => $id, 'code' => $code, 'name' => $name]; + } + + /** + * 用 config/wx_card_schemes.php 灌 30 套预设 + */ + public function initPresets(bool $overwrite = false): array + { + $schema = WxTemplateSchemaService::getInstance(); + $inserted = 0; + $updated = 0; + $skipped = 0; + $now = time(); + DB::connection('business')->transaction(function () use ($schema, $overwrite, $now, &$inserted, &$updated, &$skipped) { + foreach ((array) config('wx_card_schemes', []) as $index => $preset) { + $code = (string) ($preset['code'] ?? ''); + $name = (string) ($preset['name'] ?? ''); + if ($code === '' || $name === '') { + continue; + } + $layers = $schema->sanitizeLayers($preset['layers'] ?? [], true); + $row = [ + 'name' => $name, + 'code' => $code, + 'preview' => (string) ($preset['preview'] ?? ''), + 'layers' => json_encode($layers, JSON_UNESCAPED_UNICODE), + 'is_preset' => 1, + 'app_code' => '', + 'sort' => $index, + 'status' => 0, + 'updated_at' => $now, + ]; + $exists = WxCardSchemeModel::where('code', $code)->where('app_code', '')->first(); + if (!empty($exists)) { + if (!$overwrite) { + $skipped++; + continue; + } + WxCardSchemeModel::where('id', $exists['id'])->update($row); + $updated++; + continue; + } + $row['created_at'] = $now; + WxCardSchemeModel::insert($row); + $inserted++; + } + }); + return ['inserted' => $inserted, 'updated' => $updated, 'skipped' => $skipped]; + } + + /** + * 按 code 列表取方案(主题下发用);库没有时回退到配置预设 + * + * 必须是 static:小程序 GET wx/theme 会调这里,不能走 getInstance()。 + * 本类继承 BaseService,构造函数会按后台 JWT 验 token,小程序 token 会被直接拒掉。 + * + * @param array $codes + * @return array + */ + public static function mapByCodes(array $codes): array + { + $codes = array_values(array_unique(array_filter(array_map('strval', $codes)))); + if ($codes === []) { + return []; + } + $map = []; + try { + $rows = WxCardSchemeModel::whereIn('code', $codes) + ->where('deleted_at', 0) + ->where('status', 0) + ->get(['code', 'name', 'layers', 'is_preset']); + foreach ($rows as $row) { + $map[(string) $row['code']] = [ + 'code' => (string) $row['code'], + 'name' => (string) $row['name'], + 'layers' => is_array($row['layers']) ? $row['layers'] : [], + 'is_preset' => (int) $row['is_preset'], + ]; + } + } catch (\Throwable) { + // 表尚未建时走配置兜底,避免主题接口 500 把小程序打白 + } + foreach ((array) config('wx_card_schemes', []) as $preset) { + $code = (string) ($preset['code'] ?? ''); + if ($code === '' || isset($map[$code]) || !in_array($code, $codes, true)) { + continue; + } + $map[$code] = [ + 'code' => $code, + 'name' => (string) ($preset['name'] ?? $code), + 'layers' => (array) ($preset['layers'] ?? []), + 'is_preset' => 1, + ]; + } + return $map; + } + + private function normalize(array $params, int $excludeId = 0): array + { + $schema = WxTemplateSchemaService::getInstance(); + if (array_key_exists('layers', $params)) { + $params['layers'] = json_encode($schema->sanitizeLayers($params['layers'], true), JSON_UNESCAPED_UNICODE); + } + if (!empty($params['code'])) { + $duplicate = WxCardSchemeModel::where('code', $params['code']) + ->where('app_code', (string) ($params['app_code'] ?? '')) + ->when($excludeId > 0, fn ($query) => $query->where('id', '!=', $excludeId)) + ->exists(); + if ($duplicate) { + $this->utils->errorThrow('方案标识已存在'); + } + } + return $params; + } +} diff --git a/app/Service/business/WxTemplatePresetService.php b/app/Service/business/WxTemplatePresetService.php index 06ec5e27..31fa661a 100644 --- a/app/Service/business/WxTemplatePresetService.php +++ b/app/Service/business/WxTemplatePresetService.php @@ -5,8 +5,8 @@ namespace App\Service\business; /** * 把预设的紧凑规格展开成完整设计令牌 * - * 20 套模板真正的差异在四条轴上:配色、字体与字号梯度、圆角与阴影、动效曲线与时长。 - * 这里把后三条做成可复用的档位,预设只需要挑档位,避免 20 份 JSON 手抄跑偏, + * 4 套轻奢模板真正的差异在四条轴上:配色、字体与字号梯度、圆角与阴影、动效曲线与时长。 + * 这里把后三条做成可复用的档位,预设只需要挑档位,避免多份 JSON 手抄跑偏, * 也让「再加一套模板」变成加十几行配置而不是重写一份主题。 */ class WxTemplatePresetService diff --git a/app/Service/business/WxTemplateSchemaService.php b/app/Service/business/WxTemplateSchemaService.php index f6811105..44dcdb11 100644 --- a/app/Service/business/WxTemplateSchemaService.php +++ b/app/Service/business/WxTemplateSchemaService.php @@ -39,28 +39,234 @@ class WxTemplateSchemaService */ public const LAYOUT_SCHEMA = [ 'home' => [ - 'hero' => ['banner', 'carousel', 'split', 'fullscreen'], - 'category' => ['grid', 'scroll', 'card', 'sidebar'], - 'product' => ['waterfall', 'list', 'grid', 'magazine'], + 'hero' => ['banner', 'carousel', 'split', 'fullscreen', 'stack', 'coverflow', 'fade', 'peek', 'cube', 'caption'], + 'category' => ['grid', 'scroll', 'card', 'sidebar', 'pills', 'mosaic', 'featured', 'tile', 'contents'], + 'package' => ['card', 'scroll', 'featured', 'magazine'], + 'product' => ['waterfall', 'list', 'grid'], ], 'product' => [ - 'gallery' => ['swiper', 'stack', 'fullbleed'], + 'gallery' => ['swiper', 'stack', 'fullbleed', 'peek', 'fade', 'coverflow', 'mosaic', 'filmstrip'], 'price' => ['inline', 'card', 'sticky'], - 'action' => ['fixed', 'inline'], + 'action' => ['fixed', 'inline', 'split'], ], 'list' => [ - 'style' => ['card', 'table', 'timeline'], + 'style' => ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'], ], 'mine' => [ - 'header' => ['gradient', 'image', 'plain'], - 'menu' => ['grid', 'list'], + 'header' => ['gradient', 'image', 'plain', 'split', 'editorial'], + 'menu' => ['grid', 'list', 'card', 'tile', 'compact'], ], 'effect' => [ 'transition' => ['fade', 'slide', 'zoom', 'none'], 'skeleton' => ['shimmer', 'pulse', 'none'], + // 卡片装饰与图标风格:小程序 ThemeCardFrame / ThemeIcon 按枚举切换 + 'card' => ['plain', 'elevated', 'ornament', 'canvas-frame'], + 'icon' => ['line', 'fill', 'duotone', 'block'], ], ]; + /** + * 装修工作室可编排的页面与区块类型 + */ + public const PAGE_SECTION_TYPES = [ + 'home' => ['search', 'hero', 'category', 'package', 'product'], + 'package' => ['list'], + 'catalog' => ['filter', 'list'], + 'search' => ['search', 'list'], + 'product' => ['gallery', 'info', 'spec', 'price', 'action'], + 'cart' => ['list'], + 'mine' => ['header', 'menu'], + ]; + + /** + * 各区块允许的变体(与旧 LAYOUT_SCHEMA 对齐,方便小程序复用) + */ + public const SECTION_VARIANTS = [ + 'search' => ['bar', 'overlay', 'pill', 'float'], + 'hero' => ['banner', 'carousel', 'split', 'fullscreen', 'stack', 'coverflow', 'fade', 'peek', 'cube', 'caption'], + 'category' => ['grid', 'scroll', 'card', 'sidebar', 'pills', 'mosaic', 'featured', 'tile', 'contents'], + 'package' => ['card', 'scroll', 'featured', 'magazine'], + 'product' => ['waterfall', 'list', 'grid'], + 'filter' => ['chip', 'bar', 'sidebar', 'hidden'], + 'list' => [ + 'card', 'table', 'timeline', 'waterfall', 'grid', 'list', + 'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo', + 'ticket', 'stacked', + ], + 'gallery' => ['swiper', 'stack', 'fullbleed', 'peek', 'fade', 'coverflow', 'mosaic', 'filmstrip'], + 'info' => ['plain', 'editorial', 'split', 'overlay'], + 'spec' => ['plain', 'table', 'chips', 'cards'], + 'price' => ['inline', 'card', 'sticky'], + 'action' => ['fixed', 'inline', 'split'], + 'header' => ['gradient', 'image', 'plain', 'split', 'editorial'], + 'menu' => ['grid', 'list', 'card', 'tile', 'compact'], + ]; + + /** + * 同一 type 在不同页面含义不同:列表是封面+型号,清单是收藏夹卡片 + */ + public const PAGE_SECTION_VARIANTS = [ + 'home' => [ + 'product' => ['waterfall', 'grid', 'list'], + 'package' => ['card', 'scroll', 'featured', 'magazine'], + ], + 'package' => [ + 'list' => ['card', 'featured', 'magazine', 'list'], + ], + 'catalog' => [ + 'list' => ['waterfall', 'grid', 'list', 'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo'], + ], + 'search' => [ + 'list' => ['waterfall', 'grid', 'list', 'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo'], + ], + 'cart' => [ + 'list' => ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'], + ], + ]; + + public const PAGE_SKINS = ['shop', 'magazine']; + public const PAGE_DENSITIES = ['compact', 'regular', 'airy']; + public const PAGE_FRAMES = ['none', 'inset', 'ornament']; + public const CHROME_TABBAR = ['plain', 'line', 'pill', 'dot']; + public const CHROME_TABBAR_ANIM = ['none', 'fade', 'slide', 'spring']; + public const CHROME_NAVBAR = ['solid', 'line']; + public const LAYER_FACES = ['sans', 'serif', 'kai', 'script']; + public const LAYER_TONES = [ + 'primary', 'primary_soft', 'primary_strong', 'accent', + 'surface', 'surface_soft', 'bg_soft', 'text', 'text_soft', 'text_muted', 'border', + ]; + + /** + * 工作室样式画廊用的中文名 + */ + public const SECTION_VARIANT_LABELS = [ + 'search' => [ + 'bar' => '搜索条', + 'overlay' => '压在封面', + 'pill' => '胶囊', + 'float' => '悬浮', + ], + 'hero' => [ + 'banner' => '横幅', + 'carousel' => '轮播', + 'split' => '分栏', + 'fullscreen' => '全屏', + 'stack' => '堆叠', + 'coverflow' => '封面流', + 'fade' => '叠化', + 'peek' => '露边', + 'cube' => '立方', + 'caption' => '刊名条', + ], + 'category' => [ + 'grid' => '宫格', + 'scroll' => '横滑', + 'card' => '卡片', + 'sidebar' => '侧栏', + 'pills' => '胶囊', + 'mosaic' => '马赛克', + 'featured' => '首图', + 'tile' => '瓷砖', + 'contents' => '刊页目录', + ], + 'package' => [ + 'card' => '卡片', + 'scroll' => '横滑', + 'featured' => '首图', + 'magazine' => '刊名条', + ], + 'product' => [ + 'waterfall' => '瀑布', + 'grid' => '双列', + 'list' => '单列', + ], + 'filter' => [ + 'chip' => '分类条', + 'bar' => '仅搜索', + 'sidebar' => '侧栏', + 'hidden' => '隐藏', + ], + 'list' => [ + 'waterfall' => '瀑布', + 'grid' => '双列', + 'list' => '单列', + 'masonry' => '砌石', + 'featured' => '首图', + 'shelf' => '货架', + 'compact' => '紧凑', + 'airy' => '疏朗', + 'mosaic' => '马赛克', + 'duo' => '对开', + 'card' => '卡片', + 'table' => '表格', + 'timeline' => '时间线', + 'ticket' => '票根', + 'stacked' => '层叠', + ], + 'gallery' => [ + 'swiper' => '轮播', + 'stack' => '叠图', + 'fullbleed' => '全出血', + 'peek' => '露边', + 'fade' => '叠化', + 'coverflow' => '封面流', + 'mosaic' => '拼贴', + 'filmstrip' => '胶卷', + ], + 'info' => [ + 'plain' => '常规', + 'editorial' => '编辑', + 'split' => '分栏', + 'overlay' => '压字', + ], + 'spec' => [ + 'plain' => '规格表', + 'table' => '表格', + 'chips' => '芯片', + 'cards' => '卡片', + ], + 'price' => [ + 'inline' => '跟规格走', + 'card' => '独立报价卡', + 'sticky' => '吸顶报价', + ], + 'action' => [ + 'fixed' => '钉在底部', + 'inline' => '跟正文走', + 'split' => '左右拆开', + ], + 'header' => [ + 'gradient' => '渐变', + 'image' => '头图', + 'plain' => '素底', + 'split' => '分栏', + 'editorial' => '编辑', + ], + 'menu' => [ + 'grid' => '宫格', + 'list' => '列表', + 'card' => '卡片', + 'tile' => '瓷砖', + 'compact' => '紧凑', + ], + ]; + + /** + * 卡片图层允许的 type / bind / font + */ + public const LAYER_TYPES = ['photo', 'text', 'deco', 'shape']; + public const LAYER_BINDS = ['cover', 'gallery', 'upload', 'name', 'price', 'custom']; + public const LAYER_FONTS = ['title', 'body', 'caption', 'price']; + public const DECO_ASSETS = [ + 'paper-edge', 'pearl-line', 'walnut-frame', 'gold-foil', 'washi-1', + 'botanical', 'museum-mat', 'folio-line', 'wax-seal', 'ribbon', + 'newsprint', 'gold-corner', 'handwriting', 'arch-mat', 'double-frame', + 'inset-shadow', 'torn-edge', 'film-sprocket', 'corner-bracket', + 'emboss-plate', 'circle-crop', 'vignette', 'folio-number', 'stamp-ring', + 'lace-corner', 'thin-rule', 'shadow-deck', 'cap-rail', 'gutter-fold', + 'pearl-bead', 'vert-caption', + ]; + /** * 危险片段:出现即拒绝整个值 */ @@ -108,7 +314,10 @@ class WxTemplateSchemaService } /** - * 清洗布局:值必须是枚举里的选项,非法值退回该项的第一个选项 + * 清洗布局:旧枚举 + pages.sections + card_scheme + * + * 非法枚举在宽松模式退回第一项;strict(导入)直接报错。 + * pages / card_scheme 是装修工作室写入的,必须保留,否则保存后拖拽结果会丢。 */ public function sanitizeLayout(mixed $layout, bool $strict = false): array { @@ -133,9 +342,362 @@ class WxTemplateSchemaService $clean[$page][$key] = $value; } } + $scheme = trim((string) ($layout['card_scheme'] ?? '')); + if ($scheme !== '' && $this->isSafeCode($scheme)) { + $clean['card_scheme'] = $scheme; + } + if (isset($layout['pages']) && is_array($layout['pages'])) { + $clean['pages'] = $this->sanitizePages($layout['pages'], $strict); + } + if (isset($layout['page_presets']) && is_array($layout['page_presets'])) { + $clean['page_presets'] = $this->sanitizePagePresets($layout['page_presets']); + } + if (isset($layout['chrome']) && is_array($layout['chrome'])) { + $chrome = []; + $tabbar = (string) ($layout['chrome']['tabbar'] ?? ''); + $navbar = (string) ($layout['chrome']['navbar'] ?? ''); + $anim = (string) ($layout['chrome']['tabbar_anim'] ?? ''); + if (in_array($tabbar, self::CHROME_TABBAR, true)) { + $chrome['tabbar'] = $tabbar; + } elseif ($strict && $tabbar !== '') { + UtilsService::getInstance()->errorThrow('chrome.tabbar 只能是:' . implode('/', self::CHROME_TABBAR)); + } + if (in_array($navbar, self::CHROME_NAVBAR, true)) { + $chrome['navbar'] = $navbar; + } elseif ($strict && $navbar !== '') { + UtilsService::getInstance()->errorThrow('chrome.navbar 只能是:' . implode('/', self::CHROME_NAVBAR)); + } + if (in_array($anim, self::CHROME_TABBAR_ANIM, true)) { + $chrome['tabbar_anim'] = $anim; + } elseif ($strict && $anim !== '') { + UtilsService::getInstance()->errorThrow('chrome.tabbar_anim 只能是:' . implode('/', self::CHROME_TABBAR_ANIM)); + } + foreach (['tabbar_color', 'tabbar_color_on'] as $colorKey) { + $raw = trim((string) ($layout['chrome'][$colorKey] ?? '')); + if ($raw === '') { + continue; + } + if ($this->isSafeHexColor($raw)) { + $chrome[$colorKey] = $raw; + } elseif ($strict) { + UtilsService::getInstance()->errorThrow('chrome.' . $colorKey . ' 必须是 #RGB 或 #RRGGBB'); + } + } + if ($chrome !== []) { + $clean['chrome'] = $chrome; + } + } return $clean; } + /** + * 清洗页面区块列表:只留白名单 type/variant,props 只留安全短值 + */ + public function sanitizePages(array $pages, bool $strict = false): array + { + $clean = []; + foreach (self::PAGE_SECTION_TYPES as $page => $types) { + $pageBlock = $pages[$page] ?? []; + if (!is_array($pageBlock)) { + continue; + } + $source = $pageBlock['sections'] ?? $pageBlock; + if (!is_array($source)) { + continue; + } + // 兼容 { sections: [] } 与直接数组 + $list = isset($source['sections']) && is_array($source['sections']) ? $source['sections'] : $source; + $sections = []; + foreach ($list as $index => $item) { + if (!is_array($item)) { + continue; + } + $type = (string) ($item['type'] ?? ''); + if (!in_array($type, $types, true)) { + if ($strict) { + UtilsService::getInstance()->errorThrow("页面 {$page} 不支持区块 {$type}"); + } + continue; + } + $allowed = $this->variantsFor($page, $type); + $variant = (string) ($item['variant'] ?? $allowed[0]); + // 旧杂志卡带报价,商品流收成瀑布;套餐 magazine 是刊名条,不要改 + if ($variant === 'magazine' && in_array($page, ['home', 'catalog', 'search'], true) && $type !== 'package') { + $variant = 'waterfall'; + } + if (!in_array($variant, $allowed, true)) { + if ($strict) { + UtilsService::getInstance()->errorThrow("区块 {$type} 变体只能是:" . implode('/', $allowed)); + } + $variant = $allowed[0]; + } + $id = $this->isSafeCode((string) ($item['id'] ?? '')) + ? (string) $item['id'] + : $type . '-' . $index; + $sections[] = [ + 'id' => $id, + 'type' => $type, + 'variant' => $variant, + 'visible' => !empty($item['visible']) || !array_key_exists('visible', $item), + 'props' => $this->sanitizeSectionProps($item['props'] ?? []), + ]; + } + $row = ['sections' => $sections]; + $skin = (string) ($pageBlock['skin'] ?? ''); + $density = (string) ($pageBlock['density'] ?? ''); + $frame = (string) ($pageBlock['frame'] ?? ''); + if (in_array($skin, self::PAGE_SKINS, true)) { + $row['skin'] = $skin; + } + if (in_array($density, self::PAGE_DENSITIES, true)) { + $row['density'] = $density; + } + if (in_array($frame, self::PAGE_FRAMES, true)) { + $row['frame'] = $frame; + } + $pageScheme = trim((string) ($pageBlock['card_scheme'] ?? '')); + if ($pageScheme !== '' && $this->isSafeCode($pageScheme)) { + $row['card_scheme'] = $pageScheme; + } + $caption = $pageBlock['card_caption'] ?? ''; + if (is_string($caption) && $caption !== '' && mb_strlen($caption) <= 80 && $this->isSafeValue($caption)) { + $row['card_caption'] = $caption; + } + $clean[$page] = $row; + } + return $clean; + } + + /** + * 清洗卡片图层:坐标数值化,非法 type 丢弃 + */ + public function sanitizeLayers(mixed $layers, bool $strict = false): array + { + $layers = $this->toArray($layers); + $clean = []; + foreach ($layers as $index => $item) { + if (!is_array($item)) { + continue; + } + $type = (string) ($item['type'] ?? ''); + if (!in_array($type, self::LAYER_TYPES, true)) { + if ($strict) { + UtilsService::getInstance()->errorThrow('图层 type 只能是 photo/text/deco/shape'); + } + continue; + } + $id = $this->isSafeCode((string) ($item['id'] ?? '')) + ? (string) $item['id'] + : 'layer-' . $index; + $row = [ + 'id' => $id, + 'type' => $type, + 'x' => $this->num($item['x'] ?? 0), + 'y' => $this->num($item['y'] ?? 0), + 'w' => $this->num($item['w'] ?? 40), + 'h' => $this->num($item['h'] ?? 40), + 'rotate' => $this->num($item['rotate'] ?? 0), + 'z' => (int) ($item['z'] ?? 1), + 'visible' => !array_key_exists('visible', $item) || !empty($item['visible']), + 'locked' => !empty($item['locked']), + ]; + if (!empty($item['bind']) && in_array((string) $item['bind'], self::LAYER_BINDS, true)) { + $row['bind'] = (string) $item['bind']; + } + if (!empty($item['font']) && in_array((string) $item['font'], self::LAYER_FONTS, true)) { + $row['font'] = (string) $item['font']; + } + foreach (['font_zh', 'font_en'] as $faceKey) { + $face = (string) ($item[$faceKey] ?? ''); + if ($face !== '' && in_array($face, self::LAYER_FACES, true)) { + $row[$faceKey] = $face; + } + } + if (!empty($item['asset']) && $this->isSafeCode((string) $item['asset'])) { + $row['asset'] = (string) $item['asset']; + } + $tone = (string) ($item['tone'] ?? ''); + if ($tone !== '' && in_array($tone, self::LAYER_TONES, true)) { + $row['tone'] = $tone; + } + if (isset($item['fill']) && $this->isSafeValue($item['fill'])) { + $row['fill'] = (string) $item['fill']; + } + if (isset($item['text']) && is_string($item['text']) && mb_strlen($item['text']) <= 80) { + $row['text'] = $item['text']; + } + if (!empty($item['src']) && $this->isSafeUrl((string) $item['src'])) { + $row['src'] = (string) $item['src']; + } + $clean[] = $row; + } + return $clean; + } + + /** + * 某页某区块允许的变体:先看页面覆盖,再回落到全局 + * + * @return array + */ + public function variantsFor(string $page, string $type): array + { + $override = self::PAGE_SECTION_VARIANTS[$page][$type] ?? null; + if (is_array($override) && $override !== []) { + return $override; + } + return self::SECTION_VARIANTS[$type] ?? ['plain']; + } + + /** + * 工作室拉一次就能画样式画廊,不必再写死中文名 + */ + public function describe(): array + { + $pages = []; + foreach (self::PAGE_SECTION_TYPES as $page => $types) { + $modules = []; + foreach ($types as $type) { + $variants = $this->variantsFor($page, $type); + $labels = self::SECTION_VARIANT_LABELS[$type] ?? []; + $modules[] = [ + 'type' => $type, + 'variants' => array_map(static function (string $value) use ($labels) { + return [ + 'value' => $value, + 'label' => $labels[$value] ?? $value, + ]; + }, $variants), + ]; + } + $pages[] = [ + 'key' => $page, + 'modules' => $modules, + ]; + } + return [ + 'tokens' => self::TOKEN_SCHEMA, + 'layout' => self::LAYOUT_SCHEMA, + 'pages' => self::PAGE_SECTION_TYPES, + 'variants' => self::SECTION_VARIANTS, + 'page_variants' => self::PAGE_SECTION_VARIANTS, + 'labels' => self::SECTION_VARIANT_LABELS, + 'studio' => $pages, + 'page_presets' => $this->describePagePresets(), + 'card_families' => $this->describeCardFamilies(), + 'chrome' => [ + 'tabbar' => self::CHROME_TABBAR, + 'tabbar_anim' => self::CHROME_TABBAR_ANIM, + 'navbar' => self::CHROME_NAVBAR, + ], + 'layer_faces' => self::LAYER_FACES, + ]; + } + + /** + * 工作室整页预设画廊:只下发 code/name/家族,套用时再取完整 sections + */ + public function describePagePresets(): array + { + $out = []; + foreach ((array) config('wx_page_presets', []) as $page => $items) { + foreach ((array) $items as $item) { + if (!is_array($item) || empty($item['code'])) { + continue; + } + $out[$page][] = [ + 'code' => (string) $item['code'], + 'name' => (string) ($item['name'] ?? $item['code']), + 'family' => (string) ($item['family'] ?? ''), + 'skin' => (string) ($item['skin'] ?? 'shop'), + 'card_scheme' => (string) ($item['card_scheme'] ?? ''), + 'density' => (string) ($item['density'] ?? 'regular'), + 'frame' => (string) ($item['frame'] ?? 'none'), + 'hero' => $this->presetHeroHint($item), + 'gallery' => $this->presetTypeHint($item, 'gallery'), + 'list' => $this->presetListHint($item), + 'sections' => is_array($item['sections'] ?? null) ? $item['sections'] : [], + ]; + } + } + return $out; + } + + /** + * 按 code 取完整预设(工作室一键套用) + */ + public function pagePresetByCode(string $code): ?array + { + foreach ((array) config('wx_page_presets', []) as $items) { + foreach ((array) $items as $item) { + if (is_array($item) && (string) ($item['code'] ?? '') === $code) { + return $item; + } + } + } + return null; + } + + /** + * 卡片家族:工作室缩略旁标轻奢 / 欧美 / 手作 + */ + public function describeCardFamilies(): array + { + $map = []; + foreach ((array) config('wx_card_schemes', []) as $item) { + $code = (string) ($item['code'] ?? ''); + if ($code === '') { + continue; + } + $family = (string) ($item['family'] ?? 'lux'); + $map[$code] = match ($family) { + 'editorial' => '欧美', + 'atelier' => '手作', + default => '轻奢', + }; + } + return $map; + } + + /** + * 只留各页合法的预设 code + */ + private function sanitizePagePresets(array $presets): array + { + $clean = []; + foreach (array_keys(self::PAGE_SECTION_TYPES) as $page) { + $code = trim((string) ($presets[$page] ?? '')); + if ($code !== '' && $this->isSafeCode($code)) { + $clean[$page] = $code; + } + } + return $clean; + } + + private function presetHeroHint(array $item): string + { + foreach ((array) ($item['sections'] ?? []) as $section) { + if (($section['type'] ?? '') === 'hero') { + return (string) ($section['variant'] ?? ''); + } + } + return ''; + } + + private function presetListHint(array $item): string + { + return $this->presetTypeHint($item, 'list'); + } + + private function presetTypeHint(array $item, string $type): string + { + foreach ((array) ($item['sections'] ?? []) as $section) { + if (($section['type'] ?? '') === $type) { + return (string) ($section['variant'] ?? ''); + } + } + return ''; + } + /** * 小程序侧要的扁平 CSS 变量表:--color-primary 这种 */ @@ -153,6 +715,58 @@ class WxTemplateSchemaService return $vars; } + /** + * 区块 props:只留高度/列数/间距/卡片方案,避免塞任意 CSS + */ + private function sanitizeSectionProps(mixed $props): array + { + if (!is_array($props)) { + return []; + } + $clean = []; + foreach (['height', 'cols', 'spacing'] as $key) { + if (isset($props[$key]) && is_numeric($props[$key])) { + $clean[$key] = (int) $props[$key]; + } + } + if (!empty($props['card_scheme']) && $this->isSafeCode((string) $props['card_scheme'])) { + $clean['card_scheme'] = (string) $props['card_scheme']; + } + return $clean; + } + + /** 底栏独立色只收 hex,拒绝 CSS 语句 */ + private function isSafeHexColor(string $value): bool + { + return preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $value) === 1; + } + + private function isSafeCode(string $value): bool + { + return $value !== '' && preg_match('/^[a-zA-Z0-9_-]{1,50}$/', $value) === 1; + } + + private function isSafeUrl(string $value): bool + { + if ($value === '' || mb_strlen($value) > 500) { + return false; + } + foreach (self::FORBIDDEN as $needle) { + if ($needle === 'url(') { + continue; + } + if (stripos($value, $needle) !== false) { + return false; + } + } + return (bool) preg_match('/^https?:\\/\\//i', $value); + } + + private function num(mixed $value): float + { + return is_numeric($value) ? (float) $value : 0; + } + private function isSafeValue(mixed $value): bool { if (is_int($value) || is_float($value)) { diff --git a/app/Service/business/WxTemplateService.php b/app/Service/business/WxTemplateService.php index 9ff20bdd..7ab13e95 100644 --- a/app/Service/business/WxTemplateService.php +++ b/app/Service/business/WxTemplateService.php @@ -220,7 +220,8 @@ class WxTemplateService extends BaseService } /** - * 用内置预设初始化模板库(20 套) + * 用内置 4 套轻奢预设初始化模板库,并灌 15 套卡片方案 + * overwrite=true 时仅覆盖同 code;旧 code 不变则保留客户已改内容 */ public function initPresets(bool $overwrite = false): array { @@ -233,6 +234,7 @@ class WxTemplateService extends BaseService $this->setDefault((int) $first['id']); } } + $result['card_schemes'] = WxCardSchemeService::getInstance()->initPresets($overwrite); return $result; } diff --git a/app/Service/common/JWTService.php b/app/Service/common/JWTService.php index eb5ea787..a022245c 100755 --- a/app/Service/common/JWTService.php +++ b/app/Service/common/JWTService.php @@ -69,7 +69,11 @@ class JWTService $payload = [ 'iat' => $issuedAt, 'exp' => $expirationTime, - 'data' => $data + // sub 是标准声明,网关/旧客户端剥掉自定义 data 时还能靠它找回用户 + 'sub' => (string) ($data['id'] ?? ''), + // 与小程序 token(scope=wx)区分,两边共用 JWT_SECRET 但不能互认 + 'scope' => 'admin', + 'data' => $data, ]; RedisService::getInstance()->init(config('nl.redis.jwt'))->set($data['id'], json_encode($data)); return JWT::encode($payload, $this->secretKey, 'HS256'); @@ -83,9 +87,14 @@ class JWTService public function parseToken(): ?object { try { - if (empty($this->token)) return UtilsService::getInstance()->notAuth('请先登录'); + if (empty($this->token)) { + return UtilsService::getInstance()->notAuth('请先登录'); + } return JWT::decode($this->token, new Key($this->secretKey, 'HS256')); } catch (Exception $e) { + if ((int) $e->getCode() === 401) { + throw $e; + } return UtilsService::getInstance()->notAuth('【1】Token解析失败!请重新登录:'. $e->getMessage()); } } @@ -100,14 +109,59 @@ class JWTService { try { $jwt = $this->parseToken(); - $user = RedisService::getInstance()->init(config('nl.redis.jwt'))->get($jwt->data->id); - if (empty($user)) return UtilsService::getInstance()->notAuth('登录状态过期'); - return json_decode($user, true); - } catch (Exception $e) { + $payload = json_decode(json_encode($jwt), true); + if (is_array($payload) && ($payload['scope'] ?? '') === 'wx') { + return UtilsService::getInstance()->notAuth('请使用后台账号登录'); + } + $data = $this->extractUserData($jwt); + $userId = (int) ($data['id'] ?? 0); + if ($userId <= 0) { + return UtilsService::getInstance()->notAuth('Token 无效,请重新登录'); + } + $user = RedisService::getInstance()->init(config('nl.redis.jwt'))->get($userId); + if (empty($user)) { + // Redis 会话丢了但签名有效:用 payload 顶住,避免刚登录就被踢 + return $data; + } + $decoded = json_decode($user, true); + return is_array($decoded) ? $decoded : $data; + } catch (\Throwable $e) { + if ((int) $e->getCode() === 401) { + throw $e; + } return UtilsService::getInstance()->notAuth('【2】Token解析失败!请重新登录:'. $e->getMessage()); } } + /** + * 从解码后的 JWT 取出用户数据 + * + * 本系统签发的是 { data: { id, ... } };有的网关/旧 token 会把字段摊到顶层, + * 或只留 sub。这里都认,避免再出现 Undefined property::$data。 + */ + public function extractUserData(?object $jwt): array + { + if (!is_object($jwt)) { + return []; + } + $payload = json_decode(json_encode($jwt), true); + if (!is_array($payload)) { + return []; + } + $data = $payload['data'] ?? null; + if (is_array($data) && $data !== []) { + return $data; + } + if (is_object($data)) { + return (array) $data; + } + $id = $payload['id'] ?? $payload['sub'] ?? 0; + if ((int) $id > 0) { + return array_merge($payload, ['id' => (int) $id]); + } + return []; + } + /** * 续签 JWT Token * @return string|null 新的 JWT 字符串或者 null 如果原 token 已过期或无效 @@ -116,11 +170,12 @@ class JWTService public function refreshToken(): ?string { $decoded = $this->parseToken(); - if ($decoded === null || !property_exists($decoded, 'data')) { + $data = $this->extractUserData($decoded); + if (($data['id'] ?? 0) <= 0) { return null; } - return $this->generateToken((array)$decoded->data); + return $this->generateToken($data); } /** diff --git a/app/Service/common/MediaUrlService.php b/app/Service/common/MediaUrlService.php index 2202829b..f7b62775 100644 --- a/app/Service/common/MediaUrlService.php +++ b/app/Service/common/MediaUrlService.php @@ -75,7 +75,14 @@ class MediaUrlService public function firstOf(mixed $value): string { if (is_array($value)) { - $value = $value[0] ?? ''; + $first = $value[0] ?? ''; + if (is_array($first)) { + $value = $first['url'] ?? $first['uid'] ?? ''; + } elseif (isset($value['url'])) { + $value = $value['url']; + } else { + $value = $first; + } } return $this->toStorage(is_string($value) ? $value : ''); } diff --git a/app/Service/common/oss/OssRuntimeConfigService.php b/app/Service/common/oss/OssRuntimeConfigService.php index ea2995a1..043cf9f4 100644 --- a/app/Service/common/oss/OssRuntimeConfigService.php +++ b/app/Service/common/oss/OssRuntimeConfigService.php @@ -111,8 +111,8 @@ class OssRuntimeConfigService extends BaseService 'id' => (int) $row->id, 'driver' => (string) $row->driver, 'name' => (string) $row->name, - 'access_key' => $enc->decryptFromStorage((string) ($row->access_key ?? ''), true), - 'secret_key' => $enc->decryptFromStorage((string) ($row->secret_key ?? ''), true), + 'access_key' => $this->decryptSecret($enc, (string) ($row->access_key ?? ''), 'AccessKey'), + 'secret_key' => $this->decryptSecret($enc, (string) ($row->secret_key ?? ''), 'SecretKey'), 'endpoint' => (string) ($row->endpoint ?? ''), 'region' => (string) ($row->region ?? ''), 'bucket' => (string) ($row->bucket ?? ''), @@ -121,4 +121,21 @@ class OssRuntimeConfigService extends BaseService 'extra_json' => $extra, ]; } + + /** + * 解密库内密钥;密文在但解不开时必须说清楚,不能静默变空串 + * + * 以前 silentFail=true,解密失败后驱动只看到空 AccessKey, + * 素材拉取就会报「七牛云配置不完整」,运营以为没填,其实是 ENCRYPT_KEY 对不上。 + */ + private function decryptSecret(FieldEncryptService $enc, string $raw, string $label): string + { + $plain = $enc->decryptFromStorage($raw, true); + if ($raw !== '' && $plain === '' && $enc->isEncrypted($raw)) { + $this->utils->errorThrow( + '存储配置的' . $label . '解密失败,请到「系统配置 → 存储」重新填写密钥后保存' + ); + } + return $plain; + } } diff --git a/app/Service/common/oss/OssStorageInterface.php b/app/Service/common/oss/OssStorageInterface.php index d8dd070a..1b0442fd 100644 --- a/app/Service/common/oss/OssStorageInterface.php +++ b/app/Service/common/oss/OssStorageInterface.php @@ -38,7 +38,7 @@ interface OssStorageInterface * 素材库靠 marker 一页一页往回补:bucket 上万对象时一次拉全量必然打穿 * PHP 的执行时限,所以约定「调用方拿着 next_marker 继续要下一页」。 * - * @param string $prefix 只列举该前缀,留空时退回配置里的 path_prefix + * @param string $prefix 只列举该前缀,留空表示整个 Bucket(不套 path_prefix) * @param string $marker 上一页返回的 next_marker,首页传空串 * @param int $limit 单页条数 * @return array{items: array, next_marker: string, finished: bool} @@ -48,7 +48,7 @@ interface OssStorageInterface /** * 删除对象 * - * @param string $key 对象键(缺 path_prefix 时由实现补齐) + * @param string $key 对象键(素材库存的是列举/上传后的真实路径,不再补 path_prefix) */ public function deleteObject(string $key): bool; } diff --git a/app/Service/common/upload/AliyunStorageService.php b/app/Service/common/upload/AliyunStorageService.php index bb72f2c1..463c0145 100644 --- a/app/Service/common/upload/AliyunStorageService.php +++ b/app/Service/common/upload/AliyunStorageService.php @@ -70,7 +70,7 @@ class AliyunStorageService extends BaseNotAuthService implements OssStorageInter public function deleteObject(string $key): bool { $this->assertConfig(); - $key = $this->normalizeObjectKey($key); + $key = $this->normalizeListedKey($key); if ($key === '') { return false; } diff --git a/app/Service/common/upload/LocalhostStorageService.php b/app/Service/common/upload/LocalhostStorageService.php index 2f059e19..6177fcf5 100644 --- a/app/Service/common/upload/LocalhostStorageService.php +++ b/app/Service/common/upload/LocalhostStorageService.php @@ -64,7 +64,7 @@ class LocalhostStorageService extends BaseNotAuthService implements OssStorageIn $items = []; foreach ($page as $file) { - $key = $this->normalizeObjectKey($file); + $key = $this->normalizeListedKey($file); if ($key === '') { continue; } @@ -90,7 +90,7 @@ class LocalhostStorageService extends BaseNotAuthService implements OssStorageIn public function deleteObject(string $key): bool { - $key = $this->normalizeObjectKey($key); + $key = $this->normalizeListedKey($key); if ($key === '') { return false; } diff --git a/app/Service/common/upload/ObjectKeyNormalizeTrait.php b/app/Service/common/upload/ObjectKeyNormalizeTrait.php index 65d2ce2a..439feeac 100644 --- a/app/Service/common/upload/ObjectKeyNormalizeTrait.php +++ b/app/Service/common/upload/ObjectKeyNormalizeTrait.php @@ -5,14 +5,26 @@ namespace App\Service\common\upload; /** * 对象键规整 * - * 五个驱动在列举 / 删除时对 key 的处理完全一致(补 path_prefix、压斜杠、拼 domain), - * 抽出来免得同一段逻辑在五个文件里各写一遍、各改一半。 + * 上传用 normalizeObjectKey(可补 path_prefix), + * 列举 / 删除用 normalizeListedKey(只压斜杠,用服务端真实 key)。 * 使用方需要有 $this->config(由 OssRuntimeConfigService 注入,含 path_prefix / domain)。 */ trait ObjectKeyNormalizeTrait { /** - * 补上 path_prefix 并压平重复斜杠 + * 压平重复斜杠,不补 path_prefix + * + * 列举结果必须走这里:OSS 回的 key 就是对象真实路径。 + * 若再套一层 path_prefix,根目录的历史文件(如 b_xxx.jpg)会被改写成 + * uploads/b_xxx.jpg,素材库入库地址全 404。 + */ + protected function normalizeListedKey(string $key): string + { + return ltrim((string) preg_replace('#/{2,}#', '/', trim($key)), '/'); + } + + /** + * 上传 / 删除用:缺 path_prefix 时补上,并压平重复斜杠 * * 为什么必须压平:uploads//spa/a.jpg 与 uploads/spa/a.jpg 在 OSS 上是两个对象, * 但拼出来的访问地址会被 CDN 归一成同一个。素材库按 path 建索引, @@ -20,7 +32,7 @@ trait ObjectKeyNormalizeTrait */ protected function normalizeObjectKey(string $key): string { - $key = ltrim((string) preg_replace('#/{2,}#', '/', trim($key)), '/'); + $key = $this->normalizeListedKey($key); if ($key === '') { return ''; } @@ -32,19 +44,16 @@ trait ObjectKeyNormalizeTrait } /** - * 列举前缀:调用方没给就退回 path_prefix + * 列举前缀:只认调用方传入的值,留空 = 扫整个 Bucket * - * 同一个 bucket 常常被多个项目共用,不加这层兜底会把别人的对象也拉进素材库, - * 之后走回收流程就等于跨项目删文件。 + * 以前空前缀会偷偷套配置里的 path_prefix。种子数据把七牛写成 uploads, + * 而本站历史文件都在根目录(b_*.jpg),一拉就是空列表。 + * 素材库文案也是「留空表示整个 Bucket」,这里必须跟文案一致; + * 要收窄范围请在同步抽屉里显式填前缀。 */ protected function scopedListPrefix(string $prefix): string { - $prefix = trim($prefix); - if ($prefix !== '') { - return $this->normalizeObjectKey($prefix); - } - $base = trim((string) ($this->config['path_prefix'] ?? ''), '/'); - return $base === '' ? '' : $base . '/'; + return $this->normalizeListedKey($prefix); } /** diff --git a/app/Service/common/upload/ObjectListXmlTrait.php b/app/Service/common/upload/ObjectListXmlTrait.php index 0d8addac..d15a6edf 100644 --- a/app/Service/common/upload/ObjectListXmlTrait.php +++ b/app/Service/common/upload/ObjectListXmlTrait.php @@ -34,7 +34,7 @@ trait ObjectListXmlTrait $rawKey = (string) $node->Key; // 续拉游标必须用服务端原样返回的 key,不能用补过 prefix 的规整值 $lastRawKey = $rawKey; - $key = $this->normalizeObjectKey($rawKey); + $key = $this->normalizeListedKey($rawKey); if ($key === '' || str_ends_with($key, '/')) { // 以 / 结尾的是控制台建目录留下的占位对象,不是素材 continue; diff --git a/app/Service/common/upload/QcloudStorageService.php b/app/Service/common/upload/QcloudStorageService.php index f4d2fb0e..3c74f3bc 100644 --- a/app/Service/common/upload/QcloudStorageService.php +++ b/app/Service/common/upload/QcloudStorageService.php @@ -67,7 +67,7 @@ class QcloudStorageService extends BaseNotAuthService implements OssStorageInter public function deleteObject(string $key): bool { $this->assertConfig(); - $key = $this->normalizeObjectKey($key); + $key = $this->normalizeListedKey($key); if ($key === '') { return false; } diff --git a/app/Service/common/upload/QiniuStorageService.php b/app/Service/common/upload/QiniuStorageService.php index 60ddd905..02937ff1 100644 --- a/app/Service/common/upload/QiniuStorageService.php +++ b/app/Service/common/upload/QiniuStorageService.php @@ -21,12 +21,19 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf 'path_prefix' => '', ]; + /** 同一次请求里复用,避免每页列举都去 UC 查一遍区域 */ + private ?\Qiniu\Storage\BucketManager $cachedBucketMgr = null; + + private string $cachedBucketMgrKey = ''; + /** * 注入解密后的运行时配置 */ public function withConfig(array $config): static { $this->config = array_merge($this->config, $config); + $this->cachedBucketMgr = null; + $this->cachedBucketMgrKey = ''; return $this; } @@ -54,14 +61,17 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf * 列举空间对象(BucketManager::listFiles,marker 分页) * * 七牛只在还有下一页时才回 marker,所以 marker 为空即等于列完了。 + * 列举结果的 key 必须用服务端原值,不能再套 path_prefix, + * 否则根目录历史文件会被改写成 uploads/b_xxx.jpg。 */ public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array { $bucketMgr = $this->bucketManager(); $bucket = (string) ($this->config['bucket'] ?? ''); + $listPrefix = $this->scopedListPrefix($prefix); [$ret, $err] = $bucketMgr->listFiles( $bucket, - $this->scopedListPrefix($prefix), + $listPrefix !== '' ? $listPrefix : null, $marker !== '' ? $marker : null, $this->boundedLimit($limit) ); @@ -69,9 +79,19 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf UtilsService::getInstance()->errorThrow('七牛云列举对象失败:' . $this->errorText($err)); } + $ret = is_array($ret) ? $ret : []; + $rawItems = $ret['items'] ?? $ret['Items'] ?? []; + // 个别 SDK 版本成功时直接回文件数组,而不是 {items: [...]} + if ($rawItems === [] && isset($ret[0]) && is_array($ret[0])) { + $rawItems = $ret; + } + $items = []; - foreach ((array) ($ret['items'] ?? []) as $row) { - $key = $this->normalizeObjectKey((string) ($row['key'] ?? '')); + foreach ((array) $rawItems as $row) { + if (!is_array($row)) { + continue; + } + $key = $this->normalizeListedKey((string) ($row['key'] ?? '')); if ($key === '' || str_ends_with($key, '/')) { continue; } @@ -81,7 +101,7 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf 'hash' => (string) ($row['hash'] ?? ''), // putTime 的单位是 100 纳秒,当秒用会得到五亿年后的时间戳 'last_modified' => intdiv((int) ($row['putTime'] ?? 0), 10000000), - 'url' => $this->publicUrlOf($key, $key), + 'url' => $this->publicUrlOf($key), ]; } @@ -95,7 +115,7 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf public function deleteObject(string $key): bool { - return $this->deleteFile($this->normalizeObjectKey($key)); + return $this->deleteFile($this->normalizeListedKey($key)); } /** @@ -135,30 +155,50 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf private function deleteFile(string $key): bool { - if (!class_exists(\Qiniu\Auth::class)) { - return false; - } - $auth = new \Qiniu\Auth($this->config['access_key'], $this->config['secret_key']); - $bucketMgr = new \Qiniu\Storage\BucketManager($auth); - $err = $bucketMgr->delete($this->config['bucket'], $key); + $err = $this->bucketManager()->delete((string) $this->config['bucket'], $key); return $err === null; } /** * 构造 BucketManager,顺手把「没装 SDK / 没填配置」两种情况前置拦掉 + * + * 必须走 HTTPS:SDK 默认 HTTP,部分机房出网只放行 443,列举会直接空失败。 */ private function bucketManager(): \Qiniu\Storage\BucketManager { if (!class_exists(\Qiniu\Auth::class)) { UtilsService::getInstance()->errorThrow('未安装 qiniu/php-sdk,请改用本地存储或安装依赖'); } - $accessKey = (string) ($this->config['access_key'] ?? ''); - $secretKey = (string) ($this->config['secret_key'] ?? ''); - $bucket = (string) ($this->config['bucket'] ?? ''); - if ($accessKey === '' || $secretKey === '' || $bucket === '') { - UtilsService::getInstance()->errorThrow('七牛云配置不完整'); + $accessKey = trim((string) ($this->config['access_key'] ?? '')); + $secretKey = trim((string) ($this->config['secret_key'] ?? '')); + $bucket = trim((string) ($this->config['bucket'] ?? '')); + $missing = []; + if ($accessKey === '') { + $missing[] = 'AccessKey'; } - return new \Qiniu\Storage\BucketManager(new \Qiniu\Auth($accessKey, $secretKey)); + if ($secretKey === '') { + $missing[] = 'SecretKey'; + } + if ($bucket === '') { + $missing[] = 'Bucket'; + } + if ($missing !== []) { + UtilsService::getInstance()->errorThrow( + '七牛云配置不完整,缺少:' . implode('、', $missing) . '。请到「系统配置 → 存储」重新填写后保存' + ); + } + $cacheKey = $accessKey . "\0" . $bucket; + if ($this->cachedBucketMgr !== null && $this->cachedBucketMgrKey === $cacheKey) { + return $this->cachedBucketMgr; + } + $sdkConfig = new \Qiniu\Config(); + $sdkConfig->useHTTPS = true; + $this->cachedBucketMgr = new \Qiniu\Storage\BucketManager( + new \Qiniu\Auth($accessKey, $secretKey), + $sdkConfig + ); + $this->cachedBucketMgrKey = $cacheKey; + return $this->cachedBucketMgr; } /** @@ -167,7 +207,13 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf private function errorText(mixed $err): string { if (is_object($err) && method_exists($err, 'message')) { - return (string) $err->message(); + $text = trim((string) $err->message()); + if ($text !== '') { + return $text; + } + } + if (is_object($err) && method_exists($err, 'code')) { + return 'HTTP ' . $err->code(); } if (is_object($err) || is_array($err)) { return (string) json_encode($err, JSON_UNESCAPED_UNICODE); diff --git a/app/Service/common/upload/S3CompatibleStorageService.php b/app/Service/common/upload/S3CompatibleStorageService.php index b1095483..02b29fb6 100644 --- a/app/Service/common/upload/S3CompatibleStorageService.php +++ b/app/Service/common/upload/S3CompatibleStorageService.php @@ -64,7 +64,7 @@ class S3CompatibleStorageService extends BaseNotAuthService implements OssStorag public function deleteObject(string $key): bool { $this->assertConfig(); - $key = $this->normalizeObjectKey($key); + $key = $this->normalizeListedKey($key); if ($key === '') { return false; } diff --git a/app/Service/wx/WxAppService.php b/app/Service/wx/WxAppService.php index 4b1080ee..7d635984 100644 --- a/app/Service/wx/WxAppService.php +++ b/app/Service/wx/WxAppService.php @@ -89,6 +89,19 @@ class WxAppService ]; } + /** + * 当前品牌是否启用套餐。只读开关列,不解密密钥,主题和下发列表都能安全调用。 + */ + public function packageEnabled(): bool + { + $code = $this->currentCode(); + $query = WxAppModel::where('deleted_at', 0)->where('status', 0); + $app = $code !== '' + ? (clone $query)->where('code', $code)->first(['package_enabled']) + : $query->first(['package_enabled']); + return (int) ($app['package_enabled'] ?? 0) === 1; + } + /** * 当前请求声明的品牌标识 * diff --git a/app/Service/wx/WxAuthService.php b/app/Service/wx/WxAuthService.php index e88b1c23..5fd3e22a 100644 --- a/app/Service/wx/WxAuthService.php +++ b/app/Service/wx/WxAuthService.php @@ -16,7 +16,7 @@ class WxAuthService extends BaseWxService { protected bool $needLogin = false; - private const DEFAULT_AVATAR = 'http://qiniu.boerman.top/b_2010f38d40d7d4426787b9131020e2a3.png'; + private const DEFAULT_AVATAR = 'https://qiniu.boerman.top/b_2010f38d40d7d4426787b9131020e2a3.png'; /** * code 登录:老用户更新 session_key,新用户建档并送一份默认清单 diff --git a/app/Service/wx/WxListService.php b/app/Service/wx/WxListService.php index 9317b650..7117162c 100644 --- a/app/Service/wx/WxListService.php +++ b/app/Service/wx/WxListService.php @@ -5,6 +5,8 @@ namespace App\Service\wx; use App\BaseApp\BaseWxService; use App\Models\business\ListItemModel; use App\Models\business\ListModel; +use App\Models\business\PriceSheetModel; +use App\Service\business\PackageQuoteService; use App\Service\business\PriceService; use App\Service\business\SerialNoService; @@ -78,6 +80,7 @@ class WxListService extends BaseWxService $info = ListModel::with([ 'items' => fn ($query) => $query->where('deleted_at', 0), 'items.catalogue', + 'items.priceSheet', 'items.catalogue.priceSheet' => fn ($query) => $query->where('deleted_at', 0), ])->where('id', $id) ->where('user_id', $this->userId) @@ -93,6 +96,23 @@ class WxListService extends BaseWxService $multiplier = $this->priceMultiplier(); foreach ($info['items'] as &$item) { $sheet = $item['catalogue']['price_sheet'] ?? []; + $rawRoutine = ''; + foreach ($sheet as $line) { + if ((int) ($line['id'] ?? 0) === (int) ($item['price_sheet_id'] ?? 0)) { + $rawRoutine = (string) ($line['routine'] ?? ''); + break; + } + } + if ($rawRoutine === '') { + $rawRoutine = (string) ($item['price_sheet']['routine'] ?? ''); + } + if ((int) ($item['unit_price'] ?? 0) <= 0) { + $item['unit_price'] = $price->resolveUnitPrice( + $rawRoutine, + (string) ($item['material_key'] ?? 'routine'), + $multiplier + ); + } if (!empty($sheet)) { $applied = $price->applyToRows($sheet, $showPrice, $multiplier); $item['catalogue']['price_sheet'] = $applied['rows']; @@ -102,6 +122,13 @@ class WxListService extends BaseWxService } unset($item); $info['is_show_price'] = $showPrice; + $payable = PackageQuoteService::getInstance()->listPayable( + (int) ($info['package_id'] ?? 0), + (int) ($info['package_amount'] ?? 0), + (int) ($info['original_amount'] ?? 0), + $info['items'] + ); + $info = array_merge($info, PackageQuoteService::getInstance()->withText($payable, $showPrice)); return $info; } @@ -210,6 +237,22 @@ class WxListService extends BaseWxService if (array_key_exists('remark', $params)) { $update['remark'] = (string) $params['remark']; } + $item = ListItemModel::where('id', $itemId)->whereIn('list_id', $listIds)->first(); + if (empty($item)) { + $this->utils->errorThrow('明细不存在'); + } + $sheetId = (int) ($update['price_sheet_id'] ?? $item['price_sheet_id']); + $material = (string) ($update['material_key'] ?? $item['material_key'] ?? 'routine'); + if ($sheetId > 0) { + $sheet = PriceSheetModel::where('id', $sheetId)->where('deleted_at', 0)->first(); + if (!empty($sheet)) { + $update['unit_price'] = PriceService::getInstance()->resolveUnitPrice( + (string) ($sheet['routine'] ?? ''), + $material, + $this->priceMultiplier() + ); + } + } return ListItemModel::where('id', $itemId)->whereIn('list_id', $listIds)->update($update); } } diff --git a/app/Service/wx/WxPackageService.php b/app/Service/wx/WxPackageService.php new file mode 100644 index 00000000..a852915e --- /dev/null +++ b/app/Service/wx/WxPackageService.php @@ -0,0 +1,274 @@ +media = MediaUrlService::getInstance(); + $this->quote = PackageQuoteService::getInstance(); + $this->price = PriceService::getInstance(); + } + + /** + * 首页热门 + */ + public function hot(): array + { + if (!$this->featureEnabled()) { + return []; + } + $rows = PackageModel::where('deleted_at', 0) + ->where('status', 0) + ->where('is_hot', 1) + ->orderBy('sort') + ->orderBy('id', 'desc') + ->get(); + return $this->mapCards($rows->toArray()); + } + + /** + * 套餐 tab 列表;keyword / name 按名称、副标题模糊查 + */ + public function list(): array + { + if (!$this->featureEnabled()) { + return []; + } + $page = max(1, (int) request()->get('page', 1)); + $pageSize = max(1, (int) request()->get('pageSize', 20)); + $keyword = trim((string) request()->get('keyword', request()->get('name', ''))); + $query = PackageModel::where('deleted_at', 0)->where('status', 0)->orderBy('sort')->orderBy('id', 'desc'); + if ($keyword !== '') { + $query->where(function ($inner) use ($keyword) { + $inner->where('name', 'like', '%' . $keyword . '%') + ->orWhere('subtitle', 'like', '%' . $keyword . '%'); + }); + } + $total = (clone $query)->count(); + $rows = $query->forPage($page, $pageSize)->get()->toArray(); + return [ + 'items' => $this->mapCards($rows), + 'total' => $total, + 'page' => $page, + 'pageSize' => $pageSize, + 'has_more' => ($page * $pageSize) < $total, + ]; + } + + /** + * 套餐详情(含搭配与价格) + * + * 带 p_user_id 时与商品详情同一套:代理商分享进来的普通用户继承倍率。 + */ + public function detail(int $id, int $shareUserId = 0): array + { + if ($shareUserId > 0) { + $this->inheritAgentPrice($shareUserId); + } + if (!$this->featureEnabled()) { + $this->utils->errorThrow('套餐功能未启用'); + } + $info = PackageModel::with([ + 'items' => fn ($query) => $query->where('deleted_at', 0)->orderBy('sort')->orderBy('id'), + 'items.catalogue', + 'items.priceSheet', + ])->where('id', $id)->where('deleted_at', 0)->where('status', 0)->first(); + if (empty($info)) { + $this->utils->errorThrow('套餐不存在或已下架'); + } + $info = $info->toArray(); + $show = $this->canSeePrice(); + $multiplier = $this->priceMultiplier(); + $info['cover'] = $this->media->toPublic($info['cover'] ?? ''); + $info['item_count'] = count($info['items'] ?? []); + foreach ($info['items'] as &$item) { + $item['title'] = $item['catalogue']['title'] ?? ''; + $item['cover'] = $this->media->toPublic($item['catalogue']['cover'] ?? ''); + $item['specification'] = $item['price_sheet']['specification'] ?? ''; + $item['dimension'] = $item['price_sheet']['dimension'] ?? ''; + $unit = $this->quote->applyMultiplier((int) ($item['unit_price'] ?? 0), $multiplier); + $item['unit_price'] = $unit; + $item['unit_price_text'] = $show ? $this->price->centsToYuan($unit) : PriceService::MASK; + $item['product'] = $item['catalogue'] ?? null; + } + unset($item); + return $this->withWxAmounts($info, $show, $multiplier); + } + + /** + * 一件加入清单:记下套餐价/原价和每行快照,供以后补差价 + */ + public function toList(array $params): array + { + // 写操作必须登录,构造时 needLogin=false 是为了列表免鉴权 + if ($this->userId <= 0) { + $this->utils->errorThrow('请先登录'); + } + if (!$this->featureEnabled()) { + $this->utils->errorThrow('套餐功能未启用'); + } + $packageId = (int) ($params['package_id'] ?? 0); + $detail = $this->detail($packageId); + $items = $detail['items'] ?? []; + if (empty($items)) { + $this->utils->errorThrow('套餐还没有搭配商品'); + } + + $listId = (int) ($params['list_id'] ?? 0); + $list = null; + if ($listId > 0) { + $list = ListModel::where('id', $listId) + ->where('user_id', $this->userId) + ->where('deleted_at', 0) + ->first(); + if (empty($list)) { + $this->utils->errorThrow('清单不存在'); + } + $bound = (int) ($list['package_id'] ?? 0); + if ($bound > 0 && $bound !== $packageId) { + $this->utils->errorThrow('该清单已绑定其他套餐,请新建清单'); + } + } + + // detail() 已经乘过用户倍率,这里直接快照,不能再乘一次 + $packageAmount = (int) ($detail['package_amount'] ?? 0); + $originalAmount = (int) ($detail['original_amount'] ?? 0); + + $newListId = 0; + DB::connection('business')->transaction(function () use ( + &$newListId, $list, $packageId, $packageAmount, $originalAmount, $items, $detail + ) { + $now = time(); + if (empty($list)) { + $newListId = ListModel::insertGetId([ + 'list_no' => SerialNoService::getInstance()->generate(SerialNoService::PREFIX_LIST, 'list', 'list_no'), + 'user_id' => $this->userId, + 'name' => (string) ($detail['name'] ?? '套餐'), + 'remark' => '', + 'package_id' => $packageId, + 'package_amount' => $packageAmount, + 'original_amount' => $originalAmount, + 'created_at' => $now, + ]); + } else { + $newListId = (int) $list['id']; + $update = ['updated_at' => $now]; + if ((int) ($list['package_id'] ?? 0) === 0) { + $update['package_id'] = $packageId; + $update['package_amount'] = $packageAmount; + $update['original_amount'] = $originalAmount; + } + ListModel::where('id', $newListId)->update($update); + } + + foreach ($items as $item) { + $catalogueId = (int) ($item['catalogue_id'] ?? 0); + $sheetId = (int) ($item['price_sheet_id'] ?? 0); + $qty = max(1, (int) ($item['quantity'] ?? 1)); + $unit = (int) ($item['unit_price'] ?? 0); + $exists = ListItemModel::where('list_id', $newListId) + ->where('catalogue_id', $catalogueId) + ->where('price_sheet_id', $sheetId) + ->where('deleted_at', 0) + ->first(); + if (!empty($exists)) { + ListItemModel::where('id', $exists['id'])->update([ + 'quantity' => (int) $exists['quantity'] + $qty, + 'unit_price' => $unit, + 'package_item_id' => (int) ($item['id'] ?? 0), + 'updated_at' => $now, + ]); + continue; + } + ListItemModel::insert([ + 'list_id' => $newListId, + 'catalogue_id' => $catalogueId, + 'price_sheet_id' => $sheetId, + 'material_key' => (string) ($item['material_key'] ?? 'routine'), + 'quantity' => $qty, + 'unit_price' => $unit, + 'package_item_id' => (int) ($item['id'] ?? 0), + 'snapshot_unit_price' => $unit, + 'snapshot_quantity' => $qty, + 'remark' => (string) ($item['remark'] ?? ''), + 'created_at' => $now, + ]); + } + }); + + return ['list_id' => $newListId]; + } + + /** + * 当前品牌是否打开套餐 + */ + public function featureEnabled(): bool + { + return WxAppService::getInstance()->packageEnabled(); + } + + /** + * 卡片列表:封面、件数、有价权才带价格 + */ + private function mapCards(array $rows): array + { + $ids = array_column($rows, 'id'); + $counts = PackageItemModel::whereIn('package_id', $ids) + ->where('deleted_at', 0) + ->selectRaw('package_id, count(*) as total') + ->groupBy('package_id') + ->get() + ->keyBy('package_id'); + $show = $this->canSeePrice(); + $multiplier = $this->priceMultiplier(); + foreach ($rows as &$row) { + $row['cover'] = $this->media->toPublic($row['cover'] ?? ''); + $row['item_count'] = (int) ($counts[$row['id']]['total'] ?? 0); + $row = $this->withWxAmounts($row, $show, $multiplier); + unset($row['remark']); + } + unset($row); + return $rows; + } + + /** + * 把基准价乘倍率,并按价权决定是否打码 + */ + private function withWxAmounts(array $row, bool $show, mixed $multiplier): array + { + $original = $this->quote->applyMultiplier((int) ($row['original_amount'] ?? 0), $multiplier); + $package = $this->quote->applyMultiplier((int) ($row['package_amount'] ?? 0), $multiplier); + $row['original_amount'] = $original; + $row['package_amount'] = $package; + $row['discount_amount'] = max(0, $original - $package); + $row['is_show_price'] = $show; + return $this->quote->withText($row, $show); + } +} diff --git a/app/Service/wx/WxProductService.php b/app/Service/wx/WxProductService.php index 74682a1e..5ac8dfbf 100644 --- a/app/Service/wx/WxProductService.php +++ b/app/Service/wx/WxProductService.php @@ -6,7 +6,6 @@ use App\BaseApp\BaseWxService; use App\Models\business\CatalogueModel; use App\Models\business\CategoryModel; use App\Models\business\ImageModel; -use App\Models\business\WxUserModel; use App\Service\business\PriceService; /** @@ -92,34 +91,4 @@ class WxProductService extends BaseWxService $info['is_show_price'] = $applied['is_show_price']; return $info; } - - /** - * 继承分享代理商的倍率 - * - * 只有「还没有上级、自己也不是代理商」的用户会被绑定;已绑定同一个上级时同步最新倍率, - * 代理商改了倍率下级要跟着变。 - */ - private function inheritAgentPrice(int $shareUserId): void - { - $isAgent = (int) ($this->userInfo['is_p'] ?? 0) === 1; - $pid = (int) ($this->userInfo['pid'] ?? 0); - if ($isAgent) { - return; - } - if ($pid !== 0 && $pid !== $shareUserId) { - return; - } - $agent = WxUserModel::where('id', $shareUserId)->where('deleted_at', 0)->first(); - if (empty($agent) || (int) $agent['is_p'] !== 1) { - return; - } - $this->userInfo['show_price'] = 1; - $this->userInfo['price_number'] = $agent['price_number']; - WxUserModel::where('id', $this->userId)->update([ - 'pid' => $shareUserId, - 'show_price' => 1, - 'price_number' => $agent['price_number'], - 'updated_at' => time(), - ]); - } } diff --git a/app/Service/wx/WxThemeService.php b/app/Service/wx/WxThemeService.php index 78c08999..4c8aa37f 100644 --- a/app/Service/wx/WxThemeService.php +++ b/app/Service/wx/WxThemeService.php @@ -6,6 +6,7 @@ use App\BaseApp\BaseWxService; use App\Models\business\WxTemplateModel; use App\Models\business\WxUserModel; use App\Models\WxAppModel; +use App\Service\business\WxCardSchemeService; use App\Service\business\WxTemplatePresetService; use App\Service\business\WxTemplateSchemaService; @@ -68,32 +69,88 @@ class WxThemeService extends BaseWxService $preset = WxTemplatePresetService::getInstance()->all()[0] ?? []; $tokens = $preset['tokens'] ?? []; $layout = $preset['layout'] ?? []; - return [ - 'code' => (string) ($preset['code'] ?? 'lux-champagne'), - 'name' => (string) ($preset['name'] ?? '轻奢·香槟金'), - 'style_tag' => (string) ($preset['style_tag'] ?? '轻奢'), - 'version' => 0, - 'tokens' => $tokens, - 'layout' => $layout, - 'css_vars' => $schema->toCssVariables($tokens), - 'fallback' => true, - ]; + return $this->packTheme( + (string) ($preset['code'] ?? 'lux-champagne'), + (string) ($preset['name'] ?? '轻奢·香槟金'), + (string) ($preset['style_tag'] ?? '轻奢'), + 0, + $tokens, + $layout, + $schema, + true, + ); } $template = $template->toArray(); $tokens = is_array($template['tokens']) ? $template['tokens'] : []; + $layout = is_array($template['layout']) ? $template['layout'] : []; + return $this->packTheme( + (string) $template['code'], + (string) $template['name'], + (string) $template['style_tag'], + (int) $template['version'], + $tokens, + $layout, + $schema, + false, + ); + } + + /** + * 组装主题包:令牌 + 布局 + 本模板引用到的卡片方案 + */ + private function packTheme( + string $code, + string $name, + string $styleTag, + int $version, + array $tokens, + array $layout, + WxTemplateSchemaService $schema, + bool $fallback, + ): array { return [ - 'code' => (string) $template['code'], - 'name' => (string) $template['name'], - 'style_tag' => (string) $template['style_tag'], - 'version' => (int) $template['version'], + 'code' => $code, + 'name' => $name, + 'style_tag' => $styleTag, + 'version' => $version, 'tokens' => $tokens, - 'layout' => is_array($template['layout']) ? $template['layout'] : [], + 'layout' => $layout, 'css_vars' => $schema->toCssVariables($tokens), - 'fallback' => false, + 'card_schemes' => WxCardSchemeService::mapByCodes($this->collectSchemeCodes($layout)), + 'features' => [ + 'package_enabled' => WxAppService::getInstance()->packageEnabled(), + ], + 'fallback' => $fallback, ]; } + /** + * 从根 card_scheme、页级 pages.*.card_scheme、区块 props.card_scheme 收集引用 + * + * @return array + */ + private function collectSchemeCodes(array $layout): array + { + $codes = []; + if (!empty($layout['card_scheme'])) { + $codes[] = (string) $layout['card_scheme']; + } + foreach ((array) ($layout['pages'] ?? []) as $page) { + $pageScheme = (string) ($page['card_scheme'] ?? ''); + if ($pageScheme !== '') { + $codes[] = $pageScheme; + } + foreach ((array) ($page['sections'] ?? []) as $section) { + $code = (string) ($section['props']['card_scheme'] ?? ''); + if ($code !== '') { + $codes[] = $code; + } + } + } + return $codes; + } + /** * 可选风格列表,用于小程序里让用户自己换肤 */ diff --git a/bootstrap/app.php b/bootstrap/app.php index ddb03ae3..6715ad37 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -26,6 +26,8 @@ return Application::configure(basePath: dirname(__DIR__)) 'nl.auth' => \App\Http\Middleware\ApiAuthMiddleware::class, // 小程序登录态,只挂在 wx 路由组上(与后台 token 互不通用) 'nl.wx' => \App\Http\Middleware\WxAuthMiddleware::class, + // 小程序成功响应 result 内 http→https(对齐老 ApiMiddleware::checkHttp) + 'nl.wx.https' => \App\Http\Middleware\WxHttpsRewriteMiddleware::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { diff --git a/config/lgp_menu.php b/config/lgp_menu.php index 74d9f734..1cf48b78 100644 --- a/config/lgp_menu.php +++ b/config/lgp_menu.php @@ -24,6 +24,7 @@ return [ ['title' => '商品相册', 'name' => 'GoodsImage', 'path' => '/goods/image', 'component' => '/goods/image/index', 'icon' => 'lucide:images', 'sort' => 3], ['title' => '轮播图', 'name' => 'GoodsCarousel', 'path' => '/goods/carousel', 'component' => '/goods/carousel/index', 'icon' => 'lucide:gallery-horizontal', 'sort' => 4], ['title' => '报价单', 'name' => 'GoodsQuote', 'path' => '/goods/quote', 'component' => '/goods/quote/index', 'icon' => 'lucide:file-text', 'sort' => 5], + ['title' => '套餐搭配', 'name' => 'GoodsPackage', 'path' => '/goods/package', 'component' => '/goods/package/index', 'icon' => 'lucide:package', 'sort' => 6], ], ], [ diff --git a/config/wx_card_schemes.php b/config/wx_card_schemes.php new file mode 100644 index 00000000..7f39d21c --- /dev/null +++ b/config/wx_card_schemes.php @@ -0,0 +1,222 @@ + $id, + 'type' => $type, + 'x' => 0, + 'y' => 0, + 'w' => 100, + 'h' => 40, + 'rotate' => 0, + 'z' => 1, + 'visible' => true, + 'locked' => false, + ], $opts); +}; + +$pack = static function (string $code, string $name, string $family, array $layers): array { + return [ + 'code' => $code, + 'name' => $name, + 'family' => $family, + 'is_preset' => 1, + 'size' => ['w' => 340, 'h' => 420], + 'layers' => $layers, + ]; +}; + +return [ + $pack('clay-edge', '素胚纸边', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('edge', 'deco', ['asset' => 'paper-edge', 'tone' => 'primary', 'x' => 10, 'y' => 10, 'w' => 320, 'h' => 400, 'z' => 1]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 36, 'y' => 40, 'w' => 268, 'h' => 220, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 36, 'y' => 288, 'w' => 268, 'h' => 72, 'font' => 'title', 'z' => 5]), + ]), + $pack('pearl-line', '珠贝双线', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('line', 'deco', ['asset' => 'pearl-line', 'tone' => 'primary_soft', 'x' => 14, 'y' => 14, 'w' => 312, 'h' => 392, 'z' => 1]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 40, 'y' => 28, 'w' => 260, 'h' => 260, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 40, 'y' => 308, 'w' => 260, 'h' => 56, 'font' => 'caption', 'z' => 5]), + ]), + $pack('walnut-frame', '胡桃厚框', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'bg_soft', 'z' => 0, 'locked' => true]), + $layer('frame', 'deco', ['asset' => 'walnut-frame', 'tone' => 'primary_strong', 'x' => 8, 'y' => 8, 'w' => 324, 'h' => 404, 'z' => 1]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 36, 'y' => 36, 'w' => 268, 'h' => 268, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 36, 'y' => 322, 'w' => 268, 'h' => 56, 'font' => 'title', 'z' => 5]), + ]), + $pack('champagne-foil', '香槟金箔', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('foil', 'deco', ['asset' => 'gold-foil', 'tone' => 'primary', 'x' => 16, 'y' => 16, 'w' => 308, 'h' => 388, 'z' => 1]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 32, 'y' => 32, 'w' => 276, 'h' => 232, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 32, 'y' => 280, 'w' => 220, 'h' => 48, 'font' => 'title', 'z' => 5]), + $layer('stamp', 'deco', ['asset' => 'wax-seal', 'tone' => 'accent', 'x' => 264, 'y' => 348, 'w' => 52, 'h' => 52, 'z' => 6]), + ]), + $pack('museum-mat', '博物馆卡纸', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface_soft', 'z' => 0, 'locked' => true]), + $layer('mat', 'deco', ['asset' => 'museum-mat', 'tone' => 'border', 'x' => 12, 'y' => 12, 'w' => 316, 'h' => 292, 'z' => 1]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 40, 'y' => 40, 'w' => 260, 'h' => 236, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 24, 'y' => 328, 'w' => 292, 'h' => 52, 'font' => 'caption', 'z' => 5]), + ]), + $pack('folio-line', '刊页双线', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('folio', 'deco', ['asset' => 'folio-line', 'tone' => 'primary', 'x' => 18, 'y' => 18, 'w' => 304, 'h' => 384, 'z' => 1]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 36, 'y' => 48, 'w' => 268, 'h' => 220, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 36, 'y' => 292, 'w' => 268, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('gold-corner', '金角护片', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('corners', 'deco', ['asset' => 'gold-corner', 'tone' => 'primary', 'x' => 8, 'y' => 8, 'w' => 324, 'h' => 404, 'z' => 6]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 24, 'y' => 24, 'w' => 292, 'h' => 260, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 24, 'y' => 304, 'w' => 292, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('emboss-plate', '浮雕铭牌', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 20, 'y' => 20, 'w' => 300, 'h' => 250, 'z' => 2]), + $layer('plate', 'deco', ['asset' => 'emboss-plate', 'tone' => 'primary_soft', 'x' => 48, 'y' => 292, 'w' => 244, 'h' => 88, 'z' => 4]), + $layer('title', 'text', ['bind' => 'name', 'x' => 60, 'y' => 312, 'w' => 220, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('thin-rule', '细金线题签', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 16, 'y' => 16, 'w' => 308, 'h' => 268, 'z' => 2]), + $layer('rule', 'deco', ['asset' => 'thin-rule', 'tone' => 'primary', 'x' => 24, 'y' => 300, 'w' => 120, 'h' => 8, 'z' => 4]), + $layer('title', 'text', ['bind' => 'name', 'x' => 24, 'y' => 318, 'w' => 292, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('inset-slot', '内凹卡槽', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'bg_soft', 'z' => 0, 'locked' => true]), + $layer('slot', 'deco', ['asset' => 'inset-shadow', 'tone' => 'border', 'x' => 20, 'y' => 20, 'w' => 300, 'h' => 268, 'z' => 1]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 36, 'y' => 36, 'w' => 268, 'h' => 236, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 28, 'y' => 312, 'w' => 284, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('double-frame', '双线金框', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('outer', 'deco', ['asset' => 'double-frame', 'tone' => 'primary', 'x' => 10, 'y' => 10, 'w' => 320, 'h' => 400, 'z' => 1]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 34, 'y' => 34, 'w' => 272, 'h' => 248, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 34, 'y' => 304, 'w' => 272, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('arch-mat', '拱形衬纸', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('arch', 'deco', ['asset' => 'arch-mat', 'tone' => 'primary_soft', 'x' => 28, 'y' => 20, 'w' => 284, 'h' => 280, 'z' => 1]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 44, 'y' => 36, 'w' => 252, 'h' => 248, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 28, 'y' => 320, 'w' => 284, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('circle-window', '圆形开窗', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('ring', 'deco', ['asset' => 'circle-crop', 'tone' => 'primary', 'x' => 46, 'y' => 24, 'w' => 248, 'h' => 248, 'z' => 3]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 58, 'y' => 36, 'w' => 224, 'h' => 224, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 24, 'y' => 300, 'w' => 292, 'h' => 52, 'font' => 'title', 'z' => 5]), + ]), + $pack('l-bracket', 'L 型护角', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 28, 'y' => 28, 'w' => 284, 'h' => 252, 'z' => 2]), + $layer('br', 'deco', ['asset' => 'corner-bracket', 'tone' => 'accent', 'x' => 12, 'y' => 12, 'w' => 316, 'h' => 284, 'z' => 6]), + $layer('title', 'text', ['bind' => 'name', 'x' => 28, 'y' => 308, 'w' => 284, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('vignette', '暗角剧照', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 0, 'y' => 0, 'w' => 340, 'h' => 300, 'z' => 2]), + $layer('vig', 'deco', ['asset' => 'vignette', 'tone' => 'text', 'x' => 0, 'y' => 0, 'w' => 340, 'h' => 300, 'z' => 3]), + $layer('title', 'text', ['bind' => 'name', 'x' => 20, 'y' => 324, 'w' => 300, 'h' => 52, 'font' => 'title', 'z' => 5]), + ]), + $pack('cap-rail', '上沿帽檐', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('cap', 'deco', ['asset' => 'cap-rail', 'tone' => 'primary', 'x' => 0, 'y' => 0, 'w' => 340, 'h' => 28, 'z' => 6]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 16, 'y' => 36, 'w' => 308, 'h' => 252, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 20, 'y' => 308, 'w' => 300, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('gutter-fold', '对开折页', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 16, 'y' => 16, 'w' => 308, 'h' => 268, 'z' => 2]), + $layer('fold', 'deco', ['asset' => 'gutter-fold', 'tone' => 'border', 'x' => 164, 'y' => 16, 'w' => 12, 'h' => 268, 'z' => 4]), + $layer('title', 'text', ['bind' => 'name', 'x' => 20, 'y' => 308, 'w' => 300, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('pearl-bead', '软圆角珠边', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('bead', 'deco', ['asset' => 'pearl-bead', 'tone' => 'accent', 'x' => 12, 'y' => 12, 'w' => 316, 'h' => 396, 'z' => 1]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 36, 'y' => 36, 'w' => 268, 'h' => 236, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 36, 'y' => 296, 'w' => 268, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('vert-caption', '侧向竖排', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 16, 'y' => 16, 'w' => 248, 'h' => 388, 'z' => 2]), + $layer('rail', 'deco', ['asset' => 'vert-caption', 'tone' => 'primary', 'x' => 276, 'y' => 24, 'w' => 48, 'h' => 372, 'z' => 4]), + $layer('title', 'text', ['bind' => 'name', 'x' => 268, 'y' => 80, 'w' => 64, 'h' => 260, 'font' => 'caption', 'rotate' => 90, 'z' => 5]), + ]), + $pack('ribbon-bow', '缎带压角', 'lux', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 24, 'y' => 36, 'w' => 292, 'h' => 248, 'z' => 2]), + $layer('bow', 'deco', ['asset' => 'ribbon', 'tone' => 'accent', 'x' => 236, 'y' => 8, 'w' => 88, 'h' => 56, 'rotate' => 8, 'z' => 6]), + $layer('title', 'text', ['bind' => 'name', 'x' => 24, 'y' => 308, 'w' => 292, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('polaroid', '宝丽来题签', 'editorial', [ + $layer('bg', 'shape', ['x' => 18, 'y' => 12, 'w' => 304, 'h' => 396, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 34, 'y' => 28, 'w' => 272, 'h' => 272, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 34, 'y' => 320, 'w' => 272, 'h' => 56, 'font' => 'caption', 'z' => 5]), + ]), + $pack('stacked-offset', '错层影卡', 'editorial', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('deck', 'deco', ['asset' => 'shadow-deck', 'tone' => 'border', 'x' => 52, 'y' => 40, 'w' => 236, 'h' => 200, 'rotate' => 7, 'z' => 1]), + $layer('photo-back', 'photo', ['bind' => 'gallery', 'x' => 52, 'y' => 40, 'w' => 236, 'h' => 200, 'rotate' => 7, 'z' => 1]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 28, 'y' => 52, 'w' => 236, 'h' => 200, 'rotate' => -3, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 24, 'y' => 292, 'w' => 292, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('film-sprocket', '胶片齿孔', 'editorial', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface_soft', 'z' => 0, 'locked' => true]), + $layer('film', 'deco', ['asset' => 'film-sprocket', 'tone' => 'text_soft', 'x' => 8, 'y' => 36, 'w' => 324, 'h' => 248, 'z' => 3]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 36, 'y' => 52, 'w' => 268, 'h' => 216, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 24, 'y' => 312, 'w' => 292, 'h' => 48, 'font' => 'caption', 'z' => 5]), + ]), + $pack('folio-number', '刊号页码', 'editorial', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('num', 'deco', ['asset' => 'folio-number', 'tone' => 'primary', 'x' => 20, 'y' => 16, 'w' => 88, 'h' => 36, 'z' => 6]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 20, 'y' => 60, 'w' => 300, 'h' => 240, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 20, 'y' => 320, 'w' => 300, 'h' => 48, 'font' => 'title', 'z' => 5]), + $layer('text-vol', 'text', ['text' => 'VOL. 08', 'x' => 24, 'y' => 18, 'w' => 80, 'h' => 28, 'font' => 'caption', 'z' => 7]), + ]), + $pack('stamp-ring', '邮戳圆环', 'editorial', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 24, 'y' => 24, 'w' => 292, 'h' => 252, 'z' => 2]), + $layer('stamp', 'deco', ['asset' => 'stamp-ring', 'tone' => 'accent', 'x' => 232, 'y' => 300, 'w' => 84, 'h' => 84, 'rotate' => -12, 'z' => 6]), + $layer('title', 'text', ['bind' => 'name', 'x' => 24, 'y' => 300, 'w' => 196, 'h' => 56, 'font' => 'title', 'z' => 5]), + ]), + $pack('lace-corner', '蕾丝角花', 'editorial', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('lace', 'deco', ['asset' => 'lace-corner', 'tone' => 'primary_soft', 'x' => 6, 'y' => 6, 'w' => 328, 'h' => 408, 'z' => 6]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 36, 'y' => 40, 'w' => 268, 'h' => 236, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 36, 'y' => 300, 'w' => 268, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('washi-tape', '和纸胶带', 'atelier', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 28, 'y' => 40, 'w' => 284, 'h' => 236, 'z' => 2]), + $layer('tape-t', 'deco', ['asset' => 'washi-1', 'tone' => 'accent', 'x' => 44, 'y' => 16, 'w' => 96, 'h' => 26, 'rotate' => 8, 'z' => 6]), + $layer('tape-b', 'deco', ['asset' => 'washi-1', 'tone' => 'primary_soft', 'x' => 200, 'y' => 252, 'w' => 88, 'h' => 24, 'rotate' => -7, 'z' => 6]), + $layer('title', 'text', ['bind' => 'name', 'x' => 28, 'y' => 304, 'w' => 284, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('botanical', '干花压角', 'atelier', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 32, 'y' => 40, 'w' => 276, 'h' => 236, 'z' => 2]), + $layer('leaf-tl', 'deco', ['asset' => 'botanical', 'tone' => 'accent', 'x' => 4, 'y' => 4, 'w' => 80, 'h' => 80, 'rotate' => -14, 'z' => 6]), + $layer('leaf-br', 'deco', ['asset' => 'botanical', 'tone' => 'primary', 'x' => 256, 'y' => 336, 'w' => 72, 'h' => 72, 'rotate' => 148, 'z' => 6]), + $layer('title', 'text', ['bind' => 'name', 'x' => 32, 'y' => 296, 'w' => 276, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('torn-edge', '撕边剪贴', 'atelier', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('torn', 'deco', ['asset' => 'torn-edge', 'tone' => 'text_soft', 'x' => 20, 'y' => 24, 'w' => 300, 'h' => 260, 'rotate' => -1, 'z' => 1]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 32, 'y' => 36, 'w' => 276, 'h' => 236, 'z' => 2]), + $layer('title', 'text', ['bind' => 'name', 'x' => 28, 'y' => 308, 'w' => 284, 'h' => 48, 'font' => 'title', 'z' => 5]), + ]), + $pack('handwriting', '手写题签', 'atelier', [ + $layer('bg', 'shape', ['x' => 0, 'y' => 0, 'w' => 340, 'h' => 420, 'fill' => 'surface', 'z' => 0, 'locked' => true]), + $layer('photo', 'photo', ['bind' => 'cover', 'x' => 24, 'y' => 20, 'w' => 292, 'h' => 268, 'z' => 2]), + $layer('tag', 'deco', ['asset' => 'handwriting', 'tone' => 'primary', 'x' => 28, 'y' => 348, 'w' => 200, 'h' => 36, 'rotate' => -3, 'z' => 6]), + $layer('title', 'text', ['bind' => 'name', 'x' => 36, 'y' => 308, 'w' => 240, 'h' => 40, 'font' => 'caption', 'rotate' => -2, 'z' => 5]), + ]), +]; diff --git a/config/wx_page_presets.php b/config/wx_page_presets.php new file mode 100644 index 00000000..7dc7261f --- /dev/null +++ b/config/wx_page_presets.php @@ -0,0 +1,419 @@ + $type, + 'type' => $type, + 'variant' => $variant, + 'visible' => $visible, + 'props' => $props, + ]; +}; + +$row = static function ( + string $code, + string $name, + string $page, + array $sections, + string $cardScheme = 'clay-edge', + string $density = 'regular', + string $frame = 'none', + string $skin = 'shop', + string $family = '', +): array { + return [ + 'code' => $code, + 'name' => $name, + 'page' => $page, + 'card_scheme' => $cardScheme, + 'density' => $density, + 'frame' => $frame, + 'skin' => $skin, + 'family' => $family, + 'sections' => $sections, + ]; +}; + +$home = [ + $row('home-01-classic', '经典轮播宫格', 'home', [ + $sec('search', 'bar'), $sec('hero', 'carousel', true, ['height' => 480]), + $sec('category', 'grid', true, ['cols' => 3]), $sec('product', 'grid', false), + ], 'clay-edge', 'regular', 'none', 'shop', '店面'), + $row('home-02-banner-pills', '横幅胶囊', 'home', [ + $sec('search', 'pill'), $sec('hero', 'banner', true, ['height' => 280]), + $sec('category', 'pills'), $sec('product', 'grid', false), + ], 'pearl-line', 'compact', 'none', 'shop', '店面'), + $row('home-03-split-card', '对开卡片', 'home', [ + $sec('search', 'bar'), $sec('hero', 'split', true, ['height' => 420]), + $sec('category', 'card', true, ['cols' => 2]), $sec('product', 'grid', false), + ], 'walnut-frame', 'regular', 'inset', 'shop', '店面'), + $row('home-04-full-scroll', '全屏横滑', 'home', [ + $sec('search', 'bar'), $sec('hero', 'fullscreen', true, ['height' => 640]), + $sec('category', 'scroll'), $sec('product', 'waterfall', false), + ], 'champagne-foil', 'airy', 'none', 'shop', '店面'), + $row('home-05-stack-grid', '堆叠宫格', 'home', [ + $sec('search', 'bar'), $sec('hero', 'stack', true, ['height' => 520]), + $sec('category', 'grid', true, ['cols' => 3]), $sec('product', 'grid', false), + ], 'double-frame', 'regular', 'none', 'shop', '店面'), + $row('home-06-coverflow-tile', '封面流瓷砖', 'home', [ + $sec('search', 'float'), $sec('hero', 'coverflow', true, ['height' => 480]), + $sec('category', 'tile'), $sec('product', 'grid', false), + ], 'gold-corner', 'regular', 'inset', 'shop', '店面'), + $row('home-07-fade-mosaic', '叠化马赛克', 'home', [ + $sec('search', 'bar'), $sec('hero', 'fade', true, ['height' => 500]), + $sec('category', 'mosaic'), $sec('product', 'grid', false), + ], 'museum-mat', 'airy', 'none', 'shop', '店面'), + $row('home-08-peek-featured', '露边首图', 'home', [ + $sec('search', 'pill'), $sec('hero', 'peek', true, ['height' => 460]), + $sec('category', 'featured'), $sec('product', 'list', false), + ], 'cap-rail', 'regular', 'none', 'shop', '店面'), + $row('home-09-cube-sidebar', '立方侧栏', 'home', [ + $sec('search', 'bar'), $sec('hero', 'cube', true, ['height' => 440]), + $sec('category', 'sidebar'), $sec('product', 'grid', false), + ], 'l-bracket', 'compact', 'inset', 'shop', '店面'), + $row('home-10-caption-grid', '刊名条宫格', 'home', [ + $sec('search', 'bar'), $sec('hero', 'caption', true, ['height' => 520]), + $sec('category', 'grid', true, ['cols' => 4]), $sec('product', 'grid', false), + ], 'thin-rule', 'regular', 'ornament', 'shop', '店面'), + $row('home-11-carousel-card', '轮播大卡', 'home', [ + $sec('search', 'bar'), $sec('hero', 'carousel', true, ['height' => 400]), + $sec('category', 'card', true, ['cols' => 2]), $sec('product', 'waterfall', false), + ], 'inset-slot', 'regular', 'none', 'shop', '店面'), + $row('home-12-banner-tile', '横幅瓷砖', 'home', [ + $sec('search', 'float'), $sec('hero', 'banner', true, ['height' => 240]), + $sec('category', 'tile'), $sec('product', 'grid', false), + ], 'pearl-bead', 'compact', 'none', 'shop', '店面'), + $row('home-13-split-scroll', '对开横滑', 'home', [ + $sec('search', 'pill'), $sec('hero', 'split', true, ['height' => 400]), + $sec('category', 'scroll'), $sec('product', 'grid', false), + ], 'gutter-fold', 'regular', 'none', 'shop', '店面'), + $row('home-14-full-pills', '全屏胶囊', 'home', [ + $sec('search', 'overlay'), $sec('hero', 'fullscreen', true, ['height' => 680]), + $sec('category', 'pills'), $sec('product', 'grid', false), + ], 'vignette', 'airy', 'none', 'shop', '店面'), + $row('home-15-stack-mosaic', '堆叠马赛克', 'home', [ + $sec('search', 'bar'), $sec('hero', 'stack', true, ['height' => 500]), + $sec('category', 'mosaic'), $sec('product', 'grid', false), + ], 'arch-mat', 'regular', 'inset', 'shop', '店面'), + $row('home-16-coverflow-sidebar', '封面流侧栏', 'home', [ + $sec('search', 'bar'), $sec('hero', 'coverflow', true, ['height' => 460]), + $sec('category', 'sidebar'), $sec('product', 'list', false), + ], 'circle-window', 'compact', 'none', 'shop', '店面'), + $row('home-17-fade-card', '叠化卡片', 'home', [ + $sec('search', 'bar'), $sec('hero', 'fade', true, ['height' => 480]), + $sec('category', 'card', true, ['cols' => 2]), $sec('product', 'grid', false), + ], 'emboss-plate', 'airy', 'ornament', 'shop', '店面'), + $row('home-18-peek-grid', '露边宫格', 'home', [ + $sec('search', 'pill'), $sec('hero', 'peek', true, ['height' => 440]), + $sec('category', 'grid', true, ['cols' => 3]), $sec('product', 'grid', false), + ], 'vert-caption', 'regular', 'none', 'shop', '店面'), + $row('home-19-cube-featured', '立方首图', 'home', [ + $sec('search', 'bar'), $sec('hero', 'cube', true, ['height' => 460]), + $sec('category', 'featured'), $sec('product', 'waterfall', false), + ], 'ribbon-bow', 'regular', 'inset', 'shop', '店面'), + $row('home-20-caption-scroll', '刊名横滑', 'home', [ + $sec('search', 'float'), $sec('hero', 'caption', true, ['height' => 500]), + $sec('category', 'scroll'), $sec('product', 'grid', false), + ], 'folio-line', 'airy', 'none', 'shop', '店面'), + $row('home-21-carousel-mosaic', '轮播马赛克', 'home', [ + $sec('search', 'bar'), $sec('hero', 'carousel', true, ['height' => 460]), + $sec('category', 'mosaic'), $sec('product', 'grid', false), + ], 'washi-tape', 'regular', 'none', 'shop', '店面'), + $row('home-22-banner-sidebar', '横幅侧栏', 'home', [ + $sec('search', 'bar'), $sec('hero', 'banner', true, ['height' => 260]), + $sec('category', 'sidebar'), $sec('product', 'list', false), + ], 'botanical', 'compact', 'inset', 'shop', '店面'), + $row('home-23-split-pills', '对开胶囊', 'home', [ + $sec('search', 'pill'), $sec('hero', 'split', true, ['height' => 400]), + $sec('category', 'pills'), $sec('product', 'grid', false), + ], 'handwriting', 'regular', 'none', 'shop', '店面'), + $row('home-24-stack-tile', '堆叠瓷砖', 'home', [ + $sec('search', 'bar'), $sec('hero', 'stack', true, ['height' => 500]), + $sec('category', 'tile'), $sec('product', 'grid', false), + ], 'torn-edge', 'regular', 'none', 'shop', '店面'), + $row('home-25-peek-card', '露边大卡', 'home', [ + $sec('search', 'overlay'), $sec('hero', 'peek', true, ['height' => 480]), + $sec('category', 'card', true, ['cols' => 2]), $sec('product', 'grid', false), + ], 'polaroid', 'airy', 'ornament', 'shop', '店面'), + $row('home-26-cube-grid', '立方宫格', 'home', [ + $sec('search', 'bar'), $sec('hero', 'cube', true, ['height' => 440]), + $sec('category', 'grid', true, ['cols' => 3]), $sec('product', 'grid', false), + ], 'stacked-offset', 'regular', 'none', 'shop', '店面'), + $row('home-magazine-folio', '刊页封面目录', 'home', [ + $sec('search', 'overlay'), $sec('hero', 'caption', true, ['height' => 640]), + $sec('category', 'contents'), $sec('product', 'grid', false), + ], 'folio-number', 'airy', 'ornament', 'magazine', '刊页'), + $row('home-magazine-spread', '刊页对开目录', 'home', [ + $sec('search', 'overlay'), $sec('hero', 'split', true, ['height' => 560]), + $sec('category', 'contents'), $sec('product', 'grid', false), + ], 'folio-line', 'airy', 'ornament', 'magazine', '刊页'), + $row('home-magazine-fade', '刊页叠化目录', 'home', [ + $sec('search', 'overlay'), $sec('hero', 'fade', true, ['height' => 620]), + $sec('category', 'contents'), $sec('product', 'grid', false), + ], 'vignette', 'airy', 'ornament', 'magazine', '刊页'), + $row('home-magazine-film', '刊页胶片目录', 'home', [ + $sec('search', 'overlay'), $sec('hero', 'peek', true, ['height' => 520]), + $sec('category', 'contents'), $sec('product', 'grid', false), + ], 'film-sprocket', 'airy', 'ornament', 'magazine', '刊页'), +]; + +$schemes = [ + 'clay-edge', 'pearl-line', 'walnut-frame', 'champagne-foil', 'museum-mat', + 'double-frame', 'arch-mat', 'circle-window', 'polaroid', 'film-sprocket', +]; + +/** 图册:10 列表 × 3 顶栏,两套之间至少差列表+顶栏 */ +$catalogPairs = []; +foreach (['waterfall', 'grid', 'list', 'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo'] as $list) { + foreach (['chip', 'bar', 'sidebar'] as $filter) { + $catalogPairs[] = [$list, $filter]; + } +} +$catalogNames = [ + '瀑布·分类条', '瀑布·仅搜索', '瀑布·侧栏', + '双列·分类条', '双列·仅搜索', '双列·侧栏', + '单列·分类条', '单列·仅搜索', '单列·侧栏', + '砌石·分类条', '砌石·仅搜索', '砌石·侧栏', + '首图·分类条', '首图·仅搜索', '首图·侧栏', + '货架·分类条', '货架·仅搜索', '货架·侧栏', + '紧凑·分类条', '紧凑·仅搜索', '紧凑·侧栏', + '疏朗·分类条', '疏朗·仅搜索', '疏朗·侧栏', + '马赛克·分类条', '马赛克·仅搜索', '马赛克·侧栏', + '对开·分类条', '对开·仅搜索', '对开·侧栏', +]; +$catalog = []; +foreach ($catalogPairs as $i => [$list, $filter]) { + $n = str_pad((string) ($i + 1), 2, '0', STR_PAD_LEFT); + $catalog[] = $row( + 'catalog-' . $n . '-' . $list, + $catalogNames[$i], + 'catalog', + [$sec('filter', $filter), $sec('list', $list, true, ['card_scheme' => $schemes[$i % 10]])], + $schemes[$i % 10], + 'regular', + 'none', + 'shop', + '图册', + ); +} + +/** 搜索:10 列表 × 3 搜索条 */ +$searchPairs = []; +foreach (['waterfall', 'grid', 'list', 'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo'] as $list) { + foreach (['bar', 'pill', 'float'] as $searchVar) { + $searchPairs[] = [$list, $searchVar]; + } +} +$searchNames = [ + '瀑布·搜索条', '瀑布·胶囊', '瀑布·悬浮', + '双列·搜索条', '双列·胶囊', '双列·悬浮', + '单列·搜索条', '单列·胶囊', '单列·悬浮', + '砌石·搜索条', '砌石·胶囊', '砌石·悬浮', + '首图·搜索条', '首图·胶囊', '首图·悬浮', + '货架·搜索条', '货架·胶囊', '货架·悬浮', + '紧凑·搜索条', '紧凑·胶囊', '紧凑·悬浮', + '疏朗·搜索条', '疏朗·胶囊', '疏朗·悬浮', + '马赛克·搜索条', '马赛克·胶囊', '马赛克·悬浮', + '对开·搜索条', '对开·胶囊', '对开·悬浮', +]; +$search = []; +foreach ($searchPairs as $i => [$list, $searchVar]) { + $n = str_pad((string) ($i + 1), 2, '0', STR_PAD_LEFT); + $search[] = $row( + 'search-' . $n . '-' . $list, + $searchNames[$i], + 'search', + [$sec('search', $searchVar), $sec('list', $list, true, ['card_scheme' => $schemes[$i % 10]])], + $schemes[$i % 10], + 'regular', + 'none', + 'shop', + '搜索', + ); +} + +/** 详情:30 套互异交叉,spec 与 action 不再锁死同一余数 */ +$productCombos = [ + ['swiper', 'plain', 'plain', 'inline', 'fixed', '轮播·表·底栏'], + ['swiper', 'editorial', 'table', 'card', 'inline', '轮播·编辑表·跟文'], + ['stack', 'plain', 'chips', 'inline', 'split', '叠图·芯片·拆开'], + ['stack', 'split', 'cards', 'card', 'fixed', '叠图·分栏卡·底栏'], + ['fullbleed', 'plain', 'table', 'sticky', 'fixed', '全出血·吸顶表'], + ['fullbleed', 'overlay', 'plain', 'sticky', 'split', '全出血·压字·拆开'], + ['peek', 'editorial', 'chips', 'inline', 'inline', '露边·编辑芯片'], + ['peek', 'split', 'cards', 'card', 'fixed', '露边·分栏卡'], + ['fade', 'plain', 'table', 'inline', 'split', '叠化·表·拆开'], + ['fade', 'overlay', 'chips', 'sticky', 'fixed', '叠化·压字芯片'], + ['coverflow', 'editorial', 'cards', 'card', 'inline', '封面流·编辑卡'], + ['coverflow', 'split', 'plain', 'inline', 'fixed', '封面流·分栏表'], + ['mosaic', 'plain', 'chips', 'card', 'split', '拼贴·芯片卡'], + ['mosaic', 'editorial', 'table', 'sticky', 'inline', '拼贴·编辑吸顶'], + ['filmstrip', 'overlay', 'cards', 'inline', 'fixed', '胶卷·压字卡'], + ['filmstrip', 'split', 'plain', 'card', 'split', '胶卷·分栏拆开'], + ['swiper', 'split', 'cards', 'sticky', 'inline', '轮播·分栏吸顶卡'], + ['stack', 'overlay', 'table', 'sticky', 'inline', '叠图·压字吸顶'], + ['fullbleed', 'editorial', 'chips', 'card', 'inline', '全出血·编辑芯片'], + ['peek', 'plain', 'plain', 'card', 'split', '露边·报价卡拆开'], + ['fade', 'editorial', 'cards', 'card', 'fixed', '叠化·编辑卡底栏'], + ['coverflow', 'overlay', 'table', 'sticky', 'split', '封面流·压字吸顶'], + ['mosaic', 'split', 'plain', 'sticky', 'fixed', '拼贴·分栏吸顶'], + ['filmstrip', 'plain', 'table', 'sticky', 'inline', '胶卷·吸顶表'], + ['swiper', 'overlay', 'chips', 'inline', 'split', '轮播·压字芯片'], + ['stack', 'editorial', 'plain', 'card', 'fixed', '叠图·编辑报价卡'], + ['fullbleed', 'split', 'cards', 'inline', 'fixed', '全出血·分栏卡'], + ['peek', 'overlay', 'table', 'sticky', 'inline', '露边·压字吸顶'], + ['fade', 'split', 'plain', 'card', 'inline', '叠化·分栏报价卡'], + ['coverflow', 'plain', 'chips', 'card', 'split', '封面流·芯片拆开'], +]; +$product = []; +foreach ($productCombos as $i => [$g, $info, $spec, $priceMode, $act, $name]) { + $n = str_pad((string) ($i + 1), 2, '0', STR_PAD_LEFT); + $product[] = $row( + 'product-' . $n . '-' . $g, + $name, + 'product', + [ + $sec('gallery', $g), + $sec('info', $info), + $sec('spec', $spec), + $sec('price', $priceMode), + $sec('action', $act), + ], + $schemes[$i % 10], + 'regular', + 'none', + $info === 'overlay' ? 'magazine' : 'shop', + '详情', + ); +} + +/** 清单:6 形态各出现一次主结构,再用 5 组不同卡片方案拉开,避免同形态连号克隆 */ +$cartModes = [ + ['card', '卡片陈列'], + ['table', '表格清单'], + ['timeline', '时间线'], + ['compact', '紧凑条'], + ['ticket', '票根'], + ['stacked', '层叠'], +]; +$cart = []; +for ($i = 0; $i < 30; $i++) { + $n = str_pad((string) ($i + 1), 2, '0', STR_PAD_LEFT); + [$mode, $label] = $cartModes[$i % 6]; + $scheme = $schemes[intdiv($i, 6) % 10]; + $cart[] = $row( + 'cart-' . $n . '-' . $mode, + $label . ' · ' . ['素胚', '珠贝', '胡桃', '香槟', '卡纸'][intdiv($i, 6) % 5], + 'cart', + [$sec('list', $mode)], + $scheme, + 'regular', + 'none', + 'shop', + '清单', + ); +} + +/** 我的:5 头 × 5 菜单 = 25 互异,后 5 套刊页皮肤换菜单 */ +$minePairs = []; +foreach (['gradient', 'image', 'plain', 'split', 'editorial'] as $h) { + foreach (['grid', 'list', 'card', 'tile', 'compact'] as $m) { + $minePairs[] = [$h, $m, 'shop']; + } +} +$minePairs[] = ['editorial', 'list', 'magazine']; +$minePairs[] = ['plain', 'tile', 'magazine']; +$minePairs[] = ['split', 'card', 'magazine']; +$minePairs[] = ['image', 'compact', 'magazine']; +$minePairs[] = ['gradient', 'grid', 'magazine']; +$mineLabels = [ + 'gradient' => '渐变', 'image' => '头图', 'plain' => '素底', 'split' => '分栏', 'editorial' => '编辑', +]; +$menuLabels = [ + 'grid' => '宫格', 'list' => '列表', 'card' => '卡片', 'tile' => '瓷砖', 'compact' => '紧凑', +]; +$mine = []; +foreach ($minePairs as $i => [$h, $m, $skin]) { + $n = str_pad((string) ($i + 1), 2, '0', STR_PAD_LEFT); + $mine[] = $row( + 'mine-' . $n . '-' . $h, + ($mineLabels[$h] ?? $h) . '·' . ($menuLabels[$m] ?? $m) . ($skin === 'magazine' ? '·刊页' : ''), + 'mine', + [$sec('header', $h), $sec('menu', $m)], + $schemes[$i % 10], + 'regular', + $skin === 'magazine' ? 'ornament' : 'none', + $skin, + '我的', + ); +} + +/** 每页最前一条可选「默认」,详情钉在底部 */ +array_unshift($home, $row('home-00-default', '默认', 'home', [ + $sec('search', 'bar'), $sec('hero', 'carousel', true, ['height' => 480]), + $sec('category', 'grid', true, ['cols' => 3]), $sec('product', 'grid', false), +], 'clay-edge', 'regular', 'none', 'shop', '默认')); +array_unshift($catalog, $row('catalog-00-default', '默认', 'catalog', [ + $sec('filter', 'chip'), $sec('list', 'waterfall', true, ['card_scheme' => 'clay-edge']), +], 'clay-edge', 'regular', 'none', 'shop', '默认')); +array_unshift($search, $row('search-00-default', '默认', 'search', [ + $sec('search', 'bar'), $sec('list', 'waterfall', true, ['card_scheme' => 'clay-edge']), +], 'clay-edge', 'regular', 'none', 'shop', '默认')); +array_unshift($product, $row('product-00-default', '默认', 'product', [ + $sec('gallery', 'swiper'), $sec('info', 'plain'), $sec('spec', 'plain'), + $sec('price', 'inline'), $sec('action', 'fixed'), +], 'clay-edge', 'regular', 'none', 'shop', '默认')); +array_unshift($cart, $row('cart-00-default', '默认', 'cart', [ + $sec('list', 'card'), +], 'clay-edge', 'regular', 'none', 'shop', '默认')); +array_unshift($mine, $row('mine-00-default', '默认', 'mine', [ + $sec('header', 'gradient'), $sec('menu', 'list'), +], 'clay-edge', 'regular', 'none', 'shop', '默认')); + +// 旧首页预设没有套餐段,插在分类后面,避免初始化预设把热门套餐漏掉 +foreach ($home as &$homeRow) { + $sections = $homeRow['sections'] ?? []; + $has = false; + foreach ($sections as $section) { + if (($section['type'] ?? '') === 'package') { + $has = true; + break; + } + } + if ($has) { + continue; + } + $next = []; + foreach ($sections as $section) { + $next[] = $section; + if (($section['type'] ?? '') === 'category') { + $next[] = $sec('package', 'card'); + } + } + $homeRow['sections'] = $next; +} +unset($homeRow); + +$package = [ + $row('package-00-default', '默认', 'package', [$sec('list', 'card')], 'clay-edge', 'regular', 'none', 'shop', '默认'), + $row('package-01-card', '封面卡片', 'package', [$sec('list', 'card')], 'champagne-foil', 'regular', 'none', 'shop', '套餐'), + $row('package-02-featured', '首图通栏', 'package', [$sec('list', 'featured')], 'walnut-frame', 'regular', 'inset', 'shop', '套餐'), + $row('package-03-magazine', '刊名目录', 'package', [$sec('list', 'magazine')], 'thin-rule', 'airy', 'ornament', 'magazine', '套餐'), + $row('package-04-list', '单列图文', 'package', [$sec('list', 'list')], 'pearl-line', 'compact', 'none', 'shop', '套餐'), +]; + +return [ + 'home' => $home, + 'catalog' => $catalog, + 'search' => $search, + 'product' => $product, + 'cart' => $cart, + 'mine' => $mine, + 'package' => $package, +]; diff --git a/config/wx_templates.php b/config/wx_templates.php index 1d26ca50..e1bc49ed 100644 --- a/config/wx_templates.php +++ b/config/wx_templates.php @@ -1,234 +1,382 @@ $id, + 'type' => $type, + 'variant' => $variant, + 'visible' => $visible, + 'props' => $props, + ]; +}; + +$buildPages = static function ( + array $home, + array $product, + string $cardScheme, + string $goodsList, + string $listStyle, + string $mineHeader, + string $mineMenu, + string $homePreset = 'home-01-classic', + string $homeSkin = 'shop', + string $searchVariant = 'bar', + string $categoryVariant = '', + array $homeMeta = [], + array $pageCodes = [], + array $chrome = [], +) use ($section): array { + $cat = $categoryVariant !== '' ? $categoryVariant : $home['category']; + return [ + 'card_scheme' => $cardScheme, + 'chrome' => array_merge([ + 'tabbar' => 'plain', + 'navbar' => 'solid', + 'tabbar_anim' => 'slide', + ], $chrome), + 'page_presets' => array_merge([ + 'home' => $homePreset, + 'catalog' => 'catalog-01-waterfall', + 'search' => 'search-01-waterfall', + 'product' => 'product-00-default', + 'cart' => 'cart-01-card', + 'mine' => 'mine-01-gradient', + 'package' => 'package-00-default', + ], $pageCodes), + 'pages' => [ + 'home' => [ + 'skin' => $homeSkin, + 'density' => $homeMeta['density'] ?? 'regular', + 'frame' => $homeMeta['frame'] ?? 'none', + 'card_scheme' => $cardScheme, + 'sections' => [ + $section('search', 'search', $searchVariant), + $section('hero', 'hero', $home['hero'], ['height' => (int) ($homeMeta['hero_height'] ?? 480)]), + $section('category', 'category', $cat, ['cols' => 3]), + $section('package', 'package', 'card'), + // 原首页没有商品流,默认关,装修里可再打开 + $section('product', 'product', $home['product'], ['card_scheme' => $cardScheme], false), + ], + ], + 'package' => [ + 'card_scheme' => $cardScheme, + 'sections' => [ + $section('list', 'list', 'card'), + ], + ], + 'catalog' => [ + 'card_scheme' => $cardScheme, + 'sections' => [ + $section('filter', 'filter', 'chip'), + $section('list', 'list', $goodsList, ['card_scheme' => $cardScheme]), + ], + ], + 'search' => [ + 'card_scheme' => $cardScheme, + 'sections' => [ + $section('search', 'search', 'bar'), + $section('list', 'list', $goodsList, ['card_scheme' => $cardScheme]), + ], + ], + 'product' => [ + 'card_scheme' => $cardScheme, + 'sections' => [ + $section('gallery', 'gallery', $product['gallery']), + $section('info', 'info', $product['info'] ?? 'plain'), + $section('spec', 'spec', $product['spec'] ?? 'plain'), + $section('price', 'price', $product['price'] ?? 'inline'), + $section('action', 'action', $product['action'] ?? 'fixed'), + ], + ], + 'cart' => [ + 'card_scheme' => $cardScheme, + 'sections' => [ + $section('list', 'list', $listStyle), + ], + ], + 'mine' => [ + 'card_scheme' => $cardScheme, + 'sections' => [ + $section('header', 'header', $mineHeader), + $section('menu', 'menu', $mineMenu), + ], + ], + ], + ]; +}; + return [ [ - 'code' => 'lux-champagne', - 'name' => '轻奢·香槟金', + 'code' => 'nature-clay', + 'name' => '素胚', 'style_tag' => '轻奢', - 'palette' => ['primary' => '#B08D57', 'accent' => '#8C6A3F', 'bg' => '#FAF7F2', 'surface' => '#FFFFFF', 'text' => '#1C1917', 'border' => '#E7DFD3'], - 'scale' => 'serif-elegant', + 'palette' => [ + 'primary' => '#8A8178', + 'accent' => '#C4B8A8', + 'bg' => '#F4EFE6', + 'surface' => '#EFE8DC', + 'text' => '#1C1917', + 'border' => '#E7DFD3', + ], + 'scale' => 'serif-book', 'radius' => 'soft', - 'shadow' => 'airy', - 'motion' => 'silk', - 'layout' => ['home' => ['hero' => 'fullscreen', 'category' => 'scroll', 'product' => 'magazine'], 'product' => ['gallery' => 'fullbleed', 'price' => 'sticky', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'list'], 'effect' => ['transition' => 'fade', 'skeleton' => 'shimmer']], - ], - [ - 'code' => 'lux-ink', - 'name' => '轻奢·墨玉', - 'style_tag' => '轻奢', - 'palette' => ['primary' => '#C9A063', 'accent' => '#E8D5AE', 'bg' => '#14110F', 'surface' => '#1F1B18', 'text' => '#F5F0E8', 'border' => '#332C25'], - 'scale' => 'serif-elegant', - 'radius' => 'soft', - 'shadow' => 'deep', - 'motion' => 'silk', - 'layout' => ['home' => ['hero' => 'fullscreen', 'category' => 'sidebar', 'product' => 'grid'], 'product' => ['gallery' => 'stack', 'price' => 'card', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'image', 'menu' => 'list'], 'effect' => ['transition' => 'fade', 'skeleton' => 'shimmer']], + 'shadow' => 'flat', + 'motion' => 'gentle', + 'layout' => array_merge( + [ + 'home' => ['hero' => 'split', 'category' => 'card', 'product' => 'waterfall'], + 'product' => ['gallery' => 'swiper', 'price' => 'card', 'action' => 'fixed'], + 'list' => ['style' => 'card'], + 'mine' => ['header' => 'plain', 'menu' => 'list'], + 'effect' => [ + 'transition' => 'fade', + 'skeleton' => 'pulse', + 'card' => 'plain', + 'icon' => 'line', + ], + ], + $buildPages( + ['hero' => 'split', 'category' => 'card', 'product' => 'waterfall'], + ['gallery' => 'swiper', 'action' => 'fixed', 'info' => 'editorial', 'spec' => 'table', 'price' => 'card'], + 'clay-edge', + 'waterfall', + 'card', + 'plain', + 'list', + 'home-03-split-card', + 'shop', + 'bar', + '', + [], + [ + 'catalog' => 'catalog-02-waterfall', + 'search' => 'search-02-waterfall', + 'product' => 'product-00-default', + 'cart' => 'cart-02-table', + 'mine' => 'mine-11-plain', + ], + ['tabbar' => 'plain', 'navbar' => 'solid', 'tabbar_anim' => 'none'], + ), + ), ], [ 'code' => 'lux-pearl', 'name' => '轻奢·珠贝白', 'style_tag' => '轻奢', - 'palette' => ['primary' => '#9C8E7E', 'accent' => '#CBBFA8', 'bg' => '#FFFFFF', 'surface' => '#F7F5F1', 'text' => '#2B2724', 'border' => '#EAE5DC'], + 'palette' => [ + 'primary' => '#9C8E7E', + 'accent' => '#D4C4A8', + 'bg' => '#FFFCF8', + 'surface' => '#F7F4EF', + 'text' => '#1C1917', + 'border' => '#E7DFD3', + ], 'scale' => 'sans-refined', 'radius' => 'sharp', 'shadow' => 'airy', 'motion' => 'silk', - 'layout' => ['home' => ['hero' => 'split', 'category' => 'grid', 'product' => 'waterfall'], 'product' => ['gallery' => 'swiper', 'price' => 'inline', 'action' => 'inline'], 'list' => ['style' => 'table'], 'mine' => ['header' => 'plain', 'menu' => 'grid'], 'effect' => ['transition' => 'slide', 'skeleton' => 'shimmer']], + 'layout' => array_merge( + [ + 'home' => ['hero' => 'split', 'category' => 'grid', 'product' => 'waterfall'], + 'product' => ['gallery' => 'swiper', 'price' => 'inline', 'action' => 'fixed'], + 'list' => ['style' => 'table'], + 'mine' => ['header' => 'plain', 'menu' => 'grid'], + 'effect' => [ + 'transition' => 'fade', + 'skeleton' => 'shimmer', + 'card' => 'elevated', + 'icon' => 'line', + ], + ], + $buildPages( + ['hero' => 'split', 'category' => 'grid', 'product' => 'waterfall'], + ['gallery' => 'swiper', 'action' => 'fixed', 'info' => 'plain', 'spec' => 'plain', 'price' => 'inline'], + 'pearl-line', + 'waterfall', + 'table', + 'plain', + 'grid', + 'home-01-classic', + 'shop', + 'bar', + '', + [], + [ + 'catalog' => 'catalog-04-grid', + 'search' => 'search-04-grid', + 'product' => 'product-00-default', + 'cart' => 'cart-02-table', + 'mine' => 'mine-03-gradient', + ], + ['tabbar' => 'line', 'navbar' => 'line', 'tabbar_anim' => 'slide'], + ), + ), ], [ 'code' => 'lux-walnut', 'name' => '轻奢·胡桃木', 'style_tag' => '轻奢', - 'palette' => ['primary' => '#6F4E37', 'accent' => '#A9784F', 'bg' => '#F6F1EA', 'surface' => '#FFFDFA', 'text' => '#241B14', 'border' => '#E2D6C7'], + 'palette' => [ + 'primary' => '#6F4E37', + 'accent' => '#A9784F', + 'bg' => '#F6F1EA', + 'surface' => '#FFFDFA', + 'text' => '#1C1917', + 'border' => '#E7DFD3', + ], 'scale' => 'serif-elegant', 'radius' => 'round', 'shadow' => 'soft', 'motion' => 'gentle', - 'layout' => ['home' => ['hero' => 'carousel', 'category' => 'card', 'product' => 'magazine'], 'product' => ['gallery' => 'fullbleed', 'price' => 'card', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'list'], 'effect' => ['transition' => 'fade', 'skeleton' => 'pulse']], + 'layout' => array_merge( + [ + 'home' => ['hero' => 'carousel', 'category' => 'card', 'product' => 'waterfall'], + 'product' => ['gallery' => 'fullbleed', 'price' => 'card', 'action' => 'fixed'], + 'list' => ['style' => 'card'], + 'mine' => ['header' => 'gradient', 'menu' => 'list'], + 'effect' => [ + 'transition' => 'fade', + 'skeleton' => 'shimmer', + 'card' => 'elevated', + 'icon' => 'line', + ], + ], + $buildPages( + ['hero' => 'carousel', 'category' => 'card', 'product' => 'waterfall'], + ['gallery' => 'fullbleed', 'action' => 'fixed', 'info' => 'plain', 'spec' => 'table', 'price' => 'card'], + 'walnut-frame', + 'featured', + 'card', + 'gradient', + 'list', + 'home-11-carousel-card', + 'shop', + 'bar', + '', + [], + [ + 'catalog' => 'catalog-13-featured', + 'search' => 'search-13-featured', + 'product' => 'product-00-default', + 'cart' => 'cart-01-card', + 'mine' => 'mine-01-gradient', + ], + ['tabbar' => 'pill', 'navbar' => 'solid', 'tabbar_anim' => 'spring'], + ), + ), ], [ - 'code' => 'minimal-linen', - 'name' => '极简·亚麻', - 'style_tag' => '极简', - 'palette' => ['primary' => '#3F3F46', 'accent' => '#A1A1AA', 'bg' => '#FAFAF9', 'surface' => '#FFFFFF', 'text' => '#18181B', 'border' => '#E4E4E7'], - 'scale' => 'sans-compact', - 'radius' => 'sharp', - 'shadow' => 'flat', - 'motion' => 'snappy', - 'layout' => ['home' => ['hero' => 'banner', 'category' => 'grid', 'product' => 'grid'], 'product' => ['gallery' => 'swiper', 'price' => 'inline', 'action' => 'inline'], 'list' => ['style' => 'table'], 'mine' => ['header' => 'plain', 'menu' => 'list'], 'effect' => ['transition' => 'none', 'skeleton' => 'pulse']], - ], - [ - 'code' => 'minimal-mono', - 'name' => '极简·黑白', - 'style_tag' => '极简', - 'palette' => ['primary' => '#000000', 'accent' => '#525252', 'bg' => '#FFFFFF', 'surface' => '#F5F5F5', 'text' => '#0A0A0A', 'border' => '#D4D4D4'], - 'scale' => 'sans-wide', - 'radius' => 'none', - 'shadow' => 'flat', - 'motion' => 'snappy', - 'layout' => ['home' => ['hero' => 'split', 'category' => 'scroll', 'product' => 'list'], 'product' => ['gallery' => 'stack', 'price' => 'inline', 'action' => 'inline'], 'list' => ['style' => 'table'], 'mine' => ['header' => 'plain', 'menu' => 'list'], 'effect' => ['transition' => 'slide', 'skeleton' => 'none']], - ], - [ - 'code' => 'modern-indigo', - 'name' => '现代·靛蓝', - 'style_tag' => '现代', - 'palette' => ['primary' => '#4F46E5', 'accent' => '#818CF8', 'bg' => '#F8FAFC', 'surface' => '#FFFFFF', 'text' => '#0F172A', 'border' => '#E2E8F0'], - 'scale' => 'sans-refined', - 'radius' => 'round', - 'shadow' => 'soft', - 'motion' => 'bouncy', - 'layout' => ['home' => ['hero' => 'carousel', 'category' => 'card', 'product' => 'waterfall'], 'product' => ['gallery' => 'swiper', 'price' => 'card', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'grid'], 'effect' => ['transition' => 'zoom', 'skeleton' => 'shimmer']], - ], - [ - 'code' => 'modern-teal', - 'name' => '现代·青瓷', - 'style_tag' => '现代', - 'palette' => ['primary' => '#0D9488', 'accent' => '#5EEAD4', 'bg' => '#F0FDFA', 'surface' => '#FFFFFF', 'text' => '#134E4A', 'border' => '#CCFBF1'], - 'scale' => 'sans-refined', - 'radius' => 'round', - 'shadow' => 'soft', - 'motion' => 'gentle', - 'layout' => ['home' => ['hero' => 'banner', 'category' => 'grid', 'product' => 'grid'], 'product' => ['gallery' => 'swiper', 'price' => 'inline', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'grid'], 'effect' => ['transition' => 'fade', 'skeleton' => 'shimmer']], - ], - [ - 'code' => 'warm-terracotta', - 'name' => '暖调·陶土', - 'style_tag' => '暖调', - 'palette' => ['primary' => '#C05621', 'accent' => '#F6AD55', 'bg' => '#FFFAF0', 'surface' => '#FFFFFF', 'text' => '#2D2016', 'border' => '#FBD9B5'], - 'scale' => 'sans-compact', - 'radius' => 'round', - 'shadow' => 'soft', - 'motion' => 'bouncy', - 'layout' => ['home' => ['hero' => 'carousel', 'category' => 'scroll', 'product' => 'waterfall'], 'product' => ['gallery' => 'swiper', 'price' => 'card', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'image', 'menu' => 'grid'], 'effect' => ['transition' => 'slide', 'skeleton' => 'pulse']], - ], - [ - 'code' => 'warm-sand', - 'name' => '暖调·沙丘', - 'style_tag' => '暖调', - 'palette' => ['primary' => '#A16207', 'accent' => '#FDE68A', 'bg' => '#FEFCE8', 'surface' => '#FFFFFF', 'text' => '#292524', 'border' => '#F3E8C8'], - 'scale' => 'serif-book', + 'code' => 'lux-champagne', + 'name' => '轻奢·香槟金', + 'style_tag' => '轻奢', + 'palette' => [ + 'primary' => '#B08D57', + 'accent' => '#8C6A3F', + 'bg' => '#FAF7F2', + 'surface' => '#FFFFFF', + 'text' => '#1C1917', + 'border' => '#E7DFD3', + ], + 'scale' => 'serif-elegant', 'radius' => 'soft', 'shadow' => 'airy', - 'motion' => 'gentle', - 'layout' => ['home' => ['hero' => 'split', 'category' => 'card', 'product' => 'magazine'], 'product' => ['gallery' => 'stack', 'price' => 'inline', 'action' => 'inline'], 'list' => ['style' => 'timeline'], 'mine' => ['header' => 'plain', 'menu' => 'list'], 'effect' => ['transition' => 'fade', 'skeleton' => 'shimmer']], - ], - [ - 'code' => 'cool-slate', - 'name' => '冷调·石板', - 'style_tag' => '冷调', - 'palette' => ['primary' => '#334155', 'accent' => '#94A3B8', 'bg' => '#F1F5F9', 'surface' => '#FFFFFF', 'text' => '#0F172A', 'border' => '#CBD5E1'], - 'scale' => 'sans-wide', - 'radius' => 'sharp', - 'shadow' => 'flat', - 'motion' => 'snappy', - 'layout' => ['home' => ['hero' => 'banner', 'category' => 'sidebar', 'product' => 'list'], 'product' => ['gallery' => 'swiper', 'price' => 'sticky', 'action' => 'fixed'], 'list' => ['style' => 'table'], 'mine' => ['header' => 'plain', 'menu' => 'list'], 'effect' => ['transition' => 'slide', 'skeleton' => 'pulse']], - ], - [ - 'code' => 'cool-mist', - 'name' => '冷调·雾蓝', - 'style_tag' => '冷调', - 'palette' => ['primary' => '#0369A1', 'accent' => '#7DD3FC', 'bg' => '#F0F9FF', 'surface' => '#FFFFFF', 'text' => '#0C4A6E', 'border' => '#BAE6FD'], - 'scale' => 'sans-refined', - 'radius' => 'round', - 'shadow' => 'airy', 'motion' => 'silk', - 'layout' => ['home' => ['hero' => 'carousel', 'category' => 'grid', 'product' => 'grid'], 'product' => ['gallery' => 'swiper', 'price' => 'card', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'grid'], 'effect' => ['transition' => 'fade', 'skeleton' => 'shimmer']], + 'layout' => array_merge( + [ + 'home' => ['hero' => 'fullscreen', 'category' => 'scroll', 'product' => 'waterfall'], + 'product' => ['gallery' => 'fullbleed', 'price' => 'sticky', 'action' => 'fixed'], + 'list' => ['style' => 'ticket'], + 'mine' => ['header' => 'split', 'menu' => 'card'], + 'effect' => [ + 'transition' => 'fade', + 'skeleton' => 'shimmer', + 'card' => 'canvas-frame', + 'icon' => 'line', + ], + ], + $buildPages( + ['hero' => 'fullscreen', 'category' => 'scroll', 'product' => 'waterfall'], + ['gallery' => 'fullbleed', 'action' => 'fixed', 'info' => 'overlay', 'spec' => 'plain', 'price' => 'sticky'], + 'champagne-foil', + 'shelf', + 'ticket', + 'split', + 'card', + 'home-04-full-scroll', + 'shop', + 'bar', + '', + [], + [ + 'catalog' => 'catalog-16-shelf', + 'search' => 'search-16-shelf', + 'product' => 'product-00-default', + 'cart' => 'cart-05-ticket', + 'mine' => 'mine-16-split', + ], + ['tabbar' => 'pill', 'navbar' => 'line', 'tabbar_anim' => 'fade'], + ), + ), ], [ - 'code' => 'nature-olive', - 'name' => '自然·橄榄', - 'style_tag' => '自然', - 'palette' => ['primary' => '#4D7C0F', 'accent' => '#BEF264', 'bg' => '#F7FEE7', 'surface' => '#FFFFFF', 'text' => '#1A2E05', 'border' => '#D9F99D'], - 'scale' => 'sans-compact', - 'radius' => 'soft', - 'shadow' => 'soft', - 'motion' => 'gentle', - 'layout' => ['home' => ['hero' => 'banner', 'category' => 'scroll', 'product' => 'waterfall'], 'product' => ['gallery' => 'swiper', 'price' => 'inline', 'action' => 'inline'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'image', 'menu' => 'grid'], 'effect' => ['transition' => 'fade', 'skeleton' => 'pulse']], - ], - [ - 'code' => 'nature-clay', - 'name' => '自然·素坯', - 'style_tag' => '自然', - 'palette' => ['primary' => '#78716C', 'accent' => '#D6D3D1', 'bg' => '#FAFAF9', 'surface' => '#F5F5F4', 'text' => '#1C1917', 'border' => '#E7E5E4'], - 'scale' => 'serif-book', - 'radius' => 'soft', - 'shadow' => 'flat', - 'motion' => 'gentle', - 'layout' => ['home' => ['hero' => 'split', 'category' => 'card', 'product' => 'magazine'], 'product' => ['gallery' => 'fullbleed', 'price' => 'inline', 'action' => 'inline'], 'list' => ['style' => 'timeline'], 'mine' => ['header' => 'plain', 'menu' => 'list'], 'effect' => ['transition' => 'fade', 'skeleton' => 'shimmer']], - ], - [ - 'code' => 'bold-crimson', - 'name' => '张扬·绛红', - 'style_tag' => '浓烈', - 'palette' => ['primary' => '#9F1239', 'accent' => '#FB7185', 'bg' => '#FFF1F2', 'surface' => '#FFFFFF', 'text' => '#4C0519', 'border' => '#FECDD3'], - 'scale' => 'sans-wide', - 'radius' => 'round', - 'shadow' => 'deep', - 'motion' => 'bouncy', - 'layout' => ['home' => ['hero' => 'fullscreen', 'category' => 'card', 'product' => 'grid'], 'product' => ['gallery' => 'stack', 'price' => 'sticky', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'grid'], 'effect' => ['transition' => 'zoom', 'skeleton' => 'shimmer']], - ], - [ - 'code' => 'bold-violet', - 'name' => '张扬·紫罗兰', - 'style_tag' => '浓烈', - 'palette' => ['primary' => '#7E22CE', 'accent' => '#D8B4FE', 'bg' => '#FAF5FF', 'surface' => '#FFFFFF', 'text' => '#3B0764', 'border' => '#E9D5FF'], - 'scale' => 'sans-refined', - 'radius' => 'pill', - 'shadow' => 'deep', - 'motion' => 'bouncy', - 'layout' => ['home' => ['hero' => 'carousel', 'category' => 'scroll', 'product' => 'waterfall'], 'product' => ['gallery' => 'swiper', 'price' => 'card', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'grid'], 'effect' => ['transition' => 'zoom', 'skeleton' => 'shimmer']], - ], - [ - 'code' => 'dark-graphite', - 'name' => '暗色·石墨', - 'style_tag' => '暗色', - 'palette' => ['primary' => '#E5E5E5', 'accent' => '#A3A3A3', 'bg' => '#0A0A0A', 'surface' => '#171717', 'text' => '#FAFAFA', 'border' => '#262626'], - 'scale' => 'sans-compact', + 'code' => 'lux-folio', + 'name' => '轻奢·刊页', + 'style_tag' => '刊页', + 'palette' => [ + 'primary' => '#C4A574', + 'accent' => '#A3845A', + 'bg' => '#F7F1E6', + 'surface' => '#FFF9F0', + 'text' => '#3F2F24', + 'border' => '#E4D6C2', + ], + 'scale' => 'serif-elegant', 'radius' => 'sharp', - 'shadow' => 'deep', - 'motion' => 'snappy', - 'layout' => ['home' => ['hero' => 'banner', 'category' => 'grid', 'product' => 'grid'], 'product' => ['gallery' => 'stack', 'price' => 'sticky', 'action' => 'fixed'], 'list' => ['style' => 'table'], 'mine' => ['header' => 'plain', 'menu' => 'list'], 'effect' => ['transition' => 'slide', 'skeleton' => 'pulse']], - ], - [ - 'code' => 'dark-emerald', - 'name' => '暗色·祖母绿', - 'style_tag' => '暗色', - 'palette' => ['primary' => '#34D399', 'accent' => '#065F46', 'bg' => '#04120D', 'surface' => '#0B1F18', 'text' => '#ECFDF5', 'border' => '#14392C'], - 'scale' => 'sans-refined', - 'radius' => 'round', - 'shadow' => 'deep', - 'motion' => 'silk', - 'layout' => ['home' => ['hero' => 'fullscreen', 'category' => 'sidebar', 'product' => 'waterfall'], 'product' => ['gallery' => 'fullbleed', 'price' => 'card', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'image', 'menu' => 'list'], 'effect' => ['transition' => 'fade', 'skeleton' => 'shimmer']], - ], - [ - 'code' => 'retro-cream', - 'name' => '复古·奶油', - 'style_tag' => '复古', - 'palette' => ['primary' => '#92400E', 'accent' => '#FBBF24', 'bg' => '#FFFBEB', 'surface' => '#FEF3C7', 'text' => '#451A03', 'border' => '#FDE68A'], - 'scale' => 'serif-book', - 'radius' => 'soft', - 'shadow' => 'soft', - 'motion' => 'gentle', - 'layout' => ['home' => ['hero' => 'split', 'category' => 'card', 'product' => 'magazine'], 'product' => ['gallery' => 'swiper', 'price' => 'inline', 'action' => 'inline'], 'list' => ['style' => 'timeline'], 'mine' => ['header' => 'image', 'menu' => 'list'], 'effect' => ['transition' => 'fade', 'skeleton' => 'pulse']], - ], - [ - 'code' => 'retro-ocean', - 'name' => '复古·海蓝', - 'style_tag' => '复古', - 'palette' => ['primary' => '#155E75', 'accent' => '#67E8F9', 'bg' => '#ECFEFF', 'surface' => '#FFFFFF', 'text' => '#083344', 'border' => '#A5F3FC'], - 'scale' => 'sans-wide', - 'radius' => 'pill', 'shadow' => 'airy', - 'motion' => 'bouncy', - 'layout' => ['home' => ['hero' => 'carousel', 'category' => 'scroll', 'product' => 'list'], 'product' => ['gallery' => 'swiper', 'price' => 'card', 'action' => 'inline'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'grid'], 'effect' => ['transition' => 'slide', 'skeleton' => 'shimmer']], + 'motion' => 'silk', + 'layout' => array_merge( + [ + 'home' => ['hero' => 'caption', 'category' => 'contents', 'product' => 'waterfall'], + 'product' => ['gallery' => 'filmstrip', 'price' => 'sticky', 'action' => 'fixed'], + 'list' => ['style' => 'stacked'], + 'mine' => ['header' => 'editorial', 'menu' => 'list'], + 'effect' => [ + 'transition' => 'fade', + 'skeleton' => 'shimmer', + 'card' => 'canvas-frame', + 'icon' => 'line', + ], + ], + $buildPages( + ['hero' => 'caption', 'category' => 'contents', 'product' => 'waterfall'], + ['gallery' => 'filmstrip', 'action' => 'fixed', 'info' => 'overlay', 'spec' => 'cards', 'price' => 'sticky'], + 'folio-number', + 'featured', + 'stacked', + 'editorial', + 'list', + 'home-magazine-folio', + 'magazine', + 'overlay', + 'contents', + ['density' => 'airy', 'frame' => 'ornament', 'hero_height' => 640], + [ + 'catalog' => 'catalog-13-featured', + 'search' => 'search-13-featured', + 'product' => 'product-00-default', + 'cart' => 'cart-06-stacked', + 'mine' => 'mine-26-editorial', + ], + ['tabbar' => 'dot', 'navbar' => 'line', 'tabbar_anim' => 'spring'], + ), + ), ], ]; diff --git a/database/sql/2026-08-17/01_cull_wx_templates.sql b/database/sql/2026-08-17/01_cull_wx_templates.sql new file mode 100644 index 00000000..05353c7b --- /dev/null +++ b/database/sql/2026-08-17/01_cull_wx_templates.sql @@ -0,0 +1,49 @@ +-- ============================================================ +-- 装修模板只留 4 套轻奢:素胚 / 珠贝白 / 胡桃木 / 香槟金 +-- 其余软删;经销商与应用绑了已删 code 的回落到空(走品牌默认) +-- 本脚本可重复执行:缺列先补,已处理过的行不再变 +-- ============================================================ + +SET @db := DATABASE(); +SET @now := UNIX_TIMESTAMP(); + +-- 有的库还没跑过 2026-08-14/03,cc_wx_user 没有 template_code,先补列再纠偏 +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_wx_user' AND COLUMN_NAME = 'template_code' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `cc_wx_user` ADD COLUMN `template_code` varchar(50) NOT NULL DEFAULT '''' COMMENT ''经销商专属装修模板 code,空=跟随品牌默认'' AFTER `price_number`', + 'SELECT ''skip cc_wx_user.template_code'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +UPDATE `cc_wx_template` +SET `deleted_at` = @now, `updated_at` = @now, `is_default` = 0 +WHERE `deleted_at` = 0 + AND `code` NOT IN ('nature-clay', 'lux-pearl', 'lux-walnut', 'lux-champagne'); + +UPDATE `cc_wx_template` +SET `name` = '素胚', `style_tag` = '轻奢', `updated_at` = @now +WHERE `code` = 'nature-clay' AND `deleted_at` = 0; + +UPDATE `cc_wx_user` u +LEFT JOIN `cc_wx_template` t + ON t.`code` = u.`template_code` AND t.`deleted_at` = 0 +SET u.`template_code` = '' +WHERE u.`template_code` <> '' AND t.`id` IS NULL; + +-- nl_wx_app 可能尚未建表,没有表就跳过 +SET @app_exists := ( + SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_wx_app' +); +SET @sql := IF(@app_exists = 0, + 'SELECT ''skip nl_wx_app (table missing)'' AS msg', + 'UPDATE `nl_wx_app` a + LEFT JOIN `cc_wx_template` t + ON t.`code` = a.`template_code` AND t.`deleted_at` = 0 + SET a.`template_code` = ''lux-champagne'', a.`updated_at` = UNIX_TIMESTAMP() + WHERE a.`template_code` <> '''' AND t.`id` IS NULL' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/database/sql/2026-08-17/02_cc_wx_card_scheme.sql b/database/sql/2026-08-17/02_cc_wx_card_scheme.sql new file mode 100644 index 00000000..38d7de8c --- /dev/null +++ b/database/sql/2026-08-17/02_cc_wx_card_scheme.sql @@ -0,0 +1,29 @@ +-- ============================================================ +-- 卡片画布方案表(15 套预设由后台 init-presets 灌 layers JSON) +-- ============================================================ + +SET @db := DATABASE(); +SET @exists := ( + SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_wx_card_scheme' +); +SET @sql := IF(@exists = 0, + 'CREATE TABLE `cc_wx_card_scheme` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(50) NOT NULL DEFAULT '''' COMMENT ''方案名'', + `code` varchar(50) NOT NULL DEFAULT '''' COMMENT ''方案标识'', + `preview` varchar(500) NOT NULL DEFAULT '''' COMMENT ''缩略图'', + `layers` mediumtext COMMENT ''图层 JSON'', + `is_preset` tinyint NOT NULL DEFAULT 0 COMMENT ''1=内置预设,不可删'', + `app_code` varchar(20) NOT NULL DEFAULT '''' COMMENT ''品牌,空=通用'', + `sort` int NOT NULL DEFAULT 0, + `status` tinyint NOT NULL DEFAULT 0 COMMENT ''0=启用 1=停用'', + `created_at` int NOT NULL DEFAULT 0, + `updated_at` int NOT NULL DEFAULT 0, + `deleted_at` int NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_wx_card_scheme_code` (`code`, `app_code`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''小程序卡片画布方案''', + 'SELECT ''skip cc_wx_card_scheme'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/database/sql/2026-08-18/01_qiniu_clear_default_prefix.sql b/database/sql/2026-08-18/01_qiniu_clear_default_prefix.sql new file mode 100644 index 00000000..b14905bf --- /dev/null +++ b/database/sql/2026-08-18/01_qiniu_clear_default_prefix.sql @@ -0,0 +1,13 @@ +-- ============================================================ +-- 七牛种子默认 path_prefix=uploads,但本站历史对象都在 Bucket 根目录 +-- (b_*.jpg)。列举再套这层前缀会拉回空列表。 +-- 只清掉仍是种子默认值的行;运营自己改过的前缀不动。 +-- ============================================================ + +SET @now := UNIX_TIMESTAMP(); + +UPDATE `nl_oss_config` +SET `path_prefix` = '', `updated_at` = @now +WHERE `driver` = 'qiniu' + AND `path_prefix` = 'uploads' + AND `deleted_at` = 0; diff --git a/database/sql/2026-08-18/02_cc_package.sql b/database/sql/2026-08-18/02_cc_package.sql new file mode 100644 index 00000000..1c384a39 --- /dev/null +++ b/database/sql/2026-08-18/02_cc_package.sql @@ -0,0 +1,58 @@ +-- ============================================================ +-- 家具套餐:主表 + 搭配明细(幂等) +-- 金额一律整数分;original_amount 由明细自动算,package_amount 是独立报价 +-- ============================================================ + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_package' +); +SET @sql := IF(@exists = 0, + 'CREATE TABLE `cc_package` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL DEFAULT '''' COMMENT ''套餐名'', + `cover` varchar(500) NOT NULL DEFAULT '''' COMMENT ''封面'', + `subtitle` varchar(200) NOT NULL DEFAULT '''' COMMENT ''副标题'', + `remark` varchar(255) NOT NULL DEFAULT '''' COMMENT ''备注'', + `original_amount` int NOT NULL DEFAULT 0 COMMENT ''原价合计(分,倍率=1)'', + `package_amount` int NOT NULL DEFAULT 0 COMMENT ''套餐报价(分,倍率=1)'', + `is_hot` tinyint NOT NULL DEFAULT 0 COMMENT ''1=首页热门'', + `sort` int NOT NULL DEFAULT 0, + `status` tinyint NOT NULL DEFAULT 0 COMMENT ''0=上架 1=下架'', + `created_at` int NOT NULL DEFAULT 0, + `updated_at` int NOT NULL DEFAULT 0, + `deleted_at` int NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_package_hot` (`is_hot`, `sort`, `status`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''家具套餐''', + 'SELECT ''skip cc_package'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @exists := ( + SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_package_item' +); +SET @sql := IF(@exists = 0, + 'CREATE TABLE `cc_package_item` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `package_id` int NOT NULL DEFAULT 0 COMMENT ''套餐'', + `catalogue_id` int NOT NULL DEFAULT 0 COMMENT ''商品图册'', + `price_sheet_id` int NOT NULL DEFAULT 0 COMMENT ''规格行'', + `material_key` varchar(50) NOT NULL DEFAULT ''routine'' COMMENT ''材质键'', + `quantity` int NOT NULL DEFAULT 1 COMMENT ''数量'', + `unit_price` int NOT NULL DEFAULT 0 COMMENT ''该规格基准单价快照(分)'', + `sort` int NOT NULL DEFAULT 0, + `remark` varchar(255) NOT NULL DEFAULT '''', + `created_at` int NOT NULL DEFAULT 0, + `updated_at` int NOT NULL DEFAULT 0, + `deleted_at` int NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_package_item_pkg` (`package_id`), + KEY `idx_package_item_cat` (`catalogue_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''套餐搭配明细''', + 'SELECT ''skip cc_package_item'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/database/sql/2026-08-18/03_cc_list_package_snapshot.sql b/database/sql/2026-08-18/03_cc_list_package_snapshot.sql new file mode 100644 index 00000000..4a7da9e9 --- /dev/null +++ b/database/sql/2026-08-18/03_cc_list_package_snapshot.sql @@ -0,0 +1,66 @@ +-- ============================================================ +-- 清单绑定套餐快照(幂等) +-- 加入套餐时记下套餐价/原价和每行快照,改规格后用差额补差价 +-- ============================================================ + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_list' AND COLUMN_NAME = 'package_id' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `cc_list` ADD COLUMN `package_id` int NOT NULL DEFAULT 0 COMMENT ''绑定套餐,0=普通清单'' AFTER `status`', + 'SELECT ''skip cc_list.package_id'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_list' AND COLUMN_NAME = 'package_amount' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `cc_list` ADD COLUMN `package_amount` int NOT NULL DEFAULT 0 COMMENT ''加入时套餐价快照(分,已乘倍率)'' AFTER `package_id`', + 'SELECT ''skip cc_list.package_amount'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_list' AND COLUMN_NAME = 'original_amount' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `cc_list` ADD COLUMN `original_amount` int NOT NULL DEFAULT 0 COMMENT ''加入时原价快照(分,已乘倍率)'' AFTER `package_amount`', + 'SELECT ''skip cc_list.original_amount'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_list_item' AND COLUMN_NAME = 'package_item_id' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `cc_list_item` ADD COLUMN `package_item_id` int NOT NULL DEFAULT 0 COMMENT ''来源套餐明细,0=散件'' AFTER `remark`', + 'SELECT ''skip cc_list_item.package_item_id'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_list_item' AND COLUMN_NAME = 'snapshot_unit_price' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `cc_list_item` ADD COLUMN `snapshot_unit_price` int NOT NULL DEFAULT 0 COMMENT ''加入时单价快照(分,已乘倍率)'' AFTER `package_item_id`', + 'SELECT ''skip cc_list_item.snapshot_unit_price'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_list_item' AND COLUMN_NAME = 'snapshot_quantity' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `cc_list_item` ADD COLUMN `snapshot_quantity` int NOT NULL DEFAULT 1 COMMENT ''加入时数量快照'' AFTER `snapshot_unit_price`', + 'SELECT ''skip cc_list_item.snapshot_quantity'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/database/sql/2026-08-18/04_nl_wx_app_package_enabled.sql b/database/sql/2026-08-18/04_nl_wx_app_package_enabled.sql new file mode 100644 index 00000000..0e8e2719 --- /dev/null +++ b/database/sql/2026-08-18/04_nl_wx_app_package_enabled.sql @@ -0,0 +1,14 @@ +-- ============================================================ +-- 小程序应用:按品牌开关套餐(两品牌共库,不能写 nl_system_config) +-- ============================================================ + +SET @db := DATABASE(); +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_wx_app' AND COLUMN_NAME = 'package_enabled' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `nl_wx_app` ADD COLUMN `package_enabled` tinyint NOT NULL DEFAULT 0 COMMENT ''1=启用套餐(首页热门+底栏)'' AFTER `template_code`', + 'SELECT ''skip nl_wx_app.package_enabled'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/public/nl_admin.sql b/public/nl_admin.sql index ce6a6e74..8ae766a4 100644 --- a/public/nl_admin.sql +++ b/public/nl_admin.sql @@ -586,7 +586,7 @@ INSERT INTO `nl_oss_config` VALUES (1, 'local', '本地存储', '', '', '', '', '', '', 'uploads', NULL, 1, 1, '默认本地磁盘,文件落到 storage/app', 100, 1786329600, 0, 0), (2, 'aliyun', '阿里云 OSS', '', '', 'oss-cn-hangzhou.aliyuncs.com', 'cn-hangzhou', '', '', 'uploads', NULL, 0, 1, '请填写 AccessKey/Secret、Bucket、访问域名;Endpoint 可按地域修改', 90, 1786329600, 0, 0), (3, 'qcloud', '腾讯云 COS', '', '', '', 'ap-guangzhou', '', '', 'uploads', NULL, 0, 1, '请填写 SecretId/SecretKey、Bucket、Region、访问域名', 80, 1786329600, 0, 0), - (4, 'qiniu', '七牛云 Kodo', '', '', '', '', '', '', 'uploads', NULL, 0, 1, '请填写 AccessKey/SecretKey、Bucket、外链域名(含协议)', 70, 1786329600, 0, 0), + (4, 'qiniu', '七牛云 Kodo', '', '', '', '', '', '', '', NULL, 0, 1, '请填写 AccessKey/SecretKey、Bucket、外链域名(含协议);历史文件在根目录,路径前缀留空', 70, 1786329600, 0, 0), (5, 'huawei', '华为云 OBS', '', '', 'https://obs.cn-north-4.myhuaweicloud.com', 'cn-north-4', '', '', 'uploads', NULL, 0, 1, '请填写 AK/SK、Bucket、Endpoint、访问域名', 60, 1786329600, 0, 0), (6, 'aws', 'AWS S3', '', '', 'https://s3.amazonaws.com', 'us-east-1', '', '', 'uploads', NULL, 0, 1, '请填写 AccessKey/Secret、Bucket、Region、访问域名', 50, 1786329600, 0, 0), (7, 'minio', 'MinIO', '', '', 'http://127.0.0.1:9000', 'us-east-1', '', '', 'uploads', NULL, 0, 1, '私有化 S3 兼容;请填写 Key、Bucket、Endpoint', 40, 1786329600, 0, 0), @@ -866,6 +866,7 @@ CREATE TABLE `nl_wx_app` ( `platform_public_key` text NULL COMMENT '微信支付平台证书公钥', `notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '支付回调地址', `template_code` varchar(50) NOT NULL DEFAULT '' COMMENT '默认装修模板 code', + `package_enabled` tinyint NOT NULL DEFAULT 0 COMMENT '1=启用套餐(首页热门+底栏)', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0=启用 1=停用', `remark` varchar(255) NOT NULL DEFAULT '', `created_at` int NOT NULL DEFAULT 0, diff --git a/routes/api.php b/routes/api.php index 7e8f6fa3..5ee9b291 100644 --- a/routes/api.php +++ b/routes/api.php @@ -30,11 +30,13 @@ UtilsService::class::getInstance()->autoRouteRegister([ * * 小程序侧只需把 baseURL 改成 .../api/wx/,路径与老接口逐字对齐。 * 免鉴权组:登录、首页、主题、支付回调;其余一律走 nl.wx 中间件。 + * nl.wx.https:整组成功响应把 result 里 http:// 改成 https://(老 ApiMiddleware)。 */ -Route::group(['prefix' => 'wx'], function () { +Route::group(['prefix' => 'wx', 'middleware' => 'nl.wx.https'], function () { UtilsService::class::getInstance()->autoRouteRegister([ 'auth' => \App\Http\Controllers\Wx\AuthController::class, // 小程序登录 'home' => \App\Http\Controllers\Wx\HomeController::class, // 首页轮播与分类 + 'package' => \App\Http\Controllers\Wx\PackageController::class, // 套餐热门/列表/详情/一件加入 '' => \App\Http\Controllers\Wx\ThemeController::class, // 主题下发与支付回调 ]); @@ -78,6 +80,7 @@ Route::group(['middleware' => 'nl.auth'], function () { 'price-sheet' => \App\Http\Controllers\Api\PriceSheetController::class, // 报价单(规格) 'image' => \App\Http\Controllers\Api\ImageController::class, // 商品相册 'carousel' => \App\Http\Controllers\Api\CarouselController::class, // 小程序轮播图 + 'package' => \App\Http\Controllers\Api\PackageController::class, // 套餐搭配 'factory-info' => \App\Http\Controllers\Api\FactoryInfoController::class, // 工厂管理 'factory-classification' => \App\Http\Controllers\Api\FactoryClassificationController::class, // 工厂分类 'factory-image' => \App\Http\Controllers\Api\FactoryImageController::class, // 工厂产品图 @@ -89,6 +92,7 @@ Route::group(['middleware' => 'nl.auth'], function () { 'list' => \App\Http\Controllers\Api\ListController::class, // 清单管理 'order' => \App\Http\Controllers\Api\OrderController::class, // 订单管理 'wx-template' => \App\Http\Controllers\Api\WxTemplateController::class, // 小程序装修模板 + 'wx-card-scheme' => \App\Http\Controllers\Api\WxCardSchemeController::class, // 卡片画布方案 'wx-app' => \App\Http\Controllers\Api\WxAppController::class, // 小程序应用配置 // 需要登录的路由生成地址 ]); diff --git a/themes/_template.example.json b/themes/_template.example.json new file mode 100644 index 00000000..d95daab6 --- /dev/null +++ b/themes/_template.example.json @@ -0,0 +1,57 @@ +{ + "version": 1, + "templates": [ + { + "code": "my-atelier-01", + "name": "我的主题名", + "style_tag": "自研", + "app_code": "", + "preview": "", + "tokens": { + "color": { + "primary": "#2C1810", + "primary_soft": "rgba(44,24,16,0.08)", + "accent": "#C45C26", + "bg": "#F3EEE4", + "bg_soft": "#EDE6DA", + "surface": "#FFFDF8", + "surface_soft": "#F7F2EA", + "text": "#1A1210", + "text_soft": "#6B5E55", + "border": "#E5D9C8", + "price": "#C45C26" + }, + "font": {}, + "radius": {}, + "shadow": {}, + "space": {}, + "motion": {} + }, + "layout": { + "home": { + "hero": "split", + "category": "card", + "product": "waterfall" + }, + "product": { + "gallery": "stack", + "price": "card", + "action": "inline" + }, + "list": { + "style": "timeline" + }, + "mine": { + "header": "image", + "menu": "grid" + }, + "effect": { + "transition": "zoom", + "skeleton": "shimmer", + "card": "ornament", + "icon": "line" + } + } + } + ] +}