Compare commits

...

45 Commits

Author SHA1 Message Date
a026adaec7 chore:表单样式修改 2026-09-01 09:24:09 +08:00
278d8c94f9 chore:表单样式修改表单浮于图片上方 2026-09-01 08:54:26 +08:00
f66b70c087 chore:推荐码分享按钮上移 2026-08-31 15:40:14 +08:00
c1407a84bb chore:H5登录正常 2026-08-29 18:22:10 +08:00
6ffbae0f33 chore:表单弹窗正常 2026-08-29 17:44:26 +08:00
f6038575cc chore:表单弹窗正常 2026-08-29 16:04:20 +08:00
5cdafb6f7f chore:推荐码立即分享跳转页面正常 2026-08-28 17:49:56 +08:00
7ebbdaf996 chore:微信小程序的表单多选可以垂直对齐 2026-08-28 14:13:32 +08:00
ecb0789780 chore:表单样式更新 2026-08-28 11:58:23 +08:00
e693e4273c chore:表单样式更新 2026-08-28 10:22:24 +08:00
e3171ef7ad chore:H5正常 2026-08-26 18:13:00 +08:00
3ca1ca8cd7 chore:推荐码样式新增 2026-08-26 11:44:39 +08:00
fcbbb05afe chore:表单正常 2026-08-25 18:06:35 +08:00
dd94e53d81 chore:表单提交正常 2026-08-25 17:39:03 +08:00
b8ff67aaa3 chore:提交次数 2026-08-25 11:18:43 +08:00
40a6f739d0 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221
# Conflicts:
#	.local.config.js
2026-08-24 09:56:36 +08:00
58a5a7d535 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp59 2026-08-24 09:31:08 +08:00
0e8db6f821 chore:PDF名字可以自定义 2026-08-22 16:04:33 +08:00
e6a45f6b7d chore:PDF有显示了 2026-08-22 15:55:00 +08:00
1b7cc4b679 chore:打开PDF右边新加一个分享按钮 2026-08-22 13:59:06 +08:00
876f639bb5 chore:红色按钮不乱飘了 2026-08-20 18:26:59 +08:00
02c9fa602e chore:购物车按钮正常 2026-08-20 17:51:48 +08:00
b5b27039ee feat(form): add captcha and form behavior support
实现表单行为相关功能,新增验证码输入模块,支持表单登录校验、提交次数限制、表单开放状态判断,补充多语言文案,优化空状态展示逻辑
2026-08-20 15:55:41 +08:00
8901e302e4 style: 修复配置文件缩进格式不一致问题
统一调整配置文件内所有对象的缩进样式,移除多余的空格,修正代码格式对齐问题,提升代码可读性。
2026-08-17 14:43:54 +08:00
68bcd3ae7c Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp2797
# Conflicts:
#	.local.config.js
2026-08-17 14:41:25 +08:00
9f89172829 chore: 2026-08-17 08:48:14 +08:00
ddd1ec3056 chore(gitignore): 补充 Trae IDE 临时文件的忽略规则
新增匹配根目录下所有以 .trae_ 开头的临时文件和目录,完善git忽略配置
2026-08-15 17:32:16 +08:00
d305268cb8 Merge branch 'dev/1.0' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-08-15 17:29:52 +08:00
1efb866e41 style(config): 格式化本地配置文件的缩进与空格
1. 统一所有配置项的缩进风格为4空格
2. 修正多余的尾随和冗余空格
3. 添加新增的5g-1测试配置项
4. 调整注释掉的配置项格式
2026-08-15 17:29:01 +08:00
b8182a0f97 chore: 2026-08-15 14:31:01 +08:00
37463a173c chore:一级分类的不选中的样式后台可以自定义 2026-08-15 10:37:55 +08:00
422ca8cdb5 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp2797 2026-08-15 09:53:01 +08:00
f92b1fb5b7 chore: 2026-08-15 09:52:30 +08:00
a0043418c8 chore: 2026-08-15 09:46:12 +08:00
927f763d0a chore:后台可以自定义配置一级分类选中后的字体颜色和字体大小 2026-08-15 09:28:15 +08:00
69ad814985 Merge remote-tracking branch 'remotes/origin/dev/1.0' into PR-1-ai-agent-link 2026-08-15 09:27:58 +08:00
92599453c2 chore:竖版视频可以正常显示正常大小比例 2026-08-14 15:10:03 +08:00
5d7facc94d chore:竖版视频可以正常在容器里 2026-08-14 15:03:23 +08:00
f6eb1fad11 Merge branch 'dev/1.0' into PR-1-ai-agent-link 2026-08-14 14:03:49 +08:00
83b253bced chore:表单鸿蒙做更改 2026-08-14 11:21:05 +08:00
19cb332645 refactor(ai-page): 统一页面路由管理并修复AI智能体路径
1.  将硬编码的页面路径替换为PAGE_KEY_MAP常量统一管理
2.  修正AI智能体页面路径为/pages_tool/ai-agent/index
3.  优化客服跳转逻辑,避免重复注入tabBar隐藏标记
4.  为AI聊天页面添加自定义组件占位配置
2026-08-14 09:36:04 +08:00
e5406a27ba chore:苹果ios表单显示正常,商品详情的视频可以支持竖版横板视频 2026-08-14 09:09:07 +08:00
ffa170ff13 feat(ai): PR-7 前端 ai-service 按 agent_id 路由 endpoint
背景:
- 后端 aikefu 和 ai-agent 两个独立 addon (PR-6 v2.0)
- 前端 ai-chat 之前所有 API endpoint 都 hardcode 到 /api/kefu/* + /ws/aikefu
- 5 个 agent (智能客服默认 + 4 个 AI智能体) 实际上无法被前端独立路由

改动:
- 新增 resolveAddonName(agentId) -> 'aikefu' / 'aiagent'
- 新增 resolveApiUrl(apiName) -> '/api/kefu/X' / '/api/aiagent/X'
- 新增 resolveWsPath() -> '/ws/aikefu' / '/ws/aiagent'

- 全量替换 sendMessage / sendHttpStream / sendStreamMessage / generateConversationId /
  getServiceStatus / getServiceConfig / clearConversation / getConversationHistory /
  chatWithEventSource / WebSocket connect URL:
  全部用 resolveApiUrl / resolveWsPath 动态计算

- setAgentId 跨 addon 切换时自动 disconnectWebSocket (路径变了)
- 新增 getAddonName() / getApiPrefix() 测试辅助方法

后端配套 PR-8+9:
- /api/aiagent/api/Chat/chat (HTTP, 含流式 SSE)
- /api/aiagent/api/Chat/{info,health,getHistory,clearConversation,createConversation}
- /ws/aiagent WebSocket 端点 (从 aikefu WebSocket 复制改名)

路由规则:
- agent_aikefu -> aikefu addon (老路径, 默认智能客服)
- 其他 agent (knowledge / quick_reply / sales / hr) -> aiagent addon (新路径)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-13 18:30:00 +08:00
8a95b0310d feat(ai-chat): 适配浮动按钮入口隐藏底部tabBar
实现浮动按钮跳转AI聊天页时自动隐藏底部tabBar,通过__dyu标记区分入口来源:
1. 浮动按钮点击时注入__dyu=FloatBtn来源标记
2. AI聊天页根据该标记临时隐藏tabBar并在卸载时恢复
3. 兼容旧store无对应mutation的场景
2026-08-12 18:18:19 +08:00
f656be789d feat(ai-agent): 新增AI智能体通用支持框架
1.  新增多语言文案,适配AI智能体通用场景
2.  新增AI智能体页面路由常量与跳转逻辑
3.  改造AI聊天页面支持动态agent_id和页面标题
4.  升级AI服务层,支持多agent_id自动传递
2026-08-12 15:12:00 +08:00
34 changed files with 5262 additions and 617 deletions

View File

@@ -21,7 +21,7 @@ const localDevConfig = ({
'2881': { // 防爆电器
uniacid: 2881,
domain: 'https://xcx6.aigc-quickapp.com/',
},
},
'2724': { // 生物菌肥
uniacid: 2724,
domain: 'https://xcx.aigc-quickapp.com/',
@@ -45,11 +45,11 @@ const localDevConfig = ({
'11-test': { // 测试平台
uniacid: 11,
domain: 'https://test.aigc-quickapp.com',
},
},
'18-test': { // 测试平台
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
'local-2': { // 测试平台
uniacid: 2,
domain: 'http://localhost:8050/',
@@ -59,15 +59,15 @@ const localDevConfig = ({
domain: 'http://192.168.197.1:8050/',
// domain: 'http://localhost:8050/',
},
'192.168.8.118-1': { // 测试平台
uniacid: 1,
domain: 'http://192.168.8.118:8050/',
// domain: 'http://localhost:8050/',
},
'192.168.8.118-1': { // 测试平台
uniacid: 1,
domain: 'http://192.168.8.118:8050/',
// domain: 'http://localhost:8050/',
},
'local-11': { // 测试平台
uniacid: 11,
domain: 'http://localhost:8050/',
},
},
'100': {
uniacid: 100,
domain: 'https://5g.aigc-quickapp.com/',
@@ -83,15 +83,15 @@ const localDevConfig = ({
'5g-11': {
uniacid: 11,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-206': {
uniacid: 206,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-165': { // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
uniacid: 165,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'47': { // 修改弹窗
uniacid: 47,
domain: 'https://5g.aigc-quickapp.com/',
@@ -99,27 +99,27 @@ const localDevConfig = ({
'145': { // 表单链接转义问题
uniacid: 145,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-213': { // 单图组点击链接无法跳转
uniacid: 213,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-4': { // 公告点击链接无法跳转
uniacid: 4,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-97': { // 大宗商品贸易,支持自定义字体大小、颜色
uniacid: 97,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-50': { // 燃气安全产品机器人,购物车无反应
uniacid: 50,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-237': { // 商品分类,没有立即咨询文字
uniacid: 237,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'2847': { //
uniacid: 2847,
domain: 'https://xcx.aigc-quickapp.com/',
@@ -132,38 +132,63 @@ const localDevConfig = ({
uniacid: 2485,
domain: 'https://xcx6.aigc-quickapp.com/',
},
'2821': { // 离型纸硅油纸
uniacid: 2821,
domain: 'https://xcx.aigc-quickapp.com/',
},
'18': { // 测试平台
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
'2': { // 测试平台
uniacid: 2,
domain: 'https://test.aigc-quickapp.com',
},
'48': { // 测试平台
uniacid: 48,
domain: 'https://test.aigc-quickapp.com',
},
'2821': { // 离型纸硅油纸
uniacid: 2821,
domain: 'https://xcx.aigc-quickapp.com/',
},
'18': { // 测试平台
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
'2': { // 测试平台
uniacid: 2,
domain: 'https://test.aigc-quickapp.com',
},
'48': { // 测试平台
uniacid: 48,
domain: 'https://test.aigc-quickapp.com',
},
'test-12': { // 户外鞋,测试优惠券
uniacid: 12,
domain: 'https://test.aigc-quickapp.com',
},
'248': { // 大宗商品贸易,支持自定义字体大小、颜色
uniacid: 248,
domain: 'https://5g.aigc-quickapp.com/',
},
'228': { // 商品分类,没有立即咨询文字
uniacid: 228,
domain: 'https://5g.aigc-quickapp.com/',
},
'2797': { //
uniacid: 2797,
domain: 'https://xcx.aigc-quickapp.com/',
},
},
'248': { // 大宗商品贸易,支持自定义字体大小、颜色
uniacid: 248,
domain: 'https://5g.aigc-quickapp.com/',
},
'228': { // 商品分类,没有立即咨询文字
uniacid: 228,
domain: 'https://5g.aigc-quickapp.com/',
},
'2797': { //气雾栽培机,视频组件安卓ios播放正常
uniacid: 2797,
domain: 'https://xcx.aigc-quickapp.com/',
},
'5g-95': { // 商品分类,没有立即咨询文字
uniacid: 95,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-160': { // 过瘤胃瘤胃保护,商品详情视频
uniacid: 160,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-268': { // 合成孔径雷达,一级分类背景字体颜色
uniacid: 268,
domain: 'https://5g.aigc-quickapp.com/',
},
'225-test': { // 测试平台
uniacid: 225,
domain: 'https://test.aigc-quickapp.com',
},
'192.168.8.118-321': { // 测试平台
uniacid: 321,
domain: 'http://192.168.8.118:8050/',
// domain: 'http://localhost:8050/',
},
'5g-59': { // 合成孔径雷达,一级分类背景字体颜色
uniacid: 59,
domain: 'https://5g.aigc-quickapp.com/',
},
// })['5g-237']; // 商品分类,没有立即咨询文字
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
@@ -177,7 +202,7 @@ const localDevConfig = ({
// })['2724']; // 生物菌肥
// })['2811']; // 选择要使用的环境配置
// })['local-11']; // 选择要使用的环境配置
})['2797']; // 本地
})['2811']; // 本地
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test

View File

@@ -118,6 +118,8 @@
.goods-video video {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
}
.goods-video .uni-video-cover {
@@ -1223,6 +1225,7 @@
}
.popup-layer {
position: relative;
background: #fff;
.head-wrap {
font-size: $font-size-toolbar;

View File

@@ -4,8 +4,56 @@ import store from '@/store/index.js'
import lang from './lang.js'
let currentConversationId = null;
// ====== AI Agent Generalization (customer service is the first agent) ======
// setAgentId updates currentAgentId; default is agent_aikefu.
// Any sendMessage / sendStreamMessage / getServiceConfig call passes agent_id automatically.
//
// PR-7: 按 agent_id 路由 endpoint
// - agent_aikefu (默认智能客服) -> 走 aikefu addon (/api/kefu/* + /ws/aikefu)
// - 其他 agent (knowledge / quick_reply / sales / hr) -> 走 ai-agent addon (/api/aiagent/* + /ws/aiagent)
// - 前端不关心后端两个 addon 之间的差异,只关心 agent_id 路由
export const DEFAULT_AGENT_ID = 'agent_aikefu';
let currentAgentId = DEFAULT_AGENT_ID;
export const CONVERSATION_KEY = 'ai_conversation_id';
/**
* PR-7: agent_id -> 后端 addon 路由映射
*
* 规则:
* - agent_aikefu (默认智能客服) -> aikefu addon (老路径 /api/kefu/*)
* - 其他 agent -> aiagent addon (新路径 /api/aiagent/*)
*
* 后端两个 addon 完全独立:
* - aikefu: lucky_aikefu_config / conversation / message
* - aiagent: lucky_ai_agent_config / conversation / message
*/
function resolveAddonName(agentId) {
if (!agentId || agentId === 'agent_aikefu') return 'aikefu';
return 'aiagent';
}
/**
* PR-7: agent_id -> API endpoint URL
* agent_aikefu -> '/api/kefu/...' (兼容老路径)
* 其他 -> '/api/aiagent/...' (ai-agent 新路径)
*/
function resolveApiUrl(apiName) {
var addon = resolveAddonName(currentAgentId);
if (addon === 'aikefu') {
return '/api/kefu/' + apiName;
}
return '/api/aiagent/' + apiName;
}
/**
* PR-7: WebSocket URL 路径(/ws/{addonName})
*/
function resolveWsPath() {
return '/ws/' + resolveAddonName(currentAgentId);
}
/**
* 获取域名配置参数(避免重复代码)
*/
@@ -35,12 +83,12 @@ const GUEST_ID_KEY = 'ai_guest_user_id';
function getUserId() {
const memberId = store.state.memberInfo?.id;
if (memberId) return memberId;
let guestId;
try {
guestId = uni.getStorageSync(GUEST_ID_KEY);
} catch (e) { /* ignore */ }
if (!guestId) {
guestId = 'guest_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10);
try {
@@ -50,6 +98,20 @@ function getUserId() {
return guestId;
}
/**
* 获取当前 AI Agent。
*/
function getAgentId() {
return currentAgentId || DEFAULT_AGENT_ID;
}
/**
* 设置当前 AI Agent。
*/
function setAgentId(agentId) {
if (agentId && typeof agentId === 'string') currentAgentId = agentId;
}
// #ifdef MP
// ================================
// WebSocket 长连接管理(微信小程序)
@@ -91,7 +153,9 @@ function ensureWebSocketConnected() {
return;
}
var wssUrl = 'wss://' + domain + '/ws/aikefu';
// PR-7: WebSocket 路径按当前 agent_id 路由 — agent_aikefu 走 /ws/aikefu,其他走 /ws/aiagent
var wsPath = resolveWsPath();
var wssUrl = 'wss://' + domain + wsPath;
wsConnected = false;
wsAuthenticated = false;
wsSocketTask = wx.connectSocket({ url: wssUrl, header: {} });
@@ -106,6 +170,7 @@ function ensureWebSocketConnected() {
wsSocketTask.send({
data: JSON.stringify({
action: 'auth',
agent_id: getAgentId(),
uniacid: _uniacid,
token: _token,
user_id: _userId
@@ -233,19 +298,35 @@ export default {
return uni.getStorageSync('uniacid') || Config.uniacid; // 从缓存中获取uniacid,或使用默认uniacid
},
/**
* PR-7: 获取当前 agent 对应的 addon 名称 (aikefu / aiagent)
*/
getAddonName() {
return resolveAddonName(currentAgentId);
},
/**
* PR-7: 获取当前 agent 对应的 API endpoint 前缀
*/
getApiPrefix() {
return resolveAddonName(currentAgentId) === 'aikefu' ? '/api/kefu' : '/api/aiagent';
},
/**
* 发送普通消息(blocking 模式)
* PR-7: URL 按 agent_id 路由
*/
async sendMessage(message, options = {}) {
try {
const params = {
url: '/api/kefu/chat',
url: resolveApiUrl('chat'),
data: {
agent_id: getAgentId(),
user_id: getUserId(),
stream: false,
inputs: {},
query: message,
response_mode: 'blocking', // 强制 blocking
response_mode: 'blocking',
user: getUserId(),
conversation_id: this.getConversationId() || '',
...getDomainParams()
@@ -268,7 +349,6 @@ export default {
} catch (error) {
console.error('[AI Service] AiChat API请求失败,详细错误:', error);
console.error('[AI Service] 错误堆栈:', error.stack);
// 打印更多信息帮助调试
console.error('[AI Service] store 状态:', {
memberInfo: store.state.memberInfo,
uniacid: this.getUniacid(),
@@ -284,7 +364,6 @@ export default {
*/
async sendStreamMessage(message, onChunk, onComplete) {
// #ifdef MP
// 先确保连接已就绪
try {
await ensureWebSocketConnected();
} catch (e) {
@@ -292,7 +371,6 @@ export default {
throw e;
}
// 设置当前消息回调
wsMessageContent = '';
wsMessageActive = true;
wsMessageOnChunk = onChunk || null;
@@ -305,6 +383,7 @@ export default {
wsSocketTask.send({
data: JSON.stringify({
action: 'chat',
agent_id: getAgentId(),
uniacid: this.getUniacid(),
query: message,
user_id: getUserId(),
@@ -315,13 +394,16 @@ export default {
});
// #endif
},
/**
* HTTP 流式请求(仅 H5 使用)
* PR-7: URL 按 agent_id 路由
*/
async sendHttpStream(message, onChunk, onComplete) {
// #ifdef H5
try {
const url = Config.baseUrl + `/api/kefu/chat`;
var chatUrl = resolveApiUrl('chat');
var url = Config.baseUrl + chatUrl;
const response = await fetch(url, {
method: 'POST',
headers: {
@@ -334,6 +416,7 @@ export default {
inputs: {},
query: message,
response_mode: 'streaming',
agent_id: getAgentId(),
user_id: getUserId(),
conversation_id: this.getConversationId() || '',
...getDomainParams()
@@ -356,9 +439,8 @@ export default {
buffer += decoder.decode(value, { stream: true });
// 按行分割,保留不完整的最后一行
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 未完成的行留到下次
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
@@ -369,14 +451,12 @@ export default {
const data = JSON.parse(jsonStr);
if (data.event === 'message') {
var text = data.answer || data.text || '';
// 兼容嵌套 data.data 格式
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
}
content += text;
if (onChunk) onChunk(text);
}
// 兼容两种层级:conversation_id 可能在顶层或 data.data 中
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) {
conversationId = cid;
@@ -392,7 +472,6 @@ export default {
}
}
// 处理最后残留的 buffer(如果有)
if (buffer.trim().startsWith('data:')) {
try {
const jsonStr = buffer.trim().slice(5);
@@ -479,11 +558,13 @@ export default {
/**
* 生成新会话(如需)
* PR-7: URL 按 agent_id 路由
*/
async generateConversationId() {
const params = {
url: '/api/kefu/createConversation',
url: resolveApiUrl('createConversation'),
data: {
agent_id: getAgentId(),
uniacid: this.getUniacid(),
token: store.state.token || 'test_token',
user_id: getUserId(),
@@ -503,11 +584,12 @@ export default {
/**
* 获取服务状态
* PR-7: URL 按 agent_id 路由
*/
async getServiceStatus() {
try {
const response = await http.sendRequest({
url: '/api/kefu/health',
url: resolveApiUrl('health'),
async: false,
data: {}
});
@@ -527,12 +609,14 @@ export default {
/**
* 获取服务配置信息
* PR-7: URL 按 agent_id 路由
*/
async getServiceConfig() {
try {
const params = {
url: '/api/kefu/info',
url: resolveApiUrl('info'),
data: {
agent_id: getAgentId(),
uniacid: this.getUniacid(),
token: store.state.token || 'test_token',
user_id: getUserId(),
@@ -542,7 +626,7 @@ export default {
header: {
'Content-Type': 'application/json'
}
};
};
const response = await http.sendRequest({
...params,
async: false,
@@ -557,12 +641,14 @@ export default {
/**
* 清除会话历史(调用后端接口)
* PR-7: URL 按 agent_id 路由
*/
async clearConversation(conversationId) {
try {
const response = await http.sendRequest({
url: '/api/kefu/clear-conversation',
url: resolveApiUrl('clear-conversation'),
data: {
agent_id: getAgentId(),
conversation_id: conversationId,
...getDomainParams()
},
@@ -577,6 +663,7 @@ export default {
/**
* 获取会话历史
* PR-7: URL 按 agent_id 路由
*/
async getConversationHistory(params = {}) {
try {
@@ -585,6 +672,7 @@ export default {
}
console.log('[AI History] 请求历史, conversation_id:', params.conversation_id, 'limit:', params.limit);
const requestData = {
agent_id: params.agent_id || getAgentId(),
uniacid: params.uniacid || this.getUniacid(),
conversation_id: params.conversation_id,
user_id: params.user_id || getUserId(),
@@ -595,7 +683,7 @@ export default {
...getDomainParams()
};
const response = await http.sendRequest({
url: '/api/kefu/getHistory',
url: resolveApiUrl('getHistory'),
data: requestData,
header: {
'Content-Type': 'application/json'
@@ -625,6 +713,7 @@ export default {
/**
* [H5 Only] EventSource 流式聊天(备用)
* PR-7: URL 按 agent_id 路由
*/
chatWithEventSource(message, conversationId = '', onMessage, onComplete, onError) {
// #ifdef H5
@@ -632,6 +721,7 @@ export default {
window.currentEventSource.close();
}
const paramsObj = {
agent_id: getAgentId(),
uniacid: this.getUniacid(),
user_id: getUserId(),
member_id: getUserId(),
@@ -642,7 +732,8 @@ export default {
...getDomainParams()
};
const params = new URLSearchParams(paramsObj);
const url = `${Config.baseUrl}/api/kefu/chat?${params.toString()}`;
var chatUrl = resolveApiUrl('chat');
const url = `${Config.baseUrl}${chatUrl}?${params.toString()}`;
try {
const eventSource = new EventSource(url);
window.currentEventSource = eventSource;
@@ -687,7 +778,6 @@ export default {
*/
async chatWithFetchStream(message, conversationId = '', onMessage, onComplete, onError) {
// #ifdef H5
// 实际逻辑已在 sendHttpStream 中实现,此处可复用或留空
return this.sendHttpStream(message, onMessage, (res) => {
onComplete?.({ message: res.content, conversation_id: res.conversation_id });
});
@@ -711,4 +801,26 @@ export default {
// H5 无 WebSocket 长连接,无需操作
},
// #endif
};
/**
* Set current AI Agent (default aikefu; link system passes agent_id).
* PR-7: 切换 agent 时, 如果跨 addon 则断开 WebSocket 重连 (路径变了)
* @param {string} agentId
*/
setAgentId(agentId) {
var oldAddon = resolveAddonName(currentAgentId);
setAgentId(agentId);
var newAddon = resolveAddonName(currentAgentId);
if (oldAddon !== newAddon) {
try { disconnectWebSocket(); } catch (e) { /* ignore */ }
}
},
/**
* Get current AI Agent.
* @returns {string}
*/
getAgentId() {
return getAgentId();
},
};

View File

@@ -4,6 +4,7 @@
*/
import IMAGE_CONFIG from './image-config.js';
import Platform from './platform.js';
import PAGE_KEY_MAP from './util.js';
export class CustomerService {
constructor(vueInstance, externalConfig = null) {
@@ -118,6 +119,16 @@ export class CustomerService {
/**
* 跳转到 AI智能客服页面(AiChat)
*
* 注意:不在此处注入 __dyu=FloatBtn。
* 该标记原本是 diy-float-btn.vue 用来告诉 ai-chat "我是从浮动按钮来的,请隐藏底部 tabBar"。
* 此方法是 customer-service.js 的**通用 AI 客服入口**,被多处复用:
* - hover-nav(悬浮客服按钮)
* - 商品详情页 / 会员中心 / 个人中心 等的"联系客服"按钮
* - DIY 浮动按钮(diy-float-btn 内部已自行注入 __dyu=FloatBtn,这里再注入会重复)
* 若在此处统一注入,会让所有 AI 客服入口都隐藏底部 tabBar,语义错误且侵入性过强。
* hover-nav 需要隐藏 tabBar 的场景,由 hover-nav.vue#handleUnifiedKefuClick
* 自行注入 __dyu=FloatBtn(与 diy-float-btn.vue#onFloatBtnClick 一致)。
*/
openAiChatService() {
try {
@@ -127,7 +138,7 @@ export class CustomerService {
}
// ✅ 修正路径:必须与 pages.json 中注册的路径一致
const aiChatUrl = '/pages_tool/ai-chat/index';
const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
// ✅ 使用 navigateTo 保留返回栈(体验更好)
uni.navigateTo({
@@ -551,6 +562,9 @@ export class CustomerService {
/**
* 打开 AI 智能助手
*
* 注意:不在此处注入 __dyu=FloatBtn,理由同 openAiChatService()。
* 由调用方(hover-nav.vue 等)按需注入。
*/
openAIChat() {
try {
@@ -558,7 +572,7 @@ export class CustomerService {
this.vm.setAiUnreadCount(0);
}
const aiChatUrl = '/pages_tool/ai-chat/index';
const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
uni.navigateTo({
url: aiChatUrl,

View File

@@ -71,6 +71,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称
@@ -264,6 +268,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称
@@ -456,6 +464,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称
@@ -648,6 +660,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称
@@ -840,6 +856,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称
@@ -1032,6 +1052,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称
@@ -1224,6 +1248,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称
@@ -1416,6 +1444,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称

View File

@@ -139,6 +139,7 @@ export const CONTACT_PAGE_URL = '/pages_tool/contact/contact';
export const MEMBER_PAGE_URL = '/pages_tool/member/index';
export const LOGIN_PAGE_URL = '/pages_tool/login/login';
export const AI_CHAT_PAGE_URL = '/pages_tool/ai-chat/index';
export const AI_AGENT_PAGE_URL = '/pages_tool/ai-agent/index'; // AI 智能体入口页(智能客服是首个 agent)
// page_key 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径)
// 前端组织页面(重命名路径、分包调整)时只需要修改此映射表
@@ -1203,6 +1204,23 @@ export default {
* @param {Object} link
*/
diyRedirectTo(link) {
// ---------- AI Agent 通用链接(智能客服是首个 agent;新增 AI 销售/HR 等不用改这段) ----------
// 放在 stale-uniacid 与 resolveLinkUrl 之前:AI Agent 是纯内部路径,走后续 webview 分支会被错判。
if (link && (link.name === 'AI_AGENT' || link.kind === 'AI_AGENT')) {
const agentId = (link.page_params && link.page_params.agent_id) || 'agent_aikefu';
const title = link.title || 'AI 智能体';
// 透传来源标记:浮动按钮点击时 __dyu=FloatBtn,目标页据此隐藏底部 tabBar
// (diy-float-btn.vue#onFloatBtnClick 已写入 page_params.__dyu)
const dyuEntry = (link.page_params && link.page_params.__dyu) || '';
return this.redirectTo(AI_AGENT_PAGE_URL, {
agent_id: agentId,
title: encodeURIComponent(title),
enable_set_title: link.enable_set_title ? 1 : 0,
...(dyuEntry ? { __dyu: dyuEntry } : {}),
});
}
console.log(`-----> diyRedirectTo`, JSON.stringify(link));
//if (link == null || Object.keys(link).length == 1) return;
@@ -1626,59 +1644,118 @@ export default {
},
/**
* 获取小程序分享内容数据
*
* 重要:本函数保证永远 resolve(绝不 reject)。
* 原因:外部 8 处调用(category.vue / diy.js / promote_code.vue / contact.vue 等)
* 全部只写了 .then(res => {}) 没有 .catch(),一旦 reject 就会变成 Unhandled Promise Rejection,
* 微信小程序框架会把它包成 Error: MiniProgramError 并以红框形式打印到控制台,污染日志且易被
* 误判为业务崩溃。后台未配置某路径的分享(data:null)、接口返回 code<0、甚至 HTTP 网络失败,
* 全部退化为"从传入 path 构造最小分享对象",保证调用方始终能拿到可消费的结构。
*/
getMpShare(path) {
//如果没有特别指定 则获取当前页面的路由
// 如果没有特别指定 则获取当前页面的路由
if (!path) {
let route = this.getCurrentRoute();
path = route.path;
if (path == '/pages_tool/member/index') {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
resolve({})
});
}
}
return new Promise((resolve, reject) => {
// 从传入 path 生成的兜底分享(后台未配置 / 接口异常时使用)
const buildFallback = (p) => {
const fallbackPath = p || '/pages/index/index';
const fallback = {
title: '小程序分享',
path: fallbackPath,
imageUrl: ''
};
const appMessage = {
title: fallback.title,
path: fallback.path,
imageUrl: fallback.imageUrl,
success: () => {},
fail: () => {}
};
const query = (fallback.path.indexOf('?') > 0) ? fallback.path.split('?')[1] : '';
const timeLine = {
title: fallback.title,
query: fallback.path,
imageUrl: fallback.imageUrl
};
return { appMessage, timeLine, _fallback: true, query };
};
// 通用构建:从后端返回的 {title,path,imageUrl} 拼成标准结构
const buildFromConfig = (shareConfig) => {
const appMessage = {
title: shareConfig.title,
path: shareConfig.path,
imageUrl: shareConfig.imageUrl || '',
success: () => {},
fail: () => {}
};
const query = (shareConfig.path && shareConfig.path.indexOf('?') > 0)
? shareConfig.path.split('?')[1]
: '';
const timeLine = {
title: shareConfig.title,
query: shareConfig.path,
imageUrl: shareConfig.imageUrl || ''
};
return { appMessage, timeLine, query };
};
const fallback = buildFallback(path);
return new Promise((resolve) => {
Http.sendRequest({
url: "/weapp/api/weapp/share",
data: {
path: path
},
success: res => {
if (res.code >= 0) {
let shareConfig = res.data.data;
if (shareConfig) {
//分享给好友
let appMessageData = {
title: shareConfig.title,
path: shareConfig.path,
imageUrl: shareConfig.imageUrl,
success: res => {
},
fail: res => {
try {
if (!res || typeof res !== 'object') {
resolve(fallback);
return;
}
if (res.code >= 0) {
// 响应可能是两种形态:
// A) { code, message, data: { title, path, imageUrl } }
// B) { code, message, permission:{...}, data: null }
// 统一安全解包:外层 payload 可能在 res.data 里,也可能直接在 res 里
const outer = res.data !== undefined ? res.data : res;
let shareConfig = null;
if (outer && typeof outer === 'object') {
// 后端在 outer 内嵌真正的配置对象(A 形态):outer.data = {title, path, ...}
if (outer.data && typeof outer.data === 'object' && (outer.data.title || outer.data.path)) {
shareConfig = outer.data;
} else if (outer.title || outer.path) {
// 极少数后端版本把配置平铺在 outer 上(扁平形态)
shareConfig = outer;
}
}
//分享到朋友圈
let query = '';
if (shareConfig.path.indexOf('?') > 0) {
query = shareConfig.path.split('?')[1];
if (shareConfig) {
resolve(buildFromConfig(shareConfig));
} else {
// 后台未配置该路径(B 形态 / data=null / 空对象)→ 不 reject,直接兜底
// 防止 8 处无 catch 的调用方变成 Unhandled Promise Rejection
resolve(fallback);
}
let timeLineData = {
title: shareConfig.title,
query: shareConfig.path,
imageUrl: shareConfig.imageUrl,
}
resolve({
appMessage: appMessageData,
timeLine: timeLineData,
})
} else {
reject(res.data);
// 业务 code<0:视为"无可用分享配置",兜底 resolve,不抛错
resolve(fallback);
}
} else {
reject(res.data)
} catch (e) {
// 任何解析异常(响应结构异常/字段缺失/后端改版)一律兜底,绝不 reject
console.warn('[UTIL] getMpShare parse error, use fallback:', e && e.message ? e.message : e);
resolve(fallback);
}
}
},
fail: () => {
// 网络失败:同样兜底(之前这里没写 fail 回调,Promise 会 hang 住)
resolve(fallback);
},
complete: () => {}
});
})
},

2090
common/js/util.js.bak Normal file

File diff suppressed because it is too large Load Diff

92
common/js/video-helper.js Normal file
View File

@@ -0,0 +1,92 @@
// iOS 微信小程序 <video> 兼容工具
// 问题背景:
// 1) 弹窗内的 <video> 用 autoplay 不可靠——popup 初始隐藏,autoplay 在隐藏态不触发,
// 等 popup 显示后也不会重新 autoplay,导致"安卓有时能播、iOS 不能";
// 2) iOS 对同一个 <video> 实例在 src 重绑 / 弹窗重开 / 播放结束后,不会自动重新加载,
// 需要靠 :key 重建实例或 seek(0) 才能重播;
// 3) iOS 微信不支持 webm / vp9 / hevc / ogv 等封装或编码,会直接黑屏;
// 4) H5 端(含微信内置浏览器)$nextTick 调 play() 太快,弹窗动画未完时浏览器内部
// 会自动 pause(),导致 "The play() request was interrupted by a call to pause()" 错误。
// 这里统一提供"格式检测"与"在视频可见后触发播放"的能力,供各视频组件复用。
//
// 重要:微信小程序的 <video>.play() 不要求用户手势(与 H5 不同),只要在视频元素
// 已经渲染、popup 已经显示后调用即可。因此请务必在 uni-popup 的 @change(show=true)
// 回调里调用本方法,而不是在 open() 里紧跟着 $nextTick 调用(那时元素尚未可见)。
// iOS 微信 <video> 已知不支持的封装/编码(仅按扩展名做保守判断,用于给出友好提示)
const UNSUPPORTED_EXT_ON_IOS = ['webm', 'ogv', 'ogg', 'mov', 'm4v', 'hevc', 'av1', '3gp'];
// 取 URL 中的小写扩展名(忽略查询参数与 hash)
export function getVideoExt(url) {
if (!url || typeof url !== 'string') return '';
const clean = url.split('?')[0].split('#')[0];
const idx = clean.lastIndexOf('.');
if (idx < 0) return '';
return clean.slice(idx + 1).toLowerCase();
}
// 该地址在 iOS 上是否很可能无法播放(基于扩展名的保守判断)
export function isLikelyUnsupportedOnIOS(url) {
const ext = getVideoExt(url);
if (!ext) return false;
return UNSUPPORTED_EXT_ON_IOS.indexOf(ext) !== -1;
}
// 在"视频已经渲染、popup 已经显示"的前提下触发播放。
// 必须在 uni-popup 的 @change(show=true) 回调里调用,此时 <video> 元素已可见,
// createVideoContext 才能拿到有效实例。
// videoId: <video> 的 id;instance: 当前组件/页面实例(组件内必传)。
export function playVideoInContext(videoId, instance) {
if (!videoId || !instance) return;
// H5 端需要更长延迟:等 uni-popup 动画播完(~350ms),否则浏览器内部会自动
// pause() 导致 "play() interrupted by pause()" 错误;小程序端 $nextTick 即可。
// #ifdef H5
const delay = 400;
// #endif
// #ifndef H5
const delay = 0;
// #endif
const doPlay = () => {
let ctx = null;
try {
ctx = uni.createVideoContext(videoId, instance);
} catch (e) {
console.error('[video-helper] createVideoContext failed:', e);
return;
}
if (!ctx) return;
try {
ctx.play();
} catch (e) {
console.error('[video-helper] play() failed, will retry:', e);
// H5 端首次 play() 常被浏览器自动 pause() 打断,延迟重试一次
// #ifdef H5
setTimeout(() => {
try { ctx.play(); } catch (retryErr) {
console.error('[video-helper] retry play() also failed:', retryErr);
}
}, 300);
// #endif
}
};
if (delay > 0) {
// H5:先等 nextTick(DOM 更新),再等 delay(弹窗动画完成)
if (typeof instance.$nextTick === 'function') {
instance.$nextTick(() => setTimeout(doPlay, delay));
} else {
setTimeout(doPlay, delay);
}
} else {
// 小程序端:nextTick 即可
if (typeof instance.$nextTick === 'function') {
instance.$nextTick(doPlay);
} else {
doPlay();
}
}
}

View File

@@ -784,10 +784,10 @@ export default {
padding: 26rpx 28rpx;
box-sizing: border-box;
position: relative;
background-color: #f5f5f5;
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
view {
color: #222222;
color: var(--diy-category-tree-normal-text-color, #222222);
width: 100%;
white-space: break-word;
overflow: hidden;
@@ -804,10 +804,10 @@ export default {
}
&.select {
background: #fff;
background: var(--diy-category-tree-select-bg-color, #fff);
view {
color: #333;
color: var(--diy-category-tree-select-text-color, #333);
font-weight: bold;
font-size: var(--diy-category-tree-select-size, 28rpx);
}
@@ -1512,11 +1512,11 @@ export default {
// 左侧分类栏样式
.tree-wrap .category-item {
padding: 28rpx 16rpx;
background-color: #fff;
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
border-radius: 0;
view {
color: #666;
color: var(--diy-category-tree-normal-text-color, #222222);
font-size: 26rpx;
line-height: 1.3;
}
@@ -1527,10 +1527,10 @@ export default {
}
&.select {
background: #fff;
background: var(--diy-category-tree-select-bg-color, #fff);
view {
color: var(--base-color, #FF6B35);
color: var(--diy-category-tree-select-text-color, #333);
font-weight: bold;
font-size: 28rpx;
}
@@ -1603,22 +1603,22 @@ export default {
.tree-wrap .category-item {
padding: 30rpx 16rpx;
background-color: #f5f5f5;
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
white-space: normal;
word-break: break-all;
view {
color: #333;
color: var(--diy-category-tree-normal-text-color, #222222);
font-size: 26rpx;
line-height: 1.5;
}
&.select {
background: #fff;
background: var(--diy-category-tree-select-bg-color, #fff);
border-bottom: 1rpx solid #f0f0f0;
view {
color: #333;
color: var(--diy-category-tree-select-text-color, #333);
font-weight: bold;
font-size: 28rpx;
}

View File

@@ -3,7 +3,7 @@
:class="{ left_top: value.bottomPosition == 1, right_top: value.bottomPosition == 2, left_bottom: value.bottomPosition == 3, right_bottom: value.bottomPosition == 4 }"
:style="style">
<block v-for="(item, index) in value.list" :key="index">
<view class="button-box" @tap.stop="$util.diyRedirectTo(item.link)"
<view class="button-box" @tap.stop="onFloatBtnClick(item)"
:style="{ width: value.imageSize + 'px', height: value.imageSize + 'px', fontSize: value.imageSize + 'px' }">
<image v-if="!item.iconType || item.iconType == 'img'" :src="$util.img(item.imageUrl)" mode="aspectFit"
show-menu-by-longpress />
@@ -44,7 +44,44 @@ export default {
componentRefresh: function (nval) { }
},
components: {},
methods: {},
methods: {
/**
* 浮动按钮点击处理:
* 在跳转前给 link 注入 __dyu=FloatBtn 来源标记,
* 让目标页面(尤其是 ai-chat)能识别"来自浮动按钮"从而隐藏底部 tabBar,
* 与"从底部导航栏点击进入时显示并高亮 tabBar"的场景并存。
*
* 为什么用 __dyu 这种 3 字符命名:
* - 双下划线 __ 前缀 + 3 位字母(dy-ui 框架) 是前端约定俗成的"系统内部保留字段"标志
* (如 __proto__, __esModule),业务方配置 link 时几乎不可能使用这种短命名 → 与业务参数零冲突
* - URL 中占用空间极小,不污染业务 query 字符串
* - 未来如需扩展其他组件的入口来源,可继续使用 __dyu 前缀(如 __dyu=Banner / __dyu=Nav)
*
* 实现要点:
* - 浅拷贝 item.link 避免污染原数据(可能被 v-for 复用)
* - 把标记写入 page_params,diyRedirectTo → redirectTo 会自动拼到 URL query,
* 目标页 onLoad(options) 可直接读取
* - 同时在 wap_url 末尾追加/合并 __dyu=FloatBtn 兜底:某些路径不走 page_params 拼接
*/
onFloatBtnClick(item) {
if (!item || !item.link) return;
const link = { ...item.link };
link.page_params = Object.assign({}, link.page_params || {}, {
__dyu: 'FloatBtn'
});
// 兜底:直接把标记拼到 wap_url query,防止 page_params 在某些分支未被透传
if (link.wap_url && typeof link.wap_url === 'string' && link.wap_url.indexOf('http') === -1) {
const sep = link.wap_url.indexOf('?') === -1 ? '?' : '&';
// 避免重复追加(精确匹配 __dyu= 而不是 __dyu,避免误伤可能的 __dyu_other)
if (link.wap_url.indexOf('__dyu=') === -1) {
link.wap_url = link.wap_url + sep + '__dyu=FloatBtn';
}
}
this.$util.diyRedirectTo(link);
},
},
computed: {
style() {
let style = {},

View File

@@ -6,24 +6,44 @@
否则会触发 MP 编译器注入的 propsToData 观察者(对应编译产物里
r=function(){var o=this.value.show_banner...this.$mp.data...})
报 "Cannot read properties of undefined (reading 'show_banner')"。 -->
<view v-if="value && value.show_banner && value.form_banner" class="form-banner" :style="bannerStyle">
<view v-if="value && value.show_banner && value.form_banner" class="form-banner is-card-overlap" :style="bannerStyle + bannerBleedStyle">
<image :src="$util.img(value.form_banner)" mode="widthFix" class="banner-img" />
</view>
<!-- 提示语 -->
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
<!-- 表单组件 -->
<ns-newform
:data="formComponents"
:customAttr="value"
:mode="mergedFormConfig.formLayout || 'horizontal'"
:formConfig="mergedFormConfig"
ref="nsNewform"
></ns-newform>
<!-- 提交按钮(外层 = form-bg + form-padding-x;按钮 = submit-bg + submit-color + input-height + input-radius)。
showSubmit=false 时由父级页面渲染自己的提交按钮并通过 ref 调用本组件的 verify()/getFormData()。 -->
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
<!-- 卡片容器:仅在后台配置了 banner 图片时启用,让表单浮在 banner 之上(参考左图布局)。
无 banner 时不渲染 .form-card,保持原有平铺行为,零外溢。 -->
<view v-if="hasBannerMode" class="form-card" :style="cardWrapStyle">
<!-- 提示语 -->
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
<!-- 表单组件 -->
<ns-newform
:data="formComponents"
:customAttr="value"
:mode="mergedFormConfig.formLayout || 'vertical'"
:formConfig="mergedFormConfig"
ref="nsNewform"
></ns-newform>
<!-- 提交按钮(外层 = form-bg + form-padding-x;按钮 = submit-bg + submit-color + input-height + input-radius)。
showSubmit=false 时由父级页面渲染自己的提交按钮并通过 ref 调用本组件的 verify()/getFormData()。 -->
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
</view>
</view>
<!-- 无 banner 时仍按旧布局直接渲染 tip / ns-newform / submit,避免影响其他使用方(diy-group / diy-article 等) -->
<template v-else>
<!-- 提示语 -->
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
<!-- 表单组件 -->
<ns-newform
:data="formComponents"
:customAttr="value"
:mode="mergedFormConfig.formLayout || 'vertical'"
:formConfig="mergedFormConfig"
ref="nsNewform"
></ns-newform>
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
</view>
</template>
</view>
</template>
@@ -58,6 +78,14 @@ export default {
type: Boolean,
default: true
},
// 父级容器左右内边距(rpx)。卡片模式下让 banner 图片突破这个内边距左右铺满(如 system-form-wrap 的 padding-x)。
// 仅在 hasBannerMode=true 时生效;默认 0(不铺满)。
// 引入原因:formdata.vue 的 .system-form-wrap 默认 padding: 0 50rpx,banner 图片被左右各 50rpx 收缩,
// 后台配 form_banner 后图片应跨越屏幕宽度,而不是被容器内缩。
containerInset: {
type: Number,
default: 0
},
// 父级可选地接管提交:传入函数 prop 时,verify 通过后由它处理 formData;
// 不传时走组件内置 defaultSubmit(兜底)。default 必须是 null,不能写成函数返回 null
onSubmit: {
@@ -108,6 +136,52 @@ export default {
padding: `0 ${this._num(this.mergedFormConfig && this.mergedFormConfig.bannerPaddingX, 0)}rpx`
});
},
// 后台配了 banner 图片时启用卡片浮起布局(与参考设计左图一致);无 banner 走原平铺,零外溢
hasBannerMode() {
const v = this.value || {};
return !!(v.show_banner && v.form_banner);
},
// 卡片容器内联样式:maring-top(负偏移让卡片向上压住 banner 底部)+ border-radius + padding-x + box-shadow + background。
// 数值由 formConfig 走 camelCase/snake_case 双读,缺省走默认值,使后端零配置时也能看到卡片浮起效果。
// 关键:卡片必须自带白底 + z-index(见 SCSS .form-card)—— 只有卡片自己的背景才能"盖住"banner 图片底部,
// 父级容器(如 formdata.vue 的 .system-form-wrap)是 banner 的祖先,其背景会被 banner 覆盖,无法形成浮起效果。
cardWrapStyle() {
const cfg = this.mergedFormConfig || {};
// 父级传了 containerInset(即 formdata.vue 场景),外层 system-form-wrap 已给左右 padding-x,这里默认 0 不重复
// 其他场景(diy-group / diy-article 等未传 containerInset),回退到 bannerPaddingX 或 28rpx 保证卡片模式视觉正常
const parentInset = this._num(this.containerInset, 0);
const defaultSidePad = parentInset > 0 ? 0 : this._num(cfg.bannerPaddingX, 28);
const sidePad = this._num(cfg.cardSidePad, defaultSidePad);
// overlap:负值,让卡片向上"压"住 banner 底部(与设计图一致)
const overlap = -Math.abs(this._num(cfg.cardOverlap, 48));
// 卡片圆角:缺省 24rpx(浮起卡片的圆角,比外层卡片的 16rpx 更明显,突出浮起感)
const radius = this._num(cfg.cardRadius, 24);
// 阴影:缺省中等阴影(与设计方案一致)—— 由本容器承担浮起感
const shadow = cfg.cardShadow || '0 8rpx 24rpx rgba(0,0,0,0.08)';
// 卡片底色:纯白,与设计方案一致;可由后台 formConfig.cardBgColor 覆盖
const bgColor = cfg.cardBgColor || '#ffffff';
return this.$util.objToStyle({
marginTop: overlap + 'rpx',
paddingLeft: sidePad + 'rpx',
paddingRight: sidePad + 'rpx',
borderRadius: radius + 'rpx',
boxShadow: shadow,
backgroundColor: bgColor
});
},
// Banner 容器破壳样式:仅在卡片模式下 + 父级传了 containerInset>0 时启用,让 banner 突破父容器 padding 跨越屏幕宽度。
// 计算逻辑:负 margin-left/right + 等量扩宽 width,使 banner 元素左/右边界对齐到屏幕边缘。
// 父级未传 containerInset 或传 0 时返回空样式,banner 保持原尺寸(零外溢)。
bannerBleedStyle() {
const inset = this._num(this.containerInset, 0);
if (!this.hasBannerMode || inset <= 0) return '';
const expand = inset * 2;
return this.$util.objToStyle({
marginLeft: -inset + 'rpx',
marginRight: -inset + 'rpx',
width: 'calc(100% + ' + expand + 'rpx)'
});
},
// 提示语样式:padding-x、font-size、color、bg-color、border-bottom(与后端 .form-preview-tip 对齐)
tipStyle() {
const cfg = this.mergedFormConfig || {};
@@ -149,7 +223,7 @@ export default {
width: widthPct + '%',
padding: `${submitPaddingY}rpx 0`,
color: cfg.submitColor || '#ffffff',
'background-color': cfg.submitBgColor || '#ff4544',
'background-color': cfg.submitBgColor || '#3b82f6',
'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`,
'font-size': `${this._num(cfg.labelFontSize, 28)}rpx`
});
@@ -229,7 +303,13 @@ export default {
tipFontSize: pick('tipFontSize', 'tip_font_size'),
tipColor: pick('tipColor', 'tip_color'),
tipBgColor: pick('tipBgColor', 'tip_bg_color'),
tipBorderColor: pick('tipBorderColor', 'tip_border_color')
tipBorderColor: pick('tipBorderColor', 'tip_border_color'),
// 卡片浮起(仅 show_banner && form_banner 启用)
cardOverlap: pick('cardOverlap', 'card_overlap'),
cardRadius: pick('cardRadius', 'card_radius'),
cardShadow: pick('cardShadow', 'card_shadow'),
cardSidePad: pick('cardSidePad', 'card_side_pad'),
cardBgColor: pick('cardBgColor', 'card_bg_color')
};
// 移除 undefined / 空字符串(保留 0/false)
Object.keys(base).forEach(function(k) {
@@ -237,9 +317,33 @@ export default {
delete base[k];
}
});
// 前端视觉默认:强制覆盖后端 formConfig 中可能存在的旧默认(horizontal / 红按钮 / 灰背景等),
// 与 formdata.vue 的 styleDefaults 保持一致,使所有 diy-form(含 diy-group 内的表单)统一呈现新设计稿样式。
// 后端仍保留行为/文案类配置(behavior / submitText / banner / tip 等),本合并仅覆盖视觉字段。
const styleDefaults = {
formLayout: 'vertical',
formPaddingX: 28,
rowPaddingY: 24,
labelGap: 20,
labelFontSize: 32,
labelFontWeight: 700,
labelColor: '#1f2937',
inputHeight: 84,
inputRadius: 16,
inputBgColor: '#ffffff',
inputBorderColor: '#e5e7eb',
inputFocusBorderColor: '#3b82f6',
inputFocusBgColor: '#ffffff',
placeholderColor: '#9ca3af',
placeholderFontSize: 28,
checkedColor: '#3b82f6',
showRowDivider: false,
submitColor: '#ffffff',
submitBgColor: '#3b82f6'
};
// 平台差异化配置(terminal 块在 formConfig 子对象内)
// 优先级:v.formConfig.terminal > v.form_config_terminal > v.terminal > v.form_config_platform
const result = Object.assign({}, base);
const result = Object.assign({}, base, styleDefaults);
const termBlock = (v.formConfig && v.formConfig.terminal) || v.form_config_terminal || v.terminal || v.form_config_platform;
if (termBlock && typeof termBlock === 'object') {
Object.keys(termBlock).forEach(function(platform) {
@@ -368,12 +472,37 @@ export default {
// 默认左右无边距(与后端 --banner-padding-x 对齐;由内联 bannerStyle 覆盖)
margin: 0;
// banner 图片作为卡片上方的"底图":建立层叠上下文,让 .form-card 通过 z-index 浮在其上。
// 仅在卡片浮起模式(.is-card-overlap)启用,不影响其它使用方(diy-group 等)的旧布局。
&.is-card-overlap {
position: relative;
z-index: 1;
}
.banner-img {
width: 100%;
display: block;
}
}
// 卡片容器:浮在 banner 图片上方(参考设计左图)。
// 仅在 hasBannerMode=true 时渲染(见 template v-if);
// 阴影 + margin-top(负偏移)+ 圆角 + 白底 全部由 cardWrapStyle 内联写入(读取后台 formConfig.card*)。
// 关键:卡片必须自带白底并 z-index 高于 banner,才能真正"盖住"banner 图片底部形成浮起;
// 父级容器(formdata.vue 的 .system-form-wrap)虽也是白底,但它是 banner 的祖先、背景会被 banner 覆盖,
// 因此本容器的白底不可省略(与 .system-form-wrap 同色,视觉上从卡片向下自然延续到页面底部)。
.form-card {
// 定位上下文 + 高于 banner 的层级:让卡片盖住 banner 底部(banner z-index:1)
position: relative;
z-index: 2;
// 兜底白底 / 圆角 / 阴影:与后端未配置时内联写入的默认值一致;后台配置时由内联 cardWrapStyle 覆盖
background-color: #ffffff;
border-radius: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
// 兜底 padding-bottom:让卡片与后面内容(如推荐码、其它 diy 组件)有间距
padding-bottom: 24rpx;
}
.form-tip {
// 提示语:上下内边距 / 左右内边距 / 字号 / 颜色 / 背景 全部由 tipStyle 内联写入
padding: 0;

View File

@@ -137,8 +137,8 @@ export default {
},
computed: {
warpCss() {
var obj = '';
obj += 'background-color:' + this.value.componentBgColor + ';';
var obj = '';
obj += 'background-color:' + this.value.componentBgColor + ';';
if (this.value.componentAngle == 'round') {
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
obj += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';

View File

@@ -87,41 +87,25 @@
<!-- ============ 推荐码卡片:仅在非 consent 且非首次加载时渲染 ============ -->
<block v-if="!showConsent && !(loadingCode && !code)">
<!-- ============ 预设 1:Gradient 渐变风格(默认)全部内联样式 ============ -->
<view v-if="stylePreset === 'gradient'" :style="$util.objToStyle({ width: '100%', boxSizing: 'border-box', marginTop: '24rpx', background: '#FFFFFF', borderRadius: px2rpx(value.borderRadius || 8) + 'px', padding: px2rpx(value.padding || 16) + 'px ' + px2rpx(value.padding || 16) + 'px', position: 'relative', overflow: 'hidden', boxShadow: '0 4rpx 16rpx rgba(0,0,0,0.06)' })">
<!-- 标题行 + 已获得推荐码标签 -->
<view v-if="stylePreset === 'gradient'" :style="$util.objToStyle({ width: 'calc(100% - 28rpx)', boxSizing: 'border-box', marginTop: '24rpx', marginLeft: 'auto', marginRight: 'auto', background: '#FFFFFF', borderRadius: px2rpx(value.borderRadius || 8) + 'px', padding: px2rpx(value.padding || 16) + 'px ' + px2rpx(value.padding || 16) + 'px', position: 'relative', overflow: 'hidden', boxShadow: '0 4rpx 16rpx rgba(0,0,0,0.06)' })">
<!-- 标题行 -->
<view style="display:flex;align-items:center;gap:12rpx;position:relative;z-index:1;">
<text style="font-size:30rpx;">🔑</text>
<text :style="$util.objToStyle({ fontSize: px2rpx(value.titleFontSize || 17) + 'px', fontWeight: '800', color: value.titleColor || '#1A1A1A' })">{{ value.title || '我的推荐码' }}</text>
<view style="display:inline-flex;align-items:center;background:#52C41A;border-radius:16rpx;padding:4rpx 14rpx;gap:4rpx;">
<text style="font-size:18rpx;color:#FFFFFF;">✓</text>
<text style="font-size:20rpx;color:#FFFFFF;font-weight:600;">已获得推荐码</text>
</view>
<!-- 推荐码区域:浅蓝虚线框 + 右侧蓝色方形复制按钮 -->
<view style="margin-top:24rpx;display:flex;align-items:center;gap:16rpx;position:relative;z-index:1;">
<view style="flex:1;display:flex;align-items:center;justify-content:center;height:124rpx;background:#F0F7FF;border:4rpx dashed #3B82F6;border-radius:24rpx;box-sizing:border-box;">
<text :style="$util.objToStyle({ fontSize: px2rpx(Math.max(20, Math.min(40, Number(value.codeFontSize) || 28))) + 'px', fontWeight: '900', color: value.codeColor || '#2563EB', letterSpacing: '2rpx', fontFamily: 'Courier New, monospace' })">{{ code || value.placeholderCode || 'HK-8888' }}</text>
</view>
<view v-if="value.showCopyButton !== false" @tap="copyRecommendCode" style="flex-shrink:0;width:96rpx;height:96rpx;display:flex;align-items:center;justify-content:center;background:linear-gradient(180deg,#3B82F6 0%,#2563EB 100%);border-radius:20rpx;box-shadow:0 4rpx 12rpx rgba(37,99,235,0.3);">
<text style="font-size:44rpx;color:#FFFFFF;">📋</text>
</view>
</view>
<!-- 推荐码+复制按钮区域:浅米黄色背景,圆角紧凑 -->
<view style="margin-top:24rpx;background:#FFF8F0;border-radius:16rpx;padding:32rpx 20rpx 24rpx;position:relative;z-index:1;">
<!-- 推荐码居中显示 -->
<view style="text-align:center;">
<text :style="$util.objToStyle({ fontSize: px2rpx(Math.max(16, Math.min(48, (Number(value.codeFontSize) || 28) * 0.93))) + 'px', fontWeight: '900', color: value.codeColor || '#DC143C', letterSpacing: '2rpx', fontFamily: 'Courier New, monospace' })">{{ code || value.placeholderCode || 'ABCD1234' }}</text>
</view>
<!-- 复制推荐码按钮:白色小按钮,红边框,在推荐码下方居中 -->
<view v-if="value.showCopyButton !== false" style="margin-top:16rpx;text-align:center;">
<view @tap="copyRecommendCode" :style="$util.objToStyle(copyBtnStyleObj({display:'inline-flex',alignItems:'center',borderRadius:'15px',padding:'0 16px',height:'30px',gap:'4px'}))">
<text style="font-size:22rpx;">📋</text>
<text :style="$util.objToStyle(copyBtnStyleObj({}, true))">{{ nc.copyButtonText || '复制推荐码' }}</text>
</view>
</view>
</view>
<!-- 立即分享链接按钮:黄橙渐变,胶囊形圆角,紧凑布局
固定走 linear-gradient,不读 diyData.buttonBgColor(避免被后端默认色覆盖)
H5 端用 view 触发(避免 button 默认样式),小程序/APP 端保留 button 以触发 open-type=share -->
<button v-if="value.showShareButton !== false && !isH5Platform" open-type="share" @tap="onShareClick" style="position:relative;left:0;right:0;margin:16rpx 0 0 0;width:100%;text-align:left;background:transparent;border:0;padding:0;line-height:normal;display:block;box-sizing:border-box;">
<view style="display:block;width:100%;height:80rpx;line-height:80rpx;background:linear-gradient(90deg,#FFC107 0%,#FF8F00 100%);border-radius:40rpx;box-shadow:0 4rpx 12rpx rgba(255,143,0,0.3);box-sizing:border-box;text-align:center;">
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">📤 {{ value.buttonText || '立即分享' }}</text>
</view>
</button>
<view v-else-if="value.showShareButton !== false" @tap="onShareClick" style="margin-top:16rpx;width:100%;">
<view style="display:block;height:80rpx;line-height:80rpx;background:linear-gradient(90deg,#FFC107 0%,#FF8F00 100%);border-radius:40rpx;box-shadow:0 4rpx 12rpx rgba(255,143,0,0.3);box-sizing:border-box;text-align:center;">
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">📤 {{ nc.buttonText || '立即分享' }}</text>
</view>
<!-- 提示文字 -->
<view style="margin-top:16rpx;display:flex;align-items:flex-start;gap:8rpx;position:relative;z-index:1;">
<text style="font-size:24rpx;color:#F59E0B;flex-shrink:0;">💡</text>
<text style="font-size:24rpx;color:#999999;line-height:1.5;flex:1;">{{ value.tipText || '复制推荐码分享给合作企业,对方填表时填入即可绑定' }}</text>
</view>
<!-- showLink -->
<view v-if="isFeatureOn(value.showLink)" style="margin-top:20rpx;text-align:center;position:relative;z-index:1;">
@@ -135,6 +119,29 @@
<text style="font-size:22rpx;color:#999;margin-top:12rpx;">{{ value.posterQrTip || '扫码进入并填写入驻信息' }}</text>
</view>
</view>
<!-- 分享推荐链接按钮:深蓝渐变,左侧图标 + 文字,右侧“复制链接”小按钮(独立于白色卡片外) -->
<button v-if="stylePreset === 'gradient' && value.showShareButton !== false && !isH5Platform" open-type="share" @tap="onShareClick" style="position:relative;left:0;right:0;margin:24rpx auto 20rpx auto;width:calc(100% - 28rpx);text-align:left;background:transparent;border:0;padding:0;line-height:normal;display:block;box-sizing:border-box;">
<view style="display:flex;align-items:center;justify-content:space-between;width:100%;height:100rpx;background:linear-gradient(90deg,#3B82F6 0%,#1E40AF 100%);border-radius:20rpx;padding:0 24rpx;box-shadow:0 4rpx 16rpx rgba(29,78,216,0.35);box-sizing:border-box;">
<view style="display:flex;align-items:center;gap:12rpx;">
<text style="font-size:36rpx;color:#FFFFFF;">📤</text>
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">{{ value.buttonText || '分享推荐链接给企业' }}</text>
</view>
<view @tap.stop="copyLink" style="flex-shrink:0;height:52rpx;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.2);border-radius:26rpx;padding:0 20rpx;">
<text style="font-size:24rpx;color:#FFFFFF;">复制链接</text>
</view>
</view>
</button>
<view v-else-if="stylePreset === 'gradient' && value.showShareButton !== false" @tap="onShareClick" style="margin:24rpx auto 20rpx auto;width:calc(100% - 28rpx);">
<view style="display:flex;align-items:center;justify-content:space-between;width:100%;height:100rpx;background:linear-gradient(90deg,#3B82F6 0%,#1E40AF 100%);border-radius:20rpx;padding:0 24rpx;box-shadow:0 4rpx 16rpx rgba(29,78,216,0.35);box-sizing:border-box;">
<view style="display:flex;align-items:center;gap:12rpx;">
<text style="font-size:36rpx;color:#FFFFFF;">📤</text>
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">{{ nc.buttonText || '分享推荐链接给企业' }}</text>
</view>
<view @tap.stop="copyLink" style="flex-shrink:0;height:52rpx;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.2);border-radius:26rpx;padding:0 20rpx;">
<text style="font-size:24rpx;color:#FFFFFF;">复制链接</text>
</view>
</view>
</view>
<!-- ============ 预设 2:Minimal 极简风格(全部内联样式) ============ -->
<view v-if="stylePreset === 'minimal'" :style="$util.objToStyle({ width: '100%', boxSizing: 'border-box', marginTop: '24rpx', background: 'linear-gradient(90deg,#FFFFFF 0%,#FFF8F8 30%,#FFF5F5 60%,#FFF0F0 100%)', borderRadius: px2rpx(value.borderRadius || 8) + 'px', padding: px2rpx(value.padding || 16) + 'px ' + px2rpx(value.padding || 16) + 'px', position: 'relative', overflow: 'hidden', boxShadow: '0 4rpx 16rpx rgba(255,107,107,0.08)' })">
@@ -323,6 +330,56 @@
<text style="font-size:22rpx;color:#999;margin-top:8rpx;">{{ value.posterQrTip || '扫码进入并填写入驻信息' }}</text>
</view>
</view>
<!-- ============ 预设 5(样式5):直接采用用户提供的代码,原样新增 ============ -->
<view v-if="stylePreset === 'style5'" :style="$util.objToStyle({ width: '100%', boxSizing: 'border-box', marginTop: '24rpx', background: '#FFFFFF', borderRadius: px2rpx(value.borderRadius || 8) + 'px', padding: px2rpx(value.padding || 16) + 'px ' + px2rpx(value.padding || 16) + 'px', position: 'relative', overflow: 'hidden', boxShadow: '0 4rpx 16rpx rgba(0,0,0,0.06)' })">
<!-- 标题行 + 已获得推荐码标签 -->
<view style="display:flex;align-items:center;gap:12rpx;position:relative;z-index:1;">
<text :style="$util.objToStyle({ fontSize: px2rpx(value.titleFontSize || 17) + 'px', fontWeight: '800', color: value.titleColor || '#1A1A1A' })">{{ value.title || '我的推荐码' }}</text>
<view style="display:inline-flex;align-items:center;background:#52C41A;border-radius:16rpx;padding:4rpx 14rpx;gap:4rpx;">
<text style="font-size:18rpx;color:#FFFFFF;">✓</text>
<text style="font-size:20rpx;color:#FFFFFF;font-weight:600;">已获得推荐码</text>
</view>
</view>
<!-- 推荐码+复制按钮区域:浅米黄色背景,圆角紧凑 -->
<view style="margin-top:24rpx;background:#FFF8F0;border-radius:16rpx;padding:32rpx 20rpx 24rpx;position:relative;z-index:1;">
<!-- 推荐码居中显示 -->
<view style="text-align:center;">
<text :style="$util.objToStyle({ fontSize: px2rpx(Math.max(16, Math.min(48, (Number(value.codeFontSize) || 28) * 0.93))) + 'px', fontWeight: '900', color: value.codeColor || '#DC143C', letterSpacing: '2rpx', fontFamily: 'Courier New, monospace' })">{{ code || value.placeholderCode || 'ABCD1234' }}</text>
</view>
<!-- 复制推荐码按钮:白色小按钮,红边框,在推荐码下方居中 -->
<view v-if="value.showCopyButton !== false" style="margin-top:16rpx;text-align:center;">
<view @tap="copyRecommendCode" :style="$util.objToStyle(copyBtnStyleObj({display:'inline-flex',alignItems:'center',borderRadius:'15px',padding:'0 16px',height:'30px',gap:'4px'}))">
<text style="font-size:22rpx;">📋</text>
<text :style="$util.objToStyle(copyBtnStyleObj({}, true))">{{ nc.copyButtonText || '复制推荐码' }}</text>
</view>
</view>
</view>
<!-- 立即分享链接按钮:黄橙渐变,胶囊形圆角,紧凑布局
固定走 linear-gradient,不读 diyData.buttonBgColor(避免被后端默认色覆盖)
H5 端用 view 触发(避免 button 默认样式),小程序/APP 端保留 button 以触发 open-type=share -->
<button v-if="stylePreset === 'style5' && value.showShareButton !== false && !isH5Platform" open-type="share" @tap="onShareClick" style="position:relative;left:0;right:0;margin:16rpx 0 0 0;width:100%;text-align:left;background:transparent;border:0;padding:0;line-height:normal;display:block;box-sizing:border-box;">
<view style="display:block;width:100%;height:80rpx;line-height:80rpx;background:linear-gradient(90deg,#FFC107 0%,#FF8F00 100%);border-radius:40rpx;box-shadow:0 4rpx 12rpx rgba(255,143,0,0.3);box-sizing:border-box;text-align:center;">
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">📤 {{ value.buttonText || '立即分享' }}</text>
</view>
</button>
<view v-else-if="stylePreset === 'style5' && value.showShareButton !== false" @tap="onShareClick" style="margin-top:16rpx;width:100%;">
<view style="display:block;height:80rpx;line-height:80rpx;background:linear-gradient(90deg,#FFC107 0%,#FF8F00 100%);border-radius:40rpx;box-shadow:0 4rpx 12rpx rgba(255,143,0,0.3);box-sizing:border-box;text-align:center;">
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">📤 {{ nc.buttonText || '立即分享' }}</text>
</view>
</view>
<!-- showLink -->
<view v-if="isFeatureOn(value.showLink)" style="margin-top:20rpx;text-align:center;position:relative;z-index:1;">
<text @tap="copyLink" style="font-size:22rpx;color:#999;text-decoration:underline;">{{ formatShareLink }}</text>
</view>
<!-- showQR -->
<view v-if="isFeatureOn(value.showQR)" style="margin-top:20rpx;display:flex;flex-direction:column;align-items:center;position:relative;z-index:1;">
<view :style="$util.objToStyle({ width: px2rpx(nc.qrSize || 200) + 'px', height: px2rpx(nc.qrSize || 200) + 'px', background: nc.qrBoxBackground || 'transparent', border: nc.qrBoxBorderWidth && nc.qrBoxBorderColor ? (px2rpx(nc.qrBoxBorderWidth) + 'px solid ' + nc.qrBoxBorderColor) : 'none', borderRadius: px2rpx(nc.qrBoxBorderRadius || 0) + 'px', padding: px2rpx(nc.qrBoxPadding || 0) + 'px', boxSizing: 'border-box' })">
<image :src="qrcodeRenderUrl" mode="aspectFit" style="width:100%;height:100%;display:block;" />
</view>
<text style="font-size:22rpx;color:#999;margin-top:12rpx;">{{ value.posterQrTip || '扫码进入并填写入驻信息' }}</text>
</view>
</view>
</block>
</view>
<!-- 登录组件占位(ns-login 全局 mixin 已注册) -->
@@ -382,6 +439,7 @@ function rcFallbackCopy(text, cb) {
/* =========================== 组件本体 =========================== */
import DiyMinx from './minx.js';
import golbalConfig from '@/common/js/golbalConfig.js';
import { extractInternalAppPath } from '@/common/js/util-navigate.js';
export default {
name: 'DiyRecommendCode',
mixins: [DiyMinx, golbalConfig],
@@ -429,6 +487,13 @@ export default {
qrBoxBackground: '', // 容器背景色;空=透明
qrBoxPadding: 0, // 容器内边距(rpx)
/* ============== 分享目标配置(后台可配,避免写死 pages_tool/form/formdata) ============== */
// H5 完整分享链接模板,必须包含 {code} 占位符;为空时走默认兜底链接
customShareLink: '',
// 微信小程序分享路径模板,必须包含 {code} 占位符;为空时自动从 customShareLink 还原
// 示例:'/pages_tool/index/diy?id=123&recommend_code={code}'
miniProgramPath: '',
/* ============== 兼容旧字段(不破坏之前商家已经配置过的卡片) ============== */
// 外间距
marginTop: 20, marginBottom: 20,
@@ -478,7 +543,7 @@ export default {
},
stylePreset() {
const s = String(this.nc.stylePreset || 'gradient').toLowerCase();
if (['minimal', 'card', 'bookgold', 'horizontal'].includes(s)) return s;
if (['minimal', 'card', 'bookgold', 'horizontal', 'style5'].includes(s)) return s;
return 'gradient';
},
requireConsentComputed() {
@@ -651,9 +716,13 @@ export default {
return 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&margin=0&data=' + url;
},
formatShareLink() {
// 读取后台配置完整带{code}的模板
const template = this.nc.customShareLink || "http://192.168.8.118/hwappx/common/pages_tool/form/formdata?id=13&uniacid=1&recommend_code={code}";
// 把模板里 {code} 整体替换成 {推荐码}
// 读取后台配置完整带 {code} 的 H5 链接模板,未配置时兜底到原表单链接
// 兼容后台传对象 { wap_url, url } 或字符串的情况
let template = this._pickShareLinkTemplate(this.nc.customShareLink);
if (!template) {
template = "http://192.168.8.118/hwappx/common/pages_tool/form/formdata?id=13&uniacid=1&recommend_code={code}";
}
// 把模板里 {code} 整体替换成当前推荐码
return template.replace(/\{code\}/g, this.code);
},
},
@@ -768,13 +837,13 @@ export default {
*/
onShareTimeline() {
const v = this.nc;
// 从H5分享链接中提取完整查询参数,与分享给好友保持一致
// 从小程序分享路径中提取完整查询参数,与分享给好友保持一致
var query = '';
try {
var shareLink = this.formatShareLink;
var queryIndex = shareLink.indexOf('?');
var miniPath = this.extractMiniProgramPath();
var queryIndex = miniPath.indexOf('?');
if (queryIndex >= 0) {
query = shareLink.substring(queryIndex + 1); // id=13&uniacid=1&recommend_code=XXX
query = miniPath.substring(queryIndex + 1); // id=13&uniacid=1&recommend_code=XXX
}
} catch(e) {}
if (!query && this.code) query = 'recommend_code=' + encodeURIComponent(this.code);
@@ -1248,20 +1317,55 @@ onShareClick() {
});
},
extractMiniProgramPath() {
// 从H5分享链接中提取完整查询参数,保持小程序分享与H5链接参数一致
// H5链接如: http://xxx/hwappx/common/pages_tool/form/formdata?id=13&uniacid=1&recommend_code=XXX
// 小程序路径如: /pages_tool/form/formdata?id=13&uniacid=1&recommend_code=XXX
// 1) 优先使用后台单独配置的小程序路径模板(支持 {code} 占位符)
const pathTemplate = this._pickShareLinkTemplate(this.nc.miniProgramPath);
if (pathTemplate) {
return this._ensureRecommendCode(pathTemplate.replace(/\{code\}/g, this.code || ''));
}
// 2) 否则从 H5 分享链接(后台「从平台链接中选择」或「自定义 URL」)中还原小程序内部路径
const shareLink = this.formatShareLink;
let miniPath = '';
try {
var queryIndex = shareLink.indexOf('?');
if (queryIndex >= 0) {
var query = shareLink.substring(queryIndex); // ?id=13&uniacid=1&recommend_code=XXX
return '/pages_tool/form/formdata' + query;
}
// 强制按内部链接还原:截取 /pages 或 /pages_xxx 起始段
miniPath = extractInternalAppPath(shareLink, true);
} catch(e) {}
// 兜底:只带recommend_code
if (!this.code) return '/pages_tool/form/formdata';
return '/pages_tool/form/formdata?recommend_code=' + encodeURIComponent(this.code);
// 如果是相对路径且未被上面识别,也视为内部路径
if (!miniPath && shareLink && shareLink.charAt(0) === '/') {
miniPath = shareLink;
}
// 兜底:原表单页面
if (!miniPath) {
miniPath = '/pages_tool/form/formdata';
}
return this._ensureRecommendCode(miniPath);
},
/**
* 确保小程序路径携带 recommend_code 参数
*/
_ensureRecommendCode(miniPath) {
if (!this.code) return miniPath;
try {
const hasCode = /[?&]recommend_code=/.test(miniPath);
if (hasCode) return miniPath;
const sep = miniPath.indexOf('?') >= 0 ? '&' : '?';
return miniPath + sep + 'recommend_code=' + encodeURIComponent(this.code);
} catch(e) { return miniPath; }
},
/**
* 统一解析后台配置的链接字段:支持字符串 / { wap_url } / { url }
*/
_pickShareLinkTemplate(raw) {
if (!raw) return '';
if (typeof raw === 'string') return raw.trim();
if (raw && typeof raw === 'object') {
if (typeof raw.wap_url === 'string' && raw.wap_url.trim()) return raw.wap_url.trim();
if (typeof raw.url === 'string' && raw.url.trim()) return raw.url.trim();
}
return '';
},
_persistRecommendInfo() {
try {

View File

@@ -743,7 +743,53 @@ export default {
this.$langConfig.change(targetLang, url);
},
/**
* 客服按钮点击的统一入口。
*
* 关键修复:智能客服(AI 客服)类型需要走 ai-chat 页面,
* 且必须在 URL 上注入 __dyu=FloatBtn 标记,
* 让 ai-chat/index.vue 的 onLoad(options) 识别"来自悬浮按钮/浮动按钮",
* commit setBottomNavHidden(true) 隐藏底部 tabBar。
*
* 为什么不在 customer-service.js#openAiChatService 统一注入:
* 该方法是通用 AI 客服入口,被多处复用(hover-nav / 商品详情页 / 会员中心 / DIY 浮动按钮等),
* 其中 DIY 浮动按钮(diy-float-btn.vue)已自行注入 __dyu=FloatBtn;其他入口(商品详情页等)
* 是否要隐藏 tabBar 是另一个独立的产品决策,不能在此一刀切。
* 因此由调用方按需注入:本方法(hover-nav)显式注入,diy-float-btn 显式注入,
* 其他入口保持原样。
*
* 与 diy-float-btn.vue#onFloatBtnClick 的行为完全一致,只是触发位置不同。
*/
handleUnifiedKefuClick() {
// 仅智能客服(aikefu)类型需要走 ai-chat 页面并隐藏 tabBar;
// 其他类型(企业微信 / 第三方 / 小程序原生客服等)由 customerService 内部
// 路由到对应渠道(navigateToMiniProgram / openCustomerServiceChat 等),
// 与底部 tabBar 无关,继续走原统一入口。
if (this.customerService && this.customerService.isAIKefu && this.customerService.isAIKefu()) {
// 清除未读数(与 openAiChatService 行为保持一致)
if (typeof this.setAiUnreadCount === 'function') {
this.setAiUnreadCount(0);
} else if (this.$store && typeof this.$store.commit === 'function') {
try { this.$store.commit('setAiUnreadCount', 0); } catch (e) { /* 兼容旧 store */ }
}
// 注入 __dyu=FloatBtn 标记,与 diy-float-btn.vue#onFloatBtnClick 语义一致
const aiChatUrl = `${this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT}?__dyu=FloatBtn`;
uni.navigateTo({
url: aiChatUrl,
fail: (err) => {
console.error('[hover-nav] 跳转 AI智能客服失败:', err);
// H5 兜底
// #ifdef H5
window.location.href = aiChatUrl;
// #endif
uni.showToast({ title: this.$lang('customerService.openFailed'), icon: 'none' });
}
});
return;
}
// 非智能客服类型:走原统一入口(企业微信 / 第三方 / 微信原生客服 等)
this.customerService.handleUnifiedKefuClick({
sendMessageTitle: '来自悬浮按钮的咨询',
sendMessagePath: '/pages/index/index'

View File

@@ -4,8 +4,8 @@
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Text'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view class="box">
<input
@@ -22,8 +22,8 @@
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Textarea'">
<view class="order-cell flex-box textarea" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view class="box">
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
@@ -48,11 +48,11 @@
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box">
<view :class="['box', 'box-flex']">
<text v-if="item.val != ''">{{ item.val }}</text>
<text v-else class="color-tip">{{ $lang('components.nsForm.selectPlaceholder') }}</text>
<text class="iconfont icon-right"></text>
</view>
<text class="iconfont icon-right"></text>
</view>
</view>
</picker>
@@ -60,8 +60,8 @@
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Checkbox'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'check-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
@@ -80,8 +80,8 @@
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Radio'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'radio-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
@@ -100,8 +100,8 @@
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Img'">
<view class="order-cell flex-box" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view class="box img-boxs">
<view class="img-box" v-for="(v, k) in item.img_lists" :key="k" @click="uploadImg(index)">
@@ -116,23 +116,23 @@
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Date'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view class="box box-flex">
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
</picker>
<view :class="['box', 'box-flex']">
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
</picker>
<text class="iconfont icon-right"></text>
</view>
</view>
<text class="iconfont icon-right"></text>
</view>
</view>
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Datelimit'">
<view class="order-cell flex-box" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view class="box date-boxs">
<view class="date-box">
@@ -157,23 +157,23 @@
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Time'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view class="box box-flex">
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
</picker>
<view :class="['box', 'box-flex']">
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
</picker>
<text class="iconfont icon-right"></text>
</view>
</view>
<text class="iconfont icon-right"></text>
</view>
</view>
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Timelimit'">
<view class="order-cell flex-box" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view class="box date-boxs">
<view class="date-box">
@@ -196,20 +196,20 @@
</view>
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'City'">
<view class="order-cell box-flex" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'box-flex']">
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)">
<view class="select-address " :class="{ empty: !item.val, 'color-tip': !item.val }">
{{ item.val ? item.val : (item.select_arr == '2' ? $lang('components.nsForm.selectProvinceCity') : $lang('components.nsForm.selectProvinceCityDistrict')) }}
</view>
</pick-regions>
<text class="iconfont icon-right"></text>
</view>
</view>
<view class="box">
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)">
<view class="select-address " :class="{ empty: !item.val, 'color-tip': !item.val }">
{{ item.val ? item.val : (item.select_arr == '2' ? $lang('components.nsForm.selectProvinceCity') : $lang('components.nsForm.selectProvinceCityDistrict')) }}
</view>
</pick-regions>
</view>
<text class="iconfont icon-right"></text>
</view>
</view>
</block>
</view>
@@ -263,22 +263,22 @@
config() {
// 1) 内置默认值(最低优先级,与重构前的硬编码值一一对应,确保未传 formConfig 时零回归)
const defaults = {
formPaddingX: 0,
formPaddingX: 28,
formBgColor: '#ffffff',
rowPaddingY: 20,
rowPaddingY: 24,
rowMinHeight: 60,
labelGap: 16,
inputHeight: 60,
inputRadius: 0,
labelWidthMode: 'fixed', // 旧版 width: 80vw 用 fixed 180rpx 等价替代
labelGap: 20,
inputHeight: 84,
inputRadius: 16,
labelWidthMode: 'auto', // 与 ns-newform 对齐:标签在上、输入框在下,整行宽度
labelFixedWidth: 180,
labelFontSize: 28,
labelFontSize: 32,
labelColor: '#1f2937',
labelFontWeight: 600,
labelFontWeight: 700,
labelAutoMaxWidth: 200,
requiredColor: '#ef4444',
checkedColor: '#ff4544',
placeholderColor: '#909399',
checkedColor: '#3b82f6',
placeholderColor: '#9ca3af',
placeholderFontSize: 28,
// textarea 显示行数 / 行高(与 ns-newform 对齐)
// 改用 :rows + line-height 驱动高度:rows 给原生 <textarea>(H5/微信原生层/鸿蒙 ArkUI 都识别),
@@ -404,13 +404,10 @@
'min-height': this.$util.rpx(this.config.rowMinHeight)
});
},
// 标签宽度:根据 labelWidthMode 决定;fixed 用 --label-fixed-width,auto 用 maxLabelWidth
// 标签宽度:垂直布局下标签整行显示,宽度交由 CSS(.name { width:100% })控制;
// 字号 / 颜色 / 字重已由 .form-wrap 注入的 --label-* 变量统一驱动,无需 inline 注入。
nameWidthStyle() {
const cfg = this.config;
if (cfg.labelWidthMode === 'fixed') {
return { width: this.$util.rpx(cfg.labelFixedWidth) };
}
return this.maxLabelWidth ? { width: this.$util.rpx(this.maxLabelWidth) } : { width: 'auto' };
return {};
},
// 输入框样式(input / textarea 共用)
inputStyle() {
@@ -948,31 +945,31 @@
后端 formConfig 一律以 rpx 传递,前端按 rpx 写入;
仅 H5 端需要在 JS computed(formWrapStyle 等)里通过 _rpx() 转 px。 */
.form-wrap {
--form-padding-x: 0rpx;
--form-padding-x: 28rpx;
--form-bg-color: #ffffff;
--row-padding-y: 20rpx;
--row-padding-y: 24rpx;
--row-min-height: 60rpx;
--label-gap: 16rpx;
--input-height: 60rpx;
--input-radius: 0rpx;
--label-gap: 20rpx;
--input-height: 84rpx;
--input-radius: 16rpx;
--label-fixed-width: 180rpx;
--label-font-size: 28rpx;
--label-font-size: 32rpx;
--label-color: #1f2937;
--label-font-weight: 600;
--label-font-weight: 700;
--required-color: #ef4444;
--placeholder-font-size: 28rpx;
--checked-color: #ff4544;
--placeholder-color: #909399;
--checked-color: #3b82f6;
--placeholder-color: #9ca3af;
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
--textarea-rows: 4;
--textarea-line-height: 44rpx;
--textarea-line-height: 48rpx;
// 输入框视觉增强(与 ns-newform 对齐)
--input-border-color: #e5e7eb;
--input-bg-color: #f9fafb;
--input-focus-border-color: #ff4544;
--input-bg-color: #ffffff;
--input-focus-border-color: #3b82f6;
--input-focus-bg-color: #ffffff;
--row-divider-color: #f0f0f0;
--show-row-divider: 1;
--show-row-divider: 0;
}
.order-wrap {
@@ -990,7 +987,8 @@
.order-cell {
display: flex;
align-items: center;
flex-direction: column; // 与 ns-newform vertical 对齐:标签在上、输入框在下
align-items: flex-start;
background: #fff;
position: relative;
min-height: var(--row-min-height, 60rpx);
@@ -1023,18 +1021,23 @@
}
.name {
width: var(--label-fixed-width, 180rpx);
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: var(--label-gap, 16rpx);
display: flex;
align-items: center;
min-height: var(--input-height, 60rpx);
line-height: 1.5;
margin-bottom: var(--label-gap, 20rpx);
margin-right: 0;
}
.tit {
text-align: left;
font-size: var(--label-font-size, $font-size-base);
color: var(--label-color, #1f2937);
font-weight: var(--label-font-weight, 600);
font-weight: var(--label-font-weight, 700);
text {
font-size: var(--label-font-size, $font-size-base);
@@ -1044,15 +1047,17 @@
.required {
color: var(--required-color, #ef4444);
font-size: var(--label-font-size, 28rpx);
margin-left: 4rpx;
width: 14rpx;
margin-right: 6rpx;
font-weight: bold;
text-align: left;
display: inline-block;
vertical-align: middle;
}
.box {
flex: 1;
padding: 0 10rpx;
width: 100%;
padding: 0;
line-height: inherit;
text-align: left;
@@ -1064,26 +1069,27 @@
color: var(--label-color, #1f2937);
// 显式 height / line-height / min-height 同步覆盖 WebView 默认与鸿蒙原生 TextInput 的固有高度,
// 避免 placeholder / 文字下半部分被裁切
line-height: var(--input-height, 60rpx);
height: var(--input-height, 60rpx);
min-height: var(--input-height, 60rpx);
border-radius: var(--input-radius, 0rpx);
line-height: var(--input-height, 84rpx);
height: var(--input-height, 84rpx);
min-height: var(--input-height, 84rpx);
border-radius: var(--input-radius, 16rpx);
box-sizing: border-box;
padding: 0 12rpx;
width: 100%;
padding: 0 24rpx;
// 输入框视觉增强(与 ns-newform 对齐)
border: 1rpx solid var(--input-border-color, #e5e7eb);
background: var(--input-bg-color, #f9fafb);
background: var(--input-bg-color, #ffffff);
transition: all 0.3s ease;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
border-color: var(--input-focus-border-color, #3b82f6);
}
// 原生 placeholder 样式(WebView / H5 生效;小程序/鸿蒙的 placeholder
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
&::placeholder {
color: var(--placeholder-color, #909399);
color: var(--placeholder-color, #9ca3af);
font-size: var(--placeholder-font-size, 28rpx);
}
}
@@ -1091,9 +1097,9 @@
// placeholder-class 钩子:在鸿蒙原生 TextInput / WebView 中都生效,
// 限定在 .form-wrap 内避免污染全局 .placeholder / .color-tip
.placeholder {
color: var(--placeholder-color, #909399);
color: var(--placeholder-color, #9ca3af);
font-size: var(--placeholder-font-size, 28rpx);
line-height: var(--input-height, 60rpx);
line-height: var(--input-height, 84rpx);
}
::v-deep textarea,
@@ -1101,36 +1107,36 @@
font-size: var(--label-font-size, $font-size-base);
width: 100%;
color: var(--label-color, #1f2937);
// 行高由 formConfig.textareaLineHeight(兜底 44rpx)控制;
// 行高由 formConfig.textareaLineHeight(兜底 48rpx)控制;
// 高度 = rows × line-height + padding + border,所有值都从 CSS 变量读取,
// 真正做到了"rows 改 → 高度自动改"。
line-height: var(--textarea-line-height, 44rpx);
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
line-height: var(--textarea-line-height, 48rpx);
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
text-align: left;
border-radius: var(--input-radius, 0rpx);
border-radius: var(--input-radius, 16rpx);
box-sizing: border-box;
padding: 12rpx;
padding: 12rpx 24rpx;
// 输入框视觉增强(与 ns-newform 对齐)
border: 1rpx solid var(--input-border-color, #e5e7eb);
background: var(--input-bg-color, #f9fafb);
background: var(--input-bg-color, #ffffff);
transition: all 0.3s ease;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
border-color: var(--input-focus-border-color, #3b82f6);
}
}
.uni-input {
font-size: var(--label-font-size, $font-size-base);
color: var(--label-color, #1f2937);
line-height: var(--input-height, 60rpx);
min-height: var(--input-height, 60rpx);
line-height: var(--input-height, 84rpx);
min-height: var(--input-height, 84rpx);
// 输入框视觉增强(与 ns-newform 对齐)
border: 1rpx solid var(--input-border-color, #e5e7eb);
background: var(--input-bg-color, #f9fafb);
border-radius: var(--input-radius, 0rpx);
background: var(--input-bg-color, #ffffff);
border-radius: var(--input-radius, 16rpx);
transition: all 0.3s ease;
}
@@ -1140,26 +1146,36 @@
}
.color-tip {
color: var(--placeholder-color, #909399);
color: var(--placeholder-color, #9ca3af);
}
checkbox-group {
display: flex;
flex-wrap: wrap;
// 让原生 picker / 自定义 pick-regions 撑满父容器(纵向布局下 label 在上、控件在下)
picker,
.pick-regions {
display: block;
width: 100%;
min-width: 0;
}
// 在 box-flex(带右侧箭头)场景下,picker 不应占满,留出右侧箭头空间
&.box-flex {
picker,
.pick-regions {
width: auto;
}
.iconfont.icon-right {
flex-shrink: 0;
margin-left: 8rpx;
line-height: 1;
}
}
checkbox-group,
radio-group {
display: flex;
flex-wrap: wrap;
}
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
.option-layout-vertical {
checkbox-group,
radio-group {
flex-direction: column;
align-items: flex-start;
}
width: 100%;
}
label {
@@ -1169,6 +1185,39 @@
margin-right: 30rpx;
}
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
// 注意:option-layout-* 是 .box 自身的类,必须用 & 嵌套,否则编译成 .box .option-layout-* 会选不中
&.option-layout-vertical {
checkbox-group,
radio-group {
flex-direction: column;
align-items: flex-start;
}
label {
width: 100%;
margin-right: 0;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
}
}
&.option-layout-wrap {
checkbox-group,
radio-group {
flex-direction: row;
align-items: center;
}
label {
margin-right: 30rpx;
margin-bottom: 0;
}
}
&.img-boxs {
display: flex;
align-items: center;
@@ -1244,10 +1293,11 @@
align-items: center;
line-height: 1;
.iconfont {
font-size: 32rpx;
margin-right: 10rpx;
}
.iconfont {
font-size: 40rpx;
margin-right: 10rpx;
color: #d1d5db;
}
}
}
@@ -1261,10 +1311,11 @@
align-items: center;
line-height: 1;
.iconfont {
font-size: 32rpx;
margin-right: 10rpx;
}
.iconfont {
font-size: 40rpx;
margin-right: 10rpx;
color: #d1d5db;
}
}
}

View File

@@ -325,14 +325,69 @@ export default {
// #ifdef MP-WEIXIN
this.getCode(authData => {
this.authLogin(authData, 'authOnlyLogin');
}, (err) => {
callback && callback(err);
// 真机 __usePrivacyCheck__ 开启时 getPhoneNumber 属于微信隐私接口,
// 必须先完成平台级隐私协议授权,否则点击"授权手机号"按钮会直接报
// "privacy permission is not authorized or buttonId is invalid"。
// 这里在登录链路前主动检查并弹隐私弹窗,授权完成后再继续登录。
this.ensureWxPrivacyAuthorized(() => {
this.getCode(authData => {
this.authLogin(authData, 'authOnlyLogin');
}, (err) => {
callback && callback(err);
});
});
// #endif
},
// 微信小程序: 确保平台级隐私协议已授权后再执行回调
ensureWxPrivacyAuthorized(done) {
try {
if (typeof wx === 'undefined' || !wx.getPrivacySetting) {
// 低版本基础库无隐私 API, 直接放行
done();
return;
}
wx.getPrivacySetting({
success: (res) => {
if (!res || !res.needAuthorization) {
// 已授权或无隐私指引配置, 直接放行
done();
return;
}
// 需要授权: 优先弹自建隐私弹窗(内含 agreePrivacyAuthorization 按钮,
// 点击同意即完成平台级授权); 用户同意后通过 privacy:agreed 事件继续
const popup = this.$refs.privacyPopup;
if (popup && typeof popup.showPrivacyPopup === 'function') {
const onAgreed = () => {
uni.$off('privacy:agreed', onAgreed);
uni.$off('privacy:disagreed', onDisagreed);
done();
};
const onDisagreed = () => {
uni.$off('privacy:agreed', onAgreed);
uni.$off('privacy:disagreed', onDisagreed);
// 用户拒绝隐私协议: 不继续登录链路(隐私接口也不可用)
};
uni.$off('privacy:agreed', onAgreed);
uni.$off('privacy:disagreed', onDisagreed);
uni.$on('privacy:agreed', onAgreed);
uni.$on('privacy:disagreed', onDisagreed);
popup.showPrivacyPopup();
} else if (typeof wx.requirePrivacyAuthorize === 'function') {
// 无 privacy-popup 实例时退回微信官方隐私弹窗
wx.requirePrivacyAuthorize({
success: () => done(),
fail: () => { }
});
}
},
fail: () => done()
});
} catch (e) {
done();
}
},
// 跳转去登录页
toLoginH5Page(mode = 'navigateTo') {
if (this.url) this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {
@@ -614,8 +669,24 @@ export default {
}
});
} else {
const errMsg = (e.detail && e.detail.errMsg) || '';
// #ifdef MP-WEIXIN
// 隐私协议未授权时 getPhoneNumber 会直接失败:
// "getPhoneNumber:fail privacy permission is not authorized or buttonId is invalid"
// 此时不再抛原始错误, 引导用户同意隐私弹窗后重新点授权按钮
if (errMsg.indexOf('privacy') !== -1) {
this.$util.showToast({
title: this.$lang('components.ns-login.pleaseAgreePrivacy')
});
const popup = this.$refs.privacyPopup;
if (popup && typeof popup.showPrivacyPopup === 'function') {
popup.showPrivacyPopup();
}
return;
}
// #endif
this.$util.showToast({
title: e.detail.errMsg
title: errMsg
})
}
},

View File

@@ -5,8 +5,8 @@
<view v-if="item.controller == 'Text'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
<input
@@ -23,8 +23,8 @@
<view v-if="item.controller == 'Textarea'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
<view :class="['order-cell', 'flex-box', 'textarea', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
@@ -51,8 +51,8 @@
>
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'box-flex', 'align-right']">
<text v-if="item.val != ''">{{ item.val }}</text>
@@ -67,22 +67,23 @@
<view v-if="item.controller == 'Checkbox'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'check-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<view :class="['box', 'check-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'vertical')]">
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
<label v-for="(v, k) in item.option_lists" :key="k">
<checkbox :value="v.value" :checked="v.checked"></checkbox>
<view class="checkbox">
<text
:class="[
'iconfont',
!v.checked ? 'icon-fuxuankuang2' : '',
v.checked ? 'icon-fuxuankuang1' : ''
]"
:style="{ color: v.checked ? checkedColor : '' }"
></text>
<text
:class="[
'iconfont',
'check-icon',
!v.checked ? 'icon-fuxuankuang2' : '',
v.checked ? 'icon-fuxuankuang1' : ''
]"
:style="{ color: v.checked ? checkedColor : '' }"
></text>
{{ v.value }}
</view>
</label>
@@ -95,22 +96,23 @@
<view v-if="item.controller == 'Radio'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'radio-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<view :class="['box', 'radio-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'vertical')]">
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
<label v-for="(v, k) in item.option_lists" :key="k">
<radio :value="v.value" :checked="item.val == v.value"></radio>
<view class="radio-box">
<text
:class="[
'iconfont',
item.val != v.value ? 'icon-yuan_checkbox' : '',
item.val == v.value ? 'icon-yuan_checked' : ''
]"
:style="{ color: item.val == v.value ? checkedColor : '' }"
></text>
<text
:class="[
'iconfont',
'radio-icon',
item.val != v.value ? 'icon-yuan_checkbox' : '',
item.val == v.value ? 'icon-yuan_checked' : ''
]"
:style="{ color: item.val == v.value ? checkedColor : '' }"
></text>
{{ v.value }}
</view>
</label>
@@ -123,8 +125,8 @@
<view v-if="item.controller == 'Img'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'img-boxs', 'align-left']" :style="item._boxAlignStyle">
<view v-for="(v, k) in item.img_lists" :key="k" class="img-box" @tap="uploadImg(index)">
@@ -142,8 +144,8 @@
<view v-if="item.controller == 'Date'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'box-flex', 'align-right']">
<picker
@@ -164,8 +166,8 @@
<view v-if="item.controller == 'Datelimit'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'date-boxs', 'align-center']" :style="item._boxAlignStyle">
<view class="date-box">
@@ -203,8 +205,8 @@
<view v-if="item.controller == 'Time'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'box-flex', 'align-right']">
<picker
@@ -225,8 +227,8 @@
<view v-if="item.controller == 'Timelimit'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'date-boxs', 'align-center']" :style="item._boxAlignStyle">
<view class="date-box">
@@ -264,8 +266,8 @@
<view v-if="item.controller == 'City'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'box-flex', 'align-right']">
<pick-regions
@@ -302,7 +304,7 @@ export default {
},
mode: {
type: String,
default: 'horizontal',
default: 'vertical',
validator: (value) => !value || ['horizontal', 'vertical'].includes(value)
},
// 表单配置(按平台差异化)
@@ -362,40 +364,40 @@ export default {
config() {
// 内置默认值(最低优先级)- 与后端 D:\projects\shop-projects\backend\src\app\component\view\form\js\design.js getDefaultFormData().formConfig 对齐
const defaults = {
formPaddingX: 14,
formPaddingX: 28,
bannerPaddingX: 0,
tipPaddingX: 14,
rowPaddingY: 4,
tipPaddingX: 28,
rowPaddingY: 24,
rowMinHeight: 60,
labelGap: 16,
labelGap: 20,
formBgColor: '#ffffff',
rowDividerColor: '#f0f0f0',
showRowDivider: true,
inputHeight: 60,
inputRadius: 10,
showRowDivider: false,
inputHeight: 84,
inputRadius: 16,
labelWidthMode: 'auto',
labelFixedWidth: 180,
labelFontSize: 28,
labelFontSize: 32,
labelColor: '#1f2937',
labelFontWeight: 600,
labelFontWeight: 700,
labelAutoMaxWidth: 200, // auto 模式下 label 宽度上限(rpx),超过后 ellipsis 截断
requiredColor: '#ef4444',
checkedColor: '#ff4544',
placeholderColor: '#ddd',
checkedColor: '#3b82f6',
placeholderColor: '#9ca3af',
placeholderFontSize: 28,
// textarea 显示行数 / 行高(与 ns-form 保持一致,由 formConfig 覆盖)
// 改用 :rows + line-height 驱动高度,rows 给原生 <textarea>,line-height 在 textareaStyle 算 px 高度
textareaRows: 4,
textareaLineHeight: 44,
textareaLineHeight: 48,
// 输入框视觉增强(可由后端 form_config 覆盖)
inputBorderColor: '#e5e7eb',
inputBgColor: '#f9fafb',
inputFocusBorderColor: '#ff4544',
inputBgColor: '#ffffff',
inputFocusBorderColor: '#3b82f6',
inputFocusBgColor: '#ffffff',
labelFontWeight: 600,
labelFontWeight: 700,
// 提交按钮
submitColor: '#ffffff',
submitBgColor: '#ff4544',
submitBgColor: '#3b82f6',
submitMarginTop: 0, // 提交按钮顶部外边距(rpx):调整表单与按钮之间的距离
// 提示语
tipFontSize: 32,
@@ -588,22 +590,23 @@ export default {
const align = itemValue.input_align;
this.$set(item, '_boxAlignStyle', align ? { 'text-align': align } : {});
// 选项排列样式:vertical 时给 checkbox-group / radio-group 加 flex-direction
// 走 inline style(不依赖 dev 编译产物的 SCSS 类,跨端兼容更稳)
// 直接写 flex-direction 等 CSS 属性,inline style 优先级最高
const optLayout = itemValue.option_layout;
const optStyle = {};
if (optLayout === 'vertical') {
optStyle['flex-direction'] = 'column';
optStyle['align-items'] = 'flex-start';
optStyle['gap'] = '8px';
} else {
// wrap 模式:用 display: flex 基础属性兜底(不强制,让浏览器按 SCSS 默认渲染)
optStyle['flex-direction'] = 'row';
optStyle['align-items'] = 'center';
optStyle['gap'] = '6px';
}
this.$set(item, '_optionLayoutStyle', optStyle);
// 选项排列样式:默认 vertical(每项一行),仅当后端显式配置 wrap 时横向换行
// 走 inline style(不依赖 dev 编译产物的 SCSS 类,跨端兼容更稳)
// 直接写 flex-direction 等 CSS 属性,inline style 优先级最高
const optLayout = itemValue.option_layout;
const optStyle = {};
if (optLayout === 'wrap') {
// wrap 模式:横向自动换行
optStyle['flex-direction'] = 'row';
optStyle['align-items'] = 'center';
optStyle['gap'] = '12rpx';
} else {
// vertical 模式(默认):每项独立一行
optStyle['flex-direction'] = 'column';
optStyle['align-items'] = 'flex-start';
optStyle['gap'] = '20rpx';
}
this.$set(item, '_optionLayoutStyle', optStyle);
});
},
// 容器样式(提供给外部 diy-form 使用)
@@ -1012,38 +1015,38 @@ export default {
.form-wrap {
background: #fff;
/* 默认值兜底:与 ns-newform.config 默认值一致 */
--form-padding-x: 14rpx;
--form-padding-x: 28rpx;
--banner-padding-x: 0rpx;
--tip-padding-x: 14rpx;
--tip-font-size: 24rpx;
--tip-padding-x: 28rpx;
--tip-font-size: 32rpx;
--tip-color: #666666;
--tip-bg-color: #ffffff;
--row-padding-y: 4rpx;
--row-padding-y: 24rpx;
--row-min-height: 60rpx;
--label-gap: 16rpx;
--label-gap: 20rpx;
--form-bg-color: #ffffff;
--row-divider-color: #f0f0f0;
--input-height: 60rpx;
--input-radius: 10rpx;
--input-height: 84rpx;
--input-radius: 16rpx;
--label-fixed-width: 180rpx;
--label-font-size: 28rpx;
--label-font-size: 32rpx;
--label-color: #1f2937;
--required-color: #ef4444;
--checked-color: #ff4544;
--checked-color: #3b82f6;
--submit-color: #ffffff;
--submit-bg-color: #ff4544;
--placeholder-color: #ddd;
--submit-bg-color: #3b82f6;
--placeholder-color: #9ca3af;
--placeholder-font-size: 28rpx;
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
--textarea-rows: 4;
--textarea-line-height: 44rpx;
--textarea-line-height: 48rpx;
// 输入框视觉增强(边框 / 背景 / 聚焦 / label 字重)
--input-border-color: #e5e7eb;
--input-bg-color: #f9fafb;
--input-focus-border-color: #ff4544;
--input-bg-color: #ffffff;
--input-focus-border-color: #3b82f6;
--input-focus-bg-color: #ffffff;
--label-font-weight: 600;
--show-row-divider: 1;
--label-font-weight: 700;
--show-row-divider: 0;
// 与后端 form-preview-wrap.no-divider-all 对齐:整组隐藏所有行分割线
&.no-divider-all {
@@ -1097,7 +1100,8 @@ export default {
.name {
width: 100%;
margin-bottom: var(--label-gap, 16rpx);
min-height: auto;
margin-bottom: var(--label-gap, 20rpx);
margin-right: 0;
}
@@ -1161,7 +1165,7 @@ export default {
.required {
color: var(--required-color, #ef4444);
font-size: var(--item-label-font-size, var(--label-font-size, 28rpx));
margin-left: 4rpx;
margin-right: 6rpx;
font-weight: bold;
display: inline-block;
vertical-align: middle;
@@ -1223,12 +1227,12 @@ export default {
::v-deep input {
font-size: var(--label-font-size, 28rpx);
text-align: left;
height: var(--input-height, 60rpx);
height: var(--input-height, 84rpx);
border: 1rpx solid var(--input-border-color, #e5e7eb);
border-radius: var(--input-radius, 10rpx);
line-height: var(--input-height, 60rpx);
padding: 0 12rpx;
background: var(--input-bg-color, #f9fafb);
border-radius: var(--input-radius, 16rpx);
line-height: calc(var(--input-height, 84rpx) - 2rpx);
padding: 0 24rpx;
background: var(--input-bg-color, #ffffff);
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
width: 100%;
@@ -1236,11 +1240,11 @@ export default {
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
border-color: var(--input-focus-border-color, #3b82f6);
}
&::placeholder {
color: var(--placeholder-color, #ddd);
color: var(--placeholder-color, #9ca3af);
font-size: var(--placeholder-font-size, 28rpx);
}
}
@@ -1249,12 +1253,12 @@ export default {
input {
font-size: var(--label-font-size, 28rpx);
text-align: left;
height: var(--input-height, 60rpx);
height: var(--input-height, 84rpx);
border: 1rpx solid var(--input-border-color, #e5e7eb);
border-radius: var(--input-radius, 10rpx);
line-height: var(--input-height, 60rpx);
padding: 0 12rpx;
background: var(--input-bg-color, #f9fafb);
border-radius: var(--input-radius, 16rpx);
line-height: calc(var(--input-height, 84rpx) - 2rpx);
padding: 0 24rpx;
background: var(--input-bg-color, #ffffff);
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
width: 100%;
@@ -1262,65 +1266,68 @@ export default {
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
border-color: var(--input-focus-border-color, #3b82f6);
}
&::placeholder {
color: var(--placeholder-color, #ddd);
color: var(--placeholder-color, #9ca3af);
font-size: var(--placeholder-font-size, 28rpx);
}
}
// H5 端 <textarea> 被 uni-app 编译为 <uni-textarea> 自定义组件,
// 真实 <textarea> 是其子节点,需要 ::v-deep 穿透 scoped 才能命中
::v-deep textarea {
font-size: var(--label-font-size, 28rpx);
width: 100%;
// 行高 / 高度全部由 formConfig 动态决定(CSS 兜底公式与 JS textareaStyle 一致)
line-height: var(--textarea-line-height, 44rpx);
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
line-height: var(--textarea-line-height, 48rpx);
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
text-align: left;
border: 1rpx solid var(--input-border-color, #e5e7eb);
border-radius: var(--input-radius, 10rpx);
padding: 12rpx;
background: var(--input-bg-color, #f9fafb);
border-radius: var(--input-radius, 16rpx);
padding: 12rpx 24rpx;
background: var(--input-bg-color, #ffffff);
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
box-sizing: border-box;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
border-color: var(--input-focus-border-color, #3b82f6);
}
&::placeholder {
color: var(--placeholder-color, #ddd);
color: var(--placeholder-color, #9ca3af);
font-size: var(--placeholder-font-size, 28rpx);
}
}
// 非 H5 端(小程序 / 鸿蒙 / App)<textarea> 是直接原生元素,普通选择器即可
textarea {
font-size: var(--label-font-size, 28rpx);
width: 100%;
// 行高 / 高度全部由 formConfig 动态决定(与 ::v-deep textarea 同步)
line-height: var(--textarea-line-height, 44rpx);
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
line-height: var(--textarea-line-height, 48rpx);
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
text-align: left;
border: 1rpx solid var(--input-border-color, #e5e7eb);
border-radius: var(--input-radius, 10rpx);
padding: 12rpx;
background: var(--input-bg-color, #f9fafb);
border-radius: var(--input-radius, 16rpx);
padding: 12rpx 24rpx;
background: var(--input-bg-color, #ffffff);
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
box-sizing: border-box;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
border-color: var(--input-focus-border-color, #3b82f6);
}
&::placeholder {
color: var(--placeholder-color, #ddd);
color: var(--placeholder-color, #9ca3af);
font-size: var(--placeholder-font-size, 28rpx);
}
}
@@ -1328,22 +1335,22 @@ export default {
.uni-input, .select-address {
display: inline-block;
padding: 0;
border-radius: var(--input-radius, 10rpx);
border-radius: var(--input-radius, 16rpx);
background: transparent;
border: none;
color: var(--label-color, #1f2937);
font-size: var(--label-font-size, 28rpx);
transition: all 0.3s ease;
min-height: var(--input-height, 60rpx);
min-height: var(--input-height, 84rpx);
box-sizing: border-box;
line-height: var(--input-height, 60rpx);
line-height: var(--input-height, 84rpx);
&.color-tip {
color: var(--placeholder-color, #ddd);
color: var(--placeholder-color, #9ca3af);
}
&.empty {
color: var(--placeholder-color, #ddd);
color: var(--placeholder-color, #9ca3af);
}
}
@@ -1381,13 +1388,20 @@ export default {
}
}
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
.option-layout-vertical {
// 选项排列样式:vertical = 每个选项独立一行;wrap = 横向自动换行
// option-layout-* 是 .box 自身的类,必须用 & 嵌套,否则编译成 .box .option-layout-* 选不中实际 DOM
&.option-layout-vertical {
checkbox-group,
radio-group {
flex-direction: column;
align-items: flex-start;
gap: 16rpx;
width: 100%;
label {
width: 100%;
justify-content: flex-start;
padding: 12rpx 0;
}
}
}
@@ -1416,7 +1430,14 @@ export default {
.radio-box {
display: inline-flex;
align-items: center;
gap: 8rpx;
gap: 12rpx;
}
.check-icon,
.radio-icon {
font-size: 40rpx;
line-height: 1;
color: #d1d5db;
}
&.img-boxs {
@@ -1509,7 +1530,7 @@ export default {
<style lang="scss" scoped>
.placeholder {
color: #909399;
color: #9ca3af;
font-size: 28rpx;
// #ifdef H5
@@ -1518,6 +1539,6 @@ export default {
}
.color-tip {
color: #909399;
color: #9ca3af;
}
</style>

View File

@@ -281,18 +281,18 @@ export default {
// #ifdef MP-WEIXIN
if (wx.onNeedPrivacyAuthorization) {
wx.onNeedPrivacyAuthorization((resolve) => {
// 检查是否已同意
if (!PrivacyManager.needShow()) {
// 已同意, 直接放行
resolve({ event: 'agree' })
// 关键: 微信规定 resolve({ event: 'agree' }) 必须携带 buttonId
// (值为页面上 open-type="agreePrivacyAuthorization" 按钮的 id),
// 缺 buttonId 的 agree 会被判定非法, 被拦截的隐私接口(如 getPhoneNumber)
// 直接报 "privacy permission is not authorized or buttonId is invalid"。
// 所以无论 storage 是否标记过同意, 都必须弹自定义弹窗让用户点同意按钮。
if (typeof this.privacyAuthorization === 'function') {
// 存 resolve 等用户操作, 由 handleAgree 携带 buttonId='agree-btn' 放行
this.privacyAuthorization(resolve)
} else {
// 需要弹窗, 存 resolve 等用户操作
if (typeof this.privacyAuthorization === 'function') {
this.privacyAuthorization(resolve)
} else {
// 兜底: 组件未就绪, 直接放行避免 API 卡死
resolve({ event: 'agree' })
}
// 兜底: 组件未就绪, disagree 放行避免平台 API 永久挂起
// (disagree 无需 buttonId, 不会产生非法 resolve)
resolve({ event: 'disagree' })
}
})
}

View File

@@ -81,6 +81,7 @@
"loggingIn": "登录中",
"loginFailed": "登录失败",
"pleaseAuthorizeMobile": "请授权您的手机号码",
"pleaseAgreePrivacy": "请先同意《隐私条款》后重试",
"pleaseEnterNickname": "请输入昵称",
"avatarPullFailed": "头像拉取失败"
},

View File

@@ -136,4 +136,8 @@ export const lang = {
logInitNavbarFailed: 'Failed to initialize navigation bar',
logChatComponentNotInit: 'Chat component not initialized',
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...'
}
// ====== AI Agent general copy (customer service is the first agent) ======
aiAgentDefaultTitle: 'AI Agent',
aiAgentOpenFailed: 'Failed to open AI Agent, please try again later',
aiAgentNotConfigured: 'This AI Agent is not enabled'}

View File

@@ -45,6 +45,7 @@ export const lang = {
loggingIn: 'Logging in',
loginFailed: 'Login failed',
pleaseAuthorizeMobile: 'Please authorize your mobile number',
pleaseAgreePrivacy: 'Please agree to the Privacy Policy and retry',
pleaseEnterNickname: 'Please enter nickname',
avatarPullFailed: 'Avatar pull failed',
}

View File

@@ -7,5 +7,18 @@ export const lang = {
submitSuccess: 'Submitted successfully',
// Form information not available
noFormInfo: 'Form information not available',
// Behavior settings
// Captcha input placeholder
captchaPlaceholder: 'Please enter the captcha',
// Captcha required
captchaRequired: 'Please enter the captcha',
// Captcha error
captchaError: 'Incorrect captcha, please try again',
// Submit limit reached
submitLimit: 'Submission limit reached',
// Form not open
notOpen: 'The form is not open yet',
// Login required
loginRequired: 'Please log in first',
}

View File

@@ -1,6 +1,6 @@
export const lang = {
//title为每个页面的标题
title: 'AI智能客服',
title: 'AI智能',
// ====== 通用 ======
cancel: '取消',
@@ -136,4 +136,8 @@ export const lang = {
logInitNavbarFailed: '初始化导航栏失败',
logChatComponentNotInit: '聊天组件未初始化',
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...'
}
// ====== AI Agent 通用文案(智能客服是首个 agent) ======
aiAgentDefaultTitle: 'AI 智能体',
aiAgentOpenFailed: '打开 AI 智能体失败,请稍后再试',
aiAgentNotConfigured: '该 AI 智能体暂未启用'}

View File

@@ -45,6 +45,7 @@ export const lang = {
loggingIn: '登录中',
loginFailed: '登录失败',
pleaseAuthorizeMobile: '请授权您的手机号码',
pleaseAgreePrivacy: '请先同意《隐私条款》后重试',
pleaseEnterNickname: '请输入昵称',
avatarPullFailed: '头像拉取失败',
}

View File

@@ -6,4 +6,17 @@ export const lang = {
submitSuccess: '提交成功',
// 未获取到表单信息
noFormInfo: '未获取到表单信息',
// 行为设置相关
// 请输入验证码
captchaPlaceholder: '请输入验证码',
// 请输入验证码(校验提示)
captchaRequired: '请输入验证码',
// 验证码错误
captchaError: '验证码错误,请重试',
// 提交次数已达上限
submitLimit: '提交次数已达上限',
// 表单未开放
notOpen: '表单未开放',
// 请先登录
loginRequired: '请先登录',
}

View File

@@ -1086,7 +1086,64 @@
{
"path": "ai-chat/index",
"style": {
"navigationBarTitleText": "AI智能客服"
"navigationBarTitleText": "AI智能客服",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
"diy-bargain": "view",
"diy-bottom-nav": "view",
"diy-category": "view",
"diy-category-item": "view",
"diy-channel-list": "view",
"diy-channel-video": "view",
"diy-comp-extend": "view",
"diy-coupon": "view",
"diy-digit": "view",
"diy-fenxiao-goods-list": "view",
"diy-float-btn": "view",
"diy-follow-official-account": "view",
"diy-form": "view",
"diy-goods-brand": "view",
"diy-goods-list": "view",
"diy-goods-recommend": "view",
"diy-graphic-nav": "view",
"diy-group": "view",
"diy-groupbuy": "view",
"diy-horz-blank": "view",
"diy-horz-line": "view",
"diy-hot-area": "view",
"diy-icon": "view",
"diy-image-nav": "view",
"diy-img-ads": "view",
"diy-index-page": "view",
"diy-kefu": "view",
"diy-listmenu": "view",
"diy-live": "view",
"diy-many-goods-list": "view",
"diy-map": "view",
"diy-member-info": "view",
"diy-member-my-order": "view",
"diy-merch-list": "view",
"diy-notes": "view",
"diy-notice": "view",
"diy-payment-qrcode": "view",
"diy-picture": "view",
"diy-pinfan": "view",
"diy-pintuan": "view",
"diy-presale": "view",
"diy-quick-nav": "view",
"diy-recommend-code": "view",
"diy-rich-text": "view",
"diy-rubik-cube": "view",
"diy-search": "view",
"diy-seckill": "view",
"diy-store": "view",
"diy-store-label": "view",
"diy-tab": "view",
"diy-text": "view",
"diy-video": "view",
"diy-video-list": "view"
}
}
},
{
@@ -2388,7 +2445,8 @@
{
"path": "file-preview/file-preview",
"style": {
"navigationBarTitleText": "文件预览"
"navigationBarTitleText": "文件预览",
"navigationStyle": "custom"
}
}
]

View File

@@ -56,10 +56,10 @@
</view>
</view>
<!-- 商品视频 -->
<!-- 商品视频:objectFit=contain 保留原始宽高比,在固定高度的图片容器内居中播放 -->
<view class="goods-video" :class="{ show: switchMedia == 'video' }">
<video id="goodsVideo" :src="$util.img(goodsSkuDetail.video_url)"
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="cover"
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="contain"
playsinline webkit-playsinline x5-playsinline></video>
</view>

View File

@@ -384,5 +384,14 @@ export default {
border-radius: 20rpx 20rpx 0 0;
}
// 购物车页面SKU弹窗底部按钮上移,避开底部导航栏
::v-deep .goods-sku .sku-info .footer {
bottom: calc(30rpx + env(safe-area-inset-bottom) + 112rpx) !important;
}
::v-deep .goods-sku .sku-info .body-item {
padding-bottom: 112rpx;
}
@import './public/css/cart.scss';
</style>

View File

@@ -603,7 +603,7 @@ export default {
uni.setStorageSync('selectedProduct', productInfo);
uni.navigateTo({
url: '/pages_tool/ai-chat/index'
url: this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT
});
},

View File

@@ -13,7 +13,7 @@
<!-- #endif -->
<!-- 聊天内容区域:从导航栏下方到容器底部 -->
<view class="chat-content">
<view class="chat-content" :style="{ bottom: tabBarHeight }">
<ai-chat-message
ref="chat"
:initial-messages="initialMessages"
@@ -68,6 +68,15 @@
</view>
</view>
</view>
<!-- 底部导航栏(仅在 tabBar 场景下显示):
通过 golbalConfig mixin 的 shouldShowTabBar 控制 v-show,
组件内部已绑定 data-component-name="diy-bottom-nav"。
- 后端把 ai-chat 配为底部导航栏项 → 用户点击该项进入 → tabBar 显示并高亮对应项
- 浮动按钮(diy-float-btn)进入时 onLoad commit setBottomNavHidden(true)
→ shouldShowTabBar=false → tabBar 隐藏
- 离开页面(onUnload)还原 bottomNavHidden → 不污染其他页面 -->
<diy-bottom-nav></diy-bottom-nav>
</view>
</template>
@@ -87,6 +96,9 @@ export default {
return {
initialMessages: [],
fallbackWelcome: '',
// AI Agent 通用入参(链接系统透传;老客服入口不传也能跑,默认 agent_aikefu)
agentId: 'agent_aikefu',
pageTitle: '',
// --- 系统导航栏/状态栏相关 ---
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
@@ -105,7 +117,15 @@ export default {
keyboardHeight: 0,
// 事件处理器引用(用于清理)
safeEventHandlers: new Map()
safeEventHandlers: new Map(),
// 是否从浮动按钮进入
// 用来在运行时区分两种场景:
// 1) 后台把 ai-chat 配为底部导航栏 tabBar 项 → 用户点击该项进入 → 底部 tabBar 显示并高亮
// 2) 浮动按钮(diy-float-btn)的链接指向 ai-chat → 用户点击浮动按钮进入 → 底部 tabBar 隐藏
// 通过 onLoad(options).from 判定;同时会通过 $store.state.bottomNavHidden 临时隐藏 tabBar,
// 并在 onUnload 还原,避免影响其他页面。
isFromFloatBtn: false,
}
},
@@ -127,6 +147,21 @@ export default {
async onLoad(options) {
this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome');
// ====== AI Agent 通用入参(链接系统 -> 通用 agent_id/title) ======
// 老客服入口不传 options 也能跑(智能客服是首个 agent,默认 agent_aikefu)。
// 新增 AI 销售/HR/...智能体只需在 link.page_params.agent_id 传不同值,
// 当前 onShow/initChat 不区分 agent 行为,后续按业务扩展按 agentId 路由即可。
try {
this.agentId = (options && options.agent_id) || 'agent_aikefu';
const rawTitle = (options && options.title) || this.$lang('ai-chat.index.title');
this.pageTitle = decodeURIComponent(rawTitle);
// 同步 agentId 给 ai-service,后续 sendMessage / WS auth 自动带新 agent_id
try { aiService.setAgentId(this.agentId); } catch (e) { /* 兼容旧 ai-service 版本 */ }
} catch (e) {
this.agentId = 'agent_aikefu';
this.pageTitle = this.$lang('ai-chat.index.title');
}
// ✅ 修改:取消权限校验,允许所有客服类型访问此页面
// const hasAccess = this.checkAccessPermission();
// if (!hasAccess) {
@@ -134,7 +169,27 @@ export default {
// return;
// }
this.$langConfig.title(this.$lang('ai-chat.index.title'));
this.$langConfig.title(this.pageTitle || this.$lang('ai-chat.index.title'));
// ====== 区分底部导航栏/浮动按钮两种进入来源 ======
// diy-float-btn 跳转时会在 link 上注入 __dyu=FloatBtn
// (见 diy-float-btn.vue#onFloatBtnClick)
// 这种情况应当隐藏底部 tabBar(AI 客服页是全屏聊天体验,不应被 tabBar 占用屏幕)
// 而点击底部导航栏 tabBar 项进入时,通常不会携带 __dyu query,
// 此时 tabBar 正常显示并由 checkTabBarActive 步骤2精确命中高亮当前项。
// 用 __dyu 这种 3 字符 + 双下划线前缀的命名,几乎不可能与业务 page_params 冲突
// (业务方不会使用 __ 开头的短变量名)。
const dyuEntry = (options && options.__dyu) || '';
if (dyuEntry === 'FloatBtn') {
this.isFromFloatBtn = true;
// 临时隐藏底部导航栏;onUnload 会还原。
try {
this.$store.commit('setBottomNavHidden', true);
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
} else {
this.isFromFloatBtn = false;
}
this.initChat();
},
@@ -180,6 +235,15 @@ export default {
console.log(this.$lang('ai-chat.index.logOnUnloadTriggered'));
this.cleanup();
aiService.disconnectWebSocket();
// 还原底部导航栏显示状态:如果 onLoad 时因为来自浮动按钮而临时隐藏,
// 必须在页面卸载时还原,否则返回到 tabBar 页时 tabBar 会保持隐藏状态。
if (this.isFromFloatBtn) {
try {
this.$store.commit('setBottomNavHidden', false);
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
this.isFromFloatBtn = false;
}
},
methods: {
@@ -647,7 +711,9 @@ export default {
/* #endif */
left: 0;
right: 0;
bottom: 0;
/* bottom 由模板动态绑定到 tabBarHeight:
- 浮动按钮入口 / 其他隐藏 tabBar 场景:0rpx,占满到底部
- tabBar 项入口:tabBarHeight,避免被底部 tabBar 遮挡聊天内容 */
}
/* 底部tabBar占位样式 */

View File

@@ -1,35 +1,93 @@
<template>
<view class="file-preview-container" :style="themeColor">
<!-- #ifdef MP-WEIXIN -->
<!-- 微信小程序:web-view 是原生组件,必须用 cover-view 盖在上面;cover-view 不支持 iconfont,改用 cover-image PNG -->
<cover-view class="mp-header">
<cover-view class="mp-header-inner">
<cover-view class="nav-btn back-btn" @click="goBack">
<cover-image src="/static/img/icon-back.png" class="nav-icon"></cover-image>
</cover-view>
<cover-view class="header-title">{{ headerTitle }}</cover-view>
<button class="nav-btn share-btn" open-type="share" hover-class="none">
<cover-image src="/static/img/icon-share.png" class="nav-icon"></cover-image>
</button>
</cover-view>
<!-- 状态栏占位(navigationStyle custom 时,避免被时间/电量遮挡) -->
<cover-view class="mp-status-bar" :style="{ height: statusBarHeight + 'px' }"></cover-view>
</cover-view>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<!-- 非小程序:普通 view + iconfont 即可 -->
<view class="header">
<view class="back-btn" @click="goBack">
<view class="nav-btn back-btn" @click="goBack">
<text class="iconfont icon-back"></text>
</view>
<view class="header-title">{{ fileName }}</view>
<view class="placeholder"></view>
<view class="header-title">{{ headerTitle }}</view>
<button class="nav-btn share-btn" open-type="share" hover-class="none" @click.stop>
<text class="iconfont icon-fenxiang"></text>
</button>
</view>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<!-- 微信小程序 web-view 是全屏原生组件,父容器 padding/margin 对它无效,必须在它前面放一个等高的占位 view 把它顶下去 -->
<view class="mp-content-spacer" :style="{ height: headerTotalHeightPx + 'px' }"></view>
<!-- #endif -->
<view class="content">
<!-- PDF 文件预览 -->
<view v-if="fileType === 'pdf'" class="file-viewer">
<!-- #ifdef H5 -->
<!-- H5 端:web-view 渲染为 iframe,若被 X-Frame-Options/CORS 拦截则显示空白;额外提供"新标签页打开"与"下载"按钮兜底 -->
<view class="h5-action-bar">
<view class="h5-action-tip">如页面空白,请使用下方按钮:</view>
<view class="h5-action-btns">
<button class="h5-action-btn primary" @click="openInNewTab">新标签页打开</button>
<button class="h5-action-btn" @click="triggerDownload">下载文件</button>
</view>
</view>
<!-- #endif -->
<web-view v-if="fileUrl" :src="fileUrl"></web-view>
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
</view>
<!-- Word 文件预览 -->
<view v-else-if="fileType === 'word'" class="file-viewer">
<!-- #ifdef H5 -->
<view class="h5-action-bar">
<view class="h5-action-tip">浏览器可能无法直接内嵌预览 Word,建议下载后查看:</view>
<view class="h5-action-btns">
<button class="h5-action-btn primary" @click="triggerDownload">下载文件</button>
<button class="h5-action-btn" @click="openInNewTab">新标签页打开</button>
</view>
</view>
<!-- #endif -->
<web-view v-if="fileUrl" :src="fileUrl"></web-view>
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
</view>
<!-- 视频文件预览 -->
<view v-else-if="fileType === 'video'" class="video-viewer">
<video v-if="fileUrl" :src="fileUrl" controls autoplay class="video-player"></video>
<video v-if="fileUrl" id="filePreviewVideo" :src="fileUrl" controls class="video-player"></video>
<view v-else class="error-message">{{ $lang('file-preview.file-preview.videoUrlNotExist') }}</view>
</view>
<!-- 其他文件类型 -->
<view v-else class="file-viewer">
<!-- #ifdef H5 -->
<view v-if="fileUrl" class="h5-action-bar">
<view class="h5-action-tip">{{ $lang('file-preview.file-preview.unsupportedFileType') }}</view>
<view class="h5-action-btns">
<button class="h5-action-btn primary" @click="triggerDownload">下载文件</button>
<button class="h5-action-btn" @click="openInNewTab">新标签页打开</button>
</view>
</view>
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
<!-- #endif -->
<!-- #ifndef H5 -->
<view class="error-message">{{ $lang('file-preview.file-preview.unsupportedFileType') }}</view>
<!-- #endif -->
</view>
</view>
</view>
@@ -41,32 +99,114 @@ export default {
return {
fileName: '',
fileUrl: '',
fileType: ''
fileType: '',
statusBarHeight: 20,
windowWidth: 375
};
},
computed: {
// #ifdef MP-WEIXIN
// 整个自定义 header 高度(状态栏 + 标题栏 88rpx),单位 px —— 用于占位 view 和内容区下移
headerTotalHeightPx() {
const rpxToPx = this.windowWidth / 750;
return this.statusBarHeight + Math.round(88 * rpxToPx);
},
// #endif
// 页面标题栏显示的短标题(避免超长挤压分享按钮;对用户无意义的tmp名统一显示通用"文件预览")
headerTitle() {
const name = (this.fileName || '').trim();
// 如果是临时文件名(tmp_ 开头 或 带路径hash的长串),统一显示通用"文件预览"
if (/^tmp_/i.test(name) || (name.length > 30 && /[a-f0-9]{20,}/i.test(name))) {
return this.$lang('file-preview.file-preview.title');
}
// 否则显示原始文件名(列表里传入的,如"乐瑞固企业介绍",本身就短)
if (name) {
// JS 层保险截断(超过16字 + …),应对 cover-view CSS 省略号不生效
if (name.length > 16) return name.slice(0, 16) + '…';
return name;
}
return this.$lang('file-preview.file-preview.title');
},
// 分享的真实标题(好友收到的卡片显示真实文件名,不截断)
shareTitle() {
const name = (this.fileName || '').trim();
if (name && !/^tmp_/i.test(name)) return name;
return this.$lang('file-preview.file-preview.title');
},
// 分享路径:只带文件三要素,不挂推荐码/关系
sharePath() {
const query = [];
if (this.fileName) query.push(`fileName=${encodeURIComponent(this.fileName)}`);
if (this.fileUrl) query.push(`fileUrl=${encodeURIComponent(this.fileUrl)}`);
if (this.fileType) query.push(`fileType=${encodeURIComponent(this.fileType)}`);
return `/pages_tool/file-preview/file-preview${query.length ? '?' + query.join('&') : ''}`;
},
themeColor() {
if (this.$shop && this.$shop.themeColor) {
return `--theme-color: ${this.$shop.themeColor};`;
}
return '';
}
},
onLoad(option) {
// 解析传递的参数
if (option.fileName) {
this.fileName = decodeURIComponent(option.fileName);
}
if (option.fileUrl) {
this.fileUrl = decodeURIComponent(option.fileUrl);
}
if (option.fileType) {
this.fileType = decodeURIComponent(option.fileType);
} else {
// 根据文件名后缀推断文件类型
this.inferFileType();
}
// #ifdef MP-WEIXIN
const sys = uni.getSystemInfoSync();
this.statusBarHeight = sys.statusBarHeight || 20;
this.windowWidth = sys.windowWidth || 375;
// #endif
},
onReady() {
// 视频加载后手动触发一次播放占位(去掉 autoplay 防自动播放被系统拦截)
// #ifndef H5
if (this.fileType === 'video' && this.fileUrl) {
try {
const ctx = uni.createVideoContext('filePreviewVideo', this);
if (ctx && typeof ctx.play === 'function') { /* 留给用户点击,不自动播 */ }
} catch (e) {}
}
// #endif
},
// #ifdef MP-WEIXIN
// 微信小程序:右上角胶囊"分享给朋友" / button[open-type=share] 触发
onShareAppMessage() {
return {
title: this.shareTitle,
path: this.sharePath
};
},
// 微信小程序:分享到朋友圈
onShareTimeline() {
return {
title: this.shareTitle,
query: this.sharePath.split('?')[1] || ''
};
},
// #endif
methods: {
/**
* 根据文件名后缀推断文件类型
*/
inferFileType() {
if (this.fileName) {
if (this.fileUrl) {
const clean = String(this.fileUrl).split('?')[0];
const ext = clean.split('.').pop().toLowerCase();
if (['pdf'].includes(ext)) {
this.fileType = 'pdf';
} else if (['doc', 'docx'].includes(ext)) {
this.fileType = 'word';
} else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext)) {
this.fileType = 'video';
}
} else if (this.fileName) {
const ext = this.fileName.split('.').pop().toLowerCase();
if (['pdf'].includes(ext)) {
this.fileType = 'pdf';
@@ -77,13 +217,48 @@ export default {
}
}
},
/**
* 返回上一页
*/
goBack() {
uni.navigateBack();
const pages = getCurrentPages();
if (pages && pages.length > 1) {
uni.navigateBack();
} else {
uni.switchTab({ url: '/pages/index/index' }).catch(() => {
uni.navigateBack();
});
}
},
// #ifdef H5
// H5 端:新标签页打开文件(绕过 iframe X-Frame-Options/CORS 限制)
openInNewTab() {
if (!this.fileUrl) return;
try {
window.open(this.fileUrl, '_blank', 'noopener,noreferrer');
} catch (e) {
uni.showToast({ title: '打开失败,请稍后重试', icon: 'none' });
}
},
// H5 端:通过 <a download> 触发浏览器下载(最稳定的跨浏览器方案)
triggerDownload() {
if (!this.fileUrl) return;
try {
const link = document.createElement('a');
link.href = this.fileUrl;
link.target = '_blank';
link.rel = 'noopener noreferrer';
if (this.fileName) {
// 仅提示浏览器用这个名字;受 CORS 影响,跨域文件时 download 属性可能被忽略,
// 此时浏览器会直接按服务器返回的文件名/直接显示,但不会丢失文件本身
link.download = this.fileName;
}
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (e) {
// 兜底:新标签页打开
this.openInNewTab();
}
}
// #endif
}
};
</script>
@@ -92,7 +267,10 @@ export default {
.file-preview-container {
min-height: 100vh;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
/* =================== 非 MP 端 header =================== */
.header {
display: flex;
align-items: center;
@@ -101,22 +279,34 @@ export default {
padding: 0 30rpx;
background-color: #fff;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.back-btn {
flex-shrink: 0;
z-index: 10;
.nav-btn {
flex-shrink: 0;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
line-height: 1;
border: none;
margin: 0;
&::after { border: none; }
}
.back-btn {
.icon-back {
font-size: 32rpx;
font-size: 36rpx;
color: #333;
}
}
.header-title {
flex: 1;
min-width: 0;
font-size: 32rpx;
font-weight: 600;
color: #333;
@@ -124,17 +314,91 @@ export default {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0 20rpx;
}
.placeholder {
width: 60rpx;
.share-btn {
.icon-fenxiang {
font-size: 34rpx;
color: #333;
}
}
}
/* =================== MP 端 header (cover-view 浮层) =================== */
/* #ifdef MP-WEIXIN */
.mp-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
background-color: #ffffff;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.mp-status-bar {
width: 100%;
}
.mp-header-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 20rpx;
width: 100%;
box-sizing: border-box;
}
.nav-btn {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
line-height: 1;
border: none;
margin: 0;
&::after { border: none; }
}
.nav-icon {
width: 40rpx;
height: 40rpx;
display: block;
}
.header-title {
flex: 1;
min-width: 0;
font-size: 32rpx;
font-weight: 600;
color: #333;
text-align: center;
margin: 0 16rpx;
/* cover-view 下 ellipsis 支持有限,JS 层(headerTitle computed)已经做了截断,这里再冗余防守 */
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
/* #endif */
/* =================== 内容区 =================== */
.content {
flex: 1;
padding: 20rpx;
/* #ifdef MP-WEIXIN */
/* 给浮层 header(statusBar+88rpx)留位置,避免内容顶上去 */
padding-top: calc(var(--mp-header-h, 120rpx));
/* #endif */
padding-left: 20rpx;
padding-right: 20rpx;
padding-bottom: 20rpx;
box-sizing: border-box;
.file-viewer {
width: 100%;
min-height: 80vh;
@@ -142,7 +406,7 @@ export default {
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.error-message {
width: 100%;
height: 80vh;
@@ -152,8 +416,52 @@ export default {
color: #999;
font-size: 28rpx;
}
/* #ifdef H5 */
/* H5 兜底操作栏(PDF/Word 被 X-Frame-Options 拦截时显示)*/
.h5-action-bar {
padding: 24rpx 28rpx 20rpx;
background: linear-gradient(180deg, #fffbe6 0%, #ffffff 100%);
border-bottom: 1rpx solid #f0e9c8;
.h5-action-tip {
font-size: 26rpx;
color: #8a6d1b;
margin-bottom: 18rpx;
line-height: 1.5;
}
.h5-action-btns {
display: flex;
gap: 20rpx;
.h5-action-btn {
flex: 1;
height: 72rpx;
line-height: 72rpx;
padding: 0;
font-size: 28rpx;
border-radius: 36rpx;
background: #f2f3f5;
color: #333;
border: 1rpx solid #e2e4e8;
margin: 0;
&::after { border: none; }
&.primary {
background: linear-gradient(90deg, #ff7e47 0%, #f73131 100%);
color: #fff;
border-color: transparent;
font-weight: 600;
box-shadow: 0 4rpx 12rpx rgba(247, 49, 49, 0.25);
}
}
}
}
/* #endif */
}
.video-viewer {
width: 100%;
min-height: 80vh;
@@ -161,12 +469,12 @@ export default {
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.video-player {
width: 100%;
height: 80vh;
}
.error-message {
width: 100%;
height: 80vh;

View File

@@ -13,12 +13,18 @@
<view class="article-wrap">
<ns-adv keyword="NS_ARTICLE" class-name="adv-wrap"></ns-adv>
<block v-for="(item, index) in list" :key="index">
<view class="item" @tap="toDetail(item.$orig)">
<view class="article-img">
<image class="cover-img" :src="item.g1" mode="widthFix" @error="imgError(index)" show-menu-by-longpress></image>
<view class="item" @tap="toDetail(item)">
<view class="pdf-icon">
<text class="pdf-icon-p">P</text>
<view class="pdf-icon-fold"></view>
<view class="pdf-icon-lines">
<view class="pdf-icon-line"></view>
<view class="pdf-icon-line"></view>
<view class="pdf-icon-line"></view>
</view>
</view>
<view class="info-wrap">
<view class="title">{{ item.$orig.files_title }}</view>
<view class="title">{{ item.files_title }}</view>
<view class="read-wrap">
<text>{{ $lang('files.list.viewPdf') }}</text>
</view>
@@ -84,8 +90,10 @@ export default {
if (res.code === 0 && res.data) {
list = res.data.list
if (!this.title && res.data.list.length > 0) {
const first = res.data.list[0]
const firstOrig = (first && first.$orig) ? first.$orig : first
uni.setNavigationBarTitle({
title: res.data.list[0].category_name
title: firstOrig.category_name || ''
})
}
} else {
@@ -98,9 +106,18 @@ export default {
this.list = []
}
this.list = this.list.concat(list.map(item => {
const orig = item
const g1 = this.$util.img("addon/personnel/shop/view/enterprise/fileicon.png")
return { $orig: orig, g1: g1 }
// 兼容两种后端结构:自带 $orig(如 cases 接口)或扁平结构
const src = (item && item.$orig) ? item.$orig : item
return {
files_id: src.files_id,
files_title: src.files_title,
files_url: src.files_url,
files_desc: src.files_desc,
category_id: src.category_id,
category_name: src.category_name,
// 保留完整原对象引用,避免后续扩展字段丢失
$orig: src
}
}))
if (this.$refs.loadingCover) {
this.$refs.loadingCover.hide()
@@ -115,41 +132,121 @@ export default {
})
},
toDetail(item) {
uni.showLoading({
title: this.$lang('files.list.loading')
})
try {
uni.downloadFile({
url: item.files_url,
success: res => {
if (res.statusCode === 200) {
uni.openDocument({
filePath: res.tempFilePath,
success: () => {
},
fail: () => {
},
complete: () => {
uni.hideLoading()
}
})
}
},
fail: () => {
uni.hideLoading()
},
complete: res => {
if (res.statusCode === 404) {
uni.showToast({
title: this.$lang('files.list.fileNotExist'),
icon: 'none'
})
}
}
})
} catch (e) {
uni.hideLoading()
const title = (item.files_title || '文件').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
// 根据 URL 推断后缀(若无扩展名则默认 pdf)
let ext = 'pdf';
if (item.files_url) {
const clean = item.files_url.split('?')[0];
const matched = clean.match(/\.([a-zA-Z0-9]+)$/);
if (matched) ext = matched[1].toLowerCase();
}
const fileName = `${title}.${ext}`;
const fileType =
['pdf'].includes(ext) ? 'pdf'
: ['doc', 'docx'].includes(ext) ? 'word'
: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext) ? 'video'
: 'pdf';
const previewPage = `/pages_tool/file-preview/file-preview?fileName=${encodeURIComponent(fileName)}&fileUrl=${encodeURIComponent(item.files_url)}&fileType=${encodeURIComponent(fileType)}`;
// ========== H5 端:跳转预览页(web-view 走 iframe);失败则新标签页打开原链接 ==========
// #ifdef H5
uni.navigateTo({
url: previewPage,
fail: () => {
try { window.open(item.files_url, '_blank'); } catch (e) {
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
}
}
});
return;
// #endif
// ========== 非 H5 端:下载文件后按平台分支打开 ==========
uni.showLoading({ title: this.$lang('files.list.loading'), mask: true });
uni.downloadFile({
url: item.files_url,
success: res => {
if (res.statusCode !== 200) {
uni.hideLoading();
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
return;
}
// ----- 微信小程序:saveFile 重命名 + openDocument(短文件名标题)-----
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager();
// 重命名临时文件到用户目录,用短文件名 —— openDocument 的标题会取 filePath 的文件名部分,从而显示短名"乐瑞固企业介绍"而非 tmp_xxx
const targetPath = `${wx.env.USER_DATA_PATH}/fs_${Date.now()}_${title}.${ext}`;
try {
// saveFile:复制临时文件到持久目录并改名(临时文件在某些基础库上禁止直接 rename)
fs.saveFile({
tempFilePath: res.tempFilePath,
filePath: targetPath,
success: r => {
uni.hideLoading();
uni.openDocument({
filePath: r.savedFilePath,
fileType: ext,
showMenu: true,
fail: () => {
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
},
complete: () => {
// 预览结束后清理,避免占用持久空间(失败忽略)
try { fs.removeSavedFile({ filePath: r.savedFilePath }); } catch (e) {}
}
});
},
fail: () => {
// saveFile 失败(配额等),直接用临时文件 openDocument 兜底
uni.hideLoading();
uni.openDocument({
filePath: res.tempFilePath,
fileType: ext,
showMenu: true,
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
});
}
});
} catch (e) {
// 极端异常兜底
uni.hideLoading();
uni.openDocument({
filePath: res.tempFilePath,
fileType: ext,
showMenu: true,
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
});
}
// #endif
// ----- App / 其他小程序:直接 openDocument 打开临时文件;失败兜底跳转预览页 -----
// #ifndef MP-WEIXIN
uni.hideLoading();
uni.openDocument({
filePath: res.tempFilePath,
fileType: ext,
showMenu: true,
fail: () => {
uni.navigateTo({
url: previewPage,
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
});
}
});
// #endif
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
},
complete: res => {
if (res && res.statusCode === 404) {
uni.hideLoading();
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
}
}
});
},
imgError(index) {
if (this.list[index]) {
@@ -224,18 +321,70 @@ export default {
border-radius: 16rpx;
}
.article-wrap .item .article-img {
.article-wrap .item .pdf-icon {
margin-right: 20rpx;
width: 80rpx;
height: 80rpx;
flex-shrink: 0;
background-color: #e64340;
border-radius: 10rpx;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 8rpx rgba(230, 67, 64, 0.2);
}
.article-wrap .item .article-img image {
.article-wrap .item .pdf-icon .pdf-icon-p {
position: absolute;
top: 2rpx;
left: 8rpx;
color: #ffffff;
font-size: 40rpx;
font-weight: 900;
font-family: Arial, Helvetica, sans-serif;
line-height: 1;
letter-spacing: 0;
}
.article-wrap .item .pdf-icon .pdf-icon-fold {
position: absolute;
top: 0;
right: 0;
width: 24rpx;
height: 24rpx;
background: linear-gradient(135deg, transparent 50%, #ffeceb 50%);
border-bottom-left-radius: 4rpx;
}
.article-wrap .item .pdf-icon .pdf-icon-lines {
position: absolute;
right: 6rpx;
bottom: 6rpx;
width: 36rpx;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.article-wrap .item .pdf-icon .pdf-icon-line {
width: 100%;
height: 3rpx;
background-color: #ffffff;
opacity: 0.85;
margin-bottom: 4rpx;
border-radius: 2rpx;
}
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(1) {
width: 100%;
}
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(2) {
width: 80%;
}
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(3) {
width: 60%;
margin-bottom: 0;
}
.article-wrap .item .info-wrap {
@@ -243,16 +392,21 @@ export default {
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
}
.article-wrap .item .info-wrap .title {
font-weight: 700;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
font-size: 24rpx;
font-size: 28rpx;
line-height: 1.4;
white-space: normal;
word-break: break-all;
}
.article-wrap .item .info-wrap .read-wrap {

View File

@@ -6,11 +6,28 @@
</view>
<view class="system-form-wrap">
<view class="form-title">{{ $lang('form.form.title') }}</view>
<ns-form :data="detail.json_data" :formConfig="detail.form_config" ref="form"></ns-form>
<ns-form :data="detail.json_data" :formConfig="mergedFormConfig" ref="form"></ns-form>
<!-- 行为设置:提交验证码 -->
<view class="captcha-wrap" v-if="behavior.captcha == 1">
<input class="captcha-input" type="text" v-model="captcha.code" :placeholder="$lang('form.form.captchaPlaceholder')" />
<!-- #ifdef H5 -->
<img v-if="captcha.img" class="captcha-img" :src="captcha.img" @click="getCaptcha()">
<!-- #endif -->
<!-- #ifndef H5 -->
<image v-if="captcha.img" class="captcha-img" :src="captcha.img" mode="aspectFill" @click="getCaptcha()"></image>
<!-- #endif -->
<view class="captcha-btn" @click="getCaptcha()">{{ captcha.img ? '换一张' : '获取验证码' }}</view>
</view>
<button type="primary" size="mini" class="button mini" @click="create()">{{ $lang('common.submit') }}</button>
</view>
</view>
<ns-empty :text="complete ? $lang('form.form.submitSuccess') : $lang('form.form.noFormInfo')" v-else></ns-empty>
<!-- 空态:除 ns-empty 文案外,追加一个可手动点的登录按钮——解决一切自动弹窗时序问题后的最后兜底 -->
<view v-else>
<ns-empty :text="emptyText"></ns-empty>
<view v-if="!complete" style="padding: 20rpx 40rpx 40rpx; display: flex; justify-content: center;">
<button type="primary" size="mini" @click="forceOpenLogin">点击登录查看表单</button>
</view>
</view>
<loading-cover ref="loadingCover"></loading-cover>
<ns-login ref="login"></ns-login>
@@ -30,9 +47,55 @@
isRepeat: false,
complete: false,
scroll: true,
urlParams: {}
urlParams: {},
// 登录后回跳地址(供 ns-login / H5 重定向使用)
backUrl: '',
// 行为设置(后端 __form_config__.behavior 下发,含默认值)
behavior: {},
// 非成功状态的空态文案(表单未开放/需登录/加载失败等)
formError: '',
// 图形验证码(behavior.captcha=1 时展示)
captcha: { id: '', img: '', code: '' },
// 是否已加载过验证码(避免 onShow 重复刷新导致 session 错乱)
captchaLoaded: false,
// 登录拦截时暂存待提交数据:组件内存(MP 不丢态)+ 本地存储(H5 登录整页回跳后从存储恢复)
pendingSubmit: null,
// onLoad→getData 比 ns-login 挂载早时,用此标志在 mounted 再兜底弹登录(避免 ref 空报错)
pendingLoginRequired: false
};
},
computed: {
// 前端视觉默认:强制覆盖后端 form_config 中可能存在的旧默认(horizontal / 红按钮 / 灰背景等),
// 与 diy-form 路径(formdata.vue 的 styleDefaults)保持一致,使系统表单页也统一呈现新设计稿样式。
// 后端仍保留行为类配置(behavior / submitText 等),本合并仅覆盖视觉字段。
mergedFormConfig() {
const styleDefaults = {
formLayout: 'vertical',
formPaddingX: 28,
rowPaddingY: 24,
labelGap: 20,
labelFontSize: 32,
labelFontWeight: 700,
labelColor: '#1f2937',
inputHeight: 84,
inputRadius: 16,
inputBgColor: '#ffffff',
inputBorderColor: '#e5e7eb',
inputFocusBorderColor: '#3b82f6',
inputFocusBgColor: '#ffffff',
placeholderColor: '#9ca3af',
placeholderFontSize: 28,
checkedColor: '#3b82f6',
showRowDivider: false,
submitColor: '#ffffff',
submitBgColor: '#3b82f6'
};
return Object.assign({}, this.detail ? (this.detail.form_config || {}) : {}, styleDefaults);
},
emptyText() {
return this.complete ? this.$lang('form.form.submitSuccess') : (this.formError || this.$lang('form.form.noFormInfo'));
}
},
onLoad(data) {
// #ifdef MP-ALIPAY
let options = my.getLaunchOptionsSync();
@@ -55,27 +118,100 @@
sceneParams = sceneParams.split('&');
if (sceneParams.length) {
sceneParams.forEach(item => {
if (item.indexOf('id') != -1) this.id = item.split('-')[1];
// scene 中也可能携带其他参数
if (!item) return;
// 兼容标准 key=value(id=13&uniacid=1)与旧式 id-13 两种 scene 格式
let pair = item.split('=');
if (pair.length === 2 && pair[0] !== 'id') {
this.urlParams[pair[0]] = pair[1];
if (pair.length === 2) {
const key = pair[0];
const val = pair[1];
if (key === 'id') this.id = val;
else if (key === 'uniacid') this.uniacid = val;
else this.urlParams[key] = val;
} else if (item.indexOf('id') === 0 && item.indexOf('-') !== -1) {
// 旧格式兜底:id-13
this.id = item.split('-')[1];
}
});
}
}
// #ifdef H5
// H5:从 window.location.href 解析参数,兼容 history 路由 (?key=val) 与 hash 路由 (#/path?key=val) 两种形式
// 保证 recommend_code / uniacid 等参数不丢失
try {
const href = window.location.href;
let queryStr = '';
const hashIdx = href.indexOf('#');
const qIdx = href.indexOf('?');
if (qIdx !== -1 && (hashIdx === -1 || qIdx < hashIdx)) {
// history 模式:? 在 # 之前(或无 #)
queryStr = href.substring(qIdx + 1, hashIdx !== -1 ? hashIdx : href.length);
} else if (hashIdx !== -1) {
// hash 模式:# 之后可能还有 ?
const afterHash = href.substring(hashIdx + 1);
const hq = afterHash.indexOf('?');
if (hq !== -1) queryStr = afterHash.substring(hq + 1);
}
if (queryStr) {
const pairs = queryStr.split('&');
for (const p of pairs) {
if (!p) continue;
const eq = p.indexOf('=');
let k, v;
if (eq === -1) { k = decodeURIComponent(p); v = ''; }
else { k = decodeURIComponent(p.substring(0, eq)); v = decodeURIComponent(p.substring(eq + 1)); }
if (k === 'id') { if (!this.id) this.id = v; }
else if (k === 'uniacid') { /* reserved, 透传到后端时不进 urlParams */ }
else if (k === 'scene') { continue; }
else { if (this.urlParams[k] === undefined) this.urlParams[k] = v; }
}
}
} catch (e) {}
// #endif
// 构建登录回跳地址:包含 id + 所有 urlParams(尤其 recommend_code),
// 供 ns-login 弹窗登录成功或 H5 整页重定向登录后回到本页
const backQuery = [];
if (this.id) backQuery.push('id=' + encodeURIComponent(this.id));
for (const k in this.urlParams) {
const v = this.urlParams[k];
if (v !== undefined && v !== null && v !== '') {
backQuery.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
}
}
this.backUrl = '/pages_tool/form/form' + (backQuery.length ? '?' + backQuery.join('&') : '');
},
onShow() {
if (!this.storeToken) {
this.redirectTologin('/pages_tool/form/form?id=' + this.id);
return;
}
// 打开是否需要登录由后端 behavior 决定(info 返回 FORM_LOGIN_REQUIRED 时弹登录)
this.getData();
},
mounted() {
// 1) 兜底 A:onLoad→getData 回调比 ns-login 挂载还早时,pendingLoginRequired=true
// 挂载完成再最后尝试一次自动弹窗
if (this.pendingLoginRequired) {
const tryOpen = () => {
try {
if (this.$refs.login && typeof this.$refs.login.open === 'function') {
this.$refs.login.open(this.backUrl);
}
} catch (e) { console.warn('[FORM] mounted login.open failed:', e); }
};
this.$nextTick(tryOpen);
this.pendingLoginRequired = false;
}
// 2) 兜底 B:登录态在组件创建之前就已写入(token 提前 race),但 info 因时序没拿到数据
// 此时用户实际上已经登录,再拉一次 info 就能拿到真实表单数据
if (this.storeToken && !this.detail) {
this.$nextTick(() => { this.getData(); });
}
},
watch: {
storeToken: function(nVal, oVal) {
if (nVal) this.getData();
if (nVal) {
this.getData();
// 填写需登录:弹窗登录成功后自动续提交
this.resumeSubmitAfterLogin();
}
}
},
methods: {
@@ -85,40 +221,303 @@
data: {
form_id: this.id
},
success: res => { this._handleInfoResponse(res); },
fail: err => { this._handleInfoFailure(err); }
});
},
// ====== 表单 info 响应统一处理(剥离自 getData,便于严格判定与复用) ======
// 成功分支必须同时满足:code==0 + data 是对象 + 内含 json_data 或 form_config
// 只要不满足就是"没拿到有效表单",然后用最宽的条件判定是否是登录拦截:
// - 消息包含 FORM_LOGIN_REQUIRED / LOGIN_REQUIRED / 请先登录 / 登录相关中文
// - 或者 data 无效(null/undefined/''/{}/非对象)+ 用户未登录 + 有 form_id
// 命中任一 → 一律弹登录 + 友好文案"请先登录",不再误显示"未获取到表单信息"
_handleInfoResponse(res) {
try {
const payload = (res && typeof res === 'object') ? res : {};
const dataObj = payload.data;
// —— 1) 严格判定:是否拿到了真正的表单结构? ——
const hasValidFormData =
payload.code == 0 &&
dataObj !== null && dataObj !== undefined && dataObj !== '' &&
typeof dataObj === 'object' &&
(dataObj.json_data !== undefined || dataObj.form_config !== undefined);
if (hasValidFormData) {
this.detail = dataObj;
this.formError = '';
this.behavior = (dataObj.form_config && dataObj.form_config.behavior) || {};
if (this.behavior.captcha == 1 && !this.captchaLoaded) this.getCaptcha();
this.resumeSubmitAfterLogin();
// 本地缓存一份字段 JSON:下次再打开时如果仍被登录拦截,可作为 skeleton 兜底
try { uni.setStorageSync('form_fields_cache_' + this.id, dataObj.json_data); } catch (e) {}
// 成功分支同样必须关闭 loading 遮罩:
// 之前这里直接 return 跳过了函数底部的 hide(),导致登录后进入表单一直转圈
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
return;
}
// —— 2) 没拿到有效结构 → 宽网判定:是否属于"需要登录"场景? ——
const msg = (payload.message || '').toString();
const isFormNotOpen = (msg === 'FORM_NOT_OPEN');
const isLoginMsg =
msg === 'FORM_LOGIN_REQUIRED' ||
msg === 'LOGIN_REQUIRED' ||
msg.indexOf('请先登录') !== -1 ||
(msg.indexOf('登录') !== -1 && msg.indexOf('请') !== -1) ||
(msg.toLowerCase().indexOf('login') !== -1 && msg.toLowerCase().indexOf('required') !== -1);
const dataIsEmpty =
!dataObj ||
dataObj === '' ||
dataObj === null ||
dataObj === undefined ||
(typeof dataObj === 'object' && !dataObj.json_data && !dataObj.form_config &&
((Array.isArray(dataObj) && dataObj.length === 0) ||
(!Array.isArray(dataObj) && Object.keys(dataObj).length === 0)));
// 最宽判定:只要不是"表单未开放",且(显式登录消息 OR 空数据+未登录+有 id)都按登录拦截处理
const shouldTriggerLogin =
!isFormNotOpen &&
(isLoginMsg || (dataIsEmpty && !this.storeToken && this.id));
if (shouldTriggerLogin && this.storeToken) {
// 已登录(storeToken 存在)但后端仍返回登录拦截:
// 登录一次后就不再自动弹登录/跳登录页(防止 H5 登录回跳后 token 未被
// 及时认可或双请求竞态时反复弹登录的死循环),仅展示空态文案 + 手动登录按钮
this.formError = this.$lang('form.form.loginRequired');
} else if (shouldTriggerLogin) {
this.formError = this.$lang('form.form.loginRequired');
this._openLoginWithRetry();
} else if (isFormNotOpen) {
this.formError = this.$lang('form.form.notOpen');
} else {
this.formError = msg || this.$lang('form.form.noFormInfo');
}
} catch (e) {
console.warn('[FORM] _handleInfoResponse exception:', e && e.message ? e.message : e);
this.formError = this.$lang('form.form.noFormInfo');
}
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
},
// info 接口网络失败时也做同样判定:未登录+有 id 就尝试启动登录流程,至少让用户先完成登录
// (登录成功后会再次 getData,那时网络一般已经正常)
_handleInfoFailure(err) {
try {
if (this.id && !this.storeToken) {
this.formError = this.$lang('form.form.loginRequired');
this._openLoginWithRetry();
} else {
this.formError = this.$lang('form.form.noFormInfo');
}
} catch (e) {
this.formError = this.$lang('form.form.noFormInfo');
}
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
},
// 打开登录:3 次 setTimeout 重试(0ms / 60ms / 320ms)
// 彻底覆盖 onLoad→回调→ns-login 尚未挂载的所有时序问题
_openLoginWithRetry() {
// 防重复触发:3 秒内只允许自动弹一次登录
// (响应回调 / mounted 兜底 / watcher 同时命中时只生效一次,避免 H5 反复跳登录页)
const now = Date.now();
if (this._lastAutoLoginAt && (now - this._lastAutoLoginAt) < 3000) return;
this._lastAutoLoginAt = now;
const url = this.backUrl;
let attempts = 0;
const delays = [0, 60, 320];
const tryOnce = () => {
try {
if (this.$refs.login && typeof this.$refs.login.open === 'function') {
this.$refs.login.open(url);
this.pendingLoginRequired = false;
return true;
}
} catch (e) {
console.warn('[FORM] login.open attempt ' + (attempts + 1) + ' failed:', e);
}
attempts++;
if (attempts < delays.length) {
setTimeout(tryOnce, delays[attempts]);
} else {
// 三次全失败:交给 mounted 钩子最后兜底
this.pendingLoginRequired = true;
}
return false;
};
tryOnce();
},
// 模板空态按钮的手动入口:用户主动点击也能打开登录弹窗(最后一道防线)
forceOpenLogin() {
this._openLoginWithRetry();
},
// 图形验证码(点击图片/按钮刷新;提交失败后自动刷新)
getCaptcha() {
this.captchaLoaded = true;
this.$api.sendRequest({
url: '/api/captcha/captcha',
data: {
captcha_id: this.captcha.id
},
success: res => {
if (res.code == 0 && res.data) {
this.detail = res.data;
this.captcha.id = res.data.id;
this.captcha.img = res.data.img ? res.data.img.replace(/\r\n/g, '') : '';
this.captcha.code = '';
}
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
},
fail: res => {
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
}
});
},
// 登录拦截时保存待提交数据(组件内存 + 本地存储,H5 登录会整页回跳)
savePendingSubmit(formData) {
const data = formData || (this.$refs.form && this.$refs.form.formData) || null;
const payload = {
formId: this.id,
urlParams: this.urlParams,
formData: data,
captchaId: this.captcha.id,
captchaCode: this.captcha.code,
ts: Date.now()
};
this.pendingSubmit = payload;
try { uni.setStorageSync('form_pending_submit', payload); } catch (e) {}
// H5 整页回跳标记:仅当确由登录拦截触发时才允许自动续提交,避免陈旧数据被误提交
try { uni.setStorageSync('form_pending_from_login', 1); } catch (e) {}
},
// 仅在已登录时取回并消费待提交数据(未登录 / 非登录回跳 / 过期不消费,避免误提交);取后清除
takePendingSubmit() {
let payload = this.pendingSubmit;
const fromStorage = !payload;
if (fromStorage) {
try { payload = uni.getStorageSync('form_pending_submit'); } catch (e) {}
}
if (!payload || !payload.formData) return null;
if (!this.storeToken) return null;
// H5 存储路径:必须是因登录拦截触发的回跳,否则视为陈旧数据,清理后不提交
if (fromStorage) {
let fromLogin = false;
try { fromLogin = uni.getStorageSync('form_pending_from_login'); } catch (e) {}
if (!fromLogin) {
this.pendingSubmit = null;
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
return null;
}
}
// 仅消费 10 分钟内的待提交,避免极旧数据被自动提交
if (payload.ts && Date.now() - payload.ts > 10 * 60 * 1000) {
this.pendingSubmit = null;
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
try { uni.removeStorageSync('form_pending_from_login'); } catch (e) {}
return null;
}
this.pendingSubmit = null;
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
try { uni.removeStorageSync('form_pending_from_login'); } catch (e) {}
return payload;
},
// 登录成功后:回填已填数据并自动续提交
resumeSubmitAfterLogin() {
const pending = this.takePendingSubmit();
if (!pending) return;
// 回填表单字段(nextTick 确保 ns-form 已挂载),避免 H5 整页回跳后好友看到空白表单
this.$nextTick(() => this.restoreFormData(pending.formData));
// 验证码:MP 登录不丢态可直接提交;H5 回跳后验证码失效,需重新获取并让用户重填
if (this.behavior.captcha == 1 && !this.captcha.code) {
this.getCaptcha();
this.$util.showToast({ title: '请重新输入验证码后提交' });
return;
}
this.create();
},
// 将保存的 formData 回填到 ns-form
restoreFormData(formData) {
try {
const form = this.$refs.form;
if (form && Array.isArray(formData)) {
form.formData = JSON.parse(JSON.stringify(formData));
if (typeof form.processFormData === 'function') form.processFormData();
}
} catch (e) {}
},
// 填完跳转:消费统一链接选择器的完整对象(wap_url 外链/页面路径、外部小程序、拨打电话)
doRedirect(link) {
const fallback = () => { this.complete = true; this.detail = null; };
if (!link) return fallback();
if (link.appid) {
// 跳转其他微信小程序
wx.navigateToMiniProgram({ appId: link.appid, path: link.page || '', fail: fallback });
} else if (link.mobile) {
uni.makePhoneCall({ phoneNumber: String(link.mobile), fail: fallback });
fallback();
} else if (link.wap_url) {
if (/^https?:\/\//i.test(link.wap_url)) {
uni.navigateTo({ url: '/pages_tool/webview/webview?src=' + encodeURIComponent(link.wap_url), fail: fallback });
} else {
uni.navigateTo({ url: link.wap_url, fail: fallback });
}
} else {
fallback();
}
},
create() {
if (this.$refs.form.verify()) {
if (this.isRepeat) return;
this.isRepeat = true;
const behavior = this.behavior || {};
// 填写需登录 / 限填(后端强制,前端提前拦截优化体验)
if ((behavior.require_login_submit == 1 || behavior.submit_limit > 0) && !this.storeToken) {
// 保存已填写数据,登录成功(MP 弹窗 / H5 回跳)后自动续提交,避免数据丢失
this.savePendingSubmit();
this.$refs.login && this.$refs.login.open(this.backUrl);
return;
}
// 验证码必填
if (behavior.captcha == 1 && !this.captcha.code) {
this.$util.showToast({ title: this.$lang('form.form.captchaRequired') });
return;
}
this.isRepeat = true;
const submitCaptchaId = String(this.captcha.id || '');
const submitCaptchaCode = String(this.captcha.code || '').toUpperCase();
console.log('[FORM-SUBMIT-CAPTCHA]', { captcha_id: submitCaptchaId, captcha_code: submitCaptchaCode, raw_code: this.captcha.code });
this.$api.sendRequest({
url: '/form/api/form/create',
data: {
form_id: this.id,
form_data: typeof this.$refs.form.formData === 'string' ? this.$refs.form.formData : JSON.stringify(this.$refs.form.formData),
url_params: JSON.stringify(this.urlParams),
member_id: this.memberInfo ? this.memberInfo.id : 0
member_id: this.memberInfo ? this.memberInfo.id : 0,
captcha_id: submitCaptchaId,
captcha_code: submitCaptchaCode
},
success: res => {
if (res.code == 0) {
this.$util.showToast({ title: this.$lang('form.form.submitSuccess') })
this.$util.showToast({ title: this.$lang('form.form.submitSuccess') });
const link = behavior.redirect;
setTimeout(() => {
this.complete = true;
this.detail = null;
}, 1500)
link ? this.doRedirect(link)
: (this.complete = true, this.detail = null);
}, link ? 800 : 1500);
} else {
this.isRepeat = false;
this.$util.showToast({ title: res.message })
if (res.message == 'FORM_LOGIN_REQUIRED') {
// 提交时后端仍要求登录(如登录态过期):暂存数据,重登后自动续提交
this.savePendingSubmit();
this.$refs.login && this.$refs.login.open(this.backUrl);
} else if (res.message == 'CAPTCHA_ERROR' || res.message == 'CAPTCHA_FAILURE') {
this.$util.showToast({ title: this.$lang('form.form.captchaError') });
if (behavior.captcha == 1) this.getCaptcha();
} else if (res.message == 'FORM_SUBMIT_LIMIT') {
this.$util.showToast({ title: this.$lang('form.form.submitLimit') });
this.complete = true;
this.detail = null;
} else if (res.message == 'FORM_NOT_OPEN') {
this.$util.showToast({ title: this.$lang('form.form.notOpen') });
} else {
this.$util.showToast({ title: res.message });
}
}
}
});
@@ -153,6 +552,48 @@
padding-top: 20rpx;
}
.captcha-wrap {
display: flex;
align-items: center;
background: #fff;
border-radius: 12rpx;
padding: 16rpx 20rpx;
margin-top: 24rpx;
.captcha-input {
flex: 1;
height: 84rpx;
line-height: 84rpx;
font-size: 28rpx;
border: 1rpx solid #e5e7eb;
border-radius: 16rpx;
padding: 0 24rpx;
box-sizing: border-box;
background: #ffffff;
}
.captcha-img {
width: 200rpx;
height: 72rpx;
margin-left: 20rpx;
border-radius: 12rpx;
border: 2rpx solid #e5e7eb;
}
.captcha-btn {
margin-left: 20rpx;
height: 72rpx;
line-height: 72rpx;
padding: 0 24rpx;
font-size: 26rpx;
color: #ff4544;
background: #fff;
border: 2rpx solid #ff4544;
border-radius: 12rpx;
white-space: nowrap;
}
}
.button {
height: 80rpx;
line-height: 80rpx !important;

View File

@@ -1,5 +1,14 @@
<template>
<!-- #ifdef MP-HARMONY -->
<view :style="themeColor" class="harmony-root">
<!-- #endif -->
<!-- #ifndef MP-HARMONY -->
<view :style="themeColor">
<!-- #endif -->
<!-- 顶部图条:位于 navbar 之下、表单卡片之上。宽度 calc(100% - 2*containerInset) 水平居中,
留下与卡片等宽的左右对称留白(默认容器内缩 50rpx ≈ 屏宽 6.7%,即内容宽 87%)。
图片由后台 form_config.bg_image / bgImage 下发;未配置时不渲染,保持原来的纯白布局。 -->
<view v-if="bgImage" class="formdata-bg" :style="bgLayerStyle"></view>
<!--
自绘导航栏(仅在 navigationStyle:custom 的端渲染:APP / 微信小程序 / 华为快应用 / 鸿蒙元服务)
- pages.json 已为这几端开启 navigationStyle:custom,系统不再提供原生 navbar
@@ -23,19 +32,64 @@
</view>
<!-- #endif -->
<block v-if="detail">
<!-- 鸿蒙元服务:用原生 scroll-view 包裹表单,替代 CSS overflow 滚动(ArkUI 不识别 WebKit overflow 语义)。
根容器 harmony-root 占满 100vh 并 flex 列布局,navbar 占顶部固定高度,page flex:1 占剩余,scroll-view 在其内滚动。
提交按钮由 diy-form 内部渲染,处于 scroll-view 内,用户可滚到末尾点击。 -->
<!-- #ifdef MP-HARMONY -->
<view class="page harmony-page">
<scroll-view scroll-y="true" :show-scrollbar="false" class="form-scroll">
<view class="system-form-wrap" :style="containerStyle">
<diy-form ref="form" :value="diyValue" dataMode="raw" :showSubmit="!showCaptcha" :onSubmit="handleSubmit" :container-inset="containerInset" />
<!-- 行为设置:提交验证码开启时,隐藏 diy-form 内置按钮,由本页渲染验证码+提交按钮 -->
<view v-if="showCaptcha" class="form-extra-wrap">
<view class="captcha-wrap">
<input class="captcha-input" type="text" v-model="captcha.code" :placeholder="$lang('form.form.captchaPlaceholder')" />
<!-- #ifdef H5 -->
<img v-if="captcha.img" class="captcha-img" :src="captcha.img" @click="getCaptcha()">
<!-- #endif -->
<!-- #ifndef H5 -->
<image v-if="captcha.img" class="captcha-img" :src="captcha.img" mode="aspectFill" @click="getCaptcha()"></image>
<!-- #endif -->
<view class="captcha-btn" @click="getCaptcha()">{{ captcha.img ? '换一张' : '获取验证码' }}</view>
</view>
<view class="custom-submit" @click="submitFromPage">{{ submitBtnText }}</view>
</view>
</view>
</scroll-view>
</view>
<!-- #endif -->
<!-- #ifndef MP-HARMONY -->
<view class="page">
<view class="system-form-wrap">
<view class="system-form-wrap" :style="containerStyle">
<!-- 复用 diy-form:banner / tip / 表单字段 / 提交按钮 全部由其内部渲染,样式(bannerPaddingX/tipColor/submitBgColor 等)由 form_config 控制。
:onSubmit 接管提交逻辑(带 uniacid + showModal);跨 H5 / MP 一致,不依赖 $listeners。
注意:create 重度依赖本组件 this(id/uniacid/memberInfo/$api 等),必须在 created 阶段 bind this,
否则 diy-form 通过 this.onSubmit(formData) 调用时 this 会漂移到子组件实例 -->
<diy-form ref="form" :value="diyValue" dataMode="raw" :onSubmit="handleSubmit" />
:onSubmit 接管提交逻辑(带 uniacid + showModal);跨 H5 / MP 一致,不依赖 $listeners。
注意:create 重度依赖本组件 this(id/uniacid/memberInfo/$api 等),必须在 created 阶段 bind this,
否则 diy-form 通过 this.onSubmit(formData) 调用时 this 会漂移到子组件实例 -->
<diy-form ref="form" :value="diyValue" dataMode="raw" :showSubmit="!showCaptcha" :onSubmit="handleSubmit" :container-inset="containerInset" />
<!-- 行为设置:提交验证码开启时,隐藏 diy-form 内置按钮,由本页渲染验证码+提交按钮 -->
<view v-if="showCaptcha" class="form-extra-wrap">
<view class="captcha-wrap">
<input class="captcha-input" type="text" v-model="captcha.code" :placeholder="$lang('form.form.captchaPlaceholder')" />
<!-- #ifdef H5 -->
<img v-if="captcha.img" class="captcha-img" :src="captcha.img" @click="getCaptcha()">
<!-- #endif -->
<!-- #ifndef H5 -->
<image v-if="captcha.img" class="captcha-img" :src="captcha.img" mode="aspectFill" @click="getCaptcha()"></image>
<!-- #endif -->
<view class="captcha-btn" @click="getCaptcha()">{{ captcha.img ? '换一张' : '获取验证码' }}</view>
</view>
<view class="custom-submit" @click="submitFromPage">{{ submitBtnText }}</view>
</view>
</view>
</view>
<!-- #endif -->
</block>
<block v-else>
<ns-empty :text="complete ? $lang('form.formdata.submitSuccess') : $lang('form.formdata.noFormInfo')"
:isIndex="false"></ns-empty>
<ns-empty :text="emptyText" :isIndex="false"></ns-empty>
<!-- 空态追加手动登录按钮:任何自动弹窗机制失效时的最后兜底,用户一点就开 -->
<view v-if="!complete" style="padding: 20rpx 40rpx 40rpx; display: flex; justify-content: center;">
<button type="primary" size="mini" @click="forceOpenLogin">点击登录查看表单</button>
</view>
</block>
<loading-cover ref="loadingCover"></loading-cover>
<ns-login ref="login"></ns-login>
@@ -62,6 +116,20 @@ export default {
showHomeBtn: false, // 查询参数 show_home=1 时,在系统标题栏左侧显示 Home 按钮
urlParams: {}, // 通用 URL 透传参数收集
handleSubmit: null, // create 的 this 绑定版本,作为 :onSubmit 传给 diy-form(见 created)
// 行为设置(后端 __form_config__.behavior 下发,含默认值)
behavior: {},
// 非成功状态的空态文案(表单未开放/需登录/加载失败等)
formError: '',
// 图形验证码(behavior.captcha=1 时展示,由本页渲染而非 diy-form)
captcha: { id: '', img: '', code: '' },
// 是否已加载过验证码(避免 onShow 重复刷新导致 session 错乱)
captchaLoaded: false,
// 登录后回跳地址(onLoad 时由原始 options 重建)
backUrl: '',
// 登录拦截时暂存待提交数据:组件内存(MP 不丢态)+ 本地存储(H5 登录整页回跳后从存储恢复)
pendingSubmit: null,
// onLoad→getData 比 ns-login 挂载早时,用此标志在 mounted 再兜底弹登录(避免 ref 空报错)
pendingLoginRequired: false,
// #ifndef H5
navbarHeight: 44, // 自绘 navbar 内容区高度(不含状态栏),跨端兜底 44px
@@ -106,16 +174,112 @@ export default {
// diy-form 内部 mergedFormConfig 同时兼容 snake_case 后再交给 ns-newform
// - show_banner / form_banner:交给 diy-form 渲染 banner,样式走 form_config.bannerPaddingX;form_banner 给保底图
// - show_tip / form_tip:交给 diy-form 渲染 tip,样式走 form_config;form_tip 给保底文案
// 注:本 computed 只在 <block v-if="detail"> 内被消费,无需 null 守卫
// 注:小程序端模板引用的 computed 会进入 setData 数据流、每次更新都重新求值,
// 即使消费节点都在 v-if="detail" 内,detail 置空瞬间(提交成功/登录回跳等)也会求值,
// 必须做空值守卫,否则真机抛 Cannot read properties of null (reading 'json_data') 导致渲染中断
diyValue() {
// 新视觉默认样式:强制覆盖后端 form_config 中可能存在的旧默认(horizontal / 红按钮 / 灰背景等),
// 使表单在前端统一呈现为新设计稿样式;后台仍保留行为类配置(如 captcha / behavior / submitText)。
const styleDefaults = {
formLayout: 'vertical',
formPaddingX: 28,
rowPaddingY: 24,
labelGap: 20,
labelFontSize: 32,
labelFontWeight: 700,
labelColor: '#1f2937',
inputHeight: 84,
inputRadius: 16,
inputBgColor: '#ffffff',
inputBorderColor: '#e5e7eb',
inputFocusBorderColor: '#3b82f6',
inputFocusBgColor: '#ffffff',
placeholderColor: '#9ca3af',
placeholderFontSize: 28,
checkedColor: '#3b82f6',
showRowDivider: false,
submitColor: '#ffffff',
submitBgColor: '#3b82f6'
};
if (!this.detail) {
return {
jsonData: null,
formConfig: Object.assign({}, styleDefaults),
show_banner: 0,
form_banner: '',
show_tip: 0,
form_tip: ''
};
}
return {
jsonData: this.detail.json_data,
formConfig: this.detail.form_config,
show_banner: this.detail.show_banner,
formConfig: Object.assign({}, this.detail.form_config || {}, styleDefaults),
// 全屏底图模式下隐藏卡片内 banner,避免与底图重复(见 hideInnerBanner)
show_banner: this.hideInnerBanner ? 0 : this.detail.show_banner,
form_banner: this.detail.form_banner || 'public/uniapp/form/banner.png',
show_tip: this.detail.show_tip,
form_tip: this.detail.form_tip || this.$lang('form.formdata.formTipPlaceholder')
}
},
// ===== 容器左右内缩(默认开启,不依赖 bgImage) =====
// 图条图片:后台 form_config.bg_image / bgImage 下发(兼容 snake_case / camelCase)。
// 未配置时不渲染图条,但表单卡片本身已有内缩效果(见 .system-form-wrap 默认 padding)
bgImage() {
if (!this.detail) return '';
const fc = this.detail.form_config || {};
const raw = fc.bgImage || fc.bg_image || fc.backgroundImage || fc.background_image || '';
return raw ? this.$util.img(raw) : '';
},
// 容器左右内缩(rpx)。后台 form_config.container_inset / containerInset 可调;
// 默认 50rpx,在 750rpx 屏宽下左右各留 50rpx ≈ 6.7%,内容宽 ≈ 87%,
// 与参考图一致;CSS .system-form-wrap 中也写了这个默认 padding(防 H5 computed 未及时就绪时也生效)
containerInset() {
const fc = (this.detail && this.detail.form_config) || {};
const raw = fc.containerInset !== undefined ? fc.containerInset : fc.container_inset;
const n = parseInt(raw);
return isNaN(n) ? 50 : Math.min(150, Math.max(0, n));
},
// 顶部图条高度(rpx)。后台 form_config.bg_image_height 可调;clamp 80-600
bgImageHeight() {
const fc = (this.detail && this.detail.form_config) || {};
const raw = fc.bgImageHeight !== undefined ? fc.bgImageHeight : fc.bg_image_height;
const n = parseInt(raw);
return isNaN(n) ? 200 : Math.min(600, Math.max(80, n));
},
// 图条与表单卡片之间的间距(rpx)。后台 form_config.card_gap 可调;clamp 0-80
cardGap() {
const fc = (this.detail && this.detail.form_config) || {};
const raw = fc.cardGap !== undefined ? fc.cardGap : fc.card_gap;
const n = parseInt(raw);
return isNaN(n) ? 24 : Math.min(80, Math.max(0, n));
},
// 图条内联样式:水平居中(width=100%-2*inset + 左右各 inset 边距),高度由配置控制
bgLayerStyle() {
const inset = this.containerInset;
return 'width:calc(100% - ' + (inset * 2) + 'rpx);margin:0 ' + inset + 'rpx;height:' + this.bgImageHeight + 'rpx;background-image:url(' + this.bgImage + ');';
},
// 表单容器内联样式:覆盖 .system-form-wrap 默认 padding,使后台可配。始终返回有效样式(含默认值),
// 不再依赖 bgImage 开关 —— 表单默认就有左右内缩居中(不需后台任何配置)
containerStyle() {
return 'padding-left:' + this.containerInset + 'rpx;padding-right:' + this.containerInset + 'rpx;';
},
// 图条模式下隐藏 diy-form 卡片顶部的 banner,避免与图条重复
// 如需保留卡片内 banner:把下面这行改成 `return false;` 即可
hideInnerBanner() {
return !!this.bgImage;
},
// 行为设置:提交验证码开启时 diy-form 隐藏内置提交按钮,本页渲染验证码 + 自有提交按钮
showCaptcha() {
return this.behavior.captcha == 1;
},
// 自有提交按钮文案:跟随表单配置的提交按钮文字(form_config 为 camelCase)
submitBtnText() {
return (this.detail && this.detail.form_config && this.detail.form_config.submitText) || this.$lang('common.submit');
},
// 空状态文案:提交成功 / 表单未开放 / 需登录 / 加载失败
emptyText() {
return this.complete ? this.$lang('form.formdata.submitSuccess')
: (this.formError || this.$lang('form.formdata.noFormInfo'));
}
},
onLoad(options) {
@@ -180,17 +344,67 @@ export default {
const scene = decodeURIComponent(options.scene)
const params = scene.split('&')
params.length && params.forEach(item => {
if (item.indexOf('id') != -1) {
this.id = item.split('-')[1]
}
// scene 中也可能携带其他参数
if (!item) return;
// 兼容标准 key=value(id=13&uniacid=1)与旧式 id-13 两种 scene 格式
let pair = item.split('=');
if (pair.length === 2 && pair[0] !== 'id') {
this.urlParams[pair[0]] = pair[1];
if (pair.length === 2) {
const key = pair[0];
const val = pair[1];
if (key === 'id') this.id = val;
else if (key === 'uniacid') this.uniacid = val;
else this.urlParams[key] = val;
} else if (item.indexOf('id') === 0 && item.indexOf('-') !== -1) {
// 旧格式兜底:id-13
this.id = item.split('-')[1];
}
})
}
// #ifdef H5
// H5:从 window.location.href 解析参数,兼容 history 路由 (?key=val) 与 hash 路由 (#/path?key=val) 两种形式
// 保证 recommend_code / uniacid 等参数在任何路由模式下都不丢失
try {
const href = window.location.href;
let queryStr = '';
const hashIdx = href.indexOf('#');
const qIdx = href.indexOf('?');
if (qIdx !== -1 && (hashIdx === -1 || qIdx < hashIdx)) {
// history 模式:? 在 # 之前(或无 #)
queryStr = href.substring(qIdx + 1, hashIdx !== -1 ? hashIdx : href.length);
} else if (hashIdx !== -1) {
// hash 模式:# 之后可能还有 ?
const afterHash = href.substring(hashIdx + 1);
const hq = afterHash.indexOf('?');
if (hq !== -1) queryStr = afterHash.substring(hq + 1);
}
if (queryStr) {
const pairs = queryStr.split('&');
const reservedH5 = ['scene', 'from', 'hide_navbar', 'show_home', 'title', 'enable_set_title'];
for (const p of pairs) {
if (!p) continue;
const eq = p.indexOf('=');
let k, v;
if (eq === -1) { k = decodeURIComponent(p); v = ''; }
else { k = decodeURIComponent(p.substring(0, eq)); v = decodeURIComponent(p.substring(eq + 1)); }
if (k === 'id') { if (!this.id) this.id = v; }
else if (k === 'uniacid') { if (!this.uniacid) this.uniacid = v; }
else if (reservedH5.indexOf(k) !== -1) {
// 导航/显示类参数:仅当 options 没有传时才从 href 补
if (options[k] === undefined && this[k] === undefined) {
if (k === 'from') this.isFromMpWebView = (v === 'mp-webview');
else if (k === 'hide_navbar') this.hideNavbar = this.$util.coerceBool(v);
else if (k === 'show_home') this.showHomeBtn = this.$util.coerceBool(v);
else if (k === 'title') this.formTitle = v;
else if (k === 'enable_set_title') this.enableSetTitle = (v == 1);
}
} else {
if (this.urlParams[k] === undefined) this.urlParams[k] = v;
}
}
}
} catch (e) {}
// #endif
// #ifdef H5
if (this.isFromMpWebView || this.hideNavbar) {
this.$nextTick(() => {
@@ -198,19 +412,64 @@ export default {
if (navbar) navbar.style.display = 'none'
})
}
// #endif
// #endif
// 登录后回跳地址:基于最终状态(this.id / this.uniacid + urlParams + 导航类参数)重建,
// 确保 options/scene/H5 href 三个来源的参数都被带到登录回跳 URL 中(尤其 recommend_code)
const backQuery = [];
if (this.uniacid) backQuery.push('uniacid=' + encodeURIComponent(this.uniacid));
if (this.id) backQuery.push('id=' + encodeURIComponent(this.id));
for (const k in this.urlParams) {
const v = this.urlParams[k];
if (v !== undefined && v !== null && v !== '') {
backQuery.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
}
}
// 导航类参数(不在 urlParams 中,单独收集)
if (options && options.from) backQuery.push('from=' + encodeURIComponent(options.from));
if (this.hideNavbar) backQuery.push('hide_navbar=1');
if (this.showHomeBtn) backQuery.push('show_home=1');
// #ifdef H5
if (this.formTitle) backQuery.push('title=' + encodeURIComponent(this.formTitle));
if (this.enableSetTitle) backQuery.push('enable_set_title=1');
// #endif
this.backUrl = '/pages_tool/form/formdata' + (backQuery.length ? '?' + backQuery.join('&') : '');
this.getData()
},
watch: {
storeToken(newVal, oldVal) {
newVal && this.getData()
if (newVal) {
this.getData();
// 填写需登录:弹窗登录成功后自动续提交
this.resumeSubmitAfterLogin();
}
}
},
onShow() {
this.updateNavbarTitle();
},
mounted() {
// 1) 兜底 A:onLoad→getData 回调比 ns-login 挂载还早时,pendingLoginRequired=true
// 挂载完成再最后尝试一次自动弹窗
if (this.pendingLoginRequired) {
const tryOpen = () => {
try {
if (this.$refs.login && typeof this.$refs.login.open === 'function') {
this.$refs.login.open(this.backUrl);
}
} catch (e) { console.warn('[FORMDATA] mounted login.open failed:', e); }
};
this.$nextTick(tryOpen);
this.pendingLoginRequired = false;
}
// 2) 兜底 B:登录态在组件创建之前就已写入(token 提前 race),但 info 因时序没拿到数据
// 此时用户实际上已经登录,再拉一次 info 就能拿到真实表单数据
if (this.storeToken && !this.detail) {
this.$nextTick(() => { this.getData(); });
}
},
methods: {
// #ifndef H5
// 返回上一页;用 navigateBack,栈深度>1 时由 hasBackStack 控制按钮可见性
@@ -233,18 +492,136 @@ export default {
form_id: this.id,
uniacid: this.uniacid
},
success: res => {
if (res.code == 0 && res.data) {
this.detail = res.data
this.updateNavbarTitle();
}
this.$refs.loadingCover && this.$refs.loadingCover.hide()
},
fail: () => {
this.$refs.loadingCover && this.$refs.loadingCover.hide()
}
success: res => { this._handleInfoResponse(res); },
fail: err => { this._handleInfoFailure(err); }
})
},
// ====== 表单 info 响应统一处理(剥离自 getData,便于严格判定与复用) ======
// 成功必须同时满足:code==0 + data 是对象 + 内含 json_data 或 form_config
// 不满足 → 宽网判定是否是登录拦截:
// - 显式消息:FORM_LOGIN_REQUIRED / LOGIN_REQUIRED / 请先登录
// - 兜底:数据为空 + 用户未登录 + 有 id
// 命中任一 → 一律弹登录 + 友好文案"请先登录",不再误显示"未获取到表单信息"
_handleInfoResponse(res) {
try {
const payload = (res && typeof res === 'object') ? res : {};
const dataObj = payload.data;
// —— 1) 严格判定:是否拿到了真正的表单结构? ——
const hasValidFormData =
payload.code == 0 &&
dataObj !== null && dataObj !== undefined && dataObj !== '' &&
typeof dataObj === 'object' &&
(dataObj.json_data !== undefined || dataObj.form_config !== undefined);
if (hasValidFormData) {
this.detail = dataObj;
this.formError = '';
this.updateNavbarTitle();
this.behavior = (dataObj.form_config && dataObj.form_config.behavior) || {};
if (this.behavior.captcha == 1 && !this.captchaLoaded) this.getCaptcha();
this.resumeSubmitAfterLogin();
// 本地缓存:下次再打开时若被登录拦截,可作为 skeleton 数据使用
try { uni.setStorageSync('form_fields_cache_' + this.id, dataObj.json_data); } catch (e) {}
// 成功分支同样必须关闭 loading 遮罩:
// 之前这里直接 return 跳过了函数底部的 hide(),导致登录后进入表单一直转圈
this.$refs.loadingCover && this.$refs.loadingCover.hide();
return;
}
// —— 2) 没拿到有效结构 → 宽网判定:是否属于"需要登录"场景? ——
const msg = (payload.message || '').toString();
const isFormNotOpen = (msg === 'FORM_NOT_OPEN');
const isLoginMsg =
msg === 'FORM_LOGIN_REQUIRED' ||
msg === 'LOGIN_REQUIRED' ||
msg.indexOf('请先登录') !== -1 ||
(msg.indexOf('登录') !== -1 && msg.indexOf('请') !== -1) ||
(msg.toLowerCase().indexOf('login') !== -1 && msg.toLowerCase().indexOf('required') !== -1);
const dataIsEmpty =
!dataObj ||
dataObj === '' ||
dataObj === null ||
dataObj === undefined ||
(typeof dataObj === 'object' && !dataObj.json_data && !dataObj.form_config &&
((Array.isArray(dataObj) && dataObj.length === 0) ||
(!Array.isArray(dataObj) && Object.keys(dataObj).length === 0)));
// 最宽判定:只要不是"表单未开放",且(显式登录消息 OR 空数据+未登录+有 id)都按登录拦截处理
const shouldTriggerLogin =
!isFormNotOpen &&
(isLoginMsg || (dataIsEmpty && !this.storeToken && this.id));
if (shouldTriggerLogin && this.storeToken) {
// 已登录(storeToken 存在)但后端仍返回登录拦截:
// 登录一次后就不再自动弹登录/跳登录页(防止 H5 登录回跳后 token 未被
// 及时认可或双请求竞态时反复弹登录的死循环),仅展示空态文案 + 手动登录按钮
this.formError = this.$lang('form.form.loginRequired');
} else if (shouldTriggerLogin) {
this.formError = this.$lang('form.form.loginRequired');
this._openLoginWithRetry();
} else if (isFormNotOpen) {
this.formError = this.$lang('form.form.notOpen');
} else {
this.formError = msg || '';
}
} catch (e) {
console.warn('[FORMDATA] _handleInfoResponse exception:', e && e.message ? e.message : e);
this.formError = '';
}
this.$refs.loadingCover && this.$refs.loadingCover.hide();
},
// info 接口网络失败时同样判定:未登录+有 id 就先走登录流程
_handleInfoFailure(err) {
try {
if (this.id && !this.storeToken) {
this.formError = this.$lang('form.form.loginRequired');
this._openLoginWithRetry();
} else {
this.formError = '';
}
} catch (e) {
this.formError = '';
}
this.$refs.loadingCover && this.$refs.loadingCover.hide();
},
// 打开登录:3 次 setTimeout 重试(0ms / 60ms / 320ms)
// 彻底覆盖 onLoad→回调→ns-login 尚未挂载的所有时序问题
_openLoginWithRetry() {
// 防重复触发:3 秒内只允许自动弹一次登录
// (响应回调 / mounted 兜底 / watcher 同时命中时只生效一次,避免 H5 反复跳登录页)
const now = Date.now();
if (this._lastAutoLoginAt && (now - this._lastAutoLoginAt) < 3000) return;
this._lastAutoLoginAt = now;
const url = this.backUrl;
let attempts = 0;
const delays = [0, 60, 320];
const tryOnce = () => {
try {
if (this.$refs.login && typeof this.$refs.login.open === 'function') {
this.$refs.login.open(url);
this.pendingLoginRequired = false;
return true;
}
} catch (e) {
console.warn('[FORMDATA] login.open attempt ' + (attempts + 1) + ' failed:', e);
}
attempts++;
if (attempts < delays.length) {
setTimeout(tryOnce, delays[attempts]);
} else {
this.pendingLoginRequired = true;
}
return false;
};
tryOnce();
},
// 模板空态按钮的手动入口(最后一道防线)
forceOpenLogin() {
this._openLoginWithRetry();
},
updateNavbarTitle() {
if (!this.detail?.form_name) return;
let formName = this.detail.form_name;
@@ -263,6 +640,125 @@ export default {
this.navbarTitle = formName;
// #endif
},
// 图形验证码(点击图片/按钮刷新;提交失败后自动刷新)
getCaptcha() {
this.captchaLoaded = true
this.$api.sendRequest({
url: '/api/captcha/captcha',
data: {
captcha_id: this.captcha.id
},
success: res => {
if (res.code == 0 && res.data) {
this.captcha.id = res.data.id
this.captcha.img = res.data.img ? res.data.img.replace(/\r\n/g, '') : ''
this.captcha.code = ''
}
}
})
},
// 登录拦截时保存待提交数据(组件内存 + 本地存储,H5 登录会整页回跳)
savePendingSubmit(formData) {
const payload = {
formId: this.id,
uniacid: this.uniacid,
urlParams: this.urlParams,
formData: formData,
captchaId: this.captcha.id,
captchaCode: this.captcha.code,
ts: Date.now()
}
this.pendingSubmit = payload
try { uni.setStorageSync('form_pending_submit', payload) } catch (e) {}
// H5 整页回跳标记:仅当确由登录拦截触发时才允许自动续提交,避免陈旧数据被误提交
try { uni.setStorageSync('form_pending_from_login', 1) } catch (e) {}
},
// 仅在已登录时取回并消费待提交数据(未登录 / 非登录回跳 / 过期不消费,避免误提交);取后清除
takePendingSubmit() {
let payload = this.pendingSubmit
const fromStorage = !payload
if (fromStorage) {
try { payload = uni.getStorageSync('form_pending_submit') } catch (e) {}
}
if (!payload || !payload.formData) return null
if (!this.storeToken) return null
// H5 存储路径:必须是因登录拦截触发的回跳,否则视为陈旧数据,清理后不提交
if (fromStorage) {
let fromLogin = false
try { fromLogin = uni.getStorageSync('form_pending_from_login') } catch (e) {}
if (!fromLogin) {
this.pendingSubmit = null
try { uni.removeStorageSync('form_pending_submit') } catch (e) {}
return null
}
}
// 仅消费 10 分钟内的待提交,避免极旧数据被自动提交
if (payload.ts && Date.now() - payload.ts > 10 * 60 * 1000) {
this.pendingSubmit = null
try { uni.removeStorageSync('form_pending_submit') } catch (e) {}
try { uni.removeStorageSync('form_pending_from_login') } catch (e) {}
return null
}
this.pendingSubmit = null
try { uni.removeStorageSync('form_pending_submit') } catch (e) {}
try { uni.removeStorageSync('form_pending_from_login') } catch (e) {}
return payload
},
// 登录成功后:回填已填数据并自动续提交
resumeSubmitAfterLogin() {
const pending = this.takePendingSubmit()
if (!pending) return
// 回填表单字段(nextTick 确保 diy-form 内部 ns-newform 已挂载),避免 H5 整页回跳后好友看到空白表单
this.$nextTick(() => this.restoreFormData(pending.formData))
// 验证码:MP 登录不丢态可直接提交;H5 回跳后验证码失效,需重新获取并让用户重填
if (this.behavior.captcha == 1 && !this.captcha.code) {
this.getCaptcha()
this.$util.showToast({ title: '请重新输入验证码后提交' })
return
}
this.create(pending.formData)
},
// 将保存的 formData 回填到 diy-form 内部的 ns-newform
restoreFormData(formData) {
try {
const ns = this.$refs.form && this.$refs.form.$refs && this.$refs.form.$refs.nsNewform
if (ns && Array.isArray(formData)) {
ns.formData = JSON.parse(JSON.stringify(formData))
if (typeof ns.processFormData === 'function') ns.processFormData()
}
} catch (e) {}
},
// 验证码模式下本页自有提交按钮:先走 diy-form 校验与取数,再进 create
submitFromPage() {
if (this.isRepeat) return;
if (!this.$refs.form || !this.$refs.form.verify()) return;
const formData = this.$refs.form.getFormData();
this.create(formData);
},
// 填完跳转:消费统一链接选择器的完整对象(wap_url 外链/页面路径、外部小程序、拨打电话)
doRedirect(link) {
const fallback = () => { this.complete = true; this.detail = null; };
if (!link) return fallback();
if (link.appid) {
// 跳转其他微信小程序(仅小程序端可用;H5 场景直接回退完成态)
// #ifdef MP-WEIXIN
wx.navigateToMiniProgram({ appId: link.appid, path: link.page || '', fail: fallback });
return;
// #endif
fallback();
} else if (link.mobile) {
uni.makePhoneCall({ phoneNumber: String(link.mobile), fail: fallback });
fallback();
} else if (link.wap_url) {
if (/^https?:\/\//i.test(link.wap_url)) {
uni.navigateTo({ url: '/pages_tool/webview/webview?src=' + encodeURIComponent(link.wap_url), fail: fallback });
} else {
uni.navigateTo({ url: link.wap_url, fail: fallback });
}
} else {
fallback();
}
},
// 由 diy-form 内置提交按钮触发:diy-form handleSubmit 已先做 verify(),
// 仅在通过时才通过 :onSubmit 函数 prop 调用本方法(跨 H5 / MP 一致,不走 $emit / $listeners)。
// 注意:本方法通过 created 中 this.handleSubmit = this.create.bind(this) 预绑定 this,
@@ -271,30 +767,69 @@ export default {
// 防重复提交:console 在拦截之前,避免日志噪声;拦截后立即置位
if (this.isRepeat) return;
this.isRepeat = true;
this.$api.sendRequest({
url: '/form/api/form/create',
data: {
form_id: this.id,
form_data: typeof formData === 'string' ? formData : JSON.stringify(formData),
uniacid: this.uniacid,
url_params: JSON.stringify(this.urlParams),
member_id: this.memberInfo ? this.memberInfo.id : 0
},
const behavior = this.behavior || {};
// 填写需登录 / 限填(后端强制,前端提前拦截优化体验)
if ((behavior.require_login_submit == 1 || behavior.submit_limit > 0) && !this.storeToken) {
// 保存已填写数据,登录成功(MP 弹窗 / H5 回跳)后自动续提交,避免数据丢失
this.savePendingSubmit(formData);
this.$refs.login && this.$refs.login.open(this.backUrl);
return;
}
// 验证码必填
if (behavior.captcha == 1 && !this.captcha.code) {
this.$util.showToast({ title: this.$lang('form.form.captchaRequired') });
return;
}
this.isRepeat = true;
const submitCaptchaId = String(this.captcha.id || '');
const submitCaptchaCode = String(this.captcha.code || '').toUpperCase();
console.log('[FORMDATA-SUBMIT-CAPTCHA]', { captcha_id: submitCaptchaId, captcha_code: submitCaptchaCode, raw_code: this.captcha.code });
this.$api.sendRequest({
url: '/form/api/form/create',
data: {
form_id: this.id,
form_data: typeof formData === 'string' ? formData : JSON.stringify(formData),
uniacid: this.uniacid,
url_params: JSON.stringify(this.urlParams),
member_id: this.memberInfo ? this.memberInfo.id : 0,
captcha_id: submitCaptchaId,
captcha_code: submitCaptchaCode
},
success: res => {
this.isRepeat = false;
if (res.code == 0) {
this.complete = true
uni.showModal({
title: this.$lang('form.formdata.tip'),
content: this.$lang('form.formdata.submitSuccess'),
showCancel: false,
success: () => { }
})
const link = behavior.redirect;
if (link) {
this.$util.showToast({ title: this.$lang('form.formdata.submitSuccess') });
setTimeout(() => this.doRedirect(link), 800);
} else {
this.complete = true
uni.showModal({
title: this.$lang('form.formdata.tip'),
content: this.$lang('form.formdata.submitSuccess'),
showCancel: false,
success: () => { }
})
}
} else {
this.isRepeat = false
this.$util.showToast({
title: res.message
})
if (res.message == 'FORM_LOGIN_REQUIRED') {
// 提交时后端仍要求登录(如登录态过期):暂存数据,重登后自动续提交
this.savePendingSubmit(formData)
this.$refs.login && this.$refs.login.open(this.backUrl)
} else if (res.message == 'CAPTCHA_ERROR' || res.message == 'CAPTCHA_FAILURE') {
this.$util.showToast({ title: this.$lang('form.form.captchaError') })
if (behavior.captcha == 1) this.getCaptcha()
} else if (res.message == 'FORM_SUBMIT_LIMIT') {
this.$util.showToast({ title: this.$lang('form.form.submitLimit') })
this.complete = true
} else if (res.message == 'FORM_NOT_OPEN') {
this.$util.showToast({ title: this.$lang('form.form.notOpen') })
} else {
this.$util.showToast({
title: res.message
})
}
}
},
fail: () => {
@@ -420,11 +955,88 @@ page {
background: #fff;
}
.system-form-wrap {
// ===== 非鸿蒙端(H5 / 微信小程序 / App):保留原有 WebKit 滚动 / 安全区处理 =====
// 鸿蒙走下方 MP-HARMONY 专属的 scroll-view 方案,不编译这些 WebKit 属性
/*
* ===== 顶部图条 =====
* 位于 navbar 之下、表单卡片之上。宽度由内联 bgLayerStyle 注入(calc(100% - 2*inset)),
* 与下方表单卡片等宽、左右各留 inset 对称留白。圆角 + overflow 防止图片溢出。
* 未配置 form_config.bg_image 时该节点不渲染(v-if="bgImage"),页面保持原来的纯白样式。
*/
.formdata-bg {
position: relative;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 16rpx;
overflow: hidden;
margin: 0 0 60rpx 0;
box-sizing: border-box;
}
/* #ifdef H5 || MP-WEIXIN || APP-PLUS */
.page {
min-height: 100vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
/* 页面容器不再垫白底 —— 只有红色框区域(.system-form-wrap 表单卡片)保留白底,
图条上下、左右留白等区域透出页面默认底色 */
}
.system-form-wrap {
overflow: visible;
margin: 0 0 60rpx 0;
/* ===== 表单卡片:唯一保留白底的区域 =====
87% 宽居中 + 16rpx 圆角 + 白底,对应红色框范围(从图条底部到页面底部) */
padding: 0 50rpx;
box-sizing: border-box;
background-color: #ffffff;
border-radius: 16rpx;
padding-bottom: env(safe-area-inset-bottom, 40rpx);
}
/* #endif */
// ===== 鸿蒙元服务:用原生 scroll-view 做滚动容器 =====
// ArkUI 不识别 WebKit 的 overflow / -webkit-overflow-scrolling,必须用 scroll-view 组件(编译为 ArkUI Scroll)。
// 根容器 harmony-root 占满 100vh 并 flex 列布局,navbar 占顶部固定高度,page flex:1 占剩余空间,
// scroll-view 在 page 内 flex:1 获得确定高度后内部滚动。提交按钮由 diy-form 渲染在 scroll-view 内,可滚动到。
/* #ifdef MP-HARMONY */
.harmony-root {
height: 100vh;
display: flex;
flex-direction: column;
}
.harmony-page {
flex: 1;
min-height: 0; // 允许内容收缩,内部 scroll-view 才能拿到确定高度并正确滚动
display: flex;
flex-direction: column;
/* 鸿蒙端与 .page 保持一致:去掉白底,只有 .system-form-wrap 是白底卡片 */
}
.form-scroll {
flex: 1;
height: 100%;
}
.system-form-wrap {
// 不再用 overflow:hidden 裁剪;提交按钮在 scroll-view 内,滚动可见
overflow: visible;
margin: 0 0 60rpx 0;
// ===== 表单卡片:默认就是「白底圆角、左右内缩居中」(参考图效果),不依赖后台配置 =====
padding: 0 50rpx;
box-sizing: border-box;
background-color: #ffffff;
border-radius: 16rpx;
padding-bottom: 40rpx; // 鸿蒙不识别 env(safe-area-inset-bottom),固定值兜底
}
/* #endif */
/*
* ===== .form-card 兼容占位(旧模板/旧调用残留) =====
* 旧实现用 .form-card 做条件类加强化,现已将白底/圆角合并到 .system-form-wrap 默认样式中。
* 保留此类名是为了不破坏潜在的 :class="formCardClass" 引用(已从模板移除,但旧页面缓存可能用到);
* 这里只做 noop,不影响任何视觉。
*/
.form-card {}
// 穿透 diy-form 包装层:消除其自带的 10rpx 圆角与白色背景,
// 让 form 内容区域保持与原始 ns-newform 接入时一致的视觉
.system-form-wrap ::v-deep .diy-from {
@@ -432,6 +1044,62 @@ page {
border-radius: 0;
}
// ===== 行为设置:验证码行 + 自有提交按钮(diy-form 内置按钮被 showSubmit=false 隐藏时接管) =====
.form-extra-wrap {
padding: 0 28rpx 20rpx;
}
.captcha-wrap {
display: flex;
align-items: center;
background: #fff;
border-radius: 12rpx;
padding: 16rpx 20rpx;
margin-top: 24rpx;
.captcha-input {
flex: 1;
height: 84rpx;
line-height: 84rpx;
font-size: 28rpx;
border: 1rpx solid #e5e7eb;
border-radius: 16rpx;
padding: 0 24rpx;
box-sizing: border-box;
background: #ffffff;
}
.captcha-img {
width: 200rpx;
height: 72rpx;
margin-left: 20rpx;
border-radius: 12rpx;
border: 2rpx solid #e5e7eb;
}
.captcha-btn {
margin-left: 20rpx;
height: 72rpx;
line-height: 72rpx;
padding: 0 24rpx;
font-size: 26rpx;
color: #ff4544;
background: #fff;
border: 2rpx solid #ff4544;
border-radius: 12rpx;
white-space: nowrap;
}
}
// 提交按钮:视觉对齐 diy-form 内置按钮(蓝底白字圆角,medium 内边距)
.custom-submit {
margin-top: 30rpx;
padding: 24rpx 0;
text-align: center;
color: #ffffff;
background-color: #3b82f6;
border-radius: 12rpx;
font-size: 30rpx;
}
::v-deep .mescroll-totop {
/* #ifdef H5 */
right: 27rpx !important;