395 lines
17 KiB
PHP
395 lines
17 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\common\service\qywx;
|
||
|
||
use InvalidArgumentException;
|
||
|
||
/**
|
||
* 获客助手公开浮窗配置与脚本。
|
||
*
|
||
* 管理端输入严格校验;数据库中的未知版本或损坏配置一律回退为关闭状态。
|
||
*/
|
||
class QywxPromotionWidgetService
|
||
{
|
||
private const VERSION = 1;
|
||
|
||
private const TEMPLATES = ['bubble', 'pill', 'card', 'message', 'edge', 'bar'];
|
||
|
||
private const POSITIONS = ['bottom-right', 'bottom-left'];
|
||
|
||
/** @return array<string, mixed> */
|
||
public static function defaults(): array
|
||
{
|
||
return [
|
||
'v' => self::VERSION,
|
||
'enabled' => false,
|
||
'template' => 'bubble',
|
||
'position' => 'bottom-right',
|
||
'title' => '专属顾问在线',
|
||
'subtitle' => '点击添加企业微信,获取一对一服务',
|
||
'button_text' => '立即咨询',
|
||
'primary_color' => '#139A8C',
|
||
'bottom_offset' => 28,
|
||
'show_mobile' => true,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
* @throws InvalidArgumentException
|
||
*/
|
||
public static function fromInput(mixed $input): array
|
||
{
|
||
if (!is_array($input)) {
|
||
throw new InvalidArgumentException('浮窗配置格式无效');
|
||
}
|
||
|
||
$defaults = self::defaults();
|
||
$version = self::integerValue(self::inputValue($input, 'v', self::VERSION), '配置版本');
|
||
if ($version !== self::VERSION) {
|
||
throw new InvalidArgumentException('不支持的浮窗配置版本');
|
||
}
|
||
|
||
$template = self::textValue(self::inputValue($input, 'template', $defaults['template']), '模板', 1, 20);
|
||
if (!in_array($template, self::TEMPLATES, true)) {
|
||
throw new InvalidArgumentException('浮窗模板无效');
|
||
}
|
||
|
||
$position = self::textValue(self::inputValue($input, 'position', $defaults['position']), '位置', 1, 20);
|
||
if (!in_array($position, self::POSITIONS, true)) {
|
||
throw new InvalidArgumentException('浮窗位置无效');
|
||
}
|
||
|
||
$color = strtoupper(trim(self::stringValue(
|
||
self::inputValue($input, 'primary_color', $defaults['primary_color']),
|
||
'主题色'
|
||
)));
|
||
if (preg_match('/^#[0-9A-F]{6}$/D', $color) !== 1) {
|
||
throw new InvalidArgumentException('主题色必须是 #RRGGBB 格式');
|
||
}
|
||
|
||
$bottomOffset = self::integerValue(
|
||
self::inputValue($input, 'bottom_offset', $defaults['bottom_offset']),
|
||
'底部距离'
|
||
);
|
||
if ($bottomOffset < 16 || $bottomOffset > 160) {
|
||
throw new InvalidArgumentException('底部距离必须在 16-160 之间');
|
||
}
|
||
|
||
return [
|
||
'v' => self::VERSION,
|
||
'enabled' => self::booleanValue(self::inputValue($input, 'enabled', $defaults['enabled']), '启用状态'),
|
||
'template' => $template,
|
||
'position' => $position,
|
||
'title' => self::textValue(self::inputValue($input, 'title', $defaults['title']), '标题', 1, 24),
|
||
'subtitle' => self::textValue(self::inputValue($input, 'subtitle', $defaults['subtitle']), '副标题', 0, 48),
|
||
'button_text' => self::textValue(
|
||
self::inputValue($input, 'button_text', $defaults['button_text']),
|
||
'按钮文案',
|
||
1,
|
||
12
|
||
),
|
||
'primary_color' => $color,
|
||
'bottom_offset' => $bottomOffset,
|
||
'show_mobile' => self::booleanValue(
|
||
self::inputValue($input, 'show_mobile', $defaults['show_mobile']),
|
||
'移动端展示状态'
|
||
),
|
||
];
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public static function decode(mixed $stored): array
|
||
{
|
||
if (!is_string($stored) || trim($stored) === '') {
|
||
return self::defaults();
|
||
}
|
||
|
||
try {
|
||
$decoded = json_decode($stored, true, 16, JSON_THROW_ON_ERROR);
|
||
if (!is_array($decoded)) {
|
||
return self::defaults();
|
||
}
|
||
foreach (array_keys(self::defaults()) as $key) {
|
||
if (!array_key_exists($key, $decoded)) {
|
||
return self::defaults();
|
||
}
|
||
}
|
||
|
||
return self::fromInput($decoded);
|
||
} catch (\Throwable) {
|
||
return self::defaults();
|
||
}
|
||
}
|
||
|
||
/** @param array<string, mixed> $config */
|
||
public static function encode(array $config): string
|
||
{
|
||
return self::jsonForScript(self::fromInput($config));
|
||
}
|
||
|
||
/**
|
||
* 生成可直接跨站安装的完整脚本,点击后直达当前方案的企业微信官方链接。
|
||
*
|
||
* @param array<string, mixed> $config
|
||
*/
|
||
public static function renderScript(string $key, string $targetUrl, array $config, bool $poolEnabled = true): string
|
||
{
|
||
$config = self::fromInput($config);
|
||
if (!$poolEnabled) {
|
||
$config['enabled'] = false;
|
||
}
|
||
|
||
$jsonKey = self::jsonForScript($key);
|
||
$jsonTarget = self::jsonForScript($targetUrl);
|
||
$jsonConfig = self::jsonForScript($config);
|
||
|
||
return <<<JS
|
||
(function(w,d){
|
||
'use strict';
|
||
var key={$jsonKey},targetPath={$jsonTarget},config={$jsonConfig},scriptNode=d.currentScript||null;
|
||
var targetUrl=resolveTargetUrl(targetPath);
|
||
var registry=w.WecomPromotion=w.WecomPromotion||{};
|
||
var previous=registry[key];
|
||
if(previous&&previous.__widgetVersion===1&&typeof previous.destroy==='function'){
|
||
previous.destroy();
|
||
}
|
||
var root=null,mediaQuery=null,readyHandler=null,destroyed=false,manuallyHidden=false,api=null;
|
||
var rootId='wecom-promotion-widget-'+key;
|
||
var selector='[data-wecom-promotion="'+key+'"],.wecom-promotion-link[data-pool="'+key+'"]';
|
||
|
||
function findScriptNode(){
|
||
if(scriptNode&&scriptNode.src){return scriptNode;}
|
||
var scripts=d.getElementsByTagName('script');
|
||
var marker='/api/qywx-promotion/js/'+key;
|
||
for(var index=scripts.length-1;index>=0;index--){
|
||
if((scripts[index].src||'').indexOf(marker)!==-1){scriptNode=scripts[index];return scriptNode;}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function resolveTargetUrl(value){
|
||
if(/^https?:\/\//i.test(value)){return value;}
|
||
var node=findScriptNode();
|
||
if(node&&node.src&&typeof w.URL==='function'){
|
||
try{return new w.URL(value,node.src).href;}catch(error){}
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function openPromotion(){
|
||
if(targetUrl){w.location.assign(targetUrl);}
|
||
}
|
||
|
||
function handleDocumentClick(event){
|
||
var path=typeof event.composedPath==='function'?event.composedPath():[];
|
||
var node=null;
|
||
for(var index=0;index<path.length;index++){
|
||
var candidate=path[index];
|
||
if(candidate&&candidate.nodeType===1&&candidate.matches&&candidate.matches(selector)){node=candidate;break;}
|
||
}
|
||
var target=event.target;
|
||
if(!node){node=target&&target.closest?target.closest(selector):null;}
|
||
if(!node){return;}
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
openPromotion();
|
||
}
|
||
|
||
function isMobileHidden(){
|
||
return config.show_mobile===false&&mediaQuery&&mediaQuery.matches;
|
||
}
|
||
|
||
function applyVisibility(){
|
||
if(root){root.hidden=manuallyHidden||isMobileHidden();}
|
||
}
|
||
|
||
function handleViewportChange(){
|
||
applyVisibility();
|
||
}
|
||
|
||
function appendText(parent,tag,className,value){
|
||
var node=d.createElement(tag);
|
||
node.className=className;
|
||
node.textContent=value;
|
||
parent.appendChild(node);
|
||
return node;
|
||
}
|
||
|
||
function mount(){
|
||
if(destroyed||root||!config.enabled||!d.body){return;}
|
||
var stale=d.getElementById(rootId);
|
||
if(stale&&stale.parentNode){stale.parentNode.removeChild(stale);}
|
||
|
||
root=d.createElement('div');
|
||
root.id=rootId;
|
||
root.className='wcp-host wcp-host-'+config.position+' wcp-host-'+config.template;
|
||
root.setAttribute('data-wecom-promotion-widget',key);
|
||
|
||
var surface=root.attachShadow?root.attachShadow({mode:'open'}):root;
|
||
var style=d.createElement('style');
|
||
var nonceNode=findScriptNode();
|
||
var nonce=nonceNode?(nonceNode.nonce||nonceNode.getAttribute('nonce')||''):'';
|
||
if(nonce){style.setAttribute('nonce',nonce);}
|
||
var hostRules='position:fixed;z-index:2147483000;right:20px;bottom:calc('+config.bottom_offset+'px + env(safe-area-inset-bottom, 0px));max-width:calc(100vw - 32px);pointer-events:none;--wcp-primary:'+config.primary_color+';font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#fff;line-height:1.4;-webkit-font-smoothing:antialiased';
|
||
style.textContent=':host{'+hostRules+'}.wcp-host{'+hostRules+'}' +
|
||
':host(.wcp-host-bottom-left){right:auto;left:20px}.wcp-host-bottom-left{right:auto;left:20px}' +
|
||
':host(.wcp-host-edge.wcp-host-bottom-right){right:0}.wcp-host-edge.wcp-host-bottom-right{right:0}' +
|
||
':host(.wcp-host-edge.wcp-host-bottom-left){right:auto;left:0}.wcp-host-edge.wcp-host-bottom-left{right:auto;left:0}' +
|
||
':host([hidden]){display:none!important}.wcp-host[hidden]{display:none!important}.wcp-root,.wcp-root *{box-sizing:border-box}.wcp-root{pointer-events:none}' +
|
||
'.wcp-button{pointer-events:auto;position:relative;display:flex;align-items:center;gap:12px;margin:0;border:0;cursor:pointer;color:#fff;background:var(--wcp-primary);font:inherit;text-align:left;box-shadow:0 14px 38px rgba(18,48,46,.24);transition:transform .2s ease,box-shadow .2s ease;appearance:none;-webkit-appearance:none}' +
|
||
'.wcp-button:hover{transform:translateY(-2px);box-shadow:0 18px 44px rgba(18,48,46,.3)}.wcp-button:active{transform:translateY(0)}.wcp-button:focus-visible{outline:3px solid rgba(255,255,255,.96);outline-offset:3px}' +
|
||
'.wcp-icon{display:flex;flex:0 0 auto;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;background:rgba(255,255,255,.18);font-size:17px;font-weight:800}' +
|
||
'.wcp-copy{display:flex;min-width:0;flex-direction:column}.wcp-title{font-size:15px;font-weight:750;line-height:1.25}.wcp-subtitle{margin-top:2px;max-width:240px;font-size:12px;line-height:1.4;opacity:.86}' +
|
||
'.wcp-cta{flex:0 0 auto;padding:7px 11px;border-radius:999px;background:#fff;color:var(--wcp-primary);font-size:12px;font-weight:750;white-space:nowrap}' +
|
||
'.wcp-bubble .wcp-button{width:66px;height:66px;justify-content:center;padding:0;border-radius:50%}.wcp-bubble .wcp-icon{width:46px;height:46px;font-size:19px}.wcp-bubble .wcp-copy,.wcp-bubble .wcp-cta{position:absolute;right:76px;visibility:hidden;opacity:0;transform:translateX(8px);transition:opacity .18s ease,transform .18s ease;pointer-events:none}' +
|
||
'.wcp-bottom-left.wcp-bubble .wcp-copy,.wcp-bottom-left.wcp-bubble .wcp-cta{right:auto;left:76px}.wcp-bubble .wcp-copy{bottom:27px;width:220px;padding:11px 13px;border-radius:12px;background:#173f3b;box-shadow:0 12px 30px rgba(0,0,0,.2)}.wcp-bubble .wcp-cta{bottom:-1px;padding:5px 10px}' +
|
||
'.wcp-bubble .wcp-button:hover .wcp-copy,.wcp-bubble .wcp-button:hover .wcp-cta,.wcp-bubble .wcp-button:focus-visible .wcp-copy,.wcp-bubble .wcp-button:focus-visible .wcp-cta{visibility:visible;opacity:1;transform:translateX(0)}' +
|
||
'.wcp-pill .wcp-button{min-height:58px;padding:9px 12px;border-radius:999px}.wcp-pill .wcp-subtitle{display:none}' +
|
||
'.wcp-card .wcp-button{width:min(340px,calc(100vw - 40px));padding:15px;border-radius:18px}.wcp-card .wcp-icon{width:46px;height:46px}.wcp-card .wcp-copy{flex:1}.wcp-card .wcp-cta{border-radius:10px}' +
|
||
'.wcp-message .wcp-button{width:min(330px,calc(100vw - 40px));padding:13px 14px;border-radius:18px 18px 4px 18px}.wcp-bottom-left.wcp-message .wcp-button{border-radius:18px 18px 18px 4px}.wcp-message .wcp-copy{flex:1}.wcp-message .wcp-cta{padding:6px 9px}' +
|
||
'.wcp-edge .wcp-button{min-height:62px;max-width:270px;padding:10px 15px;border-radius:16px 0 0 16px}.wcp-bottom-left.wcp-edge .wcp-button{border-radius:0 16px 16px 0}.wcp-edge .wcp-subtitle{display:none}.wcp-edge .wcp-cta{padding:6px 9px}' +
|
||
'.wcp-bar .wcp-button{width:min(420px,calc(100vw - 40px));padding:11px 14px;border-radius:12px}.wcp-bar .wcp-copy{flex:1}.wcp-bar .wcp-icon{width:34px;height:34px}.wcp-bar .wcp-subtitle{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
|
||
'@media(max-width:767px){.wcp-host{max-width:calc(100vw - 24px)}.wcp-card .wcp-button,.wcp-message .wcp-button,.wcp-bar .wcp-button{width:calc(100vw - 40px)}.wcp-subtitle{max-width:180px}.wcp-card .wcp-cta,.wcp-message .wcp-cta{display:none}}' +
|
||
'@media(prefers-reduced-motion:reduce){.wcp-button,.wcp-bubble .wcp-copy,.wcp-bubble .wcp-cta{transition:none!important}}';
|
||
surface.appendChild(style);
|
||
|
||
var container=d.createElement('div');
|
||
container.className='wcp-root wcp-'+config.template+' wcp-'+config.position;
|
||
var button=d.createElement('button');
|
||
button.type='button';
|
||
button.className='wcp-button';
|
||
button.setAttribute('aria-label',config.title+':'+config.button_text);
|
||
appendText(button,'span','wcp-icon','企');
|
||
var copy=d.createElement('span');
|
||
copy.className='wcp-copy';
|
||
appendText(copy,'strong','wcp-title',config.title);
|
||
if(config.subtitle!==''){appendText(copy,'span','wcp-subtitle',config.subtitle);}
|
||
button.appendChild(copy);
|
||
appendText(button,'span','wcp-cta',config.button_text);
|
||
button.addEventListener('click',function(event){
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
openPromotion();
|
||
});
|
||
container.appendChild(button);
|
||
surface.appendChild(container);
|
||
d.body.appendChild(root);
|
||
|
||
if(config.show_mobile===false&&typeof w.matchMedia==='function'){
|
||
mediaQuery=w.matchMedia('(max-width: 767px)');
|
||
if(mediaQuery.addEventListener){mediaQuery.addEventListener('change',handleViewportChange);}
|
||
else if(mediaQuery.addListener){mediaQuery.addListener(handleViewportChange);}
|
||
}
|
||
applyVisibility();
|
||
}
|
||
|
||
function show(){
|
||
if(destroyed||!config.enabled){return;}
|
||
manuallyHidden=false;
|
||
if(root){applyVisibility();return;}
|
||
if(d.body){mount();}
|
||
else if(!readyHandler){
|
||
readyHandler=function(){readyHandler=null;mount();};
|
||
d.addEventListener('DOMContentLoaded',readyHandler,{once:true});
|
||
}
|
||
}
|
||
|
||
function hide(){
|
||
manuallyHidden=true;
|
||
applyVisibility();
|
||
}
|
||
|
||
function destroy(){
|
||
if(destroyed){return;}
|
||
destroyed=true;
|
||
d.removeEventListener('click',handleDocumentClick,true);
|
||
if(readyHandler){d.removeEventListener('DOMContentLoaded',readyHandler);readyHandler=null;}
|
||
if(mediaQuery){
|
||
if(mediaQuery.removeEventListener){mediaQuery.removeEventListener('change',handleViewportChange);}
|
||
else if(mediaQuery.removeListener){mediaQuery.removeListener(handleViewportChange);}
|
||
mediaQuery=null;
|
||
}
|
||
if(root&&root.parentNode){root.parentNode.removeChild(root);}
|
||
root=null;
|
||
if(registry[key]===api){delete registry[key];}
|
||
}
|
||
|
||
d.addEventListener('click',handleDocumentClick,true);
|
||
api={open:openPromotion,show:show,hide:hide,destroy:destroy,config:config,__widgetVersion:1};
|
||
registry[key]=api;
|
||
if(config.enabled){show();}
|
||
})(window,document);
|
||
JS;
|
||
}
|
||
|
||
private static function booleanValue(mixed $value, string $label): bool
|
||
{
|
||
if (is_bool($value)) {
|
||
return $value;
|
||
}
|
||
if ($value === 1 || $value === '1') {
|
||
return true;
|
||
}
|
||
if ($value === 0 || $value === '0') {
|
||
return false;
|
||
}
|
||
|
||
throw new InvalidArgumentException($label . '必须是布尔值');
|
||
}
|
||
|
||
private static function inputValue(array $input, string $key, mixed $default): mixed
|
||
{
|
||
return array_key_exists($key, $input) ? $input[$key] : $default;
|
||
}
|
||
|
||
private static function integerValue(mixed $value, string $label): int
|
||
{
|
||
if (is_int($value)) {
|
||
return $value;
|
||
}
|
||
if (is_string($value) && preg_match('/^-?\d+$/D', $value) === 1) {
|
||
return (int) $value;
|
||
}
|
||
|
||
throw new InvalidArgumentException($label . '必须是整数');
|
||
}
|
||
|
||
private static function stringValue(mixed $value, string $label): string
|
||
{
|
||
if (!is_string($value)) {
|
||
throw new InvalidArgumentException($label . '必须是字符串');
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
private static function textValue(mixed $value, string $label, int $min, int $max): string
|
||
{
|
||
$value = self::stringValue($value, $label);
|
||
$value = preg_replace('/\s+/u', ' ', trim($value)) ?? '';
|
||
$length = mb_strlen($value);
|
||
if ($length < $min || $length > $max) {
|
||
throw new InvalidArgumentException(sprintf('%s长度必须在 %d-%d 个字符之间', $label, $min, $max));
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
private static function jsonForScript(mixed $value): string
|
||
{
|
||
return json_encode(
|
||
$value,
|
||
JSON_UNESCAPED_UNICODE
|
||
| JSON_UNESCAPED_SLASHES
|
||
| JSON_HEX_TAG
|
||
| JSON_HEX_AMP
|
||
| JSON_HEX_APOS
|
||
| JSON_HEX_QUOT
|
||
| JSON_THROW_ON_ERROR
|
||
);
|
||
}
|
||
}
|