Compare commits

...

4 Commits

Author SHA1 Message Date
LQ
4ddf5e3c5f 更新若干功能
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
2026-08-20 19:16:49 +08:00
LQ
72bc6502eb 更新若干功能 2026-08-19 08:16:49 +08:00
LQ
4979bb83d2 更新以来
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
2026-08-14 23:27:25 +08:00
LQ
11f40a75e4 初始化
缺陷:主题配色需要优化整体的同风格
2026-08-14 23:21:37 +08:00
98 changed files with 5713 additions and 401 deletions

View File

@@ -0,0 +1,27 @@
---
description: 数据库变更一律走按日 SQL 目录,禁止新建 PHP Migration
alwaysApply: true
---
# 数据库变更约定(lgp-admin-plus-api)
## 强制
- **禁止**为表结构变更新建 `database/migrations/*.php`
- **一律**落在:
```
database/sql/<YYYY-MM-DD>/01_简短英文名.sql
database/sql/<YYYY-MM-DD>/02_....sql
```
- 当天目录不存在则新建;序号从 `01` 递增
- 脚本尽量幂等(查 `information_schema` + `PREPARE`)
- 系统表(`nl_*`)若影响全新安装:同步改 `public/nl_admin.sql`
- 业务表(`cc_*`):只写 `database/sql/`,**不进** `nl_admin.sql`
- 上线由运维在目标库手工执行对应日期目录下的 SQL
## 反例
- `php artisan make:migration add_xxx_to_yyy` → 不要
- 把 DDL 塞进 Seeder / 业务代码里临时跑 → 不要

View File

@@ -1,12 +1,11 @@
APP_NAME=Xiaokang APP_NAME=lgp-admin-plus
APP_ENV=local APP_ENV=local
APP_KEY=base64:X9vy9lAitu0bA6J1/vqsiOHdNnc3XOrLihHKRGYHdbY= APP_KEY=base64:pQ7mR2wYh4KdN8sZaJ6xVbT3fLcE9gUnO5rXi1DyBqM=
APP_DEBUG=true APP_DEBUG=true
APP_TIMEZONE=Asia/Shanghai APP_TIMEZONE=Asia/Shanghai
APP_URL=http://localhost APP_URL=http://localhost
ECS=dev ECS=dev
# 修改后
APP_LOCALE=zh_CN APP_LOCALE=zh_CN
APP_FALLBACK_LOCALE=zh_CN APP_FALLBACK_LOCALE=zh_CN
APP_FAKER_LOCALE=zh_CN APP_FAKER_LOCALE=zh_CN
@@ -20,16 +19,18 @@ LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
# 数据库:同一个库两个连接 # 数据库:同一个库两个连接
# mysql 前缀 DB_PREFIX → 脚手架系统表 # mysql 前缀 nl_ → 脚手架系统表
# business 前缀 DB_BUSINESS_PREFIX → 业务表(Model 继承 BaseBusinessModel) # business 前缀 cc_ → 业务表(见 config/database.php 的 business 连接)
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=mysql8 DB_HOST=mysql8
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=cc_admin DB_DATABASE=cc_new_stash
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD=root DB_PASSWORD=root
DB_PREFIX=nl_ DB_PREFIX=nl_
DB_BUSINESS_PREFIX=cc_ DB_BUSINESS_PREFIX=cc_
# 业务库既有表主用 utf8mb4_0900_ai_ci,保持一致避免新建表混排序规则
DB_COLLATION=utf8mb4_0900_ai_ci
SESSION_DRIVER=file SESSION_DRIVER=file
SESSION_LIFETIME=120 SESSION_LIFETIME=120
@@ -43,51 +44,23 @@ FILESYSTEM_DISK=local
CACHE_STORE=redis CACHE_STORE=redis
CACHE_PREFIX= CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis REDIS_CLIENT=phpredis
REDIS_HOST=1Panel-redis-TxPD REDIS_HOST=redis
REDIS_PASSWORD=redis_Pa2Y7J REDIS_PASSWORD=redis_Q83eyF
REDIS_PORT=6379 REDIS_PORT=6379
# 邮件发送配置 QUEUE_CONNECTION=redis
MAIL_MAILER=smtp
MAIL_HOST=smtp.163.com MAIL_MAILER=log
MAIL_PORT=465 MAIL_FROM_ADDRESS="noreply@borman.top"
MAIL_USERNAME=qiqiqiyoucili@163.com
MAIL_PASSWORD=NZbPs32frizAaFM9
MAIL_ENCRYPTION=ssl
MAIL_FROM_ADDRESS=qiqiqiyoucili@163.com
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID= # 库内敏感字段 AES 密钥(密文前缀 nl_ase_256_)
AWS_SECRET_ACCESS_KEY= # 一旦有数据入库就不能再改,否则历史密文无法解密
AWS_DEFAULT_REGION=us-east-1 ENCRYPT_KEY=8d41c7b0e5a29f63d84b17ce9052af38b6e0d7419c2af58e3b06d915748ca2ef
AWS_BUCKET= # JWT HS256 密钥(至少 32 字符)
AWS_USE_PATH_STYLE_ENDPOINT=false JWT_SECRET=f92a5d6c4e8b1d70a35f9c2e64b8d017a3e5c9f2b48d61a07e3c95b28f4d6a1c
VITE_APP_NAME="${APP_NAME}"
# 消息队列 配置
#QUEUE_CONNECTION=rabbitmq
#RABBITMQ_HOST=172.26.47.10
#RABBITMQ_PORT=15672
#RABBITMQ_VHOST=/
#RABBITMQ_QUEUE=xk-api
#RABBITMQ_USER=xk_api
#RABBITMQ_PASSWORD=qiqi991012
# BaseUrl
#BASE_OLD_API_URL=https://app.xiaokang88.com
BASE_OLD_API_URL=http://www.xk888.com
BASE_OLD_SHOP_API_URL=https://shop.xiaokang88.com
# 密钥(库内敏感字段 AES,前缀 nl_ase_256_)
ENCRYPT_KEY=3a8f9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a
# JWT HS256 密钥(至少 32 字符;生产环境务必改成随机长串)
JWT_SECRET=nl_admin_jwt_secret_key_change_me_32b
# AI 供应商环境回落(库内未配置密钥时可用)
AI_DEFAULT_PROVIDER=deepseek AI_DEFAULT_PROVIDER=deepseek
SPARK_API_PASSWORD= SPARK_API_PASSWORD=
DEEPSEEK_API_KEY= DEEPSEEK_API_KEY=
@@ -95,10 +68,9 @@ DEEPSEEK_API_KEY=
# 小程序品牌声明(单品牌部署兜底用) # 小程序品牌声明(单品牌部署兜底用)
# 双品牌共用一份部署时,仍以 nl_wx_app 表 + 请求头 X-App-Code 分流为主 # 双品牌共用一份部署时,仍以 nl_wx_app 表 + 请求头 X-App-Code 分流为主
# AppSecret 不写这里,必须经后台「小程序应用配置」加密入库 # AppSecret 不写这里,必须经后台「小程序应用配置」加密入库
WX_DEFAULT_APP_CODE= WX_DEFAULT_APP_CODE=wl
WX_APP_ID= WX_APP_ID=wx57b54060a579c31a
WX_APP_NAME= WX_APP_NAME=佛山铂尔曼
# weilun # weilun

View File

@@ -2,6 +2,7 @@
namespace App\BaseApp; namespace App\BaseApp;
use App\Models\business\WxUserModel;
use App\Service\common\UtilsService; use App\Service\common\UtilsService;
use App\Service\wx\WxTokenService; use App\Service\wx\WxTokenService;
@@ -45,4 +46,40 @@ class BaseWxService extends BaseNotAuthService
{ {
return $this->userInfo['price_number'] ?? 1; 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

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\AfterSaleService;
use Illuminate\Http\JsonResponse;
/**
* 后台售后
*/
class AfterSaleController extends BaseController
{
protected array $exceptRoute = ['option', 'create'];
public function __construct()
{
parent::__construct();
$this->service = AfterSaleService::getInstance();
}
/**
* 审核售后
* @Method POST
*/
public function audit(): JsonResponse
{
$pass = (int) request()->post('status', 1) === 1;
return jok(
$this->service->audit(
(int) request()->post('id', 0),
$pass,
(string) request()->post('admin_remark', '')
),
$pass ? '已通过' : '已拒绝'
);
}
}

View File

@@ -18,7 +18,7 @@ class CatalogueController extends BaseController
$this->service = CatalogueService::getInstance(); $this->service = CatalogueService::getInstance();
$this->insertField = ['title', 'category_id', 'cover', 'identifier']; $this->insertField = ['title', 'category_id', 'cover', 'identifier'];
$this->updateField = ['id', 'title', 'category_id', 'cover', 'identifier']; $this->updateField = ['id', 'title', 'category_id', 'cover', 'identifier'];
$this->notRequest = ['alias', 'pdf', 'price', 'status']; $this->notRequest = ['alias', 'pdf', 'video', 'price', 'status'];
} }
/** /**
@@ -32,4 +32,27 @@ class CatalogueController extends BaseController
$params = $this->checkRequiredFields(request()->post()); $params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功'); return jok($this->service->status($params['id'], $params['status']), '操作成功');
} }
/**
* 导出商品表(结构化数据,前端 ExcelJS 画 xlsx)
* @Method GET
*/
public function export(): JsonResponse
{
return jok($this->service->exportExcel());
}
/**
* 导入商品表:前端已把 xlsx 拆成 rows
* @Method POST
*/
public function import(): JsonResponse
{
$rows = request()->post('rows', []);
$priceSheets = request()->post('price_sheets', []);
return jok($this->service->importRows(
is_array($rows) ? $rows : [],
is_array($priceSheets) ? $priceSheets : [],
), '导入完成');
}
} }

View File

@@ -32,4 +32,23 @@ class FactoryInfoController extends BaseController
$params = $this->checkRequiredFields(request()->post()); $params = $this->checkRequiredFields(request()->post());
return jok($this->service->status($params['id'], $params['status']), '操作成功'); return jok($this->service->status($params['id'], $params['status']), '操作成功');
} }
/**
* 导出工厂表(结构化数据,前端 ExcelJS 画 xlsx)
* @Method GET
*/
public function export(): JsonResponse
{
return jok($this->service->exportExcel());
}
/**
* 导入工厂表:前端已把 xlsx 拆成 rows
* @Method POST
*/
public function import(): JsonResponse
{
$rows = request()->post('rows', []);
return jok($this->service->importRows(is_array($rows) ? $rows : []), '导入完成');
}
} }

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\business\FeedbackService;
use Illuminate\Http\JsonResponse;
/**
* 后台用户反馈
*/
class FeedbackController extends BaseController
{
protected array $exceptRoute = ['option', 'create'];
public function __construct()
{
parent::__construct();
$this->service = FeedbackService::getInstance();
$this->updateField = ['id', 'reply'];
}
/**
* 回复
* @Method POST
*/
public function reply(): JsonResponse
{
return jok(
$this->service->reply((int) request()->post('id', 0), (string) request()->post('reply', '')),
'已回复'
);
}
}

View File

@@ -58,4 +58,13 @@ class ListController extends BaseController
{ {
return jok($this->service->deleteItem(request()->post('ids', [])), '删除成功'); return jok($this->service->deleteItem(request()->post('ids', [])), '删除成功');
} }
/**
* 导出清单报价(结构化数据 + 分享文本)
* @Method GET
*/
public function exportQuote(): JsonResponse
{
return jok($this->service->exportQuote((int) request()->get('id', 0)));
}
} }

View File

@@ -46,6 +46,8 @@ class MaterialController extends BaseController
*/ */
public function syncFromOss(): JsonResponse public function syncFromOss(): JsonResponse
{ {
// 七牛要先查区域再列举,再加一批 insert,默认 30s 很容易被网关/PHP 掐死
cc_set_time_limit();
$this->insertField = ['oss_config_id']; $this->insertField = ['oss_config_id'];
$this->notRequest = ['prefix', 'marker', 'limit']; $this->notRequest = ['prefix', 'marker', 'limit'];
$params = $this->checkRequiredFields(request()->post()); $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

@@ -18,7 +18,7 @@ class PriceSheetController extends BaseController
$this->service = PriceSheetService::getInstance(); $this->service = PriceSheetService::getInstance();
$this->insertField = ['catalogue_id']; $this->insertField = ['catalogue_id'];
$this->updateField = ['id']; $this->updateField = ['id'];
$this->notRequest = ['specification', 'dimension', 'routine', 'rows', 'status']; $this->notRequest = ['specification', 'dimension', 'routine', 'stock', 'rows', 'status'];
} }
/** /**

View File

@@ -19,7 +19,8 @@ class WxAppController extends BaseController
$this->updateField = ['id']; $this->updateField = ['id'];
$this->notRequest = [ $this->notRequest = [
'app_secret', 'mch_id', 'mch_key', 'mch_serial_no', 'mch_private_key', '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',
'subscribe_pay_tpl', 'subscribe_ship_tpl', '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 * @Method POST
*/ */
public function initPresets(): JsonResponse public function initPresets(): JsonResponse
@@ -78,14 +78,11 @@ class WxTemplateController extends BaseController
} }
/** /**
* 令牌与布局的可选项,前端渲染编辑表单用 * 令牌与布局的可选项,装修工作室渲染表单用
* @Method GET * @Method GET
*/ */
public function schema(): JsonResponse public function schema(): JsonResponse
{ {
return jok([ return jok(WxTemplateSchemaService::getInstance()->describe());
'tokens' => WxTemplateSchemaService::TOKEN_SCHEMA,
'layout' => WxTemplateSchemaService::LAYOUT_SCHEMA,
]);
} }
} }

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxAddressService;
use Illuminate\Http\JsonResponse;
/**
* 小程序收货地址
*/
class AddressController extends BaseController
{
protected array $exceptRoute = ['option'];
public function __construct()
{
parent::__construct();
$this->service = WxAddressService::getInstance();
}
/**
* 默认地址
* @Method GET
*/
public function defaultOne(): JsonResponse
{
return jok($this->service->defaultOne());
}
/**
* 设为默认
* @Method POST
*/
public function setDefault(): JsonResponse
{
return jok($this->service->setDefault((int) request()->post('id', 0)), '已设为默认');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxAfterSaleService;
use Illuminate\Http\JsonResponse;
/**
* 小程序售后
*/
class AfterSaleController extends BaseController
{
protected array $exceptRoute = ['option', 'detail', 'update', 'delete'];
public function __construct()
{
parent::__construct();
$this->service = WxAfterSaleService::getInstance();
}
/**
* 申请售后
* @Method POST
*/
public function create(): JsonResponse
{
return jok($this->service->create(request()->post()), '已提交');
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxColorcardService;
use Illuminate\Http\JsonResponse;
/**
* 小程序色卡(免登录)
*/
class ColorcardController extends BaseController
{
protected array $exceptRoute = ['option', 'create', 'update', 'delete'];
public function __construct()
{
parent::__construct();
$this->service = WxColorcardService::getInstance();
}
/**
* 色卡分类
* @Method GET
*/
public function classList(): JsonResponse
{
return jok($this->service->classList());
}
/**
* 色卡公司
* @Method GET
*/
public function companyList(): JsonResponse
{
return jok($this->service->companyList());
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxFactoryService;
use Illuminate\Http\JsonResponse;
/**
* 小程序工厂(免登录)
*/
class FactoryController extends BaseController
{
protected array $exceptRoute = ['option', 'create', 'update', 'delete'];
public function __construct()
{
parent::__construct();
$this->service = WxFactoryService::getInstance();
}
/**
* 工厂分类
* @Method GET
*/
public function classList(): JsonResponse
{
return jok($this->service->classList());
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxFavoriteService;
use Illuminate\Http\JsonResponse;
/**
* 小程序收藏
*/
class FavoriteController extends BaseController
{
protected array $exceptRoute = ['option', 'detail', 'create', 'update', 'delete'];
public function __construct()
{
parent::__construct();
$this->service = WxFavoriteService::getInstance();
}
/**
* 切换收藏
* @Method POST
*/
public function toggle(): JsonResponse
{
return jok($this->service->toggle((int) request()->post('catalogue_id', 0)));
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Wx;
use App\BaseApp\BaseController;
use App\Service\wx\WxFeedbackService;
use Illuminate\Http\JsonResponse;
/**
* 小程序反馈
*/
class FeedbackController extends BaseController
{
protected array $exceptRoute = ['option', 'detail', 'update', 'delete'];
public function __construct()
{
parent::__construct();
$this->service = WxFeedbackService::getInstance();
}
/**
* 提交反馈
* @Method POST
*/
public function create(): JsonResponse
{
return jok($this->service->create(request()->post()), '已提交');
}
}

View File

@@ -93,4 +93,13 @@ class ListController extends BaseController
{ {
return jok($this->service->updateItem(request()->post()), '保存成功'); return jok($this->service->updateItem(request()->post()), '保存成功');
} }
/**
* 导出清单报价
* @Method GET
*/
public function exportQuote(): JsonResponse
{
return jok($this->service->exportQuote((int) request()->get('id', 0)));
}
} }

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

@@ -48,4 +48,13 @@ class UserController extends BaseController
'保存成功' '保存成功'
); );
} }
/**
* 已绑定企业详情
* @Method GET
*/
public function enterprise(): JsonResponse
{
return jok($this->service->enterprise());
}
} }

View File

@@ -128,7 +128,8 @@ class ApiOpLogMiddleware
$secret = hash('sha256', $secret !== '' ? $secret : 'nl_admin_jwt_fallback_secret'); $secret = hash('sha256', $secret !== '' ? $secret : 'nl_admin_jwt_fallback_secret');
} }
$decoded = JWT::decode($token, new Key($secret, 'HS256')); $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) { } catch (\Throwable $e) {
return 0; 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', '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 * 扩展名归类到 type
* *
@@ -62,7 +85,7 @@ class FileModel extends BaseModel
public static function typeOfExt(string $ext): int public static function typeOfExt(string $ext): int
{ {
return match (strtolower(trim($ext, " \t\n\r\0\x0B."))) { 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, 'mp4', 'mov', 'avi', 'mkv', 'flv', 'wmv', 'webm', 'm3u8', 'ts' => self::TYPE_VIDEO,
'mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'amr' => self::TYPE_AUDIO, 'mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'amr' => self::TYPE_AUDIO,
'xls', 'xlsx', 'csv' => self::TYPE_EXCEL, 'xls', 'xlsx', 'csv' => self::TYPE_EXCEL,

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 售后申请(cc_after_sale)
*/
class AfterSaleModel extends BaseBusinessModel
{
protected $table = 'after_sale';
protected $guarded = [];
public const STATUS_PENDING = 0;
public const STATUS_APPROVED = 1;
public const STATUS_REJECTED = 2;
public function order(): BelongsTo
{
return $this->belongsTo(OrderModel::class, 'order_id', 'id');
}
public function user(): BelongsTo
{
return $this->belongsTo(WxUserModel::class, 'user_id', 'id');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 商品收藏(cc_favorite)
*/
class FavoriteModel extends BaseBusinessModel
{
protected $table = 'favorite';
protected $guarded = [];
public function catalogue(): BelongsTo
{
return $this->belongsTo(CatalogueModel::class, 'catalogue_id', 'id');
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 用户反馈(cc_feedback)
*/
class FeedbackModel extends BaseBusinessModel
{
protected $table = 'feedback';
protected $guarded = [];
public const STATUS_PENDING = 0;
public const STATUS_REPLIED = 1;
public function user(): BelongsTo
{
return $this->belongsTo(WxUserModel::class, 'user_id', 'id');
}
}

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,15 @@
<?php
namespace App\Models\business;
use App\BaseApp\BaseBusinessModel;
/**
* 小程序收货地址(cc_wx_address)
*/
class WxAddressModel extends BaseBusinessModel
{
protected $table = 'wx_address';
protected $guarded = [];
}

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 public function logout(): array
{ {
$decoded = JWTService::getInstance()->parseExpiringToken($this->refreshTtl()); $decoded = JWTService::getInstance()->parseExpiringToken($this->refreshTtl());
$userId = (int) ($decoded->data->id ?? 0); $userId = (int) (JWTService::getInstance()->extractUserData($decoded)['id'] ?? 0);
if ($userId > 0) { if ($userId > 0) {
JWTService::getInstance()->revoke($userId); JWTService::getInstance()->revoke($userId);
} }
@@ -141,7 +141,7 @@ class LoginService extends BaseNotAuthService
public function refresh(): array public function refresh(): array
{ {
$decoded = JWTService::getInstance()->parseExpiringToken($this->refreshTtl()); $decoded = JWTService::getInstance()->parseExpiringToken($this->refreshTtl());
$data = isset($decoded->data) ? (array) $decoded->data : []; $data = JWTService::getInstance()->extractUserData($decoded);
$userId = (int) ($data['id'] ?? 0); $userId = (int) ($data['id'] ?? 0);
if ($userId <= 0) { if ($userId <= 0) {
UtilsService::getInstance()->notAuth('登录状态已失效,请重新登录'); UtilsService::getInstance()->notAuth('登录状态已失效,请重新登录');

View File

@@ -28,11 +28,11 @@ use Throwable;
*/ */
class MaterialService extends BaseService class MaterialService extends BaseService
{ {
/** 单次同步默认条数:一屏够看,又不至于把请求跑到超时 */ /** 单次同步默认条数:七牛列举 + 入库要压在网关超时之前 */
private const SYNC_DEFAULT_LIMIT = 200; private const SYNC_DEFAULT_LIMIT = 80;
/** 单次同步上限,防止前端把 limit 传成十万 */ /** 单次同步上限;再大前端默认 10s、1panel 60s 都容易掐断 */
private const SYNC_MAX_LIMIT = 1000; private const SYNC_MAX_LIMIT = 200;
/** 分批取值 / 分批回写的批大小 */ /** 分批取值 / 分批回写的批大小 */
private const CHUNK_SIZE = 1000; private const CHUNK_SIZE = 1000;
@@ -198,6 +198,7 @@ class MaterialService extends BaseService
$result = [ $result = [
'inserted' => 0, 'inserted' => 0,
'updated' => 0, 'updated' => 0,
'listed' => count($items),
'next_marker' => (string) ($page['next_marker'] ?? ''), 'next_marker' => (string) ($page['next_marker'] ?? ''),
'finished' => (bool) ($page['finished'] ?? true), 'finished' => (bool) ($page['finished'] ?? true),
]; ];
@@ -217,7 +218,7 @@ class MaterialService extends BaseService
$seen[$key] = true; $seen[$key] = true;
$url = (string) ($item['url'] ?? ''); $url = (string) ($item['url'] ?? '');
$hash = (string) ($item['hash'] ?? ''); $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); $row = $byPath[$key] ?? $byUrl[$url] ?? ($hash !== '' ? ($byHash[$hash] ?? null) : null);
if ($row !== null) { if ($row !== null) {
@@ -252,7 +253,10 @@ class MaterialService extends BaseService
]; ];
} }
if (!empty($pending)) { if (!empty($pending)) {
FileModel::insert($pending); // 上百行一条 INSERT 容易把 SQL 撑到数兆,拆开更稳也更快提交
foreach (array_chunk($pending, 50) as $slice) {
FileModel::insert($slice);
}
$result['inserted'] = count($pending); $result['inserted'] = count($pending);
} }
return $result; return $result;
@@ -473,12 +477,33 @@ class MaterialService extends BaseService
private function existingIndexOf(array $items): array private function existingIndexOf(array $items): array
{ {
$paths = array_values(array_unique(array_column($items, 'key'))); $paths = array_values(array_unique(array_column($items, 'key')));
$urls = array_values(array_unique(array_filter(array_column($items, 'url')))); $select = ['id', 'name', 'url', 'path', 'ext', 'type', 'hash', 'oss_config_id'];
$hashes = array_values(array_unique(array_filter(array_column($items, 'hash')))); $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) $rows = FileModel::where('deleted_at', 0)
->where(function ($q) use ($paths, $urls, $hashes) { ->where(function ($q) use ($urls, $hashes) {
$q->whereIn('path', $paths);
if (!empty($urls)) { if (!empty($urls)) {
$q->orWhereIn('url', $urls); $q->orWhereIn('url', $urls);
} }
@@ -488,22 +513,14 @@ class MaterialService extends BaseService
}); });
} }
}) })
->get(['id', 'name', 'url', 'path', 'ext', 'type', 'hash', 'oss_config_id']); ->get($select);
$byPath = [];
$byUrl = [];
$byHash = [];
foreach ($rows as $row) { foreach ($rows as $row) {
$path = (string) $row->path;
$url = (string) $row->url; $url = (string) $row->url;
$hash = (string) $row->hash; $hash = (string) $row->hash;
if ($path !== '') {
$byPath[$path] = $row;
}
if ($url !== '' && !isset($byUrl[$url])) { if ($url !== '' && !isset($byUrl[$url])) {
$byUrl[$url] = $row; $byUrl[$url] = $row;
} }
if ($path === '' && $hash !== '' && !isset($byHash[$hash])) { if ((string) $row->path === '' && $hash !== '' && !isset($byHash[$hash])) {
$byHash[$hash] = $row; $byHash[$hash] = $row;
} }
} }

View File

@@ -25,7 +25,8 @@ class WxAppConfigService extends BaseService
$this->model = WxAppModel::class; $this->model = WxAppModel::class;
$this->selectField = [ $this->selectField = [
'id', 'code', 'name', 'app_id', 'mch_id', 'mch_serial_no', 'notify_url', 'id', 'code', 'name', 'app_id', 'mch_id', 'mch_serial_no', 'notify_url',
'template_code', 'status', 'remark', 'created_at', 'updated_at', 'template_code', 'package_enabled', 'subscribe_pay_tpl', 'subscribe_ship_tpl',
'status', 'remark', 'created_at', 'updated_at',
]; ];
$this->queryField = ['code' => '=', 'name' => 'like', 'status' => '=']; $this->queryField = ['code' => '=', 'name' => 'like', 'status' => '='];
$this->orderBy = ['name' => 'id', 'sort' => 'asc']; $this->orderBy = ['name' => 'id', 'sort' => 'asc'];

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\AfterSaleModel;
/**
* 后台售后审核。通过只改状态,线下退款,不自动打微信。
*/
class AfterSaleService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = AfterSaleModel::class;
$this->selectField = [
'id', 'order_id', 'user_id', 'reason', 'amount', 'images',
'status', 'admin_remark', 'created_at', 'updated_at',
];
$this->queryField = ['order_id' => '=', 'user_id' => '=', 'status' => '='];
$this->with = ['order', 'user'];
}
public function list(): array
{
$result = $this->getPageList();
$price = PriceService::getInstance();
foreach ($result['items'] as &$item) {
$item['user_name'] = $item['user']['nick_name'] ?? '';
$item['order_no'] = $item['order']['order_no'] ?? '';
$item['amount_text'] = $price->centsToYuan((int) ($item['amount'] ?? 0));
unset($item['user'], $item['order']);
}
unset($item);
return $result;
}
/**
* 审核:pass=true 通过
*/
public function audit(int $id, bool $pass, string $remark = ''): mixed
{
$row = AfterSaleModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($row)) {
$this->utils->errorThrow('售后单不存在');
}
if ((int) $row['status'] !== AfterSaleModel::STATUS_PENDING) {
$this->utils->errorThrow('已处理过');
}
return $this->save($id, [
'status' => $pass ? AfterSaleModel::STATUS_APPROVED : AfterSaleModel::STATUS_REJECTED,
'admin_remark' => mb_substr(trim($remark), 0, 255),
]);
}
public function delete($ids): mixed
{
return $this->del(is_array($ids) ? $ids : [$ids]);
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\business\ImageModel;
use App\Models\business\PriceSheetModel; use App\Models\business\PriceSheetModel;
use App\Service\common\MediaUrlService; use App\Service\common\MediaUrlService;
use Exception; use Exception;
use Illuminate\Support\Facades\DB;
use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface; use Psr\Container\NotFoundExceptionInterface;
@@ -23,7 +24,7 @@ class CatalogueService extends BaseService
{ {
parent::__construct(); parent::__construct();
$this->model = CatalogueModel::class; $this->model = CatalogueModel::class;
$this->selectField = ['id', 'title', 'category_id', 'cover', 'pdf', 'price', 'alias', 'identifier', 'status', 'created_at', 'updated_at']; $this->selectField = ['id', 'title', 'category_id', 'cover', 'pdf', 'video', 'price', 'alias', 'identifier', 'status', 'created_at', 'updated_at'];
$this->queryField = ['title' => 'like', 'alias' => 'like', 'identifier' => 'like', 'status' => '=']; $this->queryField = ['title' => 'like', 'alias' => 'like', 'identifier' => 'like', 'status' => '='];
$this->media = MediaUrlService::getInstance(); $this->media = MediaUrlService::getInstance();
} }
@@ -55,6 +56,7 @@ class CatalogueService extends BaseService
unset($item['category']); unset($item['category']);
$item['cover'] = $this->media->toPublic($item['cover'] ?? ''); $item['cover'] = $this->media->toPublic($item['cover'] ?? '');
$item['pdf'] = $this->media->toPublic($item['pdf'] ?? ''); $item['pdf'] = $this->media->toPublic($item['pdf'] ?? '');
$item['video'] = $this->media->toPublic($item['video'] ?? '');
$item['show_field'] = $this->pickUsedMaterialFields($item['price_sheet'] ?? []); $item['show_field'] = $this->pickUsedMaterialFields($item['price_sheet'] ?? []);
} }
unset($item); unset($item);
@@ -76,6 +78,7 @@ class CatalogueService extends BaseService
$info = $this->getDetail($id); $info = $this->getDetail($id);
$info->cover = $this->media->toPublic($info->cover); $info->cover = $this->media->toPublic($info->cover);
$info->pdf = $this->media->toPublic($info->pdf); $info->pdf = $this->media->toPublic($info->pdf);
$info->video = $this->media->toPublic($info->video);
$priceSheet = PriceSheetModel::where('catalogue_id', $id) $priceSheet = PriceSheetModel::where('catalogue_id', $id)
->where('deleted_at', 0) ->where('deleted_at', 0)
@@ -134,6 +137,141 @@ class CatalogueService extends BaseService
return $this->save($id, ['status' => (int) $status]); return $this->save($id, ['status' => (int) $status]);
} }
/**
* 导出商品 + 关联报价单:两个工作表,xlsx 由前端 ExcelJS 美化
*/
public function exportExcel(): array
{
$rows = CatalogueModel::where('deleted_at', 0)->orderByDesc('id')->get([
'id', 'title', 'identifier', 'alias', 'category_id', 'price', 'status',
])->toArray();
$byId = [];
foreach ($rows as &$row) {
$row['status'] = (int) ($row['status'] ?? 0) === 0 ? '上架' : '下架';
$byId[(int) $row['id']] = $row;
}
unset($row);
$ids = array_keys($byId);
$priceRows = [];
if (!empty($ids)) {
$sheets = PriceSheetModel::where('deleted_at', 0)
->whereIn('catalogue_id', $ids)
->orderBy('catalogue_id')
->orderBy('id')
->get(['id', 'catalogue_id', 'specification', 'dimension', 'routine', 'stock', 'status'])
->toArray();
foreach ($sheets as $sheet) {
$cat = $byId[(int) $sheet['catalogue_id']] ?? [];
$priceRows[] = [
'id' => $sheet['id'],
'catalogue_id' => $sheet['catalogue_id'],
'identifier' => $cat['identifier'] ?? '',
'title' => $cat['title'] ?? '',
'specification' => $sheet['specification'] ?? '',
'dimension' => $sheet['dimension'] ?? '',
'routine' => $sheet['routine'] ?? '',
'stock' => (int) ($sheet['stock'] ?? -1),
'status' => (int) ($sheet['status'] ?? 0) === 0 ? '启用' : '禁用',
];
}
}
return [
'filename' => 'catalogue.xlsx',
'title' => '商品图册',
'sheets' => [
[
'title' => '商品图册',
'sheet' => '商品',
'columns' => [
['key' => 'id', 'header' => 'ID', 'width' => 10, 'align' => 'center'],
['key' => 'title', 'header' => '商品名称', 'width' => 28],
['key' => 'identifier', 'header' => '商品编号', 'width' => 16],
['key' => 'alias', 'header' => '别名', 'width' => 24],
['key' => 'category_id', 'header' => '分类ID', 'width' => 12, 'align' => 'center'],
['key' => 'price', 'header' => '参考价', 'width' => 14, 'align' => 'right'],
['key' => 'status', 'header' => '状态', 'width' => 10, 'align' => 'center'],
],
'rows' => $rows,
],
[
'title' => '关联报价单',
'sheet' => '报价单',
'subtitle' => '按商品编号回写;库存 -1 表示不限',
'columns' => [
['key' => 'id', 'header' => 'ID', 'width' => 10, 'align' => 'center'],
['key' => 'catalogue_id', 'header' => '商品ID', 'width' => 12, 'align' => 'center'],
['key' => 'identifier', 'header' => '商品编号', 'width' => 16],
['key' => 'title', 'header' => '商品名称', 'width' => 24],
['key' => 'specification', 'header' => '规格', 'width' => 20],
['key' => 'dimension', 'header' => '尺寸', 'width' => 16],
['key' => 'routine', 'header' => '常规价', 'width' => 14, 'align' => 'right'],
['key' => 'stock', 'header' => '库存', 'width' => 10, 'align' => 'center'],
['key' => 'status', 'header' => '状态', 'width' => 10, 'align' => 'center'],
],
'rows' => $priceRows,
],
],
];
}
/**
* 导入商品 + 报价单:先落商品,再按编号挂规格
*/
public function importRows(array $rows, array $priceSheets = []): array
{
$created = 0;
$updated = 0;
$priceCreated = 0;
$priceUpdated = 0;
$now = time();
DB::connection('business')->beginTransaction();
try {
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$identifier = trim((string) ($row['identifier'] ?? $row['商品编号'] ?? ''));
$title = trim((string) ($row['title'] ?? $row['商品名称'] ?? ''));
if ($title === '') {
continue;
}
$payload = [
'title' => $title,
'identifier' => $identifier,
'alias' => (string) ($row['alias'] ?? $row['别名'] ?? ''),
'category_id' => (int) ($row['category_id'] ?? $row['分类ID'] ?? 0),
'price' => (string) ($row['price'] ?? $row['参考价'] ?? ''),
'status' => $this->parseStatus($row['status'] ?? $row['状态'] ?? 0),
'updated_at' => $now,
];
$exists = $identifier !== ''
? CatalogueModel::where('identifier', $identifier)->where('deleted_at', 0)->first()
: null;
if (!empty($exists)) {
CatalogueModel::where('id', $exists['id'])->update($payload);
$updated++;
} else {
$payload['created_at'] = $now;
CatalogueModel::insert($payload);
$created++;
}
}
[$priceCreated, $priceUpdated] = $this->importPriceSheets($priceSheets, $now);
DB::connection('business')->commit();
} catch (Exception $e) {
DB::connection('business')->rollBack();
$this->utils->errorThrow($e->getMessage());
}
return [
'created' => $created,
'updated' => $updated,
'price_created' => $priceCreated,
'price_updated' => $priceUpdated,
];
}
/** /**
* 报价单里真正有值的材质列 * 报价单里真正有值的材质列
*/ */
@@ -164,7 +302,7 @@ class CatalogueService extends BaseService
private function normalize(array $params): array private function normalize(array $params): array
{ {
foreach (['cover', 'pdf'] as $field) { foreach (['cover', 'pdf', 'video'] as $field) {
if (array_key_exists($field, $params)) { if (array_key_exists($field, $params)) {
$params[$field] = $this->media->firstOf($params[$field]); $params[$field] = $this->media->firstOf($params[$field]);
} }
@@ -190,4 +328,100 @@ class CatalogueService extends BaseService
$this->utils->errorThrow('商品编号已存在:' . $identifier); $this->utils->errorThrow('商品编号已存在:' . $identifier);
} }
} }
/**
* 导入报价单:优先商品编号,其次商品ID;同商品同规格同尺寸则更新
*/
private function importPriceSheets(array $rows, int $now): array
{
$created = 0;
$updated = 0;
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$catalogueId = $this->resolveCatalogueId($row);
$specification = trim((string) ($row['specification'] ?? $row['规格'] ?? ''));
if ($catalogueId <= 0 || $specification === '') {
continue;
}
$dimension = (string) ($row['dimension'] ?? $row['尺寸'] ?? '');
$payload = [
'catalogue_id' => $catalogueId,
'specification' => $specification,
'dimension' => $dimension,
'routine' => (string) ($row['routine'] ?? $row['常规价'] ?? ''),
'stock' => $this->parseStock($row['stock'] ?? $row['库存'] ?? -1),
'status' => $this->parseStatus($row['status'] ?? $row['状态'] ?? 0),
'updated_at' => $now,
];
$id = (int) ($row['id'] ?? $row['ID'] ?? 0);
$exists = $id > 0
? PriceSheetModel::where('id', $id)->where('catalogue_id', $catalogueId)->where('deleted_at', 0)->first()
: null;
if (empty($exists)) {
$exists = PriceSheetModel::where('catalogue_id', $catalogueId)
->where('specification', $specification)
->where('dimension', $dimension)
->where('deleted_at', 0)
->first();
}
if (!empty($exists)) {
PriceSheetModel::where('id', $exists['id'])->update($payload);
$updated++;
} else {
$payload['created_at'] = $now;
PriceSheetModel::insert($payload);
$created++;
}
}
return [$created, $updated];
}
/**
* 报价单行挂到哪件商品:编号优先,兼容导出里的商品ID
*/
private function resolveCatalogueId(array $row): int
{
$identifier = trim((string) ($row['identifier'] ?? $row['商品编号'] ?? ''));
if ($identifier !== '') {
$cat = CatalogueModel::where('identifier', $identifier)->where('deleted_at', 0)->first();
if (!empty($cat)) {
return (int) $cat['id'];
}
}
$id = (int) ($row['catalogue_id'] ?? $row['商品ID'] ?? 0);
if ($id <= 0) {
return 0;
}
$exists = CatalogueModel::where('id', $id)->where('deleted_at', 0)->exists();
return $exists ? $id : 0;
}
/**
* Excel 状态列可能是「上架/下架」或 0/1,统一成库里的数字
*/
private function parseStatus(mixed $value): int
{
$raw = trim((string) $value);
if (in_array($raw, ['上架', '启用', '正常', '0', ''], true)) {
return 0;
}
if (in_array($raw, ['下架', '禁用', '1'], true)) {
return 1;
}
return ((int) $raw) === 1 ? 1 : 0;
}
/**
* 库存:空 / 不限 → -1
*/
private function parseStock(mixed $value): int
{
$raw = trim((string) $value);
if ($raw === '' || in_array($raw, ['不限', '无限', '-1'], true)) {
return -1;
}
return (int) $raw;
}
} }

View File

@@ -100,4 +100,84 @@ class FactoryInfoService extends BaseService
{ {
return $this->save($id, ['status' => (int) $status]); return $this->save($id, ['status' => (int) $status]);
} }
/**
* 导出工厂表:只回结构化数据,xlsx 由前端 ExcelJS 美化
*/
public function exportExcel(): array
{
$rows = FactoryInfoModel::where('deleted_at', 0)->orderByDesc('id')->get([
'id', 'name', 'phone', 'classification', 'address', 'status',
])->toArray();
foreach ($rows as &$row) {
$row['status'] = (int) ($row['status'] ?? 0) === 0 ? '启用' : '禁用';
}
unset($row);
return [
'filename' => 'factory.xlsx',
'title' => '工厂信息',
'sheet' => '工厂',
'columns' => [
['key' => 'id', 'header' => 'ID', 'width' => 10, 'align' => 'center'],
['key' => 'name', 'header' => '工厂名称', 'width' => 22],
['key' => 'phone', 'header' => '电话', 'width' => 16],
['key' => 'classification', 'header' => '分类ID', 'width' => 12, 'align' => 'center'],
['key' => 'address', 'header' => '地址', 'width' => 32],
['key' => 'status', 'header' => '状态', 'width' => 10, 'align' => 'center'],
],
'rows' => $rows,
];
}
/**
* 导入工厂:前端 ExcelJS 拆好的行,按 name 更新
*/
public function importRows(array $rows): array
{
$created = 0;
$updated = 0;
$now = time();
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$name = trim((string) ($row['name'] ?? $row['工厂名称'] ?? ''));
if ($name === '') {
continue;
}
$payload = [
'name' => $name,
'phone' => (string) ($row['phone'] ?? $row['电话'] ?? ''),
'classification' => (int) ($row['classification'] ?? $row['分类ID'] ?? 0),
'address' => (string) ($row['address'] ?? $row['地址'] ?? ''),
'status' => $this->parseStatus($row['status'] ?? $row['状态'] ?? 0),
'updated_at' => $now,
];
$exists = FactoryInfoModel::where('name', $name)->where('deleted_at', 0)->first();
if (!empty($exists)) {
FactoryInfoModel::where('id', $exists['id'])->update($payload);
$updated++;
} else {
$payload['created_at'] = $now;
FactoryInfoModel::insert($payload);
$created++;
}
}
return ['created' => $created, 'updated' => $updated];
}
/**
* Excel 状态列可能是「启用/禁用」或 0/1,统一成库里的数字
*/
private function parseStatus(mixed $value): int
{
$raw = trim((string) $value);
if (in_array($raw, ['上架', '启用', '正常', '0', ''], true)) {
return 0;
}
if (in_array($raw, ['下架', '禁用', '1'], true)) {
return 1;
}
return ((int) $raw) === 1 ? 1 : 0;
}
} }

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Service\business;
use App\BaseApp\BaseService;
use App\Models\business\FeedbackModel;
/**
* 后台用户反馈
*/
class FeedbackService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = FeedbackModel::class;
$this->selectField = ['id', 'user_id', 'content', 'images', 'reply', 'status', 'created_at', 'updated_at'];
$this->queryField = ['user_id' => '=', 'status' => '=', 'content' => 'like'];
$this->with = ['user'];
}
public function list(): array
{
$result = $this->getPageList();
foreach ($result['items'] as &$item) {
$item['user_name'] = $item['user']['nick_name'] ?? '';
$item['user_phone'] = $item['user']['phone'] ?? '';
unset($item['user']);
}
unset($item);
return $result;
}
/**
* 回复反馈
*/
public function reply(int $id, string $reply): mixed
{
if ($id <= 0) {
$this->utils->errorThrow('参数错误');
}
$reply = trim($reply);
if ($reply === '') {
$this->utils->errorThrow('请填写回复');
}
return $this->save($id, [
'reply' => mb_substr($reply, 0, 500),
'status' => FeedbackModel::STATUS_REPLIED,
]);
}
public function delete($ids): mixed
{
return $this->del(is_array($ids) ? $ids : [$ids]);
}
}

View File

@@ -6,6 +6,7 @@ use App\BaseApp\BaseService;
use App\Models\business\ListItemModel; use App\Models\business\ListItemModel;
use App\Models\business\ListModel; use App\Models\business\ListModel;
use App\Models\business\OrderModel; use App\Models\business\OrderModel;
use App\Models\business\PriceSheetModel;
use App\Models\business\WxUserModel; use App\Models\business\WxUserModel;
/** /**
@@ -22,7 +23,8 @@ class ListService extends BaseService
$this->model = ListModel::class; $this->model = ListModel::class;
$this->selectField = [ $this->selectField = [
'id', 'list_no', 'name', 'user_id', 'enterprise_id', 'remark', '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 = [ $this->queryField = [
'list_no' => 'like', 'list_no' => 'like',
@@ -113,6 +115,13 @@ class ListService extends BaseService
unset($item); unset($item);
$info['total_amount'] = $total; $info['total_amount'] = $total;
$info['total_amount_text'] = $price->centsToYuan($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; return $info;
} }
@@ -171,8 +180,12 @@ class ListService extends BaseService
if ($itemId <= 0) { if ($itemId <= 0) {
$this->utils->errorThrow('参数错误'); $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()]; $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)) { if (array_key_exists($field, $params)) {
$update[$field] = (int) $params[$field]; $update[$field] = (int) $params[$field];
} }
@@ -182,6 +195,20 @@ class ListService extends BaseService
$update[$field] = (string) $params[$field]; $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); return ListItemModel::where('id', $itemId)->update($update);
} }
@@ -195,4 +222,16 @@ class ListService extends BaseService
'updated_at' => time(), 'updated_at' => time(),
]); ]);
} }
/**
* 导出清单报价:后台画 xlsx,小程序用 text 分享
*/
public function exportQuote(int $id): array
{
$info = $this->detail($id);
if (!is_array($info)) {
$this->utils->errorThrow('清单不存在');
}
return \App\Service\common\ExcelCsvService::getInstance()->quoteFromList($info);
}
} }

View File

@@ -4,6 +4,7 @@ namespace App\Service\business;
use App\Models\business\ListItemModel; use App\Models\business\ListItemModel;
use App\Models\business\ListModel; use App\Models\business\ListModel;
use App\Service\business\PackageQuoteService;
use App\Models\business\OrderDeliveryModel; use App\Models\business\OrderDeliveryModel;
use App\Models\business\OrderItemModel; use App\Models\business\OrderItemModel;
use App\Models\business\OrderModel; use App\Models\business\OrderModel;
@@ -90,6 +91,7 @@ class OrderCoreService
continue; continue;
} }
$quantity = max(1, (int) $item['quantity']); $quantity = max(1, (int) $item['quantity']);
StockService::getInstance()->assertAvailable((int) $item['price_sheet_id'], $quantity);
$unitPrice = (int) $item['unit_price']; $unitPrice = (int) $item['unit_price'];
if ($unitPrice <= 0) { if ($unitPrice <= 0) {
$unitPrice = $price->resolveUnitPrice($sheet['routine'] ?? '', (string) $item['material_key'], $multiplier); $unitPrice = $price->resolveUnitPrice($sheet['routine'] ?? '', (string) $item['material_key'], $multiplier);
@@ -118,6 +120,16 @@ class OrderCoreService
if (empty($rows)) { if (empty($rows)) {
UtilsService::getInstance()->errorThrow('清单里没有可下单的商品'); 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; $orderId = 0;
DB::connection('business')->transaction(function () use (&$orderId, $list, $userId, $user, $params, $rows, $total) { DB::connection('business')->transaction(function () use (&$orderId, $list, $userId, $user, $params, $rows, $total) {
@@ -141,6 +153,9 @@ class OrderCoreService
} }
unset($row); unset($row);
OrderItemModel::insert($rows); OrderItemModel::insert($rows);
foreach ($rows as $row) {
StockService::getInstance()->deduct((int) $row['price_sheet_id'], (int) $row['quantity']);
}
ListModel::where('id', $list['id'])->update(['status' => 1, 'updated_at' => $now]); ListModel::where('id', $list['id'])->update(['status' => 1, 'updated_at' => $now]);
}); });
@@ -163,6 +178,20 @@ class OrderCoreService
UtilsService::getInstance()->errorThrow('订单不存在'); UtilsService::getInstance()->errorThrow('订单不存在');
} }
$order = $order->toArray(); $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'] = []; $order['voucher_list'] = [];
foreach ($order['payments'] ?? [] as $payment) { foreach ($order['payments'] ?? [] as $payment) {
foreach (array_filter(explode(',', (string) $payment['voucher'])) as $image) { foreach (array_filter(explode(',', (string) $payment['voucher'])) as $image) {
@@ -245,6 +274,9 @@ class OrderCoreService
} }
$this->applyPaid($orderId, (int) $payment['amount'], $now); $this->applyPaid($orderId, (int) $payment['amount'], $now);
}); });
if ($status === OrderPaymentModel::STATUS_CONFIRMED) {
$this->notifySubscribe($orderId, 'pay');
}
return $this->detail($orderId); return $this->detail($orderId);
} }
@@ -283,11 +315,14 @@ class OrderCoreService
public function confirmWechatPay(string $outTradeNo, string $transactionId, int $amount): bool public function confirmWechatPay(string $outTradeNo, string $transactionId, int $amount): bool
{ {
$done = false; $done = false;
DB::connection('business')->transaction(function () use ($outTradeNo, $transactionId, $amount, &$done) { $fresh = false;
$orderId = 0;
DB::connection('business')->transaction(function () use ($outTradeNo, $transactionId, $amount, &$done, &$fresh, &$orderId) {
$payment = OrderPaymentModel::where('out_trade_no', $outTradeNo)->lockForUpdate()->first(); $payment = OrderPaymentModel::where('out_trade_no', $outTradeNo)->lockForUpdate()->first();
if (empty($payment)) { if (empty($payment)) {
return; return;
} }
$orderId = (int) $payment['order_id'];
if ((int) $payment['status'] === OrderPaymentModel::STATUS_CONFIRMED) { if ((int) $payment['status'] === OrderPaymentModel::STATUS_CONFIRMED) {
$done = true; $done = true;
return; return;
@@ -300,9 +335,13 @@ class OrderCoreService
'audited_at' => $now, 'audited_at' => $now,
'updated_at' => $now, 'updated_at' => $now,
]); ]);
$this->applyPaid((int) $payment['order_id'], $amount > 0 ? $amount : (int) $payment['amount'], $now); $this->applyPaid($orderId, $amount > 0 ? $amount : (int) $payment['amount'], $now);
$done = true; $done = true;
$fresh = true;
}); });
if ($fresh && $orderId > 0) {
$this->notifySubscribe($orderId, 'pay');
}
return $done; return $done;
} }
@@ -342,6 +381,7 @@ class OrderCoreService
'updated_at' => $now, 'updated_at' => $now,
]); ]);
}); });
$this->notifySubscribe($orderId, 'ship');
return $this->detail($orderId); return $this->detail($orderId);
} }
@@ -380,4 +420,47 @@ class OrderCoreService
} }
return $order->toArray(); return $order->toArray();
} }
/**
* 付款/发货后尽力发订阅消息,失败不影响主流程
*/
private function notifySubscribe(int $orderId, string $scene): void
{
if ($orderId <= 0) {
return;
}
try {
$order = OrderModel::where('id', $orderId)->first();
if (empty($order)) {
return;
}
$user = WxUserModel::where('id', (int) $order['user_id'])->first(['open_id']);
$openId = (string) ($user['open_id'] ?? '');
if ($openId === '') {
return;
}
$app = \App\Service\wx\WxAppService::getInstance()->current();
$tplKey = $scene === 'ship' ? 'subscribe_ship_tpl' : 'subscribe_pay_tpl';
$templateId = trim((string) ($app[$tplKey] ?? ''));
if ($templateId === '') {
return;
}
$price = PriceService::getInstance();
$page = 'pages/order/detail?id=' . $orderId;
$data = $scene === 'ship'
? [
'character_string1' => ['value' => (string) $order['order_no']],
'thing2' => ['value' => '订单已发货'],
'time3' => ['value' => date('Y-m-d H:i')],
]
: [
'character_string1' => ['value' => (string) $order['order_no']],
'amount2' => ['value' => $price->centsToYuan((int) $order['total_amount'])],
'thing3' => ['value' => '付款成功'],
];
\App\Service\wx\WxMiniService::getInstance()->sendSubscribe($openId, $templateId, $page, $data);
} catch (\Throwable) {
// 订阅消息是附加能力,模板字段对不上或用户未授权都不能挡住收款/发货
}
}
} }

View File

@@ -3,6 +3,7 @@
namespace App\Service\business; namespace App\Service\business;
use App\BaseApp\BaseService; use App\BaseApp\BaseService;
use App\Models\business\ListModel;
use App\Models\business\OrderModel; use App\Models\business\OrderModel;
use App\Models\business\OrderPaymentModel; use App\Models\business\OrderPaymentModel;
use App\Models\business\WxUserModel; use App\Models\business\WxUserModel;
@@ -188,6 +189,15 @@ class OrderService extends BaseService
$stat['auditing'] = OrderPaymentModel::where('deleted_at', 0) $stat['auditing'] = OrderPaymentModel::where('deleted_at', 0)
->where('status', OrderPaymentModel::STATUS_AUDITING) ->where('status', OrderPaymentModel::STATUS_AUDITING)
->count(); ->count();
$stat['list_count'] = ListModel::where('deleted_at', 0)->count();
$stat['wx_user_count'] = WxUserModel::where('deleted_at', 0)->count();
$statusMap = [];
foreach ($stat['status'] as $row) {
$statusMap[(int) $row['status']] = (int) $row['count'];
}
$stat['unpaid'] = $statusMap[OrderModel::STATUS_UNPAID] ?? 0;
$stat['paid'] = $statusMap[OrderModel::STATUS_PAID] ?? 0;
$stat['shipped'] = $statusMap[OrderModel::STATUS_SHIPPED] ?? 0;
return $stat; return $stat;
} }
} }

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, '.', ''); 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 会把价格清零 * 倍率兜底:库里可能是空串、0 或负数,直接拿去 bcmul 会把价格清零
*/ */

View File

@@ -26,7 +26,7 @@ class PriceSheetService extends BaseService
$this->selectField = array_merge( $this->selectField = array_merge(
['id', 'catalogue_id', 'specification', 'dimension'], ['id', 'catalogue_id', 'specification', 'dimension'],
PriceSheetModel::MATERIAL_FIELDS, PriceSheetModel::MATERIAL_FIELDS,
['status', 'created_at', 'updated_at'] ['stock', 'status', 'created_at', 'updated_at']
); );
$this->queryField = ['catalogue_id' => '=', 'specification' => 'like', 'status' => '=']; $this->queryField = ['catalogue_id' => '=', 'specification' => 'like', 'status' => '='];
$this->orderBy = ['name' => 'id', 'sort' => 'asc']; $this->orderBy = ['name' => 'id', 'sort' => 'asc'];
@@ -195,6 +195,9 @@ class PriceSheetService extends BaseService
$payload[$field] = is_scalar($row[$field]) ? (string) $row[$field] : ''; $payload[$field] = is_scalar($row[$field]) ? (string) $row[$field] : '';
} }
} }
if (array_key_exists('stock', $row)) {
$payload['stock'] = (int) $row['stock'];
}
return $payload; return $payload;
} }

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Service\business;
use App\Models\business\PriceSheetModel;
use App\Service\common\UtilsService;
/**
* 规格库存:stock=-1 表示不限,兼容历史数据全是不限。
*/
class StockService
{
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];
}
/**
* 加入清单 / 下单前校验。sheetId=0 时跳过(还没选规格)。
*/
public function assertAvailable(int $sheetId, int $quantity): void
{
if ($sheetId <= 0) {
return;
}
$sheet = PriceSheetModel::where('id', $sheetId)->where('deleted_at', 0)->first();
if (empty($sheet)) {
UtilsService::getInstance()->errorThrow('规格不存在');
}
$stock = (int) ($sheet['stock'] ?? -1);
if ($stock < 0) {
return;
}
if ($stock < $quantity) {
UtilsService::getInstance()->errorThrow('库存不足(剩余 ' . $stock . ')');
}
}
/**
* 下单成功后扣库存;不限库存不写库。
*/
public function deduct(int $sheetId, int $quantity): void
{
if ($sheetId <= 0 || $quantity <= 0) {
return;
}
$sheet = PriceSheetModel::where('id', $sheetId)->where('deleted_at', 0)->first();
if (empty($sheet)) {
return;
}
$stock = (int) ($sheet['stock'] ?? -1);
if ($stock < 0) {
return;
}
$next = max(0, $stock - $quantity);
PriceSheetModel::where('id', $sheetId)->update([
'stock' => $next,
'updated_at' => time(),
]);
}
}

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 套模板真正的差异在四条轴上:配色、字体与字号梯度、圆角与阴影、动效曲线与时长。 * 4 套轻奢模板真正的差异在四条轴上:配色、字体与字号梯度、圆角与阴影、动效曲线与时长。
* 这里把后三条做成可复用的档位,预设只需要挑档位,避免 20 份 JSON 手抄跑偏, * 这里把后三条做成可复用的档位,预设只需要挑档位,避免多份 JSON 手抄跑偏,
* 也让「再加一套模板」变成加十几行配置而不是重写一份主题。 * 也让「再加一套模板」变成加十几行配置而不是重写一份主题。
*/ */
class WxTemplatePresetService class WxTemplatePresetService

View File

@@ -39,28 +39,239 @@ class WxTemplateSchemaService
*/ */
public const LAYOUT_SCHEMA = [ public const LAYOUT_SCHEMA = [
'home' => [ 'home' => [
'hero' => ['banner', 'carousel', 'split', 'fullscreen'], 'hero' => ['banner', 'carousel', 'split', 'fullscreen', 'stack', 'coverflow', 'fade', 'peek', 'cube', 'caption'],
'category' => ['grid', 'scroll', 'card', 'sidebar'], 'category' => ['grid', 'scroll', 'card', 'sidebar', 'pills', 'mosaic', 'featured', 'tile', 'contents'],
'product' => ['waterfall', 'list', 'grid', 'magazine'], 'package' => ['card', 'scroll', 'featured', 'magazine'],
'product' => ['waterfall', 'list', 'grid'],
], ],
'product' => [ 'product' => [
'gallery' => ['swiper', 'stack', 'fullbleed'], 'gallery' => ['swiper', 'stack', 'fullbleed', 'peek', 'fade', 'coverflow', 'mosaic', 'filmstrip'],
'price' => ['inline', 'card', 'sticky'], 'price' => ['inline', 'card', 'sticky'],
'action' => ['fixed', 'inline'], 'action' => ['fixed', 'inline', 'split'],
], ],
'list' => [ 'list' => [
'style' => ['card', 'table', 'timeline'], 'style' => ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'],
], ],
'mine' => [ 'mine' => [
'header' => ['gradient', 'image', 'plain'], 'header' => ['gradient', 'image', 'plain', 'split', 'editorial'],
'menu' => ['grid', 'list'], 'menu' => ['grid', 'list', 'card', 'tile', 'compact'],
],
'package' => [
'list' => ['card', 'featured', 'magazine', 'list'],
], ],
'effect' => [ 'effect' => [
'transition' => ['fade', 'slide', 'zoom', 'none'], 'transition' => ['fade', 'slide', 'zoom', 'none'],
'skeleton' => ['shimmer', 'pulse', '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' => ['search', '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', 'magazine', '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' => [
'search' => ['bar', 'overlay', 'pill', 'float'],
'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' => '卡片',
'magazine' => '刊名条',
'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 +319,10 @@ class WxTemplateSchemaService
} }
/** /**
* 清洗布局:值必须是枚举里的选项,非法值退回该项的第一个选项 * 清洗布局:旧枚举 + pages.sections + card_scheme
*
* 非法枚举在宽松模式退回第一项;strict(导入)直接报错。
* pages / card_scheme 是装修工作室写入的,必须保留,否则保存后拖拽结果会丢。
*/ */
public function sanitizeLayout(mixed $layout, bool $strict = false): array public function sanitizeLayout(mixed $layout, bool $strict = false): array
{ {
@@ -133,9 +347,362 @@ class WxTemplateSchemaService
$clean[$page][$key] = $value; $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; return $clean;
} }
/**
* 清洗页面区块列表:只留白名单 type/variant,props 只留安全短值
*/
public function sanitizePages(array $pages, bool $strict = false): array
{
$clean = [];
foreach (self::PAGE_SECTION_TYPES as $page => $types) {
$pageBlock = $pages[$page] ?? [];
if (!is_array($pageBlock)) {
continue;
}
$source = $pageBlock['sections'] ?? $pageBlock;
if (!is_array($source)) {
continue;
}
// 兼容 { sections: [] } 与直接数组
$list = isset($source['sections']) && is_array($source['sections']) ? $source['sections'] : $source;
$sections = [];
foreach ($list as $index => $item) {
if (!is_array($item)) {
continue;
}
$type = (string) ($item['type'] ?? '');
if (!in_array($type, $types, true)) {
if ($strict) {
UtilsService::getInstance()->errorThrow("页面 {$page} 不支持区块 {$type}");
}
continue;
}
$allowed = $this->variantsFor($page, $type);
$variant = (string) ($item['variant'] ?? $allowed[0]);
// 旧杂志卡带报价,商品流收成瀑布;套餐 magazine 是刊名条,不要改
if ($variant === 'magazine' && in_array($page, ['home', 'catalog', 'search'], true) && $type !== 'package') {
$variant = 'waterfall';
}
if (!in_array($variant, $allowed, true)) {
if ($strict) {
UtilsService::getInstance()->errorThrow("区块 {$type} 变体只能是:" . implode('/', $allowed));
}
$variant = $allowed[0];
}
$id = $this->isSafeCode((string) ($item['id'] ?? ''))
? (string) $item['id']
: $type . '-' . $index;
$sections[] = [
'id' => $id,
'type' => $type,
'variant' => $variant,
'visible' => !empty($item['visible']) || !array_key_exists('visible', $item),
'props' => $this->sanitizeSectionProps($item['props'] ?? []),
];
}
$row = ['sections' => $sections];
$skin = (string) ($pageBlock['skin'] ?? '');
$density = (string) ($pageBlock['density'] ?? '');
$frame = (string) ($pageBlock['frame'] ?? '');
if (in_array($skin, self::PAGE_SKINS, true)) {
$row['skin'] = $skin;
}
if (in_array($density, self::PAGE_DENSITIES, true)) {
$row['density'] = $density;
}
if (in_array($frame, self::PAGE_FRAMES, true)) {
$row['frame'] = $frame;
}
$pageScheme = trim((string) ($pageBlock['card_scheme'] ?? ''));
if ($pageScheme !== '' && $this->isSafeCode($pageScheme)) {
$row['card_scheme'] = $pageScheme;
}
$caption = $pageBlock['card_caption'] ?? '';
if (is_string($caption) && $caption !== '' && mb_strlen($caption) <= 80 && $this->isSafeValue($caption)) {
$row['card_caption'] = $caption;
}
$clean[$page] = $row;
}
return $clean;
}
/**
* 清洗卡片图层:坐标数值化,非法 type 丢弃
*/
public function sanitizeLayers(mixed $layers, bool $strict = false): array
{
$layers = $this->toArray($layers);
$clean = [];
foreach ($layers as $index => $item) {
if (!is_array($item)) {
continue;
}
$type = (string) ($item['type'] ?? '');
if (!in_array($type, self::LAYER_TYPES, true)) {
if ($strict) {
UtilsService::getInstance()->errorThrow('图层 type 只能是 photo/text/deco/shape');
}
continue;
}
$id = $this->isSafeCode((string) ($item['id'] ?? ''))
? (string) $item['id']
: 'layer-' . $index;
$row = [
'id' => $id,
'type' => $type,
'x' => $this->num($item['x'] ?? 0),
'y' => $this->num($item['y'] ?? 0),
'w' => $this->num($item['w'] ?? 40),
'h' => $this->num($item['h'] ?? 40),
'rotate' => $this->num($item['rotate'] ?? 0),
'z' => (int) ($item['z'] ?? 1),
'visible' => !array_key_exists('visible', $item) || !empty($item['visible']),
'locked' => !empty($item['locked']),
];
if (!empty($item['bind']) && in_array((string) $item['bind'], self::LAYER_BINDS, true)) {
$row['bind'] = (string) $item['bind'];
}
if (!empty($item['font']) && in_array((string) $item['font'], self::LAYER_FONTS, true)) {
$row['font'] = (string) $item['font'];
}
foreach (['font_zh', 'font_en'] as $faceKey) {
$face = (string) ($item[$faceKey] ?? '');
if ($face !== '' && in_array($face, self::LAYER_FACES, true)) {
$row[$faceKey] = $face;
}
}
if (!empty($item['asset']) && $this->isSafeCode((string) $item['asset'])) {
$row['asset'] = (string) $item['asset'];
}
$tone = (string) ($item['tone'] ?? '');
if ($tone !== '' && in_array($tone, self::LAYER_TONES, true)) {
$row['tone'] = $tone;
}
if (isset($item['fill']) && $this->isSafeValue($item['fill'])) {
$row['fill'] = (string) $item['fill'];
}
if (isset($item['text']) && is_string($item['text']) && mb_strlen($item['text']) <= 80) {
$row['text'] = $item['text'];
}
if (!empty($item['src']) && $this->isSafeUrl((string) $item['src'])) {
$row['src'] = (string) $item['src'];
}
$clean[] = $row;
}
return $clean;
}
/**
* 某页某区块允许的变体:先看页面覆盖,再回落到全局
*
* @return array<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 这种 * 小程序侧要的扁平 CSS 变量表:--color-primary 这种
*/ */
@@ -153,6 +720,58 @@ class WxTemplateSchemaService
return $vars; 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 private function isSafeValue(mixed $value): bool
{ {
if (is_int($value) || is_float($value)) { 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 public function initPresets(bool $overwrite = false): array
{ {
@@ -233,6 +234,7 @@ class WxTemplateService extends BaseService
$this->setDefault((int) $first['id']); $this->setDefault((int) $first['id']);
} }
} }
$result['card_schemes'] = WxCardSchemeService::getInstance()->initPresets($overwrite);
return $result; return $result;
} }

View File

@@ -0,0 +1,142 @@
<?php
namespace App\Service\common;
/**
* 表格导出辅助:报价给结构化列/行(前端 ExcelJS 画 xlsx),小程序继续用 text。
* 仍保留 toCsv / parseUpload,避免旧调用立刻断掉。
*/
class ExcelCsvService
{
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];
}
/**
* 行数组转带 BOM 的 CSV 文本
*/
public function toCsv(array $header, array $rows): string
{
$fp = fopen('php://temp', 'r+');
fprintf($fp, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($fp, $header);
foreach ($rows as $row) {
$line = [];
foreach ($header as $key) {
$line[] = $row[$key] ?? '';
}
fputcsv($fp, $line);
}
rewind($fp);
$csv = stream_get_contents($fp);
fclose($fp);
return $csv === false ? '' : $csv;
}
/**
* 解析上传文件(csv / 文本)
*/
public function parseUpload($file): array
{
if (empty($file)) {
UtilsService::getInstance()->errorThrow('请上传表格文件');
}
$path = is_string($file) ? $file : $file->getRealPath();
$raw = file_get_contents($path);
if ($raw === false) {
UtilsService::getInstance()->errorThrow('文件读取失败');
}
if (str_starts_with($raw, "\xEF\xBB\xBF")) {
$raw = substr($raw, 3);
}
$fp = fopen('php://temp', 'r+');
fwrite($fp, $raw);
rewind($fp);
$header = fgetcsv($fp);
if (empty($header)) {
fclose($fp);
UtilsService::getInstance()->errorThrow('表格没有表头');
}
$header = array_map(static fn ($v) => trim((string) $v), $header);
$rows = [];
while (($cols = fgetcsv($fp)) !== false) {
if ($this->rowEmpty($cols)) {
continue;
}
$item = [];
foreach ($header as $i => $key) {
$item[$key] = isset($cols[$i]) ? trim((string) $cols[$i]) : '';
}
$rows[] = $item;
}
fclose($fp);
return $rows;
}
/**
* 清单报价:后台用 ExcelJS 画 xlsx,小程序继续用 text 分享
*/
public function quoteFromList(array $info): array
{
$rows = [];
$lines = [];
$name = (string) ($info['name'] ?? $info['list_no'] ?? '报价');
$lines[] = $name;
foreach ($info['items'] ?? [] as $item) {
$title = (string) ($item['title'] ?? $item['catalogue']['title'] ?? $item['product']['title'] ?? '');
$spec = (string) ($item['specification'] ?? $item['price_sheet']['specification'] ?? '');
$qty = (int) ($item['quantity'] ?? 1);
$unit = (string) ($item['unit_price_text'] ?? '');
$total = (string) ($item['total_price_text'] ?? '');
$rows[] = [
'title' => $title,
'specification' => $spec,
'quantity' => $qty,
'unit_price' => $unit,
'total_price' => $total,
];
$lines[] = trim($title . ' ' . $spec . ' x' . $qty . ($unit !== '' ? ' ¥' . $unit : ''));
}
$payable = (string) ($info['payable_amount_text'] ?? $info['total_amount_text'] ?? '');
if ($payable !== '') {
$lines[] = '合计 ¥' . $payable;
}
$no = (string) ($info['list_no'] ?? 'quote');
return [
'filename' => 'quote-' . $no . '.xlsx',
'title' => $name,
'sheet' => '报价',
'subtitle' => '清单号 ' . $no,
'columns' => [
['key' => 'title', 'header' => '商品', 'width' => 28],
['key' => 'specification', 'header' => '规格', 'width' => 22],
['key' => 'quantity', 'header' => '数量', 'width' => 10, 'align' => 'center'],
['key' => 'unit_price', 'header' => '单价', 'width' => 14, 'align' => 'right'],
['key' => 'total_price', 'header' => '小计', 'width' => 14, 'align' => 'right'],
],
'rows' => $rows,
'summary' => [
'title' => '合计',
'total_price' => $payable === '' ? '' : ('¥' . $payable),
],
'text' => implode("\n", $lines),
];
}
private function rowEmpty(array $cols): bool
{
foreach ($cols as $col) {
if (trim((string) $col) !== '') {
return false;
}
}
return true;
}
}

View File

@@ -69,7 +69,11 @@ class JWTService
$payload = [ $payload = [
'iat' => $issuedAt, 'iat' => $issuedAt,
'exp' => $expirationTime, 'exp' => $expirationTime,
'data' => $data // sub 是标准声明,网关/旧客户端剥掉自定义 data 时还能靠它找回用户
'sub' => (string) ($data['id'] ?? ''),
// 与小程序 token(scope=wx)区分,两边共用 JWT_SECRET 但不能互认
'scope' => 'admin',
'data' => $data,
]; ];
RedisService::getInstance()->init(config('nl.redis.jwt'))->set($data['id'], json_encode($data)); RedisService::getInstance()->init(config('nl.redis.jwt'))->set($data['id'], json_encode($data));
return JWT::encode($payload, $this->secretKey, 'HS256'); return JWT::encode($payload, $this->secretKey, 'HS256');
@@ -83,9 +87,14 @@ class JWTService
public function parseToken(): ?object public function parseToken(): ?object
{ {
try { 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')); return JWT::decode($this->token, new Key($this->secretKey, 'HS256'));
} catch (Exception $e) { } catch (Exception $e) {
if ((int) $e->getCode() === 401) {
throw $e;
}
return UtilsService::getInstance()->notAuth('【1】Token解析失败!请重新登录:'. $e->getMessage()); return UtilsService::getInstance()->notAuth('【1】Token解析失败!请重新登录:'. $e->getMessage());
} }
} }
@@ -100,14 +109,59 @@ class JWTService
{ {
try { try {
$jwt = $this->parseToken(); $jwt = $this->parseToken();
$user = RedisService::getInstance()->init(config('nl.redis.jwt'))->get($jwt->data->id); $payload = json_decode(json_encode($jwt), true);
if (empty($user)) return UtilsService::getInstance()->notAuth('登录状态过期'); if (is_array($payload) && ($payload['scope'] ?? '') === 'wx') {
return json_decode($user, true); return UtilsService::getInstance()->notAuth('请使用后台账号登录');
} catch (Exception $e) { }
$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()); 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 * 续签 JWT Token
* @return string|null 新的 JWT 字符串或者 null 如果原 token 已过期或无效 * @return string|null 新的 JWT 字符串或者 null 如果原 token 已过期或无效
@@ -116,11 +170,12 @@ class JWTService
public function refreshToken(): ?string public function refreshToken(): ?string
{ {
$decoded = $this->parseToken(); $decoded = $this->parseToken();
if ($decoded === null || !property_exists($decoded, 'data')) { $data = $this->extractUserData($decoded);
if (($data['id'] ?? 0) <= 0) {
return null; 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 public function firstOf(mixed $value): string
{ {
if (is_array($value)) { 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 : ''); return $this->toStorage(is_string($value) ? $value : '');
} }

View File

@@ -111,8 +111,8 @@ class OssRuntimeConfigService extends BaseService
'id' => (int) $row->id, 'id' => (int) $row->id,
'driver' => (string) $row->driver, 'driver' => (string) $row->driver,
'name' => (string) $row->name, 'name' => (string) $row->name,
'access_key' => $enc->decryptFromStorage((string) ($row->access_key ?? ''), true), 'access_key' => $this->decryptSecret($enc, (string) ($row->access_key ?? ''), 'AccessKey'),
'secret_key' => $enc->decryptFromStorage((string) ($row->secret_key ?? ''), true), 'secret_key' => $this->decryptSecret($enc, (string) ($row->secret_key ?? ''), 'SecretKey'),
'endpoint' => (string) ($row->endpoint ?? ''), 'endpoint' => (string) ($row->endpoint ?? ''),
'region' => (string) ($row->region ?? ''), 'region' => (string) ($row->region ?? ''),
'bucket' => (string) ($row->bucket ?? ''), 'bucket' => (string) ($row->bucket ?? ''),
@@ -121,4 +121,21 @@ class OssRuntimeConfigService extends BaseService
'extra_json' => $extra, '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 上万对象时一次拉全量必然打穿 * 素材库靠 marker 一页一页往回补:bucket 上万对象时一次拉全量必然打穿
* PHP 的执行时限,所以约定「调用方拿着 next_marker 继续要下一页」。 * PHP 的执行时限,所以约定「调用方拿着 next_marker 继续要下一页」。
* *
* @param string $prefix 只列举该前缀,留空时退回配置里的 path_prefix * @param string $prefix 只列举该前缀,留空表示整个 Bucket(不套 path_prefix)
* @param string $marker 上一页返回的 next_marker,首页传空串 * @param string $marker 上一页返回的 next_marker,首页传空串
* @param int $limit 单页条数 * @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} * @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; public function deleteObject(string $key): bool;
} }

View File

@@ -70,7 +70,7 @@ class AliyunStorageService extends BaseNotAuthService implements OssStorageInter
public function deleteObject(string $key): bool public function deleteObject(string $key): bool
{ {
$this->assertConfig(); $this->assertConfig();
$key = $this->normalizeObjectKey($key); $key = $this->normalizeListedKey($key);
if ($key === '') { if ($key === '') {
return false; return false;
} }

View File

@@ -64,7 +64,7 @@ class LocalhostStorageService extends BaseNotAuthService implements OssStorageIn
$items = []; $items = [];
foreach ($page as $file) { foreach ($page as $file) {
$key = $this->normalizeObjectKey($file); $key = $this->normalizeListedKey($file);
if ($key === '') { if ($key === '') {
continue; continue;
} }
@@ -90,7 +90,7 @@ class LocalhostStorageService extends BaseNotAuthService implements OssStorageIn
public function deleteObject(string $key): bool public function deleteObject(string $key): bool
{ {
$key = $this->normalizeObjectKey($key); $key = $this->normalizeListedKey($key);
if ($key === '') { if ($key === '') {
return false; 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)。 * 使用方需要有 $this->config(由 OssRuntimeConfigService 注入,含 path_prefix / domain)。
*/ */
trait ObjectKeyNormalizeTrait 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 上是两个对象, * 为什么必须压平:uploads//spa/a.jpg 与 uploads/spa/a.jpg 在 OSS 上是两个对象,
* 但拼出来的访问地址会被 CDN 归一成同一个。素材库按 path 建索引, * 但拼出来的访问地址会被 CDN 归一成同一个。素材库按 path 建索引,
@@ -20,7 +32,7 @@ trait ObjectKeyNormalizeTrait
*/ */
protected function normalizeObjectKey(string $key): string protected function normalizeObjectKey(string $key): string
{ {
$key = ltrim((string) preg_replace('#/{2,}#', '/', trim($key)), '/'); $key = $this->normalizeListedKey($key);
if ($key === '') { if ($key === '') {
return ''; return '';
} }
@@ -32,19 +44,16 @@ trait ObjectKeyNormalizeTrait
} }
/** /**
* 列举前缀:调用方没给就退回 path_prefix * 列举前缀:只认调用方传入的值,留空 = 扫整个 Bucket
* *
* 同一个 bucket 常常被多个项目共用,不加这层兜底会把别人的对象也拉进素材库, * 以前空前缀会偷偷套配置里的 path_prefix。种子数据把七牛写成 uploads,
* 之后走回收流程就等于跨项目删文件。 * 而本站历史文件都在根目录(b_*.jpg),一拉就是空列表。
* 素材库文案也是「留空表示整个 Bucket」,这里必须跟文案一致;
* 要收窄范围请在同步抽屉里显式填前缀。
*/ */
protected function scopedListPrefix(string $prefix): string protected function scopedListPrefix(string $prefix): string
{ {
$prefix = trim($prefix); return $this->normalizeListedKey($prefix);
if ($prefix !== '') {
return $this->normalizeObjectKey($prefix);
}
$base = trim((string) ($this->config['path_prefix'] ?? ''), '/');
return $base === '' ? '' : $base . '/';
} }
/** /**

View File

@@ -34,7 +34,7 @@ trait ObjectListXmlTrait
$rawKey = (string) $node->Key; $rawKey = (string) $node->Key;
// 续拉游标必须用服务端原样返回的 key,不能用补过 prefix 的规整值 // 续拉游标必须用服务端原样返回的 key,不能用补过 prefix 的规整值
$lastRawKey = $rawKey; $lastRawKey = $rawKey;
$key = $this->normalizeObjectKey($rawKey); $key = $this->normalizeListedKey($rawKey);
if ($key === '' || str_ends_with($key, '/')) { if ($key === '' || str_ends_with($key, '/')) {
// 以 / 结尾的是控制台建目录留下的占位对象,不是素材 // 以 / 结尾的是控制台建目录留下的占位对象,不是素材
continue; continue;

View File

@@ -67,7 +67,7 @@ class QcloudStorageService extends BaseNotAuthService implements OssStorageInter
public function deleteObject(string $key): bool public function deleteObject(string $key): bool
{ {
$this->assertConfig(); $this->assertConfig();
$key = $this->normalizeObjectKey($key); $key = $this->normalizeListedKey($key);
if ($key === '') { if ($key === '') {
return false; return false;
} }

View File

@@ -21,12 +21,19 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
'path_prefix' => '', 'path_prefix' => '',
]; ];
/** 同一次请求里复用,避免每页列举都去 UC 查一遍区域 */
private ?\Qiniu\Storage\BucketManager $cachedBucketMgr = null;
private string $cachedBucketMgrKey = '';
/** /**
* 注入解密后的运行时配置 * 注入解密后的运行时配置
*/ */
public function withConfig(array $config): static public function withConfig(array $config): static
{ {
$this->config = array_merge($this->config, $config); $this->config = array_merge($this->config, $config);
$this->cachedBucketMgr = null;
$this->cachedBucketMgrKey = '';
return $this; return $this;
} }
@@ -54,14 +61,17 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
* 列举空间对象(BucketManager::listFiles,marker 分页) * 列举空间对象(BucketManager::listFiles,marker 分页)
* *
* 七牛只在还有下一页时才回 marker,所以 marker 为空即等于列完了。 * 七牛只在还有下一页时才回 marker,所以 marker 为空即等于列完了。
* 列举结果的 key 必须用服务端原值,不能再套 path_prefix,
* 否则根目录历史文件会被改写成 uploads/b_xxx.jpg。
*/ */
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array
{ {
$bucketMgr = $this->bucketManager(); $bucketMgr = $this->bucketManager();
$bucket = (string) ($this->config['bucket'] ?? ''); $bucket = (string) ($this->config['bucket'] ?? '');
$listPrefix = $this->scopedListPrefix($prefix);
[$ret, $err] = $bucketMgr->listFiles( [$ret, $err] = $bucketMgr->listFiles(
$bucket, $bucket,
$this->scopedListPrefix($prefix), $listPrefix !== '' ? $listPrefix : null,
$marker !== '' ? $marker : null, $marker !== '' ? $marker : null,
$this->boundedLimit($limit) $this->boundedLimit($limit)
); );
@@ -69,9 +79,19 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
UtilsService::getInstance()->errorThrow('七牛云列举对象失败:' . $this->errorText($err)); 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 = []; $items = [];
foreach ((array) ($ret['items'] ?? []) as $row) { foreach ((array) $rawItems as $row) {
$key = $this->normalizeObjectKey((string) ($row['key'] ?? '')); if (!is_array($row)) {
continue;
}
$key = $this->normalizeListedKey((string) ($row['key'] ?? ''));
if ($key === '' || str_ends_with($key, '/')) { if ($key === '' || str_ends_with($key, '/')) {
continue; continue;
} }
@@ -81,7 +101,7 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
'hash' => (string) ($row['hash'] ?? ''), 'hash' => (string) ($row['hash'] ?? ''),
// putTime 的单位是 100 纳秒,当秒用会得到五亿年后的时间戳 // putTime 的单位是 100 纳秒,当秒用会得到五亿年后的时间戳
'last_modified' => intdiv((int) ($row['putTime'] ?? 0), 10000000), '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 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 private function deleteFile(string $key): bool
{ {
if (!class_exists(\Qiniu\Auth::class)) { $err = $this->bucketManager()->delete((string) $this->config['bucket'], $key);
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);
return $err === null; return $err === null;
} }
/** /**
* 构造 BucketManager,顺手把「没装 SDK / 没填配置」两种情况前置拦掉 * 构造 BucketManager,顺手把「没装 SDK / 没填配置」两种情况前置拦掉
*
* 必须走 HTTPS:SDK 默认 HTTP,部分机房出网只放行 443,列举会直接空失败。
*/ */
private function bucketManager(): \Qiniu\Storage\BucketManager private function bucketManager(): \Qiniu\Storage\BucketManager
{ {
if (!class_exists(\Qiniu\Auth::class)) { if (!class_exists(\Qiniu\Auth::class)) {
UtilsService::getInstance()->errorThrow('未安装 qiniu/php-sdk,请改用本地存储或安装依赖'); UtilsService::getInstance()->errorThrow('未安装 qiniu/php-sdk,请改用本地存储或安装依赖');
} }
$accessKey = (string) ($this->config['access_key'] ?? ''); $accessKey = trim((string) ($this->config['access_key'] ?? ''));
$secretKey = (string) ($this->config['secret_key'] ?? ''); $secretKey = trim((string) ($this->config['secret_key'] ?? ''));
$bucket = (string) ($this->config['bucket'] ?? ''); $bucket = trim((string) ($this->config['bucket'] ?? ''));
if ($accessKey === '' || $secretKey === '' || $bucket === '') { $missing = [];
UtilsService::getInstance()->errorThrow('七牛云配置不完整'); 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 private function errorText(mixed $err): string
{ {
if (is_object($err) && method_exists($err, 'message')) { 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)) { if (is_object($err) || is_array($err)) {
return (string) json_encode($err, JSON_UNESCAPED_UNICODE); 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 public function deleteObject(string $key): bool
{ {
$this->assertConfig(); $this->assertConfig();
$key = $this->normalizeObjectKey($key); $key = $this->normalizeListedKey($key);
if ($key === '') { if ($key === '') {
return false; return false;
} }

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\business\WxAddressModel;
/**
* 小程序收货地址
*/
class WxAddressService extends BaseWxService
{
public function list(): array
{
return WxAddressModel::where('user_id', $this->userId)
->where('deleted_at', 0)
->orderByDesc('is_default')
->orderByDesc('id')
->get()
->toArray();
}
/**
* 默认地址,下单表单回填
*/
public function defaultOne(): array
{
$row = WxAddressModel::where('user_id', $this->userId)
->where('deleted_at', 0)
->orderByDesc('is_default')
->orderByDesc('id')
->first();
return $row ? $row->toArray() : [];
}
public function create(array $params): array
{
$payload = $this->pick($params);
$now = time();
if ((int) ($payload['is_default'] ?? 0) === 1) {
$this->clearDefault();
}
$id = (int) WxAddressModel::insertGetId(array_merge($payload, [
'user_id' => $this->userId,
'created_at' => $now,
'updated_at' => $now,
]));
return WxAddressModel::where('id', $id)->first()->toArray();
}
public function update($id, $params): mixed
{
$row = $this->owned((int) $id);
$payload = $this->pick($params);
if ((int) ($payload['is_default'] ?? 0) === 1) {
$this->clearDefault();
}
$payload['updated_at'] = time();
WxAddressModel::where('id', $row['id'])->update($payload);
return WxAddressModel::where('id', $row['id'])->first()->toArray();
}
public function delete($ids): mixed
{
$id = (int) (is_array($ids) ? ($ids[0] ?? 0) : $ids);
$row = $this->owned($id);
WxAddressModel::where('id', $row['id'])->update(['deleted_at' => time(), 'updated_at' => time()]);
return true;
}
/**
* 设为默认
*/
public function detail($id): mixed
{
return $this->owned((int) $id);
}
public function setDefault(int $id): array
{
$row = $this->owned($id);
$this->clearDefault();
WxAddressModel::where('id', $row['id'])->update(['is_default' => 1, 'updated_at' => time()]);
return WxAddressModel::where('id', $row['id'])->first()->toArray();
}
private function owned(int $id): array
{
$row = WxAddressModel::where('id', $id)->where('user_id', $this->userId)->where('deleted_at', 0)->first();
if (empty($row)) {
$this->utils->errorThrow('地址不存在');
}
return $row->toArray();
}
private function clearDefault(): void
{
WxAddressModel::where('user_id', $this->userId)->where('deleted_at', 0)
->update(['is_default' => 0, 'updated_at' => time()]);
}
private function pick(array $params): array
{
$name = trim((string) ($params['name'] ?? ''));
$phone = trim((string) ($params['phone'] ?? ''));
$address = trim((string) ($params['address'] ?? ''));
if ($name === '' || $phone === '' || $address === '') {
$this->utils->errorThrow('请填写姓名、手机和地址');
}
return [
'name' => $name,
'phone' => $phone,
'address' => $address,
'is_default' => (int) ($params['is_default'] ?? 0) === 1 ? 1 : 0,
];
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\business\AfterSaleModel;
use App\Models\business\OrderModel;
use App\Service\business\PriceService;
/**
* 小程序售后:已付款/已发货/已完成可申请,后台审核,不自动退微信款。
*/
class WxAfterSaleService extends BaseWxService
{
public function list(): array
{
$price = PriceService::getInstance();
$rows = AfterSaleModel::with('order')
->where('user_id', $this->userId)
->where('deleted_at', 0)
->orderByDesc('id')
->get()
->toArray();
foreach ($rows as &$row) {
$row['amount_text'] = $price->centsToYuan((int) ($row['amount'] ?? 0));
$row['order_no'] = $row['order']['order_no'] ?? '';
$row['status_text'] = match ((int) ($row['status'] ?? 0)) {
AfterSaleModel::STATUS_APPROVED => '已通过',
AfterSaleModel::STATUS_REJECTED => '已拒绝',
default => '待审核',
};
unset($row['order']);
}
unset($row);
return $rows;
}
public function create(array $params): array
{
$orderId = (int) ($params['order_id'] ?? 0);
$reason = trim((string) ($params['reason'] ?? ''));
if ($orderId <= 0 || $reason === '') {
$this->utils->errorThrow('请填写订单和原因');
}
$order = OrderModel::where('id', $orderId)->where('user_id', $this->userId)->where('deleted_at', 0)->first();
if (empty($order)) {
$this->utils->errorThrow('订单不存在');
}
$status = (int) $order['status'];
if (!in_array($status, [OrderModel::STATUS_PAID, OrderModel::STATUS_SHIPPED, OrderModel::STATUS_DONE], true)) {
$this->utils->errorThrow('当前订单状态不能申请售后');
}
$pending = AfterSaleModel::where('order_id', $orderId)
->where('deleted_at', 0)
->where('status', AfterSaleModel::STATUS_PENDING)
->exists();
if ($pending) {
$this->utils->errorThrow('已有待审核的售后申请');
}
$amount = (int) ($params['amount'] ?? 0);
if ($amount <= 0) {
$amount = (int) $order['paid_amount'];
}
if ($amount > (int) $order['paid_amount']) {
$this->utils->errorThrow('退款金额不能超过已付金额');
}
$now = time();
$id = (int) AfterSaleModel::insertGetId([
'order_id' => $orderId,
'user_id' => $this->userId,
'reason' => mb_substr($reason, 0, 255),
'amount' => $amount,
'images' => is_array($params['images'] ?? null)
? implode(',', $params['images'])
: (string) ($params['images'] ?? ''),
'status' => AfterSaleModel::STATUS_PENDING,
'created_at' => $now,
'updated_at' => $now,
]);
return AfterSaleModel::where('id', $id)->first()->toArray();
}
}

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; 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,新用户建档并送一份默认清单 * code 登录:老用户更新 session_key,新用户建档并送一份默认清单

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\business\CardClassModel;
use App\Models\business\ColorcardModel;
use App\Models\business\CompanyModel;
use App\Service\common\MediaUrlService;
/**
* 小程序色卡目录(只读)
*/
class WxColorcardService extends BaseWxService
{
protected bool $needLogin = false;
public function list(): array
{
$classId = (int) request()->get('card_class', 0);
$companyId = (int) request()->get('company', 0);
$keyword = trim((string) request()->get('keyword', ''));
$query = ColorcardModel::with(['cardClassInfo', 'companyInfo'])
->where('deleted_at', 0)
->where('status', 0)
->orderByDesc('id');
if ($classId > 0) {
$query->where('card_class', $classId);
}
if ($companyId > 0) {
$query->where('company', $companyId);
}
if ($keyword !== '') {
$query->where('description', 'like', '%' . $keyword . '%');
}
$rows = $query->get()->toArray();
$media = MediaUrlService::getInstance();
foreach ($rows as &$row) {
$row['cover'] = $media->toPublic($row['cover'] ?? '');
$row['card_class_name'] = $row['card_class_info']['name'] ?? '';
$row['company_name'] = $row['company_info']['name'] ?? '';
unset($row['card_class_info'], $row['company_info']);
}
unset($row);
return $rows;
}
public function detail(int $id): array
{
$info = ColorcardModel::with(['cardClassInfo', 'companyInfo'])
->where('id', $id)
->where('deleted_at', 0)
->where('status', 0)
->first();
if (empty($info)) {
$this->utils->errorThrow('色卡不存在');
}
$info = $info->toArray();
$media = MediaUrlService::getInstance();
$info['cover'] = $media->toPublic($info['cover'] ?? '');
$info['card_class_name'] = $info['card_class_info']['name'] ?? '';
$info['company_name'] = $info['company_info']['name'] ?? '';
unset($info['card_class_info'], $info['company_info']);
return $info;
}
public function classList(): array
{
return CardClassModel::where('deleted_at', 0)->orderBy('id')->get(['id', 'name'])->toArray();
}
public function companyList(): array
{
return CompanyModel::where('deleted_at', 0)->orderBy('id')->get(['id', 'name'])->toArray();
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\business\FactoryClassificationModel;
use App\Models\business\FactoryImageModel;
use App\Models\business\FactoryInfoModel;
use App\Service\common\MediaUrlService;
/**
* 小程序工厂目录(只读)
*/
class WxFactoryService extends BaseWxService
{
protected bool $needLogin = false;
public function list(): array
{
$classId = (int) request()->get('classification', 0);
$keyword = trim((string) request()->get('keyword', ''));
$query = FactoryInfoModel::with('classificationInfo')
->where('deleted_at', 0)
->where('status', 0)
->orderByDesc('id');
if ($classId > 0) {
$query->where('classification', $classId);
}
if ($keyword !== '') {
$query->where('name', 'like', '%' . $keyword . '%');
}
$rows = $query->get()->toArray();
$media = MediaUrlService::getInstance();
foreach ($rows as &$row) {
$row['cover'] = $media->toPublic($row['cover'] ?? '');
$row['classification_name'] = $row['classification_info']['name'] ?? '';
unset($row['classification_info']);
}
unset($row);
return $rows;
}
public function detail(int $id): array
{
$info = FactoryInfoModel::with('classificationInfo')
->where('id', $id)
->where('deleted_at', 0)
->where('status', 0)
->first();
if (empty($info)) {
$this->utils->errorThrow('工厂不存在');
}
$info = $info->toArray();
$media = MediaUrlService::getInstance();
$info['cover'] = $media->toPublic($info['cover'] ?? '');
$info['classification_name'] = $info['classification_info']['name'] ?? '';
unset($info['classification_info']);
$images = FactoryImageModel::where('factory', $id)->where('deleted_at', 0)->orderBy('id')->get()->toArray();
$media->publicEach($images, ['url']);
$info['images'] = $images;
return $info;
}
/**
* 工厂分类
*/
public function classList(): array
{
return FactoryClassificationModel::where('deleted_at', 0)
->orderBy('id')
->get(['id', 'name'])
->toArray();
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\business\CatalogueModel;
use App\Models\business\FavoriteModel;
use App\Service\common\MediaUrlService;
/**
* 商品收藏
*/
class WxFavoriteService extends BaseWxService
{
public function list(): array
{
$rows = FavoriteModel::with('catalogue')
->where('user_id', $this->userId)
->where('deleted_at', 0)
->orderByDesc('id')
->get()
->toArray();
$media = MediaUrlService::getInstance();
foreach ($rows as &$row) {
$cat = $row['catalogue'] ?? [];
$row['title'] = $cat['title'] ?? '';
$row['cover'] = $media->toPublic($cat['cover'] ?? '');
$row['price'] = $cat['price'] ?? '';
}
unset($row);
return $rows;
}
/**
* 切换收藏,返回当前是否已收藏
*/
public function toggle(int $catalogueId): array
{
if ($catalogueId <= 0) {
$this->utils->errorThrow('参数错误');
}
$exists = CatalogueModel::where('id', $catalogueId)->where('deleted_at', 0)->exists();
if (!$exists) {
$this->utils->errorThrow('商品不存在');
}
$row = FavoriteModel::where('user_id', $this->userId)
->where('catalogue_id', $catalogueId)
->where('deleted_at', 0)
->first();
if (!empty($row)) {
FavoriteModel::where('id', $row['id'])->update(['deleted_at' => time(), 'updated_at' => time()]);
return ['favorited' => false];
}
FavoriteModel::insert([
'user_id' => $this->userId,
'catalogue_id' => $catalogueId,
'created_at' => time(),
'updated_at' => time(),
]);
return ['favorited' => true];
}
public function isFavorited(int $catalogueId): bool
{
if ($this->userId <= 0 || $catalogueId <= 0) {
return false;
}
return FavoriteModel::where('user_id', $this->userId)
->where('catalogue_id', $catalogueId)
->where('deleted_at', 0)
->exists();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Service\wx;
use App\BaseApp\BaseWxService;
use App\Models\business\FeedbackModel;
/**
* 小程序用户反馈
*/
class WxFeedbackService extends BaseWxService
{
public function list(): array
{
return FeedbackModel::where('user_id', $this->userId)
->where('deleted_at', 0)
->orderByDesc('id')
->get()
->toArray();
}
public function create(array $params): array
{
$content = trim((string) ($params['content'] ?? ''));
if ($content === '') {
$this->utils->errorThrow('请填写反馈内容');
}
$now = time();
$id = (int) FeedbackModel::insertGetId([
'user_id' => $this->userId,
'content' => mb_substr($content, 0, 500),
'images' => is_array($params['images'] ?? null)
? implode(',', $params['images'])
: (string) ($params['images'] ?? ''),
'status' => FeedbackModel::STATUS_PENDING,
'created_at' => $now,
'updated_at' => $now,
]);
return FeedbackModel::where('id', $id)->first()->toArray();
}
}

View File

@@ -5,6 +5,8 @@ namespace App\Service\wx;
use App\BaseApp\BaseWxService; use App\BaseApp\BaseWxService;
use App\Models\business\ListItemModel; use App\Models\business\ListItemModel;
use App\Models\business\ListModel; use App\Models\business\ListModel;
use App\Models\business\PriceSheetModel;
use App\Service\business\PackageQuoteService;
use App\Service\business\PriceService; use App\Service\business\PriceService;
use App\Service\business\SerialNoService; use App\Service\business\SerialNoService;
@@ -78,6 +80,7 @@ class WxListService extends BaseWxService
$info = ListModel::with([ $info = ListModel::with([
'items' => fn ($query) => $query->where('deleted_at', 0), 'items' => fn ($query) => $query->where('deleted_at', 0),
'items.catalogue', 'items.catalogue',
'items.priceSheet',
'items.catalogue.priceSheet' => fn ($query) => $query->where('deleted_at', 0), 'items.catalogue.priceSheet' => fn ($query) => $query->where('deleted_at', 0),
])->where('id', $id) ])->where('id', $id)
->where('user_id', $this->userId) ->where('user_id', $this->userId)
@@ -93,6 +96,23 @@ class WxListService extends BaseWxService
$multiplier = $this->priceMultiplier(); $multiplier = $this->priceMultiplier();
foreach ($info['items'] as &$item) { foreach ($info['items'] as &$item) {
$sheet = $item['catalogue']['price_sheet'] ?? []; $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)) { if (!empty($sheet)) {
$applied = $price->applyToRows($sheet, $showPrice, $multiplier); $applied = $price->applyToRows($sheet, $showPrice, $multiplier);
$item['catalogue']['price_sheet'] = $applied['rows']; $item['catalogue']['price_sheet'] = $applied['rows'];
@@ -102,6 +122,13 @@ class WxListService extends BaseWxService
} }
unset($item); unset($item);
$info['is_show_price'] = $showPrice; $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; return $info;
} }
@@ -164,6 +191,7 @@ class WxListService extends BaseWxService
} }
$priceSheetId = (int) ($params['price_sheet_id'] ?? 0); $priceSheetId = (int) ($params['price_sheet_id'] ?? 0);
$quantity = max(1, (int) ($params['quantity'] ?? 1)); $quantity = max(1, (int) ($params['quantity'] ?? 1));
\App\Service\business\StockService::getInstance()->assertAvailable($priceSheetId, $quantity);
$exists = ListItemModel::where('list_id', $listId) $exists = ListItemModel::where('list_id', $listId)
->where('catalogue_id', $catalogueId) ->where('catalogue_id', $catalogueId)
@@ -210,6 +238,33 @@ class WxListService extends BaseWxService
if (array_key_exists('remark', $params)) { if (array_key_exists('remark', $params)) {
$update['remark'] = (string) $params['remark']; $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']);
$qty = (int) ($update['quantity'] ?? $item['quantity']);
\App\Service\business\StockService::getInstance()->assertAvailable($sheetId, $qty);
$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); return ListItemModel::where('id', $itemId)->whereIn('list_id', $listIds)->update($update);
} }
/**
* 导出本人清单报价,给分享/下载用
*/
public function exportQuote(int $id): array
{
$info = $this->detail($id);
return \App\Service\common\ExcelCsvService::getInstance()->quoteFromList($info);
}
} }

View File

@@ -91,4 +91,32 @@ class WxMiniService
$redis->set($app['app_id'], $result['access_token'], max(60, (int) ($result['expires_in'] ?? 7200) - 300)); $redis->set($app['app_id'], $result['access_token'], max(60, (int) ($result['expires_in'] ?? 7200) - 300));
return (string) $result['access_token']; return (string) $result['access_token'];
} }
/**
* 发送订阅消息。模板字段必须与公众平台里的关键词一致,对不上微信会拒收。
* 这里只尽力发送,失败不抛错,避免挡住收款/发货。
*/
public function sendSubscribe(string $openId, string $templateId, string $page, array $data): bool
{
if ($openId === '' || $templateId === '') {
return false;
}
try {
$token = $this->accessToken();
$result = Http::asJson()->post(
$this->baseUrl . 'cgi-bin/message/subscribe/send?access_token=' . $token,
[
'touser' => $openId,
'template_id' => $templateId,
'page' => $page,
'data' => $data,
'miniprogram_state' => 'formal',
'lang' => 'zh_CN',
]
)->json();
return empty($result['errcode']);
} catch (\Throwable) {
return false;
}
}
} }

View File

@@ -0,0 +1,281 @@
<?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('套餐还没有搭配商品');
}
$stock = \App\Service\business\StockService::getInstance();
foreach ($items as $item) {
$stock->assertAvailable(
(int) ($item['price_sheet_id'] ?? 0),
max(1, (int) ($item['quantity'] ?? 1))
);
}
$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,8 +6,8 @@ use App\BaseApp\BaseWxService;
use App\Models\business\CatalogueModel; use App\Models\business\CatalogueModel;
use App\Models\business\CategoryModel; use App\Models\business\CategoryModel;
use App\Models\business\ImageModel; use App\Models\business\ImageModel;
use App\Models\business\WxUserModel;
use App\Service\business\PriceService; use App\Service\business\PriceService;
use App\Service\common\MediaUrlService;
/** /**
* 小程序商品列表与详情 * 小程序商品列表与详情
@@ -17,6 +17,8 @@ use App\Service\business\PriceService;
*/ */
class WxProductService extends BaseWxService class WxProductService extends BaseWxService
{ {
protected bool $needLogin = false;
/** /**
* 商品列表:分类点到二级时按父分类下的所有子分类查 * 商品列表:分类点到二级时按父分类下的所有子分类查
*/ */
@@ -77,12 +79,15 @@ class WxProductService extends BaseWxService
$this->utils->errorThrow('商品不存在'); $this->utils->errorThrow('商品不存在');
} }
$info = $info->toArray(); $info = $info->toArray();
$media = MediaUrlService::getInstance();
$info['cover'] = $media->toPublic($info['cover'] ?? '');
$info['video'] = $media->toPublic($info['video'] ?? '');
// 渲染图作为详情轮播 // 渲染图作为详情轮播
$info['carousel'] = []; $info['carousel'] = [];
foreach ($info['images'] ?? [] as $image) { foreach ($info['images'] ?? [] as $image) {
if ((int) $image['type'] === ImageModel::TYPE_RENDER) { if ((int) $image['type'] === ImageModel::TYPE_RENDER) {
$info['carousel'][] = $image['url']; $info['carousel'][] = $media->toPublic($image['url'] ?? '');
} }
} }
@@ -90,36 +95,9 @@ class WxProductService extends BaseWxService
$applied = PriceService::getInstance()->applyToRows($info['price_sheet'] ?? [], $showPrice, $this->priceMultiplier()); $applied = PriceService::getInstance()->applyToRows($info['price_sheet'] ?? [], $showPrice, $this->priceMultiplier());
$info['price_sheet'] = $applied['rows']; $info['price_sheet'] = $applied['rows'];
$info['is_show_price'] = $applied['is_show_price']; $info['is_show_price'] = $applied['is_show_price'];
$info['favorited'] = $this->userId > 0
? WxFavoriteService::getInstance()->isFavorited((int) $id)
: false;
return $info; 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\WxTemplateModel;
use App\Models\business\WxUserModel; use App\Models\business\WxUserModel;
use App\Models\WxAppModel; use App\Models\WxAppModel;
use App\Service\business\WxCardSchemeService;
use App\Service\business\WxTemplatePresetService; use App\Service\business\WxTemplatePresetService;
use App\Service\business\WxTemplateSchemaService; use App\Service\business\WxTemplateSchemaService;
@@ -68,32 +69,90 @@ class WxThemeService extends BaseWxService
$preset = WxTemplatePresetService::getInstance()->all()[0] ?? []; $preset = WxTemplatePresetService::getInstance()->all()[0] ?? [];
$tokens = $preset['tokens'] ?? []; $tokens = $preset['tokens'] ?? [];
$layout = $preset['layout'] ?? []; $layout = $preset['layout'] ?? [];
return [ return $this->packTheme(
'code' => (string) ($preset['code'] ?? 'lux-champagne'), (string) ($preset['code'] ?? 'lux-champagne'),
'name' => (string) ($preset['name'] ?? '轻奢·香槟金'), (string) ($preset['name'] ?? '轻奢·香槟金'),
'style_tag' => (string) ($preset['style_tag'] ?? '轻奢'), (string) ($preset['style_tag'] ?? '轻奢'),
'version' => 0, 0,
'tokens' => $tokens, $tokens,
'layout' => $layout, $layout,
'css_vars' => $schema->toCssVariables($tokens), $schema,
'fallback' => true, true,
]; );
} }
$template = $template->toArray(); $template = $template->toArray();
$tokens = is_array($template['tokens']) ? $template['tokens'] : []; $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 [ return [
'code' => (string) $template['code'], 'code' => $code,
'name' => (string) $template['name'], 'name' => $name,
'style_tag' => (string) $template['style_tag'], 'style_tag' => $styleTag,
'version' => (int) $template['version'], 'version' => $version,
'tokens' => $tokens, 'tokens' => $tokens,
'layout' => is_array($template['layout']) ? $template['layout'] : [], 'layout' => $layout,
'css_vars' => $schema->toCssVariables($tokens), 'css_vars' => $schema->toCssVariables($tokens),
'fallback' => false, 'card_schemes' => WxCardSchemeService::mapByCodes($this->collectSchemeCodes($layout)),
'features' => [
'package_enabled' => WxAppService::getInstance()->packageEnabled(),
'subscribe_pay_tpl' => $this->subscribeTpl('subscribe_pay_tpl'),
'subscribe_ship_tpl' => $this->subscribeTpl('subscribe_ship_tpl'),
],
'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;
}
/** /**
* 可选风格列表,用于小程序里让用户自己换肤 * 可选风格列表,用于小程序里让用户自己换肤
*/ */
@@ -106,4 +165,17 @@ class WxThemeService extends BaseWxService
->orderBy('sort', 'asc') ->orderBy('sort', 'asc')
->get(['code', 'name', 'style_tag', 'preview']); ->get(['code', 'name', 'style_tag', 'preview']);
} }
/**
* 当前品牌的订阅模板 ID,给小程序 requestSubscribeMessage 用
*/
private function subscribeTpl(string $field): string
{
try {
$app = WxAppService::getInstance()->current();
return trim((string) ($app[$field] ?? ''));
} catch (\Throwable) {
return '';
}
}
} }

View File

@@ -64,4 +64,19 @@ class WxUserCenterService extends BaseWxService
]); ]);
return ['nick_name' => $nickName]; return ['nick_name' => $nickName];
} }
/**
* 当前用户绑定的企业资料,给企业详情页
*/
public function enterprise(): array
{
$user = WxUserModel::with('enterprise')->where('id', $this->userId)->first();
$ent = $user['enterprise'] ?? null;
if (empty($ent)) {
$this->utils->errorThrow('未绑定企业');
}
$row = is_array($ent) ? $ent : $ent->toArray();
$row['logo'] = \App\Service\common\MediaUrlService::getInstance()->toPublic($row['logo'] ?? '');
return $row;
}
} }

View File

@@ -26,6 +26,8 @@ return Application::configure(basePath: dirname(__DIR__))
'nl.auth' => \App\Http\Middleware\ApiAuthMiddleware::class, 'nl.auth' => \App\Http\Middleware\ApiAuthMiddleware::class,
// 小程序登录态,只挂在 wx 路由组上(与后台 token 互不通用) // 小程序登录态,只挂在 wx 路由组上(与后台 token 互不通用)
'nl.wx' => \App\Http\Middleware\WxAuthMiddleware::class, 'nl.wx' => \App\Http\Middleware\WxAuthMiddleware::class,
// 小程序成功响应 result 内 http→https(对齐老 ApiMiddleware::checkHttp)
'nl.wx.https' => \App\Http\Middleware\WxHttpsRewriteMiddleware::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions) { ->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' => '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' => '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' => '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],
], ],
], ],
[ [
@@ -65,6 +66,8 @@ return [
['title' => '企业管理', 'name' => 'CustomerEnterprise', 'path' => '/customer/enterprise', 'component' => '/customer/enterprise/index', 'icon' => 'lucide:building', 'sort' => 2], ['title' => '企业管理', 'name' => 'CustomerEnterprise', 'path' => '/customer/enterprise', 'component' => '/customer/enterprise/index', 'icon' => 'lucide:building', 'sort' => 2],
['title' => '清单管理', 'name' => 'CustomerList', 'path' => '/customer/list', 'component' => '/customer/list/index', 'icon' => 'lucide:clipboard-list', 'sort' => 3], ['title' => '清单管理', 'name' => 'CustomerList', 'path' => '/customer/list', 'component' => '/customer/list/index', 'icon' => 'lucide:clipboard-list', 'sort' => 3],
['title' => '订单管理', 'name' => 'CustomerOrder', 'path' => '/customer/order', 'component' => '/customer/order/index', 'icon' => 'lucide:receipt-text', 'sort' => 4], ['title' => '订单管理', 'name' => 'CustomerOrder', 'path' => '/customer/order', 'component' => '/customer/order/index', 'icon' => 'lucide:receipt-text', 'sort' => 4],
['title' => '用户反馈', 'name' => 'CustomerFeedback', 'path' => '/customer/feedback', 'component' => '/customer/feedback/index', 'icon' => 'lucide:message-square', 'sort' => 5],
['title' => '售后管理', 'name' => 'CustomerAfterSale', 'path' => '/customer/after-sale', 'component' => '/customer/after-sale/index', 'icon' => 'lucide:undo-2', '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 只取主题 token(primary / 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('search', 'bar'), $sec('list', 'card')], 'clay-edge', 'regular', 'none', 'shop', '默认'),
$row('package-01-card', '封面卡片', 'package', [$sec('search', 'bar'), $sec('list', 'card')], 'champagne-foil', 'regular', 'none', 'shop', '套餐'),
$row('package-02-featured', '首图通栏', 'package', [$sec('search', 'bar'), $sec('list', 'featured')], 'walnut-frame', 'regular', 'inset', 'shop', '套餐'),
$row('package-03-magazine', '刊名目录', 'package', [$sec('search', 'pill'), $sec('list', 'magazine')], 'thin-rule', 'airy', 'ornament', 'magazine', '套餐'),
$row('package-04-list', '单列图文', 'package', [$sec('search', 'bar'), $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 <?php
/** /**
* 小程序装修模板预设(20 套) * 小程序装修模板预设(4 套店面轻奢 + 1 套刊页)
* *
* 每套模板的差异不只是主色:字体族与字号梯度、圆角、阴影、动效时长与曲线、 * 色板共用一族暖中性,差异只在主色/底色深浅。layout 同时带旧枚举
* 以及首页/详情/清单/我的四个页面的排布都不一样。这里用紧凑的规格描述, * (小程序未升级时仍能用)和 pages.sections(装修工作室拖拽编排)。
* 由 WxTemplatePresetService 展开成完整令牌,避免 20 份手抄 JSON 互相抄错。 * card_scheme 指向 30 套 canvas 卡片预设之一。
*
* scale/radius/shadow/motion 的取值见 WxTemplatePresetService 里的对应表。
* 首期精修「轻奢」系列(前 4 套),其余先占位可用,后续按期打磨。
*/ */
$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 [ return [
[ [
'code' => 'lux-champagne', 'code' => 'nature-clay',
'name' => '轻奢·香槟金', 'name' => '素胚',
'style_tag' => '轻奢', 'style_tag' => '轻奢',
'palette' => ['primary' => '#B08D57', 'accent' => '#8C6A3F', 'bg' => '#FAF7F2', 'surface' => '#FFFFFF', 'text' => '#1C1917', 'border' => '#E7DFD3'], 'palette' => [
'scale' => 'serif-elegant', 'primary' => '#8A8178',
'radius' => 'soft', 'accent' => '#C4B8A8',
'shadow' => 'airy', 'bg' => '#F4EFE6',
'motion' => 'silk', 'surface' => '#EFE8DC',
'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']], 'text' => '#1C1917',
'border' => '#E7DFD3',
], ],
[ 'scale' => 'serif-book',
'code' => 'lux-ink',
'name' => '轻奢·墨玉',
'style_tag' => '轻奢',
'palette' => ['primary' => '#C9A063', 'accent' => '#E8D5AE', 'bg' => '#14110F', 'surface' => '#1F1B18', 'text' => '#F5F0E8', 'border' => '#332C25'],
'scale' => 'serif-elegant',
'radius' => 'soft', 'radius' => 'soft',
'shadow' => 'deep', 'shadow' => 'flat',
'motion' => 'silk', 'motion' => 'gentle',
'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']], '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', 'code' => 'lux-pearl',
'name' => '轻奢·珠贝白', 'name' => '轻奢·珠贝白',
'style_tag' => '轻奢', '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', 'scale' => 'sans-refined',
'radius' => 'sharp', 'radius' => 'sharp',
'shadow' => 'airy', 'shadow' => 'airy',
'motion' => 'silk', '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', 'code' => 'lux-walnut',
'name' => '轻奢·胡桃木', 'name' => '轻奢·胡桃木',
'style_tag' => '轻奢', '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', 'scale' => 'serif-elegant',
'radius' => 'round', 'radius' => 'round',
'shadow' => 'soft', 'shadow' => 'soft',
'motion' => 'gentle', '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', 'code' => 'lux-champagne',
'name' => '极简·亚麻', 'name' => '轻奢·香槟金',
'style_tag' => '极简', 'style_tag' => '轻奢',
'palette' => ['primary' => '#3F3F46', 'accent' => '#A1A1AA', 'bg' => '#FAFAF9', 'surface' => '#FFFFFF', 'text' => '#18181B', 'border' => '#E4E4E7'], 'palette' => [
'scale' => 'sans-compact', 'primary' => '#B08D57',
'radius' => 'sharp', 'accent' => '#8C6A3F',
'shadow' => 'flat', 'bg' => '#FAF7F2',
'motion' => 'snappy', 'surface' => '#FFFFFF',
'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']], 'text' => '#1C1917',
'border' => '#E7DFD3',
], ],
[ 'scale' => 'serif-elegant',
'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',
'radius' => 'soft', 'radius' => 'soft',
'shadow' => 'airy', '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', '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', 'code' => 'lux-folio',
'name' => '自然·橄榄', 'name' => '轻奢·刊页',
'style_tag' => '自然', 'style_tag' => '刊页',
'palette' => ['primary' => '#4D7C0F', 'accent' => '#BEF264', 'bg' => '#F7FEE7', 'surface' => '#FFFFFF', 'text' => '#1A2E05', 'border' => '#D9F99D'], 'palette' => [
'scale' => 'sans-compact', 'primary' => '#C4A574',
'radius' => 'soft', 'accent' => '#A3845A',
'shadow' => 'soft', 'bg' => '#F7F1E6',
'motion' => 'gentle', 'surface' => '#FFF9F0',
'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']], 'text' => '#3F2F24',
'border' => '#E4D6C2',
], ],
[ 'scale' => 'serif-elegant',
'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',
'radius' => 'sharp', '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', 'shadow' => 'airy',
'motion' => 'bouncy', 'motion' => 'silk',
'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']], '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,15 @@
-- ============================================================
-- cc_carousel 补 status 列(幂等)
-- 新后台 CRUD / 启停开关依赖此列;老表从未有 status
-- ============================================================
SET @db := DATABASE();
SET @exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_carousel' AND COLUMN_NAME = 'status'
);
SET @sql := IF(@exists = 0,
'ALTER TABLE `cc_carousel` ADD COLUMN `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT ''状态 0显示 1隐藏'' AFTER `sort`',
'SELECT ''skip cc_carousel.status'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,36 @@
-- ============================================================
-- nl_wx_app 小程序应用配置表(幂等)
-- 与 public/nl_admin.sql 末尾建表等价;已装库可单独执行本文件补表
-- AppSecret 等敏感字段由后台加密入库,不要在本文件写明文密钥
-- ============================================================
SET @db := DATABASE();
SET @exists := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_wx_app'
);
SET @sql := IF(@exists = 0,
'CREATE TABLE `nl_wx_app` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`code` varchar(20) NOT NULL DEFAULT '''' COMMENT ''品牌标识 brm/wl'',
`name` varchar(50) NOT NULL DEFAULT '''',
`app_id` varchar(50) NOT NULL DEFAULT '''',
`app_secret` varchar(255) NOT NULL DEFAULT '''' COMMENT ''密文存储'',
`mch_id` varchar(50) NOT NULL DEFAULT '''' COMMENT ''微信支付商户号'',
`mch_key` varchar(255) NOT NULL DEFAULT '''' COMMENT ''APIv3 密钥,密文存储'',
`mch_serial_no` varchar(100) NOT NULL DEFAULT '''' COMMENT ''商户证书序列号'',
`mch_private_key` text NULL COMMENT ''商户私钥,密文存储'',
`platform_public_key` text NULL COMMENT ''微信支付平台证书公钥'',
`notify_url` varchar(255) NOT NULL DEFAULT '''' COMMENT ''支付回调地址'',
`template_code` varchar(50) NOT NULL DEFAULT '''' COMMENT ''默认装修模板 code'',
`status` tinyint NOT NULL DEFAULT 0 COMMENT ''0=启用 1=停用'',
`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`) USING BTREE,
UNIQUE INDEX `uk_wx_app_code`(`code` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = ''小程序应用配置'' ROW_FORMAT = DYNAMIC',
'SELECT ''skip nl_wx_app (already exists)'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,15 @@
-- ============================================================
-- cc_wx_user 补 template_code:经销商可绑定专属装修模板(幂等)
-- 空字符串 = 跟随品牌默认模板
-- ============================================================
SET @db := DATABASE();
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;

View File

@@ -0,0 +1,50 @@
-- ============================================================
-- 菜单:客户中心下增加「经销商管理」(幂等)
-- component/path 对齐前端 views/customer/dealer
-- keep_alive:0=开启缓存;affix_tab:0=固定(输出到前端 meta 时取反)
-- 注意:nl_menu 无 type/status 列,勿照搬其它脚手架字段
-- ============================================================
SET @db := DATABASE();
SET @parent_id := (
SELECT id FROM nl_menu WHERE name = 'Customer' AND deleted_at = 0 LIMIT 1
);
SET @exists := (
SELECT COUNT(*) FROM nl_menu
WHERE name = 'CustomerDealer' AND deleted_at = 0
);
-- 插在「微信用户」后:sort=1;企业管理及之后在 menu-sync / 手工时再调序
SET @sql := IF(@parent_id IS NOT NULL AND @exists = 0,
CONCAT(
'INSERT INTO `nl_menu` (`title`,`icon`,`name`,`path`,`component`,`redirect`,`keep_alive`,`hide_in_menu`,`affix_tab`,`badge`,`badge_type`,`badge_variants`,`iframe_src`,`pid`,`sort`,`query`,`created_at`,`updated_at`,`deleted_at`) VALUES (',
'''经销商管理'', ''lucide:handshake'', ''CustomerDealer'', ''/customer/dealer'', ''/customer/dealer/index'', '''', 1, 0, 1, '''', 0, 0, '''', ',
@parent_id,
', 1, '''', UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0)'
),
'SELECT ''skip menu CustomerDealer'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 与 lgp_menu 口径一致:企业/清单/订单 sort 后移(仅当经销商菜单刚存在时幂等更新)
UPDATE `nl_menu` SET `sort` = 2, `updated_at` = UNIX_TIMESTAMP()
WHERE `name` = 'CustomerEnterprise' AND `deleted_at` = 0 AND `sort` < 2;
UPDATE `nl_menu` SET `sort` = 3, `updated_at` = UNIX_TIMESTAMP()
WHERE `name` = 'CustomerList' AND `deleted_at` = 0 AND `sort` < 3;
UPDATE `nl_menu` SET `sort` = 4, `updated_at` = UNIX_TIMESTAMP()
WHERE `name` = 'CustomerOrder' AND `deleted_at` = 0 AND `sort` < 4;
-- 凡已拥有「微信用户」菜单的角色,同步挂上「经销商管理」(幂等)
SET @menu_id := (SELECT id FROM nl_menu WHERE name = 'CustomerDealer' AND deleted_at = 0 LIMIT 1);
SET @wx_user_menu_id := (SELECT id FROM nl_menu WHERE name = 'CustomerWxUser' AND deleted_at = 0 LIMIT 1);
INSERT INTO `nl_role_menu_relations` (`role_id`, `menu_id`, `created_at`)
SELECT r.role_id, @menu_id, UNIX_TIMESTAMP()
FROM `nl_role_menu_relations` r
WHERE @menu_id IS NOT NULL
AND @wx_user_menu_id IS NOT NULL
AND r.menu_id = @wx_user_menu_id
AND NOT EXISTS (
SELECT 1 FROM `nl_role_menu_relations` x
WHERE x.role_id = r.role_id AND x.menu_id = @menu_id
);

View File

@@ -0,0 +1,23 @@
-- ============================================================
-- cc_category 补 sort:分类排序(幂等)
-- 数字越小越靠前;首次加列后若全为 0,用 id 回填保持原顺序
-- ============================================================
SET @db := DATABASE();
SET @exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_category' AND COLUMN_NAME = 'sort'
);
SET @sql := IF(@exists = 0,
'ALTER TABLE `cc_category` ADD COLUMN `sort` int NOT NULL DEFAULT 0 COMMENT ''排序,数字越小越靠前'' AFTER `pid`',
'SELECT ''skip cc_category.sort'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 仅当尚无任何非 0 排序值时回填(避免重复执行覆盖手工排序)
SET @has_custom := (SELECT COUNT(*) FROM `cc_category` WHERE `sort` <> 0);
SET @sql2 := IF(@has_custom = 0,
'UPDATE `cc_category` SET `sort` = `id`',
'SELECT ''skip cc_category.sort backfill'' AS msg'
);
PREPARE stmt2 FROM @sql2; EXECUTE stmt2; DEALLOCATE PREPARE stmt2;

View File

@@ -0,0 +1,49 @@
-- ============================================================
-- 装修模板只留 4 套轻奢:素胚 / 珠贝白 / 胡桃木 / 香槟金
-- 其余软删;经销商与应用绑了已删 code 的回落到空(走品牌默认)
-- 本脚本可重复执行:缺列先补,已处理过的行不再变
-- ============================================================
SET @db := DATABASE();
SET @now := UNIX_TIMESTAMP();
-- 有的库还没跑过 2026-08-14/03,cc_wx_user 没有 template_code,先补列再纠偏
SET @exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_wx_user' AND COLUMN_NAME = 'template_code'
);
SET @sql := IF(@exists = 0,
'ALTER TABLE `cc_wx_user` ADD COLUMN `template_code` varchar(50) NOT NULL DEFAULT '''' COMMENT ''经销商专属装修模板 code,空=跟随品牌默认'' AFTER `price_number`',
'SELECT ''skip cc_wx_user.template_code'' AS msg'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
UPDATE `cc_wx_template`
SET `deleted_at` = @now, `updated_at` = @now, `is_default` = 0
WHERE `deleted_at` = 0
AND `code` NOT IN ('nature-clay', 'lux-pearl', 'lux-walnut', 'lux-champagne');
UPDATE `cc_wx_template`
SET `name` = '素胚', `style_tag` = '轻奢', `updated_at` = @now
WHERE `code` = 'nature-clay' AND `deleted_at` = 0;
UPDATE `cc_wx_user` u
LEFT JOIN `cc_wx_template` t
ON t.`code` = u.`template_code` AND t.`deleted_at` = 0
SET u.`template_code` = ''
WHERE u.`template_code` <> '' AND t.`id` IS NULL;
-- nl_wx_app 可能尚未建表,没有表就跳过
SET @app_exists := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_wx_app'
);
SET @sql := IF(@app_exists = 0,
'SELECT ''skip nl_wx_app (table missing)'' AS msg',
'UPDATE `nl_wx_app` a
LEFT JOIN `cc_wx_template` t
ON t.`code` = a.`template_code` AND t.`deleted_at` = 0
SET a.`template_code` = ''lux-champagne'', a.`updated_at` = UNIX_TIMESTAMP()
WHERE a.`template_code` <> '''' AND t.`id` IS NULL'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

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

@@ -0,0 +1,167 @@
-- ============================================================
-- 缺口补齐:地址簿 / 反馈 / 收藏 / 售后 / 库存 / 视频 / 订阅模板 / 菜单
-- 幂等;业务表 cc_*,系统表 nl_*
-- ============================================================
SET @db := DATABASE();
-- 收货地址
SET @exists := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_wx_address');
SET @sql := IF(@exists = 0,
'CREATE TABLE `cc_wx_address` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL DEFAULT 0,
`name` varchar(50) NOT NULL DEFAULT '''',
`phone` varchar(20) NOT NULL DEFAULT '''',
`address` varchar(255) NOT NULL DEFAULT '''',
`is_default` tinyint NOT NULL DEFAULT 0 COMMENT ''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_addr_user` (`user_id`, `deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''小程序收货地址''',
'SELECT ''skip cc_wx_address'' 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_feedback');
SET @sql := IF(@exists = 0,
'CREATE TABLE `cc_feedback` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL DEFAULT 0,
`content` varchar(500) NOT NULL DEFAULT '''',
`images` varchar(1000) NOT NULL DEFAULT '''' COMMENT ''图 URL,逗号分隔'',
`reply` varchar(500) NOT NULL DEFAULT '''',
`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_fb_user` (`user_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''用户反馈''',
'SELECT ''skip cc_feedback'' 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_favorite');
SET @sql := IF(@exists = 0,
'CREATE TABLE `cc_favorite` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL DEFAULT 0,
`catalogue_id` int NOT NULL DEFAULT 0,
`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_fav_user` (`user_id`, `deleted_at`),
KEY `idx_fav_cat` (`catalogue_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''商品收藏''',
'SELECT ''skip cc_favorite'' 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_after_sale');
SET @sql := IF(@exists = 0,
'CREATE TABLE `cc_after_sale` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`order_id` int NOT NULL DEFAULT 0,
`user_id` int NOT NULL DEFAULT 0,
`reason` varchar(255) NOT NULL DEFAULT '''',
`amount` int NOT NULL DEFAULT 0 COMMENT ''申请退款金额(分)'',
`images` varchar(1000) NOT NULL DEFAULT '''',
`status` tinyint NOT NULL DEFAULT 0 COMMENT ''0=待审 1=通过 2=拒绝'',
`admin_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_as_order` (`order_id`),
KEY `idx_as_user` (`user_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''售后申请''',
'SELECT ''skip cc_after_sale'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 商品视频
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_catalogue' AND COLUMN_NAME = 'video');
SET @sql := IF(@col = 0,
'ALTER TABLE `cc_catalogue` ADD COLUMN `video` varchar(500) NOT NULL DEFAULT '''' COMMENT ''商品视频'' AFTER `pdf`',
'SELECT ''skip cc_catalogue.video'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 规格库存:-1=不限
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_price_sheet' AND COLUMN_NAME = 'stock');
SET @sql := IF(@col = 0,
'ALTER TABLE `cc_price_sheet` ADD COLUMN `stock` int NOT NULL DEFAULT -1 COMMENT ''库存,-1不限'' AFTER `routine`',
'SELECT ''skip cc_price_sheet.stock'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 订阅消息模板
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_wx_app' AND COLUMN_NAME = 'subscribe_pay_tpl');
SET @sql := IF(@col = 0,
'ALTER TABLE `nl_wx_app` ADD COLUMN `subscribe_pay_tpl` varchar(64) NOT NULL DEFAULT '''' COMMENT ''付款成功订阅模板'' AFTER `package_enabled`',
'SELECT ''skip nl_wx_app.subscribe_pay_tpl'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_wx_app' AND COLUMN_NAME = 'subscribe_ship_tpl');
SET @sql := IF(@col = 0,
'ALTER TABLE `nl_wx_app` ADD COLUMN `subscribe_ship_tpl` varchar(64) NOT NULL DEFAULT '''' COMMENT ''发货订阅模板'' AFTER `subscribe_pay_tpl`',
'SELECT ''skip nl_wx_app.subscribe_ship_tpl'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 套餐菜单(若 lgp:menu-sync 未跑过)
SET @parent_id := (SELECT id FROM nl_menu WHERE name = 'Goods' AND deleted_at = 0 LIMIT 1);
SET @exists := (SELECT COUNT(*) FROM nl_menu WHERE name = 'GoodsPackage' AND deleted_at = 0);
SET @sql := IF(@parent_id IS NOT NULL AND @exists = 0,
CONCAT(
'INSERT INTO `nl_menu` (`title`,`icon`,`name`,`path`,`component`,`redirect`,`keep_alive`,`hide_in_menu`,`affix_tab`,`badge`,`badge_type`,`badge_variants`,`iframe_src`,`pid`,`sort`,`query`,`created_at`,`updated_at`,`deleted_at`) VALUES (',
'''套餐搭配'', ''lucide:package'', ''GoodsPackage'', ''/goods/package'', ''/goods/package/index'', '''', 1, 0, 1, '''', 0, 0, '''', ',
@parent_id, ', 6, '''', UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0)'
),
'SELECT ''skip menu GoodsPackage'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 反馈 / 售后菜单
SET @parent_id := (SELECT id FROM nl_menu WHERE name = 'Customer' AND deleted_at = 0 LIMIT 1);
SET @exists := (SELECT COUNT(*) FROM nl_menu WHERE name = 'CustomerFeedback' AND deleted_at = 0);
SET @sql := IF(@parent_id IS NOT NULL AND @exists = 0,
CONCAT(
'INSERT INTO `nl_menu` (`title`,`icon`,`name`,`path`,`component`,`redirect`,`keep_alive`,`hide_in_menu`,`affix_tab`,`badge`,`badge_type`,`badge_variants`,`iframe_src`,`pid`,`sort`,`query`,`created_at`,`updated_at`,`deleted_at`) VALUES (',
'''用户反馈'', ''lucide:message-square'', ''CustomerFeedback'', ''/customer/feedback'', ''/customer/feedback/index'', '''', 1, 0, 1, '''', 0, 0, '''', ',
@parent_id, ', 5, '''', UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0)'
),
'SELECT ''skip menu CustomerFeedback'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exists := (SELECT COUNT(*) FROM nl_menu WHERE name = 'CustomerAfterSale' AND deleted_at = 0);
SET @sql := IF(@parent_id IS NOT NULL AND @exists = 0,
CONCAT(
'INSERT INTO `nl_menu` (`title`,`icon`,`name`,`path`,`component`,`redirect`,`keep_alive`,`hide_in_menu`,`affix_tab`,`badge`,`badge_type`,`badge_variants`,`iframe_src`,`pid`,`sort`,`query`,`created_at`,`updated_at`,`deleted_at`) VALUES (',
'''售后管理'', ''lucide:undo-2'', ''CustomerAfterSale'', ''/customer/after-sale'', ''/customer/after-sale/index'', '''', 1, 0, 1, '''', 0, 0, '''', ',
@parent_id, ', 6, '''', UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0)'
),
'SELECT ''skip menu CustomerAfterSale'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 有微信用户菜单的角色同步新菜单
SET @wx_user_menu_id := (SELECT id FROM nl_menu WHERE name = 'CustomerWxUser' AND deleted_at = 0 LIMIT 1);
SET @menu_id := (SELECT id FROM nl_menu WHERE name = 'CustomerFeedback' AND deleted_at = 0 LIMIT 1);
INSERT INTO `nl_role_menu_relations` (`role_id`, `menu_id`, `created_at`)
SELECT r.role_id, @menu_id, UNIX_TIMESTAMP()
FROM `nl_role_menu_relations` r
WHERE @menu_id IS NOT NULL AND @wx_user_menu_id IS NOT NULL AND r.menu_id = @wx_user_menu_id
AND NOT EXISTS (SELECT 1 FROM `nl_role_menu_relations` x WHERE x.role_id = r.role_id AND x.menu_id = @menu_id);
SET @menu_id := (SELECT id FROM nl_menu WHERE name = 'CustomerAfterSale' AND deleted_at = 0 LIMIT 1);
INSERT INTO `nl_role_menu_relations` (`role_id`, `menu_id`, `created_at`)
SELECT r.role_id, @menu_id, UNIX_TIMESTAMP()
FROM `nl_role_menu_relations` r
WHERE @menu_id IS NOT NULL AND @wx_user_menu_id IS NOT NULL AND r.menu_id = @wx_user_menu_id
AND NOT EXISTS (SELECT 1 FROM `nl_role_menu_relations` x WHERE x.role_id = r.role_id AND x.menu_id = @menu_id);
SET @cat_menu := (SELECT id FROM nl_menu WHERE name = 'GoodsCatalogue' AND deleted_at = 0 LIMIT 1);
SET @pkg_menu := (SELECT id FROM nl_menu WHERE name = 'GoodsPackage' AND deleted_at = 0 LIMIT 1);
INSERT INTO `nl_role_menu_relations` (`role_id`, `menu_id`, `created_at`)
SELECT r.role_id, @pkg_menu, UNIX_TIMESTAMP()
FROM `nl_role_menu_relations` r
WHERE @pkg_menu IS NOT NULL AND @cat_menu IS NOT NULL AND r.menu_id = @cat_menu
AND NOT EXISTS (SELECT 1 FROM `nl_role_menu_relations` x WHERE x.role_id = r.role_id AND x.menu_id = @pkg_menu);

View File

@@ -586,7 +586,7 @@ INSERT INTO `nl_oss_config` VALUES
(1, 'local', '本地存储', '', '', '', '', '', '', 'uploads', NULL, 1, 1, '默认本地磁盘,文件落到 storage/app', 100, 1786329600, 0, 0), (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), (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), (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), (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), (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), (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,9 @@ CREATE TABLE `nl_wx_app` (
`platform_public_key` text NULL COMMENT '微信支付平台证书公钥', `platform_public_key` text NULL COMMENT '微信支付平台证书公钥',
`notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '支付回调地址', `notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '支付回调地址',
`template_code` varchar(50) NOT NULL DEFAULT '' COMMENT '默认装修模板 code', `template_code` varchar(50) NOT NULL DEFAULT '' COMMENT '默认装修模板 code',
`package_enabled` tinyint NOT NULL DEFAULT 0 COMMENT '1=启用套餐(首页热门+底栏)',
`subscribe_pay_tpl` varchar(64) NOT NULL DEFAULT '' COMMENT '付款成功订阅模板',
`subscribe_ship_tpl` varchar(64) NOT NULL DEFAULT '' COMMENT '发货订阅模板',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0=启用 1=停用', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0=启用 1=停用',
`remark` varchar(255) NOT NULL DEFAULT '', `remark` varchar(255) NOT NULL DEFAULT '',
`created_at` int NOT NULL DEFAULT 0, `created_at` int NOT NULL DEFAULT 0,

View File

@@ -30,21 +30,29 @@ UtilsService::class::getInstance()->autoRouteRegister([
* *
* 小程序侧只需把 baseURL 改成 .../api/wx/,路径与老接口逐字对齐。 * 小程序侧只需把 baseURL 改成 .../api/wx/,路径与老接口逐字对齐。
* 免鉴权组:登录、首页、主题、支付回调;其余一律走 nl.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([ UtilsService::class::getInstance()->autoRouteRegister([
'auth' => \App\Http\Controllers\Wx\AuthController::class, // 小程序登录 'auth' => \App\Http\Controllers\Wx\AuthController::class, // 小程序登录
'home' => \App\Http\Controllers\Wx\HomeController::class, // 首页轮播与分类 'home' => \App\Http\Controllers\Wx\HomeController::class, // 首页轮播与分类
'package' => \App\Http\Controllers\Wx\PackageController::class, // 套餐热门/列表/详情/一件加入
'product' => \App\Http\Controllers\Wx\ProductController::class, // 商品浏览免登录(和老 wx-api 一致)
'factory' => \App\Http\Controllers\Wx\FactoryController::class, // 工厂目录
'colorcard' => \App\Http\Controllers\Wx\ColorcardController::class, // 色卡目录
'' => \App\Http\Controllers\Wx\ThemeController::class, // 主题下发与支付回调 '' => \App\Http\Controllers\Wx\ThemeController::class, // 主题下发与支付回调
]); ]);
Route::group(['middleware' => 'nl.wx'], function () { Route::group(['middleware' => 'nl.wx'], function () {
UtilsService::class::getInstance()->autoRouteRegister([ UtilsService::class::getInstance()->autoRouteRegister([
'product' => \App\Http\Controllers\Wx\ProductController::class, // 商品
'list' => \App\Http\Controllers\Wx\ListController::class, // 清单 'list' => \App\Http\Controllers\Wx\ListController::class, // 清单
'user' => \App\Http\Controllers\Wx\UserController::class, // 我的 'user' => \App\Http\Controllers\Wx\UserController::class, // 我的
'order' => \App\Http\Controllers\Wx\OrderController::class, // 订单 'order' => \App\Http\Controllers\Wx\OrderController::class, // 订单
'upload' => \App\Http\Controllers\Wx\WxUploadController::class, // 上传(转账凭证等) 'upload' => \App\Http\Controllers\Wx\WxUploadController::class, // 上传(转账凭证等)
'address' => \App\Http\Controllers\Wx\AddressController::class, // 收货地址
'feedback' => \App\Http\Controllers\Wx\FeedbackController::class, // 用户反馈
'favorite' => \App\Http\Controllers\Wx\FavoriteController::class, // 收藏
'after-sale' => \App\Http\Controllers\Wx\AfterSaleController::class, // 售后
]); ]);
}); });
}); });
@@ -78,6 +86,7 @@ Route::group(['middleware' => 'nl.auth'], function () {
'price-sheet' => \App\Http\Controllers\Api\PriceSheetController::class, // 报价单(规格) 'price-sheet' => \App\Http\Controllers\Api\PriceSheetController::class, // 报价单(规格)
'image' => \App\Http\Controllers\Api\ImageController::class, // 商品相册 'image' => \App\Http\Controllers\Api\ImageController::class, // 商品相册
'carousel' => \App\Http\Controllers\Api\CarouselController::class, // 小程序轮播图 'carousel' => \App\Http\Controllers\Api\CarouselController::class, // 小程序轮播图
'package' => \App\Http\Controllers\Api\PackageController::class, // 套餐搭配
'factory-info' => \App\Http\Controllers\Api\FactoryInfoController::class, // 工厂管理 'factory-info' => \App\Http\Controllers\Api\FactoryInfoController::class, // 工厂管理
'factory-classification' => \App\Http\Controllers\Api\FactoryClassificationController::class, // 工厂分类 'factory-classification' => \App\Http\Controllers\Api\FactoryClassificationController::class, // 工厂分类
'factory-image' => \App\Http\Controllers\Api\FactoryImageController::class, // 工厂产品图 'factory-image' => \App\Http\Controllers\Api\FactoryImageController::class, // 工厂产品图
@@ -88,7 +97,10 @@ Route::group(['middleware' => 'nl.auth'], function () {
'enterprise' => \App\Http\Controllers\Api\EnterpriseController::class, // 企业管理 'enterprise' => \App\Http\Controllers\Api\EnterpriseController::class, // 企业管理
'list' => \App\Http\Controllers\Api\ListController::class, // 清单管理 'list' => \App\Http\Controllers\Api\ListController::class, // 清单管理
'order' => \App\Http\Controllers\Api\OrderController::class, // 订单管理 'order' => \App\Http\Controllers\Api\OrderController::class, // 订单管理
'feedback' => \App\Http\Controllers\Api\FeedbackController::class, // 用户反馈
'after-sale' => \App\Http\Controllers\Api\AfterSaleController::class, // 售后审核
'wx-template' => \App\Http\Controllers\Api\WxTemplateController::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, // 小程序应用配置 '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"
}
}
}
]
}