feat: 新增发票抬头、晒单、钱包日志等模型及后台管理功能

- 新增 invoice_title、showcase、wallet_log 模型及对应语言包
- 新增后台控制器、视图及搜索字段配置
- 修复服务类目删除保护逻辑
- 优化后台登录页面文案及样式
- 新增前端角色切换逻辑及页面拦截
- 新增 SVG 图标及样式变量
- 修复接口权限及下拉框默认值问题
这个提交包含在:
2026-11-11 17:02:35 +08:00
父节点 5908cf6b16
当前提交 fafddceaf2
共有 332 个文件被更改,包括 33892 次插入 和 8835 次删除

查看文件

@@ -9,7 +9,7 @@ use think\Db;
*/
class Home extends Base
{
protected $noNeedLogin = ['index', 'feed', 'cats', 'services', 'detail', 'calc', 'specs', 'masterCount', 'orderCase'];
protected $noNeedLogin = ['index', 'feed', 'cats', 'services', 'detail', 'calc', 'specs', 'masterCount', 'orderCase', 'agreement', 'systemGallery'];
/**
* 首页聚合:类目 + 热门服务 + 我的角标
@@ -161,6 +161,7 @@ class Home extends Base
'banners' => $this->banners(),
'assures' => $this->configList('home_assure', '平台认证,先行赔付,极速上门,售后无忧'),
'tutorial' => $this->tutorial(),
'service_phone' => (string)(\think\Config::get('site.service_phone') ?: ''),
'badge' => ['coupon' => 0, 'unread' => 0],
];
@@ -566,9 +567,6 @@ class Home extends Base
{
$catId = (int)$this->request->param('cat_id');
$keyword = trim((string)$this->request->param('keyword'));
if ($catId <= 0 && $keyword === '') {
$this->error('请选择服务类目');
}
$query = Db::name('service')->where('status', 'normal');
if ($catId > 0) {
$query->where('cat_id', $catId);
@@ -605,10 +603,12 @@ class Home extends Base
->select();
$ret = [];
foreach ($specs as $sp) {
$spImg = isset($sp['image']) ? trim((string)$sp['image']) : '';
$ret[] = [
'id' => (int)$sp['id'],
'service_id' => (int)$sp['service_id'],
'name' => $sp['name'],
'image_url' => $spImg !== '' ? (strpos($spImg, 'http') === 0 ? $spImg : cdnurl($spImg, true)) : '',
'description' => (string)$sp['description'],
'base_price' => (float)$sp['base_price'],
];
@@ -639,22 +639,30 @@ class Home extends Base
$province = trim((string)$this->request->param('province', ''));
$city = trim((string)$this->request->param('city', ''));
$district = trim((string)$this->request->param('district', ''));
$catId = (int)$this->request->param('cat_id', 0);
$query = Db::name('master')->where('verify_status', 'pass');
$platformCount = (int)Db::name('master')->where('verify_status', 'pass')->count();
$total = $platformCount;
$matchedLocal = false;
if ($city !== '') {
$query->where('city', 'like', '%' . $city . '%');
$shortCity = mb_strlen($city, 'UTF-8') > 2 ? preg_replace('/市$/u', '', $city) : $city;
$cityCount = (int)Db::name('master')
->where('verify_status', 'pass')
->where('city', 'like', '%' . $shortCity . '%')
->count();
if ($cityCount > 0) {
$total = $cityCount;
$matchedLocal = true;
}
}
$realCount = $query->count();
// 商业化基础运力感知(基于城市区县散列生成可信且稳定的运力规模,例如 150~1800+)
$hashSeed = sprintf('%u', crc32($province . $city . $district));
$virtualBase = 150 + ($hashSeed % 1700);
$total = max($realCount, $virtualBase);
$this->success('', [
'count' => (int)$total,
'region' => trim($city . ' ' . $district),
'tip' => "该地区可提供服务的师傅共 {$total} 位",
'count' => (int)$total,
'platform_count' => $platformCount,
'region' => trim($city . ' ' . $district),
'tip' => $matchedLocal
? "该地区可提供服务的师傅共 {$total} 位"
: "平台认证可服务师傅共 {$total} 位",
]);
}
@@ -665,7 +673,7 @@ class Home extends Base
public function calc()
{
$serviceId = (int)$this->request->param('service_id');
$goodsListRaw = $this->request->param('goods_list', '', null);
$goodsListRaw = $this->request->param('goods_list', $this->request->param('items', '', null), null);
$goodsList = [];
if (!empty($goodsListRaw)) {
if (is_string($goodsListRaw)) {
@@ -697,14 +705,24 @@ class Home extends Base
$service = Db::name('service')->where('id', $serviceId)->where('status', 'normal')->find();
}
$result = self::calcAmount($service, [
'floor' => (int)$this->request->param('floor', 1),
'has_elevator' => (int)$this->request->param('has_elevator', 1),
'urgent' => (int)$this->request->param('urgent', 0),
], (int)$this->request->param('coupon_id', 0), $this->uid(),
(string)$this->request->param('price_type', ''),
(float)$this->request->param('user_price', 0),
$goodsList);
try {
$result = self::calcAmount($service, [
'floor' => (int)$this->request->param('floor', 1),
'has_elevator' => (int)$this->request->param('has_elevator', 1),
'urgent' => (int)$this->request->param('urgent', 0),
'goods_num' => max(1, (int)$this->request->param('goods_num', $this->request->param('num', 1))),
'spec_id' => (int)$this->request->param('spec_id', 0),
'service_type' => trim((string)$this->request->param('service_type', '')),
'is_custom' => (int)$this->request->param('is_custom', 0),
'base_price' => $this->request->param('base_price', null),
], (int)$this->request->param('coupon_id', 0), $this->uid(),
(string)$this->request->param('price_type', ''),
(float)$this->request->param('user_price', 0),
$goodsList);
} catch (\InvalidArgumentException $e) {
$this->error($e->getMessage());
return;
}
$this->success('', $result);
}
@@ -713,7 +731,7 @@ class Home extends Base
* 金额计算(下单与试算共用,保证前后端口径一致)
*
* @param array $service 服务行(可选)
* @param array $params floor / has_elevator / urgent
* @param array $params floor / has_elevator / urgent / goods_num / spec_id / service_type / is_custom / strict
* @param int $couponId 用户优惠券 id
* @param int $uid 用户 id
* @param string $priceType 计价方式:fixed 一口价 / quote 师傅报价 / custom 用户自主定价
@@ -724,8 +742,10 @@ class Home extends Base
{
$allowTypes = ['fixed', 'quote', 'range', 'custom'];
$type = in_array($priceType, $allowTypes, true) ? $priceType : ($service ? $service['price_type'] : 'fixed');
$isFixedLike = in_array($type, ['fixed', 'range'], true);
$isStrict = !empty($params['strict']);
// 支持多商品组合与增项计算(后端结合 fa_service 与 fa_service_spec 权威核验单价)
// 支持多商品组合与增项计算(一口价模式下强制从 fa_service 与 fa_service_spec 读取真实价格,严禁采信前端传入 base_price)
$base = 0;
if (!empty($goodsList) && is_array($goodsList)) {
$serviceCache = [];
@@ -744,31 +764,50 @@ class Home extends Base
$sid = isset($g['service_id']) ? (int)$g['service_id'] : 0;
$specId = isset($g['spec_id']) ? (int)$g['spec_id'] : 0;
$reqServiceType = isset($g['service_type']) ? trim((string)$g['service_type']) : '';
$isCustom = !empty($g['is_custom']) || (isset($g['cat_id']) && (int)$g['cat_id'] === 15);
$isCustom = !empty($g['is_custom']);
$dbSvc = null;
if ($sid > 0 && !$isCustom) {
if ($sid > 0) {
if (!array_key_exists($sid, $serviceCache)) {
$serviceCache[$sid] = Db::name('service')->where('id', $sid)->find() ?: null;
$serviceCache[$sid] = Db::name('service')->where('id', $sid)->where('status', 'normal')->find() ?: null;
}
$dbSvc = $serviceCache[$sid];
}
$specPrice = 0.0;
if ($specId > 0 && !$isCustom) {
if (!array_key_exists($specId, $specCache)) {
$spQuery = Db::name('service_spec')->where('id', $specId);
if ($sid > 0) {
$spQuery->where('service_id', $sid);
if ($isFixedLike) {
// 纯空占位行(下单页首次加载尚未选服务时试算)容忍返回 0;只要带了 is_custom、自填名称、自定义价格或处于创单严格校验则强制拦截
$hasCustomInput = $isCustom
|| !empty($g['custom_name'])
|| !empty($g['item_name'])
|| !empty($g['goods_name'])
|| (isset($g['base_price']) && (float)$g['base_price'] > 0);
if ($sid <= 0) {
if ($hasCustomInput || $isStrict) {
throw new \InvalidArgumentException('自定义商品不支持一口价模式,请关联有效服务项目或切换为师傅报价/自主定价');
}
$specCache[$specId] = $spQuery->find() ?: null;
continue;
}
if (!empty($specCache[$specId])) {
$specPrice = (float)$specCache[$specId]['base_price'];
if (!$dbSvc || $dbSvc['price_type'] === 'quote') {
throw new \InvalidArgumentException('所选服务项目不存在或不支持一口价模式,请切换为师傅报价/自主定价');
}
}
$itemPrice = isset($g['base_price']) ? max(0, (float)$g['base_price']) : 0.0;
$specPrice = 0.0;
if ($specId > 0 && $dbSvc) {
$cacheKey = $sid . ':' . $specId;
if (!array_key_exists($cacheKey, $specCache)) {
$specCache[$cacheKey] = Db::name('service_spec')
->where('id', $specId)
->where('service_id', $sid)
->where('status', 'normal')
->find() ?: null;
}
if (!empty($specCache[$cacheKey])) {
$specPrice = (float)$specCache[$cacheKey]['base_price'];
}
}
$itemPrice = 0.0;
$dbAddonsMap = [];
if ($dbSvc) {
@@ -784,7 +823,7 @@ class Home extends Base
}
}
// 方案2计价优先级:若规格设置了专属一口价(>0)且当前为默认主服务,则优先采用规格专属价;否则采用所选主服务定价或商品默认价
// 若规格设置了专属一口价(>0)且当前为默认主服务,则优先采用规格专属价;否则采用所选主服务定价或商品默认价
$isDefaultService = ($reqServiceType === '' || $reqServiceType === $defaultCode || $reqServiceType === 'install');
if ($specPrice > 0 && $isDefaultService) {
$itemPrice = $specPrice;
@@ -807,28 +846,76 @@ class Home extends Base
}
}
$itemNum = isset($g['num']) ? max(1, (int)$g['num']) : 1;
$base += $itemPrice * $itemNum;
if ($isFixedLike && $itemPrice <= 0) {
throw new \InvalidArgumentException('所选服务规格暂未配置一口价,请切换为师傅报价或自主定价');
}
$itemNum = isset($g['num']) ? max(1, (int)$g['num']) : (isset($g['goods_num']) ? max(1, (int)$g['goods_num']) : 1);
$base += round($itemPrice * $itemNum, 2);
if (!empty($g['extras']) && is_array($g['extras'])) {
foreach ($g['extras'] as $ex) {
$exNum = isset($ex['num']) ? max(0, (int)$ex['num']) : 0;
if ($exNum <= 0) {
continue;
}
$exPrice = isset($ex['price']) ? max(0, (float)$ex['price']) : 0.0;
$exPrice = 0.0;
$exName = isset($ex['name']) ? (string)$ex['name'] : '';
$exCode = isset($ex['code']) ? (string)$ex['code'] : '';
if ($exName !== '' && isset($dbAddonsMap[$exName])) {
$exPrice = $dbAddonsMap[$exName];
} elseif ($exCode !== '' && isset($dbAddonsMap[$exCode])) {
$exPrice = $dbAddonsMap[$exCode];
} elseif (!$isFixedLike && isset($ex['price'])) {
$exPrice = max(0, (float)$ex['price']);
}
$base += $exPrice * $exNum;
$base += round($exPrice * $exNum, 2);
}
}
}
} elseif ($service) {
$base = (float)$service['base_price'];
if ($isFixedLike && $service['price_type'] === 'quote') {
throw new \InvalidArgumentException('该服务项目不支持一口价模式,请切换为师傅报价或自主定价');
}
$unitPrice = (float)$service['base_price'];
$specId = isset($params['spec_id']) ? (int)$params['spec_id'] : 0;
$reqServiceType = isset($params['service_type']) ? trim((string)$params['service_type']) : '';
$specPrice = 0.0;
if ($specId > 0) {
$spRow = Db::name('service_spec')
->where('id', $specId)
->where('service_id', (int)$service['id'])
->where('status', 'normal')
->find();
if ($spRow && (float)$spRow['base_price'] > 0) {
$specPrice = (float)$spRow['base_price'];
}
}
$svcTypes = !empty($service['service_types']) ? (json_decode((string)$service['service_types'], true) ?: []) : [];
$defaultCode = 'install';
$matchedTypePrice = 0.0;
foreach ($svcTypes as $st) {
if (!empty($st['is_default']) && !empty($st['code'])) {
$defaultCode = (string)$st['code'];
}
if ($reqServiceType !== '' && isset($st['code']) && (string)$st['code'] === $reqServiceType) {
$matchedTypePrice = isset($st['price']) ? (float)$st['price'] : 0.0;
}
}
$isDefaultService = ($reqServiceType === '' || $reqServiceType === $defaultCode || $reqServiceType === 'install');
if ($specPrice > 0 && $isDefaultService) {
$unitPrice = $specPrice;
} elseif ($matchedTypePrice > 0) {
$unitPrice = $matchedTypePrice;
} elseif ($specPrice > 0) {
$unitPrice = $specPrice;
}
if ($isFixedLike && $unitPrice <= 0) {
throw new \InvalidArgumentException('该服务项目暂未配置有效一口价,请切换为师傅报价或自主定价');
}
$goodsNum = isset($params['goods_num']) ? max(1, (int)$params['goods_num']) : 1;
$base = round($unitPrice * $goodsNum, 2);
} elseif ($isFixedLike && (!empty($params['is_custom']) || $isStrict || (isset($params['base_price']) && (float)$params['base_price'] > 0))) {
throw new \InvalidArgumentException('自定义商品不支持一口价模式,请关联有效服务项目或切换为师傅报价/自主定价');
}
$floorAmount = 0;
@@ -1009,10 +1096,12 @@ class Home extends Base
if (!isset($specsMap[$sid])) {
$specsMap[$sid] = [];
}
$spImg = isset($sp['image']) ? trim((string)$sp['image']) : '';
$specsMap[$sid][] = [
'id' => (int)$sp['id'],
'service_id' => $sid,
'name' => $sp['name'],
'image_url' => $spImg !== '' ? (strpos($spImg, 'http') === 0 ? $spImg : cdnurl($spImg, true)) : '',
'description' => (string)$sp['description'],
'base_price' => (float)$sp['base_price'],
];
@@ -1255,4 +1344,168 @@ class Home extends Base
]
]);
}
/**
* 平台协议与合规条款读取(支持后台站点配置覆盖)
*/
public function agreement()
{
$type = trim((string)$this->request->param('type', 'user_service'));
$map = [
'service' => [
'key' => 'agreement_service',
'title' => '优匠优服用户服务协议',
'content' => "1. 平台定位:优匠优服为客户与认证技能师傅提供上门安装、维修、拆旧及维保信息撮合与交易担保服务。\n\n2. 计费准则:一口价项目按公示标准计费;报价订单由师傅根据现场工况公开报价,经客户确认支付后生效。\n\n3. 验收与结算:订单资金由平台托管,师傅完工拍照并经客户确认(或提供6位核销码)后完成结算。\n\n4. 交易规范:为保障售后质保与资金安全,请勿脱离平台私下转账交易。",
],
'user_service' => [
'key' => 'agreement_service',
'title' => '优匠优服用户服务协议',
'content' => "1. 平台定位:优匠优服为客户与认证技能师傅提供上门安装、维修、拆旧及维保信息撮合与交易担保服务。\n\n2. 计费准则:一口价项目按公示标准计费;报价订单由师傅根据现场工况公开报价,经客户确认支付后生效。\n\n3. 验收与结算:订单资金由平台托管,师傅完工拍照并经客户确认(或提供6位核销码)后完成结算。\n\n4. 交易规范:为保障售后质保与资金安全,请勿脱离平台私下转账交易。",
],
'privacy' => [
'key' => 'agreement_privacy',
'title' => '优匠优服隐私保护指引',
'content' => "1. 信息收集范围:仅在您授权登录、预约上门地址及联系师傅等必要场景下收集手机号码与服务地址信息。\n\n2. 最小必要原则:客户真实联系信息仅对已接单履约的当单认证师傅可见,未接单前全程脱敏展示。\n\n3. 数据传输安全:全链路采用加密通信并严格管控访问权限,绝不向无关第三方共享或出售个人信息。\n\n4. 用户权利:您可随时在个人中心查询、修改账户资料及管理服务地址。",
],
'master' => [
'key' => 'agreement_master',
'title' => '师傅入驻与履约服务协议',
'content' => "1. 实名与技能核验:入驻师傅须提交本人真实有效身份证件及服务技能信息,严禁冒用他人资质。\n\n2. 履约时效:接单后须在平台规定时效内联系客户确认上门时间,准时到达现场并规范施工。\n\n3. 收费透明:如需现场增项补差,必须提前向客户说明并在工单内发起补差申请,严禁私自索要额外费用。\n\n4. 完工留痕与结算:施工完成后须上传真实清晰的现场完工照片,凭客户验收或核销码触发系统结算。",
],
'merchant' => [
'key' => 'agreement_merchant',
'title' => '企业商家入驻与协同服务协议',
'content' => "1. 资质真实性:申请入驻的企业或商户须提供真实有效的营业执照与主体信息。\n\n2. 成员协同管理:主账号可绑定企业协同成员共同发起批量工单与跟进履约,并对企业关联工单承担管理责任。\n\n3. 对账与开票:平台按实际支付与完工订单汇总企业月度账单,支持在线申请正规增值税普通发票或专用发票。",
],
];
$item = isset($map[$type]) ? $map[$type] : $map['user_service'];
$cfgVal = \think\Config::get('site.' . $item['key']);
$content = (is_string($cfgVal) && trim($cfgVal) !== '') ? trim($cfgVal) : $item['content'];
$this->success('', [
'type' => $type,
'title' => $item['title'],
'content' => $content,
]);
}
/**
* 官方系统图库(二级商品分类侧边栏 + 款式参考图片双列网格)
*/
public function systemGallery()
{
$catId = (int)$this->request->param('cat_id', 0);
$serviceId = (int)$this->request->param('service_id', 0);
$keyword = trim((string)$this->request->param('keyword', ''));
$rawCats = Db::name('service_cat')
->field('id,name,icon')
->where('status', 'normal')
->where('id', '<>', 15)
->order('weigh desc,id asc')
->select();
$cats = $this->formatCats($rawCats);
if ($serviceId > 0 && $catId <= 0) {
$foundCatId = (int)Db::name('service')->where('id', $serviceId)->value('cat_id');
if ($foundCatId > 0 && $foundCatId !== 15) {
$catId = $foundCatId;
}
}
if ($catId === 0 && !empty($cats)) {
$catId = (int)$cats[0]['id'];
}
$svcQuery = Db::name('service')
->field('id,cat_id,name,image')
->where('status', 'normal')
->where('cat_id', '<>', 15);
if ($catId > 0) {
$svcQuery->where('cat_id', $catId);
}
$svcRows = $svcQuery->order('weigh desc,sales desc,id asc')->select();
$svcIds = array_column($svcRows, 'id');
$galleryMap = [];
if (!empty($svcIds)) {
$gQuery = Db::name('system_gallery')
->where('service_id', 'in', $svcIds)
->where('status', 'normal');
if ($keyword !== '') {
$gQuery->where('title', 'like', '%' . $keyword . '%');
}
$gRows = $gQuery->order('weigh desc,id asc')->select();
foreach ($gRows as $gr) {
$sid = (int)$gr['service_id'];
$rawImg = trim((string)$gr['image']);
if ($rawImg === '') {
continue;
}
$galleryMap[$sid][] = [
'id' => (int)$gr['id'],
'service_id' => $sid,
'spec_id' => (int)$gr['spec_id'],
'title' => (string)$gr['title'],
'image_url' => strpos($rawImg, 'http') === 0 ? $rawImg : cdnurl($rawImg, true),
];
}
}
// 若个别服务尚未写入 fa_system_gallery,回退读取真实规格与服务主图
$services = [];
foreach ($svcRows as $sr) {
$sid = (int)$sr['id'];
$items = isset($galleryMap[$sid]) ? $galleryMap[$sid] : [];
if (empty($items) && $keyword === '') {
$defaultImg = trim((string)$sr['image']);
if ($defaultImg !== '') {
$fullDef = strpos($defaultImg, 'http') === 0 ? $defaultImg : cdnurl($defaultImg, true);
$spList = Db::name('service_spec')
->where('service_id', $sid)
->where('status', 'normal')
->order('weigh desc,id asc')
->select();
if (!empty($spList)) {
foreach ($spList as $sp) {
$spImg = !empty($sp['image']) ? trim((string)$sp['image']) : '';
$items[] = [
'id' => 100000 + (int)$sp['id'],
'service_id' => $sid,
'spec_id' => (int)$sp['id'],
'title' => (string)$sp['name'],
'image_url' => $spImg !== '' ? (strpos($spImg, 'http') === 0 ? $spImg : cdnurl($spImg, true)) : $fullDef,
];
}
} else {
$items[] = [
'id' => 200000 + $sid,
'service_id' => $sid,
'spec_id' => 0,
'title' => (string)$sr['name'],
'image_url' => $fullDef,
];
}
}
}
if ($keyword !== '' && empty($items) && mb_strpos((string)$sr['name'], $keyword) === false) {
continue;
}
if (!empty($items)) {
$services[] = [
'id' => $sid,
'cat_id' => (int)$sr['cat_id'],
'name' => (string)$sr['name'],
'count' => count($items),
'items' => $items,
];
}
}
$this->success('', [
'cats' => $cats,
'current_cat_id' => $catId,
'services' => $services,
]);
}
}