更新若干功能

This commit is contained in:
2026-08-19 08:16:49 +08:00
parent 4979bb83d2
commit 72bc6502eb
56 changed files with 3627 additions and 343 deletions

View File

@@ -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(),
]);
}
}

View File

@@ -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());

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\PackageService;
use Illuminate\Http\JsonResponse;
/**
* 后台套餐搭配
*/
class PackageController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->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());
}
}

View File

@@ -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',
];
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\WxCardSchemeService;
use App\Service\business\WxTemplateSchemaService;
use Illuminate\Http\JsonResponse;
/**
* 小程序卡片画布方案
*/
class WxCardSchemeController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->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,
]);
}
}

View File

@@ -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());
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxPackageService;
use Illuminate\Http\JsonResponse;
/**
* 小程序套餐(热门/列表/详情免登录,一件加入走 Service 内登录校验)
*/
class PackageController extends BaseController
{
protected array $exceptRoute = ['option', 'create', 'update', 'delete'];
public function __construct()
{
parent::__construct();
$this->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()), '已加入清单');
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* 小程序响应 HTTPS 改写
*
* 对齐老 lgp-wx-api ApiMiddleware::checkHttp成功包code=0)里递归把
* result 中的 http:// 换成 https://,满足微信小程序对图片等资源的 HTTPS 要求。
* 仅挂在 /api/wx/*,不改管理端接口;不写库,只做出站改写。
*/
class WxHttpsRewriteMiddleware
{
/**
* 处理完控制器后改写成功响应的 result
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (!$response instanceof JsonResponse) {
return $response;
}
$payload = $response->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;
}
}

View File

@@ -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,

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 套餐搭配明细cc_package_item
*
* 每行必须带 price_sheet_id否则一件加入清单后无法按规格下单。
*/
class PackageItemModel extends BaseBusinessModel
{
protected $table = 'package_item';
protected $guarded = [];
public function package(): BelongsTo
{
return $this->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');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 家具套餐cc_package
*
* original_amount 是明细规格价之和package_amount 是独立售价。
* 两列都是倍率=1 的基准分,展示/下单时再乘用户 price_number。
*/
class PackageModel extends BaseBusinessModel
{
protected $table = 'package';
protected $guarded = [];
public function items(): HasMany
{
return $this->hasMany(PackageItemModel::class, 'package_id', 'id');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
/**
* 小程序卡片画布方案cc_wx_card_scheme
*
* layers 是图层数组,小程序 ThemeCanvasCard 与后台画布编辑器共用同一份 JSON。
*/
class WxCardSchemeModel extends BaseBusinessModel
{
protected $table = 'wx_card_scheme';
protected $guarded = [];
protected $casts = [
'layers' => 'array',
];
}

View File

@@ -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('登录状态已失效,请重新登录');

View File

@@ -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;
}
}

View File

@@ -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'];

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Service\business;
/**
* 套餐报价与补差价
*
* 后台存倍率=1 的基准分;小程序展示/下单再乘用户倍率。
* 清单应付用「套餐价 + 当前明细合计 原价快照」,删行、换规格、加散件都落在这一条公式里。
*/
class PackageQuoteService
{
private static mixed $_instance;
public static function getInstance(): null|static
{
$name = get_called_class();
if (!isset(self::$_instance[$name])) {
self::$_instance[$name] = new static();
}
return self::$_instance[$name];
}
/**
* 后台改搭配:原价变了就把差额补到套餐报价上,不允许报负价
*/
public function applyOriginalDiff(int $oldOriginal, int $newOriginal, int $packageAmount): int
{
return max(0, $packageAmount + ($newOriginal - $oldOriginal));
}
/**
* 基准价乘用户倍率,仍回整数分
*/
public function applyMultiplier(int $cents, mixed $multiplier = 1): int
{
if ($cents <= 0) {
return 0;
}
$price = PriceService::getInstance();
$yuan = $price->centsToYuan($cents);
return $price->yuanToCents($yuan, $multiplier);
}
/**
* 套餐清单应付
*
* 未绑套餐:应付 = 当前明细合计。
* 已绑套餐:应付 = 套餐价快照 + 当前合计 原价快照(删除的套餐行会从原价里扣掉)。
*
* @param array<int, array{unit_price?:int,quantity?:int}> $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<string, int> $amounts
* @return array<string, int|string>
*/
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;
}
}

View File

@@ -0,0 +1,317 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\CatalogueModel;
use App\Models\business\PackageItemModel;
use App\Models\business\PackageModel;
use App\Models\business\PriceSheetModel;
use App\Service\common\MediaUrlService;
use Illuminate\Support\Facades\DB;
/**
* 后台套餐
*
* 明细必须带规格。保存明细时后端重算原价;套餐报价若前端没传,就按原价差额自动补。
*/
class PackageService extends BaseService
{
private MediaUrlService $media;
private PackageQuoteService $quote;
private PriceService $price;
public function __construct()
{
parent::__construct();
$this->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);
}
}

View File

@@ -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 会把价格清零
*/

View File

@@ -0,0 +1,233 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\WxCardSchemeModel;
use Illuminate\Support\Facades\DB;
/**
* 卡片画布方案15 套预设 + 运营另存为的自定义方案
*/
class WxCardSchemeService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->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<int, string> $codes
* @return array<string, 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;
}
}

View File

@@ -5,8 +5,8 @@ namespace App\Service\business;
/**
* 把预设的紧凑规格展开成完整设计令牌
*
* 20 套模板真正的差异在四条轴上:配色、字体与字号梯度、圆角与阴影、动效曲线与时长。
* 这里把后三条做成可复用的档位,预设只需要挑档位,避免 20 JSON 手抄跑偏,
* 4 轻奢模板真正的差异在四条轴上:配色、字体与字号梯度、圆角与阴影、动效曲线与时长。
* 这里把后三条做成可复用的档位,预设只需要挑档位,避免 JSON 手抄跑偏,
* 也让「再加一套模板」变成加十几行配置而不是重写一份主题。
*/
class WxTemplatePresetService

View File

@@ -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/variantprops 只留安全短值
*/
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<int, string>
*/
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)) {

View File

@@ -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;
}

View File

@@ -69,7 +69,11 @@ class JWTService
$payload = [
'iat' => $issuedAt,
'exp' => $expirationTime,
'data' => $data
// sub 是标准声明,网关/旧客户端剥掉自定义 data 时还能靠它找回用户
'sub' => (string) ($data['id'] ?? ''),
// 与小程序 tokenscope=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);
}
/**

View File

@@ -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 : '');
}

View File

@@ -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;
}
}

View File

@@ -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<int, array{key:string,size:int,hash:string,last_modified:int,url:string}>, 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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
/**

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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::listFilesmarker 分页)
*
* 七牛只在还有下一页时才回 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 / 没填配置」两种情况前置拦掉
*
* 必须走 HTTPSSDK 默认 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);

View File

@@ -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;
}

View File

@@ -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;
}
/**
* 当前请求声明的品牌标识
*

View File

@@ -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新用户建档并送一份默认清单

View File

@@ -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);
}
}

View File

@@ -0,0 +1,274 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\business\ListItemModel;
use App\Models\business\ListModel;
use App\Models\business\PackageItemModel;
use App\Models\business\PackageModel;
use App\Service\business\PackageQuoteService;
use App\Service\business\PriceService;
use App\Service\business\SerialNoService;
use App\Service\common\MediaUrlService;
use Illuminate\Support\Facades\DB;
/**
* 小程序套餐:热门/列表/详情免登录,一件加入清单要登录
*
* 应用没开套餐时列表直接空,避免关了开关首页还露出卡片。
*/
class WxPackageService extends BaseWxService
{
protected bool $needLogin = false;
private MediaUrlService $media;
private PackageQuoteService $quote;
private PriceService $price;
public function __construct()
{
parent::__construct();
$this->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);
}
}

View File

@@ -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(),
]);
}
}

View File

@@ -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<int, string>
*/
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;
}
/**
* 可选风格列表,用于小程序里让用户自己换肤
*/

View File

@@ -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) {

View File

@@ -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],
],
],
[

222
config/wx_card_schemes.php Normal file
View File

@@ -0,0 +1,222 @@
<?php
/**
* 30 套结构互异的轻奢 / 欧美 / 手作卡片
*
* 图层坐标以 340×420 为画布。deco.asset 是渲染器内置配饰名CSS 画),
* deco.tone 只取主题 tokenprimary / accent / surface…禁止写死纯黑。
* 每套开窗、边框、题签至少两项不同,拒绝只换色。
*/
$layer = static function (string $id, string $type, array $opts): array {
return array_merge([
'id' => $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]),
]),
];

419
config/wx_page_presets.php Normal file
View File

@@ -0,0 +1,419 @@
<?php
/**
* 6 × 30 套整页预设:只描述组合,不写 Vue
*
* 套用时写入 layout.page_presets.{page} + pages.{page}.sections / skin / density / frame
*/
$sec = static function (string $type, string $variant, bool $visible = true, array $props = []): array {
return [
'id' => $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,
];

View File

@@ -1,234 +1,382 @@
<?php
/**
* 小程序装修模板预设(20 套)
* 小程序装修模板预设(4 套店面轻奢 + 1 刊页
*
* 每套模板的差异不只是主色:字体族与字号梯度、圆角、阴影、动效时长与曲线、
* 以及首页/详情/清单/我的四个页面的排布都不一样。这里用紧凑的规格描述,
* WxTemplatePresetService 展开成完整令牌,避免 20 份手抄 JSON 互相抄错
*
* scale/radius/shadow/motion 的取值见 WxTemplatePresetService 里的对应表。
* 首期精修「轻奢」系列(前 4 套),其余先占位可用,后续按期打磨。
* 色板共用一族暖中性,差异只在主色/底色深浅。layout 同时带旧枚举
* (小程序未升级时仍能用)和 pages.sections装修工作室拖拽编排
* card_scheme 指向 30 canvas 卡片预设之一
*/
$section = static function (string $id, string $type, string $variant, array $props = [], bool $visible = true): array {
return [
'id' => $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'],
),
),
],
];

View File

@@ -0,0 +1,49 @@
-- ============================================================
-- 装修模板只留 4 套轻奢:素胚 / 珠贝白 / 胡桃木 / 香槟金
-- 其余软删;经销商与应用绑了已删 code 的回落到空(走品牌默认)
-- 本脚本可重复执行:缺列先补,已处理过的行不再变
-- ============================================================
SET @db := DATABASE();
SET @now := UNIX_TIMESTAMP();
-- 有的库还没跑过 2026-08-14/03cc_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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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, // 小程序应用配置
// 需要登录的路由生成地址
]);

View File

@@ -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"
}
}
}
]
}