Files
zyt/server/app/common/service/qywx/QywxPromotionWidgetService.php
T
2026-08-25 09:35:47 +08:00

395 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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
);
}
}