Files
lucky_shop/pages_tool/card/style.vue
jing 21b3247547 chore:
微信小程序正常名片
2026-10-17 17:02:22 +08:00

709 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="card-detail" :style="previewVars" :class="{ 'qs-square': previewDs.quickStyle === 'square' }">
<view class="page-layer">
<view class="ps-disabled" v-if="relation && !enabled">商家未开启员工端样式自选,暂不可选样式</view>
<!-- 实时预览卡: 与详情页同款 design 渲染(展示当前所选预设的实际落库效果) -->
<view class="card-wrap" v-if="relation">
<view class="card-wrap-bg" :style="previewHeaderBg"></view>
<view class="detail-card" :class="['layout-' + previewDs.layout, previewDs.cover && previewDs.layout !== 'classic' ? 'has-cover' : '']">
<view v-if="previewDs.cover && previewDs.layout !== 'classic'" class="dc-cover"
:class="previewDs.layout === 'compact' ? 'dc-cover-compact' : 'dc-cover-hero'">
<image class="dc-cover-img" :src="$util.img(previewDs.cover)" mode="aspectFill"></image>
</view>
<view class="dc-name">{{ relation.name || '未命名名片' }}</view>
<view class="dc-job" v-if="relation.job_title">{{ relation.job_title }}</view>
<view class="dc-company" v-if="relation.company">{{ relation.company }}</view>
<view class="dc-rows" v-if="fieldRows.length > 0">
<view v-for="(row, index) in fieldRows" :key="index" class="field-row"
:class="{ 'field-row-multi': row.multiline }">
<text v-if="row.icon" class="field-icon iconfont" :class="row.icon"></text>
<text v-else class="field-tag">{{ row.label }}</text>
<view class="field-value" :class="{ 'field-value-multi': row.multiline }">{{ row.value }}</view>
</view>
</view>
</view>
</view>
<view class="detail-loading" v-if="isLoading">加载中…</view>
<ns-empty v-if="!isLoading && !relation" :isIndex="false"
:text="errorMsg || '卡片不存在或已被删除'"></ns-empty>
<!-- 预设网格: 每个单元格 = 该预设的迷你预览(同源后台 CardTemplatePresets) -->
<view class="preset-section" v-if="relation && enabled">
<text class="ps-title">选择样式<text class="ps-count">(共 {{ presets.length }} 个)</text></text>
<view class="ps-canvas-hint" v-if="isCanvasCard">
当前名片为商家自定义画布设计,保存将不被允许
</view>
<view class="preset-grid">
<view class="preset-cell" v-for="p in presets" :key="p.key"
:class="{ active: p.key === selectedKey }" :style="cellActiveStyle(p)"
@tap="selectPreset(p.key)">
<text class="preset-tag" v-if="p.source === 'canvas'">画布式</text>
<view class="preset-thumb" :style="cellVars(p)">
<view class="pt-header" :style="cellHeader(p)"></view>
<view class="pt-body">
<view class="pt-name" :style="cellText(p)"></view>
<view class="pt-line" :style="cellSoft(p)"></view>
<view class="pt-line pt-short" :style="cellSoft(p)"></view>
</view>
<view v-if="p.key === selectedKey" class="pt-check">
<view class="pt-check-mark"></view>
</view>
</view>
<text class="preset-name">{{ p.name }}</text>
<text class="preset-desc">{{ p.desc }}</text>
<text v-if="p.key === currentKey" class="preset-current">当前</text>
</view>
</view>
<view v-if="!isLoading && presets.length === 0" class="ps-empty">
预设列表为空(后端 design/presets 未返回)
</view>
</view>
</view>
<!-- 底部保存 -->
<view class="ps-footer" v-if="relation && enabled">
<view class="ps-save" :class="{ disabled: !selectedKey || saving || isCanvasCard }" @tap="save">
{{ saving ? '保存中…' : (isCanvasCard ? '不支持修改' : '保存') }}
</view>
</view>
</view>
</template>
<script>
/**
* 名片样式(可选样式页)· 后端 v2 对接
* GET /card/api/design/presets → 与商家后台 CardTemplatePresets 同源的预设列表
* POST /card/api/design/save → 按 preset_key 应用预设并保存到本卡
* 关键点(v2):
* - 预设列表由后端下发, 前端不自建; 数量不写死(当前 14 个, 直接渲染 list.length)
* - save 只写入 10 个字段(theme_color/text_color/bg_color/font_family/font_scale/
* card_radius/header_gradient_end/header_gradient_angle/quick_style/template_id),
* 不覆盖 layout / cover; 预览据此合并(本卡 layout/cover + 预设 10 字段)
* - source==='canvas' 的预设仅改配色; 本卡自身为 canvas 设计时 save 会被拒
* - data.enabled 控制入口显隐(detail.vue 已据此隐藏按钮)
*/
function mixHex(a, b, t) {
const toRgb = h => {
h = (h || '').replace('#', '');
if (h.length === 3) h = h.split('').map(c => c + c).join('');
const n = parseInt(h, 16);
if (isNaN(n)) return [0, 0, 0];
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const [r1, g1, b1] = toRgb(a);
const [r2, g2, b2] = toRgb(b);
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const bl = Math.round(b1 + (b2 - b1) * t);
return '#' + [r, g, bl].map(x => x.toString(16).padStart(2, '0')).join('');
}
// save 实际写入的字段(与后端 v2 一致); 预览只叠加这些, 其余沿用本卡既有值
const SAVE_WRITTEN_FIELDS = [
'theme_color', 'text_color', 'bg_color', 'font_family', 'font_scale',
'card_radius', 'header_gradient_end', 'header_gradient_angle', 'quick_style', 'template_id'
];
// 归一化 design(字段缺失/非法回落文档默认值); d 可为 null(旧后端)
function normalizeDesign(d) {
const dd = d || {};
const FONTS = {
system: '-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",Arial,sans-serif',
sans: '"Source Han Sans SC","Noto Sans SC","PingFang SC",Helvetica,Arial,sans-serif',
serif: '"Source Han Serif SC","Songti SC",SimSun,Georgia,serif',
rounded: '"Yuanti SC","YouYuan","Quicksand",-apple-system,sans-serif'
};
const isHex = v => /^#[0-9a-fA-F]{3,8}$/.test(v || '');
const theme = isHex(dd.theme_color) ? dd.theme_color : '#119dff';
const text = isHex(dd.text_color) ? dd.text_color : '#1f2329';
const bg = isHex(dd.bg_color) ? dd.bg_color : '#ffffff';
const fontKey = FONTS[dd.font_family] ? dd.font_family : 'system';
const fontScale = Math.min(160, Math.max(60, Number(dd.font_scale) || 100));
const cardRadius = Number(dd.card_radius) || 16;
const layout = ['hero', 'compact', 'classic'].includes(dd.layout) ? dd.layout : 'classic';
const headerEnd = typeof dd.header_gradient_end === 'string' ? dd.header_gradient_end : '';
const headerAngle = Number(dd.header_gradient_angle) || 150;
return {
themeColor: theme,
textColor: text,
bgColor: bg,
layout: layout,
cover: dd.cover || '',
fontKey: fontKey,
fontFamily: FONTS[fontKey],
fontScale: fontScale,
cardRadius: cardRadius,
headerGradientEnd: headerEnd,
headerGradientAngle: headerAngle,
quickStyle: dd.quick_style || '',
templateId: dd.template_id || '',
source: dd.source || 'none',
textSoft: mixHex(text, bg, 0.55),
themeSoft: mixHex(theme, '#ffffff', 0.85),
fontScaleNum: fontScale / 100
};
}
function designVarsFor(s) {
return {
'--card-theme': s.themeColor,
'--card-theme-end': s.headerGradientEnd || s.themeColor,
'--card-text': s.textColor,
'--card-text-soft': s.textSoft,
'--card-bg': s.bgColor,
'--card-radius': s.cardRadius + 'px',
'--card-font': s.fontFamily,
'--card-font-scale': s.fontScaleNum
};
}
function headerBgFor(s) {
if (s.headerGradientEnd) {
return 'linear-gradient(' + s.headerGradientAngle + 'deg, ' + s.themeColor + ', ' + s.headerGradientEnd + ')';
}
return 'linear-gradient(150deg, ' + s.themeColor + ' 0%, ' + s.themeColor + ' 62%, rgba(0,0,0,0.28) 160%)';
}
export default {
data() {
return {
cardId: '',
relation: null,
// 名片设计摘要(后端 /card/api/card/detail 顶层 design, 旧后端无此键时为 null)
design: null,
// 后端下发的预设列表(同源后台 CardTemplatePresets, 数量不写死)
presets: [],
// 商家是否开启「员工端样式自选」(presets 响应 enabled)
enabled: true,
// 当前选中的预设 key(初始 = 本卡当前 template_id)
selectedKey: '',
isLoading: true,
errorMsg: '',
saving: false
};
},
computed: {
fieldRows() {
const r = this.relation;
if (!r) return [];
const rows = [];
const push = (label, value, action, icon) => {
if (value !== undefined && value !== null && String(value).trim() !== '') {
rows.push({ label, value: String(value), action: action || null, icon: icon || '' });
}
};
push('手机', r.mobile, { type: 'call', text: '拨打' }, 'icon-dianhua');
push('电话', r.phone, { type: 'call', text: '拨打' }, 'icon-dianhua');
push('邮箱', r.email, { type: 'copy', text: '复制' });
push('微信', r.wechat, { type: 'copy', text: '复制' }, 'icon-weixin');
push('网站', r.website);
push('地址', [r.country, r.province, r.city, r.address].filter(Boolean).join(' '), null, 'icon-dizhi');
push('邮编', r.postal_code);
(r.mobiles_list || []).forEach((m, i) => {
push('手机' + (i + 2), m, { type: 'call', text: '拨打' }, 'icon-dianhua');
});
(r.socials_list || []).forEach(s => {
push(s.label || s.type, s.value, { type: 'copy', text: '复制' });
});
(r.custom_list || []).forEach(c => {
push(c.label, c.value);
});
if (r.bio && String(r.bio).trim() !== '') {
rows.push({ label: '简介', value: String(r.bio), action: null, multiline: true });
}
return rows;
},
// 本卡当前 design 的 template_id(用于标记"当前")
currentKey() {
return (this.design && this.design.template_id) || '';
},
// 本卡自身为画布设计时, save 会被后端拒
isCanvasCard() {
return !!(this.design && this.design.source === 'canvas');
},
activePreset() {
return this.presets.find(p => p.key === this.selectedKey) || null;
},
// 实时预览用的 design: 以本卡既有 design 为底, 仅叠加 save 实际写入的 10 个字段
// (layout/cover 不被 save 覆盖, 预览须与落库一致, 避免误导)
previewDesign() {
const base = this.design || {};
const p = this.activePreset ? this.activePreset.design : null;
if (!p) return base;
const merged = Object.assign({}, base);
SAVE_WRITTEN_FIELDS.forEach(k => {
if (p[k] !== undefined) merged[k] = p[k];
});
return merged;
},
previewDs() {
return normalizeDesign(this.previewDesign);
},
previewVars() {
return designVarsFor(this.previewDs);
},
previewHeaderBg() {
return { background: headerBgFor(this.previewDs) };
}
},
methods: {
/**
* 预设缩略图的配色。
*
* 为什么不用 CSS 变量: 微信小程序的渲染层不支持在内联 style 里定义自定义属性
* (`style="--card-bg:#fff"`), 整条 style 会被丢弃 → var(--card-bg) 全部回落默认值,
* 表现就是「小程序端样式缩略图全是白底黑条灰线, 没有颜色」(H5 正常)。
* 这里改为直接输出具体属性 + 具体色值, 三端一致。
*/
cellDs(p) {
return normalizeDesign(p && p.design);
},
cellVars(p) {
const s = this.cellDs(p);
return { backgroundColor: s.bgColor, borderRadius: s.cardRadius + 'px' };
},
cellHeader(p) {
const s = this.cellDs(p);
// 纯色代替渐变: 小尺寸缩略图看不出渐变, 但能保证各端一定渲染
return { backgroundColor: s.themeColor };
},
cellText(p) {
return { backgroundColor: this.cellDs(p).textColor };
},
cellSoft(p) {
return { backgroundColor: this.cellDs(p).textSoft };
},
// 选中描边也要给具体色值(CSS 里的 var(--card-theme) 在小程序端同样失效)
cellActiveStyle(p) {
if (p.key !== this.selectedKey) return null;
return { borderColor: this.cellDs(p).themeColor };
},
onLoad(options) {
this.cardId = decodeURIComponent(options.card_id || '');
this.loadDetail();
this.loadPresets();
},
loadDetail() {
if (!this.cardId) {
this.isLoading = false;
this.errorMsg = '缺少名片编号';
return;
}
this.$cardApi.cardDetail({
data: { card_id: this.cardId },
success: res => {
const data = res.data || {};
if (res.code == 0) {
this.relation = data.relation || null;
this.design = data.design || null;
this.initSelected();
} else {
this.errorMsg = res.message || '卡片不存在或已被删除';
this.relation = null;
}
this.isLoading = false;
},
fail: () => {
this.isLoading = false;
this.errorMsg = '网络异常,请稍后重试';
}
});
},
loadPresets() {
this.$cardApi.cardDesignPresets({
success: res => {
const data = res.data || {};
// 商家未开启样式自选时前端隐藏入口(默认 true, 失败开放)
this.enabled = data.enabled !== false;
this.presets = Array.isArray(data.list) ? data.list : [];
this.initSelected();
},
fail: () => {
this.enabled = true;
this.presets = [];
}
});
},
// 两路加载都到位后, 若本卡 template_id 命中某个预设, 默认选中它
initSelected() {
if (this.selectedKey || !this.currentKey || this.presets.length === 0) return;
const hit = this.presets.find(p => p.key === this.currentKey);
if (hit) this.selectedKey = hit.key;
},
selectPreset(key) {
this.selectedKey = key;
},
save() {
if (!this.selectedKey || this.saving) return;
if (this.isCanvasCard) {
uni.showToast({ title: '该名片使用商家自定义画布设计,暂不支持员工端修改', icon: 'none' });
return;
}
this.saving = true;
this.$cardApi.cardDesignSave({
data: { card_id: this.cardId, preset_key: this.selectedKey },
success: res => {
this.saving = false;
if (res.code == 0) {
uni.showToast({ title: '已保存', icon: 'success' });
// 返回详情页, 其 onShow 会重新拉取 design 并刷新生效
setTimeout(() => uni.navigateBack(), 600);
} else if (res.message) {
uni.showToast({ title: res.message, icon: 'none' });
}
},
fail: () => {
this.saving = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
}
}
};
</script>
<style lang="scss" scoped>
.card-detail {
min-height: 100vh;
background-color: #ffffff;
// 底部预留高度须 ≥ 固定底栏(按钮 88 + 上下 padding + 安全区), 否则滚到底最后一行会被遮住
padding: 0 0 240rpx;
box-sizing: border-box;
font-family: var(--card-font, -apple-system, "PingFang SC", "Helvetica Neue", Arial, sans-serif);
}
.page-layer {
position: relative;
}
.ps-disabled {
margin: 24rpx;
padding: 28rpx;
text-align: center;
font-size: 26rpx;
color: #8a94a6;
background-color: #f5f7fa;
border-radius: 12rpx;
}
// 预览卡(与详情页同款 design 渲染)
.card-wrap {
position: relative;
padding-top: 24rpx;
}
.card-wrap-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 48rpx;
z-index: 0;
background-color: var(--card-theme, #2aa5ff);
border-bottom-left-radius: 240rpx 44rpx;
border-bottom-right-radius: 240rpx 44rpx;
pointer-events: none;
}
.detail-card {
position: relative;
z-index: 1;
margin: 0 24rpx;
background-color: var(--card-bg, #ffffff);
border-radius: var(--card-radius, 20rpx);
padding: 40rpx 36rpx 32rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 30, 80, 0.06);
.dc-cover {
margin: -40rpx -36rpx 24rpx;
border-top-left-radius: var(--card-radius, 20rpx);
border-top-right-radius: var(--card-radius, 20rpx);
overflow: hidden;
.dc-cover-img {
display: block;
width: 100%;
}
}
.dc-cover-hero .dc-cover-img { height: 200rpx; }
.dc-cover-compact .dc-cover-img { height: 90rpx; }
.dc-name {
font-size: 44rpx;
font-weight: 700;
color: var(--card-text, #1a1a1a);
line-height: 1.3;
}
.dc-job {
margin-top: 6rpx;
font-size: calc(28rpx * var(--card-font-scale, 1));
line-height: 1.3;
color: var(--card-text-soft, #4a4a4a);
}
.dc-company {
margin-top: 2rpx;
font-size: calc(26rpx * var(--card-font-scale, 1));
line-height: 1.3;
color: var(--card-text-soft, #9aa4b2);
}
.dc-rows {
margin-top: 56rpx;
}
&.layout-compact {
padding-top: 28rpx;
.dc-rows { margin-top: 28rpx; }
.field-row { padding: 8rpx 0; }
.dc-name { font-size: 40rpx; }
}
.field-row {
display: flex;
align-items: center;
line-height: 1.2;
padding: 12rpx 0;
&.field-row-multi {
align-items: flex-start;
}
.field-icon {
width: 40rpx;
margin-right: 18rpx;
font-size: 32rpx;
line-height: 1;
color: var(--card-text-soft, #8a94a6);
text-align: center;
flex-shrink: 0;
}
.field-tag {
min-width: 40rpx;
margin-right: 18rpx;
font-size: calc(26rpx * var(--card-font-scale, 1));
line-height: 1.2;
color: var(--card-text-soft, #9aa4b2);
flex-shrink: 0;
}
.field-value {
flex: 1;
min-width: 0;
font-size: calc(28rpx * var(--card-font-scale, 1));
line-height: 1.25;
color: var(--card-text, #333333);
word-break: break-all;
}
.field-value-multi {
line-height: 1.4;
white-space: pre-wrap;
}
}
}
/* 预设网格(3 列自适应, 14 个不再过长) */
.preset-section {
margin: 32rpx 24rpx 0;
.ps-title {
display: block;
margin-bottom: 16rpx;
font-size: 30rpx;
font-weight: 600;
color: #1f2329;
}
.ps-count {
font-size: 24rpx;
font-weight: 400;
color: #8a94a6;
}
.ps-canvas-hint {
margin-bottom: 16rpx;
font-size: 24rpx;
color: #f5a623;
}
.preset-grid {
display: flex;
flex-wrap: wrap;
}
.preset-cell {
position: relative;
// 自建堆叠上下文: 把格子内的绝对定位元素(角标/选中钩)限制在本格内,
// 避免其 z-index 逃逸到固定底栏之上
z-index: 0;
width: 32%;
margin: 0 2% 16rpx 0;
background-color: #ffffff;
border: 2rpx solid #e8ebee;
border-radius: 16rpx;
padding: 14rpx;
box-sizing: border-box;
&:nth-child(3n) {
margin-right: 0;
}
&.active {
border-color: var(--card-theme, #2aa5ff);
box-shadow: 0 6rpx 18rpx rgba(42, 165, 255, 0.18);
}
.preset-tag {
position: absolute;
top: 8rpx;
left: 8rpx;
z-index: 2;
padding: 2rpx 10rpx;
font-size: 20rpx;
color: #ffffff;
background-color: rgba(0, 0, 0, 0.45);
border-radius: 8rpx;
}
.preset-thumb {
position: relative;
height: 160rpx;
background-color: var(--card-bg, #ffffff);
border-radius: var(--card-radius, 12rpx);
overflow: hidden;
.pt-header {
height: 56rpx;
}
.pt-body {
padding: 14rpx 16rpx;
}
.pt-name {
height: 14rpx;
width: 56%;
border-radius: 7rpx;
background-color: var(--card-text, #1a1a1a);
}
.pt-line {
height: 7rpx;
width: 80%;
margin-top: 12rpx;
border-radius: 4rpx;
background-color: var(--card-text-soft, #9aa4b2);
}
.pt-short {
width: 50%;
}
.pt-check {
position: absolute;
right: 10rpx;
bottom: 10rpx;
width: 34rpx;
height: 34rpx;
border-radius: 50%;
background-color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
.pt-check-mark {
width: 13rpx;
height: 6rpx;
margin-top: -4rpx;
border-left: 3rpx solid #2aa5ff;
border-bottom: 3rpx solid #2aa5ff;
transform: rotate(-45deg);
}
}
}
.preset-name {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
font-weight: 600;
color: #1f2329;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preset-desc {
display: block;
margin-top: 4rpx;
font-size: 20rpx;
color: #8a94a6;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preset-current {
position: absolute;
top: 8rpx;
right: 8rpx;
padding: 2rpx 10rpx;
font-size: 20rpx;
color: #ffffff;
background-color: rgba(0, 0, 0, 0.35);
border-radius: 8rpx;
}
}
.ps-empty {
margin-top: 20rpx;
font-size: 24rpx;
color: #f5a623;
}
}
.detail-loading {
text-align: center;
color: #999999;
font-size: 26rpx;
padding: 120rpx 0;
}
/* 底部保存条 */
.ps-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
// 必须高于内容层: 否则预设格子里的绝对定位元素(如 .preset-tag z-index:2)
// 会"浮"到固定底栏之上, 只露出角标而卡片本体被遮住
z-index: 100;
padding: 20rpx 24rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background-color: #ffffff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
.ps-save {
height: 88rpx;
line-height: 88rpx;
text-align: center;
border-radius: 44rpx;
background-color: #2aa5ff;
color: #ffffff;
font-size: 32rpx;
&.disabled {
background-color: #c8d6e5;
}
}
}
</style>