pharmacy / index.html
data-learner's picture
Upload index.html with huggingface_hub
90271fd verified
Raw History Blame Contribute Delete
71.5 kB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>药店拜访助手</title>
<script defer src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
<script src="docx.min.js" onerror="var s=document.createElement('script');s.src='https://unpkg.com/docx/build/index.umd.min.js';s.onerror=function(){console.error('docx CDN also failed');};document.head.appendChild(s);"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f4f8; color: #1e293b; padding-bottom: 80px; }
.header { background: linear-gradient(135deg, #2563eb, #3b82f6); color: #fff; padding: 20px 16px; text-align: center; }
.header h1 { font-size: 20px; margin-bottom: 4px; }
.header p { font-size: 13px; opacity: 0.85; }
.tab-bar { display: flex; background: #fff; border-bottom: 1px solid #e2e8f0; position: sticky; top: 0; z-index: 100; }
.tab-btn { flex: 1; padding: 14px; text-align: center; font-size: 14px; border: none; background: none; color: #64748b; cursor: pointer; border-bottom: 2px solid transparent; }
.tab-btn.active { color: #2563eb; border-bottom-color: #2563eb; font-weight: 600; }
.page { padding: 16px; display: none; }
.page.active { display: block; }
.card { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.card-title { font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1e293b; }
.form-group { margin-bottom: 14px; }
.form-label { font-size: 13px; color: #64748b; margin-bottom: 4px; display: block; }
.form-input { width: 100%; padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 14px; outline: none; }
.form-input:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,0.1); }
.form-textarea { width: 100%; padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 13px; min-height: 200px; resize: vertical; outline: none; font-family: inherit; }
.form-textarea:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,0.1); }
.btn { display: inline-block; padding: 10px 20px; border-radius: 8px; font-size: 14px; font-weight: 600; border: none; cursor: pointer; text-align: center; }
.btn-primary { background: #2563eb; color: #fff; width: 100%; }
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled { background: #94a3b8; cursor: not-allowed; }
.btn-secondary { background: #e2e8f0; color: #475569; }
.btn-secondary:hover { background: #cbd5e1; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.btn-outline { background: #fff; border: 1px solid #2563eb; color: #2563eb; }
.btn-outline:hover { background: #eff6ff; }
.step-indicator { display: flex; margin-bottom: 16px; }
.step { flex: 1; text-align: center; padding: 8px; font-size: 12px; color: #94a3b8; position: relative; }
.step.done { color: #2563eb; }
.step::after { content: ''; position: absolute; top: 50%; left: 60%; width: 80%; height: 2px; background: #e2e8f0; }
.step:last-child::after { display: none; }
.step-num { display: block; width: 24px; height: 24px; line-height: 24px; border-radius: 50%; background: #e2e8f0; margin: 0 auto 4px; font-size: 12px; }
.step.done .step-num { background: #2563eb; color: #fff; }
.alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-bottom: 12px; }
.alert-warning { background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; }
.alert-success { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; }
.alert-error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
.visit-item { padding: 12px; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 8px; cursor: pointer; }
.visit-item:hover { background: #f8fafc; }
.visit-item .title { font-weight: 600; font-size: 15px; }
.visit-item .meta { font-size: 12px; color: #64748b; margin-top: 4px; }
.visit-item .actions { margin-top: 8px; display: flex; gap: 8px; }
.pharmacy-list { margin-top: 8px; }
.pharmacy-item { display: flex; padding: 8px 0; border-bottom: 1px solid #f1f5f9; }
.pharmacy-idx { width: 28px; height: 28px; line-height: 28px; text-align: center; background: #eff6ff; border-radius: 50%; color: #2563eb; font-size: 12px; font-weight: 600; margin-right: 10px; flex-shrink: 0; }
.pharmacy-info { flex: 1; }
.pharmacy-name { font-size: 14px; font-weight: 500; }
.pharmacy-people { font-size: 12px; color: #64748b; margin-top: 2px; }
.log-section { margin-top: 16px; }
.log-item { font-size: 12px; padding: 4px 8px; border-left: 3px solid #e2e8f0; margin-bottom: 4px; }
.log-item.pass { border-left-color: #22c55e; }
.log-item.fail { border-left-color: #ef4444; }
.log-item.info { border-left-color: #3b82f6; }
.inline-btn { color: #2563eb; text-decoration: underline; cursor: pointer; font-size: 13px; }
.stats-grid { display: flex; gap: 12px; margin-bottom: 16px; }
.stat-item { flex: 1; text-align: center; background: #f8fafc; border-radius: 8px; padding: 12px; }
.stat-value { font-size: 24px; font-weight: 700; color: #2563eb; }
.stat-label { font-size: 12px; color: #64748b; margin-top: 4px; }
.back-btn { display: inline-block; cursor: pointer; color: #2563eb; font-size: 14px; margin-bottom: 12px; }
</style>
</head>
<body>
<div class="header">
<h1>药店拜访助手</h1>
<p>备忘录识别 · 拜访清单生成 · Word导出</p>
</div>
<div class="tab-bar">
<button class="tab-btn active" onclick="switchTab('home')">首页</button>
<button class="tab-btn" onclick="switchTab('create')">创建</button>
<button class="tab-btn" onclick="switchTab('mine')">我的</button>
</div>
<!-- 首页 -->
<div class="page active" id="page-home">
<div class="card">
<div class="stats-grid">
<div class="stat-item"><div class="stat-value" id="home-total-records">0</div><div class="stat-label">拜访记录</div></div>
<div class="stat-item"><div class="stat-value" id="home-total-shops">0</div><div class="stat-label">药店总数</div></div>
<div class="stat-item"><div class="stat-value" id="home-total-people">0</div><div class="stat-label">拜访人数</div></div>
</div>
</div>
<div class="card">
<div class="card-title">最近拜访</div>
<div id="home-visit-list"></div>
</div>
</div>
<!-- 创建页 -->
<div class="page" id="page-create">
<div class="step-indicator">
<div class="step done" id="step-1"><span class="step-num">1</span>输入备忘录</div>
<div class="step" id="step-2"><span class="step-num">2</span>自动解析</div>
<div class="step" id="step-3"><span class="step-num">3</span>生成清单</div>
</div>
<div class="card">
<div class="card-title">步骤1: 输入备忘录文本</div>
<div class="form-group">
<label class="form-label">备忘录内容(复制粘贴或点击"手动输入"填入示例文本)</label>
<textarea class="form-textarea" id="memo-input" placeholder="在此粘贴备忘录内容..."></textarea>
</div>
<div style="display:flex; gap:8px; margin-bottom:12px;">
<button class="btn btn-outline btn-sm" onclick="manualInput()" id="manual-input-btn">不想拍照?点这里输入示例文本</button>
<label class="btn btn-outline btn-sm" style="cursor:pointer;margin:0;">
📷 上传截图识别
<input type="file" accept="image/*" onchange="uploadScreenshot(this)" style="display:none;">
</label>
</div>
<div id="ocr-status"></div>
</div>
<div class="card" id="basic-info-card" style="display:none;">
<div class="card-title">基本信息(自动解析填充,可手动修改)</div>
<div class="form-group">
<label class="form-label">拜访日期</label>
<input class="form-input" id="input-date" placeholder="如 2026-07-03">
</div>
<div class="form-group">
<label class="form-label">活动负责人</label>
<input class="form-input" id="input-person" placeholder="请输入负责人姓名">
</div>
<div class="form-group">
<label class="form-label">服务省份及地区</label>
<input class="form-input" id="input-region" placeholder="如 广西南宁">
</div>
<div style="display:flex; gap:8px;">
<button class="btn btn-secondary btn-sm" onclick="autoParse()">重新解析</button>
</div>
</div>
<div class="card" id="pharmacy-preview" style="display:none;">
<div class="card-title">识别的药店清单</div>
<div id="pharmacy-list"></div>
</div>
<div class="card" id="photo-upload-card">
<div class="card-title">打卡照片 (<span id="create-photo-count">0</span>张)</div>
<div id="create-photo-preview" style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;min-height:40px;">
<div style="color:#94a3b8;font-size:13px;line-height:40px;" id="photo-empty-hint">点击下方按钮上传拜访照片(可选)</div>
</div>
<label class="btn btn-outline btn-sm" style="cursor:pointer;display:inline-block;">
📷 上传拜访照片
<input type="file" accept="image/*" multiple onchange="uploadCreatePhotos(this)" style="display:none;">
</label>
<div style="font-size:12px;color:#94a3b8;margin-top:6px;">支持多选,照片将放入Word文档末尾的照片表中</div>
</div>
<div>
<button class="btn btn-primary" onclick="generateVisit()" id="generate-btn" disabled>生成拜访清单</button>
</div>
<div id="create-status"></div>
</div>
<!-- 详情页 -->
<div class="page" id="page-detail">
<div class="back-btn" onclick="switchTab('home')">← 返回首页</div>
<div id="detail-content"></div>
</div>
<!-- 我的页 -->
<div class="page" id="page-mine">
<div class="card">
<div class="stats-grid">
<div class="stat-item"><div class="stat-value" id="mine-total-records">0</div><div class="stat-label">拜访记录</div></div>
<div class="stat-item"><div class="stat-value" id="mine-total-shops">0</div><div class="stat-label">药店总数</div></div>
<div class="stat-item"><div class="stat-value" id="mine-total-people">0</div><div class="stat-label">拜访人数</div></div>
</div>
</div>
<div class="card">
<div class="card-title">历史记录</div>
<div id="mine-visit-list"></div>
</div>
</div>
<script>
// ============ 核心逻辑(从 src/utils/index.ts 移植) ============
const CHINESE_NUMBERS = { 一:1,二:2,三:3,四:4,五:5,六:6,七:7,八:8,九:9,十:10 };
const normalizeText = (text) => text.replace(/\r/g,'\n').replace(/[:﹕]/g,':').replace(/[,]/g,',').replace(/[;]/g,';').replace(/[(]/g,'(').replace(/[)]/g,')').replace(/[ ]/g,' ').replace(/([\u4e00-\u9fa5])[ \t]+(?=[\u4e00-\u9fa5])/g,'$1').replace(/([\u4e00-\u9fa5])[ \t]+(?=:)/g,'$1').replace(/(:)[ \t]+(?=[\u4e00-\u9fa5])/g,':').replace(/\n{3,}/g,'\n\n').trim();
const normalizeDate = (text) => {
const currentYear = new Date().getFullYear();
const normalized = normalizeText(text);
const lines = normalized.split('\n');
const fullDateRe = /(\d{4})\s*(?:年|[./-])\s*(\d{1,2})\s*(?:月|[./-])\s*(\d{1,2})\s*[日号]?/;
const shortDateRe = /(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?/;
const isTimestampLine = (line) => /\d{1,2}:\d{2}/.test(line) || /今天|昨日|前天/.test(line);
const extractDateFromLine = (line) => {
const haoRe = /(\d{1,2})\s*月\s*(\d{1,2})\s*号/;
const haoMatch = line.match(haoRe);
if (haoMatch) {
const yearMatch = normalized.match(/(\d{4})\s*年/);
const fallbackYear = yearMatch ? yearMatch[1] : String(currentYear);
return `${fallbackYear}-${String(haoMatch[1]).padStart(2,'0')}-${String(haoMatch[2]).padStart(2,'0')}`;
}
const fullMatch = line.match(fullDateRe);
if (fullMatch) return `${fullMatch[1]}-${String(fullMatch[2]).padStart(2,'0')}-${String(fullMatch[3]).padStart(2,'0')}`;
const shortMatch = line.match(shortDateRe);
if (shortMatch) {
const yearMatch = normalized.match(/(\d{4})\s*年/);
const fallbackYear = yearMatch ? yearMatch[1] : String(currentYear);
return `${fallbackYear}-${String(shortMatch[1]).padStart(2,'0')}-${String(shortMatch[2]).padStart(2,'0')}`;
}
return null;
};
for (const line of lines) {
if (/拜访/.test(line) && !isTimestampLine(line)) {
const haoRe = /(\d{1,2})\s*月\s*(\d{1,2})\s*号/;
const haoMatch = line.match(haoRe);
if (haoMatch) {
const yearMatch = normalized.match(/(\d{4})\s*年/);
const fallbackYear = yearMatch ? yearMatch[1] : String(currentYear);
return `${fallbackYear}-${String(haoMatch[1]).padStart(2,'0')}-${String(haoMatch[2]).padStart(2,'0')}`;
}
}
}
for (const line of lines) {
if (/拜访/.test(line) && !isTimestampLine(line)) {
const date = extractDateFromLine(line);
if (date) return date;
}
}
const headerLines = lines.slice(0, Math.min(5, lines.length));
for (const line of headerLines) {
if (isTimestampLine(line)) continue;
const date = extractDateFromLine(line);
if (date) return date;
}
for (const line of lines) {
if (isTimestampLine(line)) continue;
const date = extractDateFromLine(line);
if (date) return date;
}
const fullMatch = normalized.match(fullDateRe);
if (fullMatch) return `${fullMatch[1]}-${String(fullMatch[2]).padStart(2,'0')}-${String(fullMatch[3]).padStart(2,'0')}`;
const yearMatch = normalized.match(/(\d{4})\s*年/);
const fallbackYear = yearMatch ? yearMatch[1] : String(currentYear);
const shortMatch = normalized.match(shortDateRe);
if (shortMatch) return `${fallbackYear}-${String(shortMatch[1]).padStart(2,'0')}-${String(shortMatch[2]).padStart(2,'0')}`;
return '';
};
const extractFirst = (text, patterns) => {
for (const pattern of patterns) {
const match = text.match(pattern);
if (match && match[1]) return match[1].replace(/[,.,。;;\s]+$/g,'').trim();
}
return '';
};
const extractExpectedCount = (text) => {
const normalized = normalizeText(text);
const patterns = [/(?:共|合计|累计|本次|实际)?\s*(\d{1,3})\s*家(?:连锁)?药店/,/(?:拜访|走访|完成)\s*(\d{1,3})\s*家/,/(\d{1,3})\s*家(?:门店|药店|连锁药店)/,/(?:共|合计|累计)\s*([一二三四五六七八九十])\s*家/];
for (const p of patterns) {
const m = normalized.match(p);
if (m && m[1]) {
if (/^\d+$/.test(m[1])) return Number(m[1]);
return CHINESE_NUMBERS[m[1]] || 0;
}
}
return 0;
};
const cleanPharmacyName = (name) => name.replace(/^(?:药店|门店|名称|店名)[::]?\s*/g,'').replace(/[,.,。;;、\s]+$/g,'').trim();
const PHARMACY_FULL = '(?:益嘉|健之佳|大参林|老百姓)';
const PHARMACY_CONTEXT = '(?:朝阳|一心|健康|医药)';
const PHARMACY_SUFFIX = '(?:药房|药店|大药房|门诊|连锁|门店|堂)';
const buildPharmacyNameRe = () => new RegExp(PHARMACY_FULL+'[^,,、]*|'+PHARMACY_CONTEXT+PHARMACY_SUFFIX+'[^,,、]*|(?:^|[,,、])'+PHARMACY_SUFFIX+'[^,,、]*|^'+PHARMACY_SUFFIX+'[^,,、]*','g');
const extractNamesFromSegment = (segment) => {
return segment.replace(/^[一二三四五六七八九十\d]+\s*[;;::.、.]\s*/g,'').replace(buildPharmacyNameRe(),'').replace(/[((][^))]*[))]/g,'').replace(/[a-zA-Z0-9]/g,'').replace(/^[、,,:\s]+/,'').replace(/[。;;]+$/g,'').split(/[、,,]/).map(n=>n.trim()).filter(n=>n.length>=2&&n.length<=4&&/^[\u4e00-\u9fa5]+$/.test(n));
};
const isPharmacyName = (n) => new RegExp('^(?:'+PHARMACY_FULL+')').test(n) || new RegExp(PHARMACY_SUFFIX).test(n);
const extractNamesFallback = (segment) => {
return segment.replace(/^[一二三四五六七八九十\d]+\s*[;;::.、.]\s*/g,'').replace(/[((][^))]*[))]/g,'').replace(/[a-zA-Z0-9]/g,'').replace(/^[、,,:\s]+/,'').replace(/[。;;]+$/g,'').split(/[、,,]/).map(n=>n.trim()).filter(n=>n.length>=2&&n.length<=4&&/^[\u4e00-\u9fa5]+$/.test(n)).filter(n=>!isPharmacyName(n));
};
const parsePeopleAndTitles = (line) => {
const people = [], titles = [];
const text = line.replace(/\s+/g,'');
const labelRegex = /(拜访)?(药师|柜员|店员|人员)[::,,]/g;
const matches = Array.from(text.matchAll(labelRegex));
if (matches.length === 0) {
const bracketPairs = [];
const textWithoutBracket = text.replace(/([\u4e00-\u9fa5]{2,4})[((](药师|柜员|店员)[))]/g, (_m, name, title) => { bracketPairs.push({name,title}); return ''; });
let names = extractNamesFromSegment(textWithoutBracket);
if (names.length===0 && /[\u4e00-\u9fa5]/.test(textWithoutBracket)) names = extractNamesFallback(textWithoutBracket);
for (const pair of bracketPairs) { people.push(pair.name); titles.push(pair.title); }
for (const name of names) { people.push(name); titles.push('柜员'); }
return {people, titles};
}
const firstMatch = matches[0];
if (firstMatch.index > 0) {
const beforeLabel = text.substring(0, firstMatch.index);
let names = extractNamesFromSegment(beforeLabel);
if (names.length===0 && /[\u4e00-\u9fa5]/.test(beforeLabel)) names = extractNamesFallback(beforeLabel);
for (const name of names) { people.push(name); titles.push('药师'); }
}
matches.forEach((match, index) => {
const title = match[2] || '';
const valueStart = match.index + match[0].length;
const valueEnd = index+1 < matches.length ? matches[index+1].index : text.length;
const rawSegment = text.substring(valueStart, valueEnd);
const segment = rawSegment.split(/[。;;]/)[0].trim();
const names = segment.split(/[、,,]/).map(item=>item.replace(/^(拜访)?(?:药师|柜员|店员|人员)[::,,]?/g,'').replace(/[。;;]+$/g,'').trim()).filter(Boolean);
for (const item of names) {
const titleMatch = item.match(/^(.+?)[((](.+?)[))]$/);
if (titleMatch) { people.push(titleMatch[1].trim()); titles.push(titleMatch[2].trim()); }
else { people.push(item); titles.push(title); }
}
});
return {people, titles};
};
const mergeContinuationLines = (lines) => {
const merged = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
const looksLikeContinuation = /^(?:柜员|药师|店员|人员|拜访(?:药师|柜员|店员|人员)?)[::]/.test(line);
const prev = merged[merged.length-1] || '';
const prevNeedsContinuation = /(?:柜员|药师|店员|人员)[::]\s*$/.test(prev);
const prevEndsWithPartialName = /(?:拜访)?(?:药师|柜员|店员|人员)[::]\s*[\u4e00-\u9fa5]{1,2}$/.test(prev);
const currentIsNameContinuation = /^[\u4e00-\u9fa5]/.test(line)&&!/^(?:\d|[一二三四五六七八九十])/.test(line)&&!/^(?:柜员|药师|店员|人员|拜访)/.test(line)&&!/^(?:服务省份|执行人员|活动负责人|工作内容|具体事项|本次|今天|药店|门店|名称)/.test(line);
const prevHasUnmatchedParen = (/[((]/.test(prev)&&!/[))]/.test(prev))||/[((][^))]*$/.test(prev);
const currentStartsWithCloseParen = /^[\u4e00-\u9fa5]+[))]/.test(line);
const prevEndsWithPharmacy = /(?:药房|药店|医药|大药房)\s*[,,]?\s*$/.test(prev);
const currentLooksLikeRemainder = /^[\u4e00-\u9fa5()()]/.test(line)&&!/^(?:\d|[一二三四五六七八九十])\s*[;;::.、.]/.test(line)&&!/^(?:柜员|药师|店员|人员|拜访|服务省份|执行人员)/.test(line);
if ((looksLikeContinuation||prevNeedsContinuation||(prevEndsWithPartialName&&currentIsNameContinuation)||(prevHasUnmatchedParen&&currentStartsWithCloseParen)||(prevEndsWithPharmacy&&currentLooksLikeRemainder))&&merged.length>0) {
merged[merged.length-1] = `${prev} ${line}`;
} else { merged.push(line); }
}
return merged;
};
const extractPharmacies = (text) => {
const rawLines = normalizeText(text).split('\n').map(l=>l.trim()).filter(Boolean);
const lines = mergeContinuationLines(rawLines);
const pharmacies = [];
const seen = new Set();
const numberedPattern = /^(?:第?\s*)?(?:\d{1,2}|[一二三四五六七八九十]+)\s*[、..::;;]\s*(.+?)$/;
const labelPattern = /(?:药店|门店|店名|药店名称)[::]\s*([^,,。;;\n]+)/;
for (const line of lines) {
if (/拜访记录/.test(line)&&!/^(?:第?\s*)?(?:\d{1,2}|[一二三四五六七八九十])\s*[、..::;;]/.test(line)) continue;
const nameCandidate = extractFirst(line, [numberedPattern, labelPattern]);
if (!nameCandidate) continue;
const name = cleanPharmacyName(nameCandidate.replace(/(拜访.*|药师.*|柜员.*|店员.*|人员.*)$/g,'').trim());
if (!name||name.length<2||seen.has(name)) continue;
const {people, titles} = parsePeopleAndTitles(line);
if (!/(药|房|店|门诊|医药|健康|大参林|老百姓|一心|朝阳|益嘉|健之佳)/.test(name)) continue;
seen.add(name);
pharmacies.push({name, people, titles});
}
return pharmacies;
};
const extractWorkItems = (text) => {
const normalized = normalizeText(text);
const items = [];
const patterns = [/(?:工作内容|具体事项|拜访内容|事项)[::]\s*([^。;;\n]+)/g,/(?:介绍|讲解|沟通|培训|拜访|收集)[^。;;\n]{4,60}/g];
patterns.forEach(pattern => {
for (const match of normalized.matchAll(pattern)) {
const item = (match[1]||match[0]).trim();
if (item&&!items.includes(item)) items.push(item);
}
});
return items.slice(0,8);
};
const parseMemoText = (text) => {
const normalized = normalizeText(text);
const visitDate = normalizeDate(normalized);
const personInCharge = extractFirst(normalized, [
/(?:活动负责人|负责人|执行人员|执行人)\s*[::]?\s*([\u4e00-\u9fa5]{2,4})/,
/人员\s*[::]?\s*([\u4e00-\u9fa5]{2,4})/,
/(?:活动负责人|负责人|执行人员|执行人)\s*[::]?\s*([^\s,,。;;\n]+)/,
]);
const region = extractFirst(normalized, [
// 精确匹配常见标签 + 中文字符(支持更长的地区名如"广西壮族自治区南宁市")
// 分隔符支持 : : , , 以及空格
/(?:服务省份及地区|服务地区|服务省份|服务区域|负责区域|负责地区|所在地区|所在地|省份|地区|区域|城市|市区)\s*[,,::]?\s*([\u4e00-\u9fa5]{2,15})/,
// 宽松匹配:标签 + 任意非空白/非标点(兜底)
/(?:服务省份及地区|服务地区|服务省份|服务区域|负责区域|负责地区|所在地区|所在地|省份|地区|区域|城市|市区)\s*[,,::]?\s*([^\s,,。;;\n]+)/,
]);
const pharmacies = extractPharmacies(normalized);
const expectedCount = extractExpectedCount(normalized);
const shopCount = expectedCount || pharmacies.length;
const workSummaryItems = extractWorkItems(normalized);
const missingFields = [];
const confidenceWarnings = [];
if (!visitDate) missingFields.push('拜访日期');
if (!personInCharge) missingFields.push('活动负责人');
if (!region) missingFields.push('服务地区');
if (!shopCount) confidenceWarnings.push('未明确识别到拜访家数');
if (pharmacies.length===0) confidenceWarnings.push('未完整识别到药店名称');
if (workSummaryItems.length===0) confidenceWarnings.push('未明确识别到具体工作内容');
if (expectedCount&&pharmacies.length&&expectedCount!==pharmacies.length) confidenceWarnings.push(`识别到的药店名单为${pharmacies.length}家,但文本中写的是${expectedCount}家`);
if (pharmacies.some(item=>item.people.length===0)) confidenceWarnings.push('部分药店未识别到拜访人员');
return {visitDate, personInCharge, region, shopCount, workSummaryItems, pharmacies, missingFields, confidenceWarnings};
};
const formatDateDisplay = (dateStr) => {
const d = new Date(dateStr);
return `${d.getFullYear()}.${d.getMonth()+1}.${d.getDate()}`;
};
const formatDateFull = (dateStr) => {
const d = new Date(dateStr);
return `${d.getFullYear()}年${d.getMonth()+1}月${d.getDate()}日`;
};
// ============ 状态管理 ============
const STORAGE_KEY = 'test_visit_records';
const loadVisits = () => { try { const d = localStorage.getItem(STORAGE_KEY); return d ? JSON.parse(d) : []; } catch(e) { return []; } };
const saveVisits = (visits) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(visits)); };
const addVisit = (visit) => { const visits = loadVisits(); visits.unshift(visit); saveVisits(visits); return visit; };
const getVisit = (id) => { return loadVisits().find(v=>v._id===id)||null; };
const deleteVisit = (id) => { const visits = loadVisits().filter(v=>v._id!==id); saveVisits(visits); };
const getAllVisits = () => loadVisits();
let currentVisitId = null;
let currentParsed = null;
let createPagePhotos = [];
// ============ UI 渲染 ============
const switchTab = (tab) => {
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
document.querySelector(`.tab-btn[onclick*="${tab}"]`).classList.add('active');
document.getElementById(`page-${tab}`).classList.add('active');
if (tab==='home') renderHome();
if (tab==='mine') renderMine();
};
const manualInput = () => {
const mockText = `南宁7月3号拜访记录
今天 09:18 "未分类"
服务省份: 广西南宁, 执行人员: 李鹏玉,
7月3号连锁药店拜访记录,(已对应照片)
一; 益嘉医药(沙井分店),拜访药师: 冯金田,
柜员: 刘萍。
二; 健之佳(富康园分店),拜访柜员: 农梅琴。
三: 朝阳大药房(璞悦公馆分店),拜访药师: 韦英梧。
四: 老百姓大药房(碧桂园公园一号店),拜访药师: 黄雪芬,柜员: 覃雪英。
五: 一心大药房(富乐新城二分店),拜访药师: 刘国标,柜员: 黄玲娜。
六: 大参林(寒乐南都店),拜访药师: 莫秋林。
七: 一心大药房(富康园店),拜访药师: 郭萍泓。
八: 朝阳大药房(中昂国汇店),拜访药师: 赵彦。
九: 一心大药房(中昂国汇店),拜访药师: 冯丽芳,柜员: 覃小燕。
十: 朝阳大药房(智兴路分店),拜访柜员: 王海燕`;
document.getElementById('memo-input').value = mockText;
autoParse();
addLog('手动输入', '已填入示例备忘录文本(10家药店)', 'pass');
};
const uploadScreenshot = async (fileInput) => {
const file = fileInput.files[0];
if (!file) return;
const statusEl = document.getElementById('ocr-status');
statusEl.innerHTML = '<div class="alert" style="background:#eff6ff;color:#2563eb;">正在识别截图文字(首次加载需下载语言包,请稍候)...</div>';
addLog('OCR识别', '开始识别...', 'info');
try {
const worker = await Tesseract.createWorker('chi_sim', 1, {
logger: m => {
if (m.status === 'recognizing text') {
statusEl.innerHTML = `<div class="alert" style="background:#eff6ff;color:#2563eb;">正在识别... ${Math.round(m.progress*100)}%</div>`;
}
}
});
const { data: { text } } = await worker.recognize(file);
await worker.terminate();
if (text && text.trim().length > 0) {
document.getElementById('memo-input').value = text.trim();
autoParse();
const parsed = currentParsed;
const missing = parsed && parsed.missingFields.length > 0 ? parsed.missingFields.join('、') : '';
const warnMsg = missing ? `<div class="alert alert-warning" style="margin-top:8px;">以下字段未能自动识别,请手动补充: ${missing}<br><small>可点击下方"查看识别原文"核对OCR文本,或直接在文本框中修改后点"重新解析"</small></div>` : '';
statusEl.innerHTML = '<div class="alert alert-success">OCR识别成功!已自动填充并解析(请核对药店名称和人名)</div>' + warnMsg + '<details style="margin-top:8px;"><summary style="cursor:pointer;font-size:13px;color:#2563eb;">查看识别原文</summary><pre style="margin-top:8px;padding:12px;background:#f8fafc;border-radius:8px;font-size:12px;white-space:pre-wrap;word-break:break-all;max-height:300px;overflow:auto;">' + text.trim().replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</pre></details>';
addLog('OCR识别', `识别成功,提取${text.trim().length}个字符`, 'pass');
} else {
throw new Error('未识别到文字');
}
} catch (e) {
statusEl.innerHTML = `<div class="alert alert-error">OCR识别失败: ${e.message}<br><small>请使用"手动输入"功能</small></div>`;
addLog('OCR识别', `失败: ${e.message}`, 'fail');
}
fileInput.value = '';
};
const autoParse = () => {
const text = document.getElementById('memo-input').value.trim();
if (!text) { alert('请先输入备忘录文本'); return; }
const parsed = parseMemoText(text);
currentParsed = parsed;
document.getElementById('input-date').value = parsed.visitDate||'';
document.getElementById('input-person').value = parsed.personInCharge||'';
document.getElementById('input-region').value = parsed.region||'';
document.getElementById('basic-info-card').style.display = '';
if (parsed.pharmacies.length>0) {
document.getElementById('pharmacy-preview').style.display = '';
renderPharmacyList(parsed.pharmacies);
}
updateCreateSteps();
updateGenerateButton();
const statusEl = document.getElementById('ocr-status');
statusEl.innerHTML = '';
if (parsed.missingFields.length>0) {
statusEl.innerHTML = `<div class="alert alert-warning">需补充: ${parsed.missingFields.join(', ')}</div>`;
} else if (parsed.confidenceWarnings.length>0) {
statusEl.innerHTML = `<div class="alert alert-warning">${parsed.confidenceWarnings.join('<br>')}</div>`;
} else {
statusEl.innerHTML = `<div class="alert alert-success">解析成功!识别到 ${parsed.pharmacies.length} 家药店,${parsed.shopCount} 家</div>`;
}
addLog('自动解析', `解析完成: ${parsed.pharmacies.length}家药店, 日期=${parsed.visitDate}, 负责人=${parsed.personInCharge}, 地区=${parsed.region}`, parsed.missingFields.length===0?'pass':'info');
};
const renderPharmacyList = (pharmacies) => {
const el = document.getElementById('pharmacy-list');
el.innerHTML = pharmacies.map((p,i)=>`
<div class="pharmacy-item">
<div class="pharmacy-idx">${i+1}</div>
<div class="pharmacy-info">
<div class="pharmacy-name">${p.name}</div>
${p.people.length>0?`<div class="pharmacy-people">拜访人员: ${p.people.join('、')}</div>`:''}
</div>
</div>
`).join('');
};
const updateCreateSteps = () => {
const step1 = document.getElementById('step-1');
const step2 = document.getElementById('step-2');
const step3 = document.getElementById('step-3');
const text = document.getElementById('memo-input').value.trim();
step1.classList.toggle('done', text.length>0);
step2.classList.toggle('done', currentParsed!==null);
step3.classList.toggle('done', false);
};
const updateGenerateButton = () => {
const btn = document.getElementById('generate-btn');
const text = document.getElementById('memo-input').value.trim();
const date = document.getElementById('input-date').value.trim();
const person = document.getElementById('input-person').value.trim();
const region = document.getElementById('input-region').value.trim();
btn.disabled = false;
if (!text || !date || !person || !region) {
btn.style.opacity = '0.6';
} else {
btn.style.opacity = '1';
}
};
let autoParseTimer = null;
document.getElementById('memo-input').addEventListener('input', () => {
updateCreateSteps();
updateGenerateButton();
clearTimeout(autoParseTimer);
autoParseTimer = setTimeout(autoParse, 500);
});
document.getElementById('input-date').addEventListener('input', updateGenerateButton);
document.getElementById('input-person').addEventListener('input', updateGenerateButton);
document.getElementById('input-region').addEventListener('input', updateGenerateButton);
const generateVisit = () => {
const text = document.getElementById('memo-input').value.trim();
const date = document.getElementById('input-date').value.trim();
const person = document.getElementById('input-person').value.trim();
const region = document.getElementById('input-region').value.trim();
if (!text||!date||!person||!region) { alert('请完善所有必填信息'); return; }
const parsed = parseMemoText(text);
const pharmacies = parsed.pharmacies.map((p,i)=>({
id: Date.now().toString(36)+i,
name: p.name,
type: '连锁',
people: p.people,
titles: p.titles.length===p.people.length?p.titles:p.people.map(()=>'柜员'),
feedback: '无',
effect: '优'
}));
const totalCount = pharmacies.length||1;
const totalPeople = pharmacies.reduce((s,p)=>s+p.people.length,0)||1;
const visit = {
_id: 'visit_'+Date.now(),
visitDate: date,
visitDateDisplay: formatDateDisplay(date),
visitDateFull: formatDateFull(date),
personInCharge: person,
region: region,
product: '贝飞达双歧杆菌三联活菌肠溶胶囊',
pharmacies,
photos: [...createPagePhotos],
totalCount,
totalPeople,
createTime: Date.now()
};
addVisit(visit);
currentVisitId = visit._id;
createPagePhotos = [];
addLog('生成清单', `成功生成拜访清单: ${totalCount}家药店, ${totalPeople}人, ${visit.photos.length}张照片`, 'pass');
document.getElementById('step-3').classList.add('done');
document.getElementById('create-status').innerHTML = '<div class="alert alert-success">拜访清单生成成功!</div>';
renderDetail(visit._id);
switchTabPage('detail');
};
const switchTabPage = (tab) => {
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
document.getElementById(`page-${tab}`).classList.add('active');
};
const renderDetail = (id) => {
const visit = getVisit(id);
if (!visit) { document.getElementById('detail-content').innerHTML='<div class="alert alert-error">未找到拜访记录</div>'; return; }
document.getElementById('detail-content').innerHTML = `
<div class="card">
<div class="card-title">拜访日期</div>
<div style="font-size:20px;font-weight:700;color:#2563eb;">${visit.visitDateFull}</div>
<div class="stats-grid" style="margin-top:12px;">
<div class="stat-item"><div class="stat-value">${visit.totalCount}</div><div class="stat-label">药店数量</div></div>
<div class="stat-item"><div class="stat-value">${visit.totalPeople}</div><div class="stat-label">拜访人数</div></div>
<div class="stat-item"><div class="stat-value">${visit.photos.length}</div><div class="stat-label">照片数量</div></div>
</div>
</div>
<div class="card">
<div class="card-title">基本信息</div>
<div style="font-size:14px;line-height:2;">
<div><span style="color:#64748b;">负责人: </span>${visit.personInCharge}</div>
<div><span style="color:#64748b;">服务地区: </span>${visit.region}</div>
<div><span style="color:#64748b;">产品名称: </span>${visit.product}</div>
</div>
</div>
${visit.pharmacies.length>0?`
<div class="card">
<div class="card-title">拜访药店清单 (${visit.pharmacies.length}家)</div>
<div class="pharmacy-list">
${visit.pharmacies.map((p,i)=>`
<div class="pharmacy-item">
<div class="pharmacy-idx">${i+1}</div>
<div class="pharmacy-info">
<div class="pharmacy-name">${p.name}</div>
${p.people.length>0?`<div class="pharmacy-people">拜访人员: ${p.people.join('、')}</div>`:''}
</div>
</div>
`).join('')}
</div>
</div>`:''}
<div class="card">
<div class="card-title">打卡照片 (${visit.photos.length}张)</div>
<div id="photo-upload-area">
<div id="photo-preview" style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;">
${visit.photos.map((p,i)=>`
<div style="position:relative;width:80px;height:80px;">
<img src="${p}" style="width:80px;height:80px;object-fit:cover;border-radius:8px;border:1px solid #e2e8f0;">
<button onclick="removePhoto('${visit._id}',${i})" style="position:absolute;top:-4px;right:-4px;width:20px;height:20px;border-radius:50%;background:#ef4444;color:#fff;border:none;font-size:12px;cursor:pointer;line-height:20px;">x</button>
</div>
`).join('')}
</div>
<label class="btn btn-outline btn-sm" style="cursor:pointer;display:inline-block;">
📷 上传拜访照片
<input type="file" accept="image/*" multiple onchange="uploadVisitPhotos('${visit._id}',this)" style="display:none;">
</label>
</div>
<div style="font-size:12px;color:#94a3b8;margin-top:6px;">支持多选,照片将放入Word文档末尾的照片表中</div>
</div>
<div style="display:flex;gap:8px;margin-bottom:16px;">
<button class="btn btn-primary" onclick="exportWord('${visit._id}')" id="export-btn">导出Word文档(.docx)</button>
<button class="btn btn-danger" onclick="deleteVisitDetail('${visit._id}')">删除记录</button>
</div>
`;
};
const deleteVisitDetail = (id) => {
if (!confirm('确定要删除这条拜访记录吗?')) return;
deleteVisit(id);
addLog('删除记录', `已删除拜访记录: ${id}`, 'info');
switchTab('home');
};
// ============ 创建页照片上传功能 ============
const uploadCreatePhotos = async (input) => {
const files = input.files;
if (!files || files.length === 0) return;
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
const compressed = await compressImage(file, 800);
createPagePhotos.push(compressed);
}
renderCreatePhotos();
addLog('上传照片', `已上传 ${files.length} 张照片到创建页`, 'pass');
input.value = '';
};
const removeCreatePhoto = (index) => {
createPagePhotos.splice(index, 1);
renderCreatePhotos();
addLog('删除照片', `已删除第 ${index + 1} 张照片`, 'info');
};
const renderCreatePhotos = () => {
const previewEl = document.getElementById('create-photo-preview');
const countEl = document.getElementById('create-photo-count');
if (countEl) countEl.textContent = createPagePhotos.length;
if (previewEl) {
if (createPagePhotos.length === 0) {
previewEl.innerHTML = '<div style="color:#94a3b8;font-size:13px;line-height:40px;">点击下方按钮上传拜访照片(可选)</div>';
} else {
previewEl.innerHTML = createPagePhotos.map((p, i) => `
<div style="position:relative;width:80px;height:80px;">
<img src="${p}" style="width:80px;height:80px;object-fit:cover;border-radius:8px;border:1px solid #e2e8f0;">
<button onclick="removeCreatePhoto(${i})" style="position:absolute;top:-4px;right:-4px;width:20px;height:20px;border-radius:50%;background:#ef4444;color:#fff;border:none;font-size:12px;cursor:pointer;line-height:20px;">x</button>
</div>
`).join('');
}
}
};
// ============ 详情页照片上传功能 ============
const uploadVisitPhotos = async (visitId, input) => {
const files = input.files;
if (!files || files.length === 0) return;
const visits = getAllVisits();
const visit = visits.find(v => v._id === visitId);
if (!visit) return;
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
const compressed = await compressImage(file, 800);
visit.photos.push(compressed);
}
saveVisits(visits);
renderDetail(visitId);
addLog('上传照片', `已上传 ${files.length} 张照片`, 'pass');
input.value = '';
};
const removePhoto = (visitId, index) => {
const visits = getAllVisits();
const visit = visits.find(v => v._id === visitId);
if (!visit) return;
visit.photos.splice(index, 1);
saveVisits(visits);
renderDetail(visitId);
addLog('删除照片', `已删除第 ${index + 1} 张照片`, 'info');
};
const compressImage = (file, maxSize) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxSize || height > maxSize) {
if (width > height) {
height = Math.round(height * maxSize / width);
width = maxSize;
} else {
width = Math.round(width * maxSize / height);
height = maxSize;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL('image/jpeg', 0.7));
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
};
const renderHome = () => {
const visits = getAllVisits();
document.getElementById('home-total-records').textContent = visits.length;
document.getElementById('home-total-shops').textContent = visits.reduce((s,v)=>s+v.totalCount,0);
document.getElementById('home-total-people').textContent = visits.reduce((s,v)=>s+v.totalPeople,0);
const listEl = document.getElementById('home-visit-list');
if (visits.length===0) {
listEl.innerHTML = '<div style="text-align:center;padding:20px;color:#94a3b8;">暂无拜访记录</div>';
} else {
listEl.innerHTML = visits.map(v=>`
<div class="visit-item" onclick="renderDetail('${v._id}');switchTabPage('detail');">
<div class="title">${v.visitDateFull} - ${v.region}</div>
<div class="meta">${v.totalCount}家药店 | ${v.totalPeople}人 | 负责人: ${v.personInCharge}</div>
<div class="actions">
<button class="btn btn-sm btn-danger" onclick="event.stopPropagation();deleteVisitHome('${v._id}')">删除</button>
</div>
</div>
`).join('');
}
};
const deleteVisitHome = (id) => {
if (!confirm('确定要删除这条拜访记录吗?')) return;
deleteVisit(id);
addLog('删除记录', `已删除: ${id}`, 'info');
renderHome();
renderMine();
};
const renderMine = () => {
const visits = getAllVisits();
document.getElementById('mine-total-records').textContent = visits.length;
document.getElementById('mine-total-shops').textContent = visits.reduce((s,v)=>s+v.totalCount,0);
document.getElementById('mine-total-people').textContent = visits.reduce((s,v)=>s+v.totalPeople,0);
const listEl = document.getElementById('mine-visit-list');
if (visits.length===0) {
listEl.innerHTML = '<div style="text-align:center;padding:20px;color:#94a3b8;">暂无拜访记录</div>';
} else {
listEl.innerHTML = visits.map(v=>`
<div class="visit-item" onclick="renderDetail('${v._id}');switchTabPage('detail');">
<div class="title">${v.visitDateFull} - ${v.region}</div>
<div class="meta">${v.totalCount}家药店 | ${v.totalPeople}人 | 负责人: ${v.personInCharge}</div>
<div class="actions">
<button class="btn btn-sm btn-danger" onclick="event.stopPropagation();deleteVisitMine('${v._id}')">删除</button>
</div>
</div>
`).join('');
}
};
const deleteVisitMine = (id) => {
if (!confirm('确定要删除这条拜访记录吗?')) return;
deleteVisit(id);
addLog('删除记录', `已删除: ${id}`, 'info');
renderHome();
renderMine();
};
// ============ 测试日志 ============
const testLogs = [];
const addLog = (action, detail, status) => {
testLogs.push({action, detail, status, time: new Date().toLocaleTimeString()});
renderLogs();
};
const renderLogs = () => {
const el = document.getElementById('test-logs');
if (!el) return;
el.innerHTML = testLogs.slice(-20).map(l=>`<div class="log-item ${l.status}">[${l.time}] ${l.action}: ${l.detail}</div>`).join('');
};
// ============ Word 导出功能 (使用 docx.js) ============
const base64ToUint8Array = (base64) => {
try {
// 类型检查:必须是字符串
if (typeof base64 !== 'string' || base64.length === 0) {
addLog('base64转换', '数据为空或类型不合法', 'fail');
return new Uint8Array(0);
}
// 去除 data:image/...;base64, 前缀
const base64Data = base64.includes(',') ? base64.split(',')[1] : base64;
if (!base64Data || base64Data.length === 0) {
addLog('base64转换', '去除前缀后数据为空', 'fail');
return new Uint8Array(0);
}
// 尝试用 atob 解码(自动处理 URL-safe base64)
let binaryString;
try {
binaryString = atob(base64Data);
} catch (atobErr) {
addLog('base64转换', 'atob解码失败: ' + atobErr.message, 'fail');
return new Uint8Array(0);
}
if (binaryString.length === 0) {
return new Uint8Array(0);
}
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
} catch (e) {
addLog('base64转换', '未知错误: ' + e.message, 'fail');
return new Uint8Array(0);
}
};
const generateSummaryParagraphs = (year_month, total_count, total_people, month_str, day_str, product, region) => {
const product_short = '贝飞达';
const open_variants = [
`${year_month},针对${region}重点连锁药店开展${product}的学术拜访工作。本次共拜访${total_count}家连锁药店,覆盖${total_people}名合作药师及店员,累计完成${total_count}次学术拜访,所有拜访均在${month_str}月${day_str}日集中完成。本次拜访的核心目标是向一线药师及店员介绍${product_short}与同类益生菌产品在学术层面的对比优势,帮助其更客观地了解产品特点,不涉及任何推荐或促销行为。`,
`${year_month}期间,围绕${product}在${region}重点连锁药店开展了学术拜访活动。此次活动覆盖${total_count}家连锁药店,涉及${total_people}名合作药师及店员,共计完成${total_count}次学术拜访,全部于${month_str}月${day_str}当日执行完毕。活动宗旨在于向门店一线人员阐述${product_short}相较于同类益生菌产品的学术差异,供其参考了解,全程不含任何推荐或促销性质。`,
`在${year_month}的学术拜访工作中,本公司针对${region}区域内${total_count}家重点连锁药店进行了${product}的学术推广。本次工作覆盖${total_people}名合作药师及店员,累计执行${total_count}次拜访,所有门店均于${month_str}月${day_str}日当天完成走访。本次拜访旨在向一线人员客观呈现${product_short}与同类益生菌产品的学术对比信息,不包含任何商业推荐或促销内容。`,
`${year_month},本公司围绕${product}在${region}重点连锁药店范围内组织实施了学术拜访。本次工作共走访${total_count}家连锁药店,接触${total_people}名合作药师及店员,完成${total_count}次学术拜访记录,全部拜访于${month_str}月${day_str}日集中完成。本次活动的核心在于向一线药师和店员传递${product_short}与同类益生菌产品的学术对比信息,以便其自主判断,过程中未进行任何形式的推荐或促销。`,
];
const content_variants = [
`在学术推广内容方面,本次重点介绍了${product_short}作为双歧杆菌三联活菌制剂的菌株组合特点,包括其在活菌数量、肠道定植能力、储存条件及临床适用人群等方面与常见益生菌产品的差异。同时,针对药师和店员在日常工作中可能遇到的消费者疑问,提供了标准化的学术应答信息,以便其根据自身判断进行专业回应。拜访形式以一对一或一对二的小范围沟通为主,平均时长5至10分钟,注重信息的准确传达与现场反馈收集。`,
`学术信息传递方面,本次拜访着重讲解${product_short}作为双歧杆菌三联活菌制剂的菌株组合特性,涵盖活菌数量、肠道定植能力、储存条件及适用人群等维度与同类产品的差异比较。此外,围绕药店人员日常面对的消费者常见问题,整理了标准化学术应答参考,便于其结合专业判断予以回应。拜访采取一对一至一对二的小规模交流形式,单次沟通时长约5至10分钟,侧重信息精准传递及现场意见收集。`,
`就学术推广的具体内容而言,本次重点围绕${product_short}作为双歧杆菌三联活菌制剂的菌株组合优势展开,从活菌数量、肠道定植能力、储存条件及临床适用人群等方面与常见益生菌产品进行了对比说明。同时,针对药店一线人员在日常服务中可能遇到的消费者咨询,提供了规范化的学术应答要点,以便其依据自身专业判断作出回应。沟通形式以小范围交流(一对一或一对二)为主,每次约5至10分钟,重视信息的准确传达和现场反馈的收集。`,
`本次学术推广的核心内容聚焦于${product_short}作为双歧杆菌三联活菌制剂的菌株组合特征,从活菌数量、肠道定植能力、储存条件及适用人群等角度,与市面常见益生菌产品进行了差异分析。同时,针对药师及店员在日常工作中可能面对的消费者疑问,梳理了标准化的学术应答素材,方便其基于自身判断进行专业回复。拜访交流以一对一或一对二的小范围形式开展,平均时长控制在5至10分钟,注重信息传达的准确性与现场反馈的及时收集。`,
];
const good_count = Math.max(6, total_count - 4);
const neutral_count = total_count - good_count;
const result_variants = [
`从目标达成情况来看,本次拜访完成了全部${total_count}家门店的学术覆盖任务,执行率达100%。在反馈效果方面,有${good_count}家门店的药师或店员对介绍内容表现出兴趣或无明显异议,信息接受度较好;另有${neutral_count}家门店反馈较为中性,其中部分人员明确表示更倾向了解竞品信息,或未给出积极反馈。整体来看,学术信息传递顺畅,多数门店接受度良好。`,
`就目标达成而言,本次${total_count}家门店的学术覆盖任务已全部完成,执行率100%。从反馈效果看,${good_count}家门店的药师或店员对所介绍内容表现出一定兴趣或未提出异议,信息接收情况较好;其余${neutral_count}家门店的反馈相对中性,部分人员表示更关注竞品信息或未作出明确反馈。总体而言,学术信息传递过程顺利,大部分门店的接受度良好。`,
`在目标达成方面,全部${total_count}家门店的学术覆盖工作均已落实,完成率100%。反馈层面,${good_count}家门店的药师或店员对讲解内容表现出兴趣或无异议,信息接受度较为理想;另外${neutral_count}家门店的反馈偏中性,个别人员倾向于了解竞品信息或未给予明确反馈。综合来看,学术信息传递较为顺畅,多数门店展现出良好的接受度。`,
`从完成情况来看,本次${total_count}家门店的学术覆盖任务已全部达成,执行完成率100%。在反馈效果方面,${good_count}家门店的药师或店员对介绍内容表现出兴趣或未提出异议,信息接受度良好;其余${neutral_count}家门店反馈较为中性,部分人员明确表示倾向了解竞品信息或未给出积极反馈。整体而言,学术信息传递顺畅,多数门店的接受度达到预期。`,
];
const note_variants = [
'需要指出的是,本次拜访未发放任何学术资料或产品提示物,信息主要依靠口头传递。后续可考虑在类似学术介绍中补充简要的对比性资料,以增强信息留存效果。针对反馈中性的门店,建议安排二次学术沟通,进一步澄清产品学术信息,不改变本次仅作介绍的工作性质。',
'值得一提的是,本次拜访过程中未配发学术资料或产品提示物,相关信息主要通过口头交流完成。今后可考虑在类似学术推广中增加简要的对比性材料,以提升信息留存效果。对于反馈中性的门店,建议择机进行二次学术沟通,进一步补充产品学术信息,同时维持本次仅作学术介绍的工作定位。',
'此外,本次拜访未携带学术资料或产品提示物,信息传递以口头方式为主。后续在类似学术推广活动中,可考虑增加简明的对比性参考资料,以提升信息的留存与传播效果。针对反馈偏中性的门店,建议适时安排二次学术跟进,进一步补充产品学术信息,同时保持本次仅限于学术介绍的工作性质不变。',
'另外需说明的是,本次活动未发放学术资料或产品提示物,信息传达主要依赖口头沟通。后续类似学术推广中可考虑配备简要的对比性资料,以提高信息留存效果。对于反馈中性的门店,建议安排二次学术沟通以进一步明确产品学术信息,但整体工作性质仍以学术介绍为主,不做变更。',
];
return [
open_variants[Math.floor(Math.random() * open_variants.length)],
content_variants[Math.floor(Math.random() * content_variants.length)],
result_variants[Math.floor(Math.random() * result_variants.length)],
note_variants[Math.floor(Math.random() * note_variants.length)],
];
};
const generateDocx = (visit) => {
const { Document, Paragraph, TextRun, Table, TableRow, TableCell, AlignmentType, BorderStyle, WidthType, PageBreak, Packer, ImageRun } = docx;
// ===== 提取数据 =====
const region = visit.region || '';
const person = visit.personInCharge || '';
const visitDateDisplay = visit.visitDateDisplay || '';
const visitDateFull = visit.visitDateFull || '';
const product = visit.product || '贝飞达双歧杆菌三联活菌肠溶胶囊';
const pharmacies = visit.pharmacies || [];
const totalCount = visit.totalCount || pharmacies.length;
const totalPeople = visit.totalPeople || 0;
const photos = visit.photos || [];
const company = '广西科佐健康管理有限公司';
const companyStamp = '广西科佐市场管理有限公司';
const monthMatch = visitDateFull.match(/(\d+)年(\d+)月/);
const yearMonth = monthMatch ? `${monthMatch[1]}年${monthMatch[2]}月` : visitDateFull;
const dateParts = visitDateDisplay.split('.');
const dayStr = dateParts.length > 2 ? dateParts[2] : '';
const monthStr = dateParts.length > 1 ? dateParts[1] : '';
// ===== 辅助函数 =====
const FONT_FANGSONG = '仿宋';
const createCell = (text, width, opts = {}) => {
const { font = FONT_FANGSONG, size = 21, bold, alignment, lineSpacing } = opts;
const runs = [];
if (text !== undefined && text !== null && text !== '') {
const lines = String(text).split('\n');
lines.forEach((line, i) => {
runs.push(new TextRun({
text: line,
font: font,
size: size,
bold: bold,
break: i > 0 ? 1 : 0,
}));
});
}
return new TableCell({
children: [new Paragraph({
alignment: alignment || AlignmentType.CENTER,
spacing: { before: 0, after: 0, line: lineSpacing || 240 },
children: runs,
})],
width: { size: width, type: WidthType.DXA },
verticalAlign: 'center',
});
};
// ===== 表格边框定义 =====
const tableBorders = {
top: { style: BorderStyle.SINGLE, size: 4, color: 'auto' },
bottom: { style: BorderStyle.SINGLE, size: 4, color: 'auto' },
left: { style: BorderStyle.SINGLE, size: 4, color: 'auto' },
right: { style: BorderStyle.SINGLE, size: 4, color: 'auto' },
insideHorizontal: { style: BorderStyle.SINGLE, size: 4, color: 'auto' },
insideVertical: { style: BorderStyle.SINGLE, size: 4, color: 'auto' },
};
const noBorders = {
top: { style: BorderStyle.NONE, size: 0, color: 'auto' },
bottom: { style: BorderStyle.NONE, size: 0, color: 'auto' },
left: { style: BorderStyle.NONE, size: 0, color: 'auto' },
right: { style: BorderStyle.NONE, size: 0, color: 'auto' },
insideHorizontal: { style: BorderStyle.NONE, size: 0, color: 'auto' },
insideVertical: { style: BorderStyle.NONE, size: 0, color: 'auto' },
};
const tableMargins = {
marginUnitType: WidthType.DXA,
top: 60,
bottom: 60,
left: 108,
right: 108,
};
// ===== 构建文档子元素 =====
const children = [];
// P0: 标题 - 仿宋 22pt 加粗 居中
children.push(new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { line: 360, after: 200 },
children: [
new TextRun({ text: `${region}药店客户拜访清单`, font: FONT_FANGSONG, size: 44, bold: true }),
],
}));
// P1: 空行
children.push(new Paragraph({
spacing: { line: 360, before: 0, after: 0 },
}));
// P2-P4: 基本信息 - 仿宋 12pt 加粗 两端对齐 首行缩进 24.1pt 行距 1.5
const basicInfoLines = [
{ label: '服务公司:', value: company },
{ label: '服务省份及地区:', value: region },
{ label: '执行人员:', value: person },
];
for (const { label, value } of basicInfoLines) {
children.push(new Paragraph({
alignment: AlignmentType.JUSTIFIED,
indent: { firstLine: 482 },
spacing: { line: 360, before: 0, after: 0 },
children: [
new TextRun({ text: label, font: FONT_FANGSONG, size: 24, bold: true }),
new TextRun({ text: value, font: FONT_FANGSONG, size: 24, bold: false }),
],
}));
}
// P5-P6: 服务时间 - 仿宋 11pt 两端对齐 首行缩进 22.1pt 行距 1.5
for (const line of [`服务开展时间:${visitDateDisplay}`, `表单提交时间:${visitDateDisplay}`]) {
children.push(new Paragraph({
alignment: AlignmentType.JUSTIFIED,
indent: { firstLine: 442 },
spacing: { line: 360, before: 0, after: 0 },
children: [
new TextRun({ text: line, font: FONT_FANGSONG, size: 22, bold: false }),
],
}));
}
// 服务时间后空行
children.push(new Paragraph({
spacing: { line: 360, before: 0, after: 0 },
}));
// P8: 分页符
children.push(new Paragraph({
children: [new PageBreak()],
}));
// P9: "药店客户拜访清单" - 仿宋 14pt 加粗 行距 1.5
children.push(new Paragraph({
spacing: { line: 360, before: 0, after: 0 },
children: [
new TextRun({ text: '药店客户拜访清单', font: FONT_FANGSONG, size: 28, bold: true }),
],
}));
// P10: 介绍段落 - 仿宋 12pt 两端对齐 首行缩进 24pt 行距 1.5
const intro = `${visitDateFull}期间,本公司针对*<${region}>*区域药店进行拜访工作,旨在传达产品优势,了解用药反馈,在拜访当中,重点针对*<${product}>*进行学术推广及讲解,现将拜访结果汇报如下:`;
children.push(new Paragraph({
alignment: AlignmentType.JUSTIFIED,
indent: { firstLine: 480 },
spacing: { line: 360, before: 0, after: 0 },
children: [
new TextRun({ text: intro, font: FONT_FANGSONG, size: 24, bold: false }),
],
}));
// P11: "一、药店拜访开展情况总结" - 仿宋 14pt 加粗 行距 1.5 段后 6pt
children.push(new Paragraph({
spacing: { line: 360, after: 120 },
children: [
new TextRun({ text: '一、药店拜访开展情况总结', font: FONT_FANGSONG, size: 28, bold: true }),
],
}));
// P12: 统计说明 - 仿宋 12pt 缩进 24pt 行距 1.5
children.push(new Paragraph({
indent: { firstLine: 480 },
spacing: { line: 360, before: 0, after: 0 },
children: [
new TextRun({ text: `此次药店拜访针对*<${totalCount}>*家药店、*<${totalPeople}>*个`, font: FONT_FANGSONG, size: 24, bold: false }),
new TextRun({ text: '柜员', font: FONT_FANGSONG, size: 24, bold: false }),
new TextRun({ text: `开展${yearMonth}的拜访,项目总拜访次数为*<${totalCount}>*次。`, font: FONT_FANGSONG, size: 24, bold: false }),
],
}));
// P13: "拜访统计表如下:" - 仿宋 12pt 行距 1.5
children.push(new Paragraph({
spacing: { line: 360, before: 0, after: 0 },
children: [
new TextRun({ text: '拜访统计表如下:', font: FONT_FANGSONG, size: 24, bold: false }),
],
}));
// P14: 空行
children.push(new Paragraph({
spacing: { line: 360, before: 0, after: 0 },
}));
// ===== 表1: 统计表 (n+2行 x 7列) =====
const colWidths1 = [1413, 1984, 1418, 1701, 2303, 2233, 2835];
const headers1 = ['序号', '医院药店', '类型', '拜访人数', '产品', '拜访频次', '拜访小计'];
const table1Rows = [];
// 表头行
table1Rows.push(new TableRow({
children: headers1.map((h, ci) => createCell(h, colWidths1[ci], {
font: FONT_FANGSONG, size: 21, bold: false,
})),
}));
// 数据行
pharmacies.forEach((pharm, ri) => {
const values = [
String(ri + 1),
pharm.name || '',
pharm.type || '连锁',
String((pharm.people || []).length),
product,
'1',
'1',
];
table1Rows.push(new TableRow({
children: values.map((v, ci) => createCell(v, colWidths1[ci], {})),
}));
});
// 总计行
const totalRow1Cells = [];
totalRow1Cells.push(createCell('总计', colWidths1[0], { font: FONT_FANGSONG, size: 21, bold: false }));
totalRow1Cells.push(createCell(`*<拜访药店数量>*\n${totalCount}`, colWidths1[1], {}));
for (let ci = 2; ci < 6; ci++) {
totalRow1Cells.push(createCell('', colWidths1[ci], {}));
}
totalRow1Cells.push(createCell(`*<拜访次数>*\n${totalCount}`, colWidths1[6], {}));
table1Rows.push(new TableRow({ children: totalRow1Cells }));
children.push(new Table({
rows: table1Rows,
columnWidths: colWidths1,
borders: tableBorders,
margins: tableMargins,
}));
// P17: 空行 - 仿宋 12pt + 分页符
children.push(new Paragraph({
children: [new PageBreak()],
}));
// P18: "二、药店拜访开展清单" - 仿宋 14pt 加粗 行距 1.5 段后 6pt
children.push(new Paragraph({
spacing: { line: 360, after: 120 },
children: [
new TextRun({ text: '二、药店拜访开展清单', font: FONT_FANGSONG, size: 28, bold: true }),
],
}));
// P19: 空行 - 清除段后间距
children.push(new Paragraph({
alignment: AlignmentType.JUSTIFIED,
spacing: { line: 360, before: 0, after: 0 },
}));
// ===== 表2: 明细表 (n+1行 x 12列) =====
const colWidths2 = [1263, 662, 838, 976, 869, 1321, 944, 1950, 1410, 1140, 1155, 647];
const headers2 = [
'药店名称', '类型', '拜访人员名称', '拜访人员职称',
'拜访日期', '产品', '拜访目的', '拜访主要内容',
'拜访人员反馈', '平均拜访时长', '学术资料及产品提示物类型(如有)', '拜访效果',
];
const table2Rows = [];
// 表头行
table2Rows.push(new TableRow({
children: headers2.map((h, ci) => createCell(h, colWidths2[ci], {
font: FONT_FANGSONG, size: 24, bold: false,
})),
}));
// 数据行
pharmacies.forEach((pharm) => {
const people = pharm.people || [];
const peopleStr = people.join('、');
const titles = pharm.titles || [];
const titleStr = titles.length > 0 ? titles.join('、') : '柜员';
const values = [
pharm.name || '',
pharm.type || '连锁',
peopleStr,
titleStr,
visitDateDisplay,
product,
'学术信息传递',
'讲解贝飞达与同类益生菌产品的对比优势。',
'无',
'5-10分钟',
'无',
pharm.effect || '优',
];
table2Rows.push(new TableRow({
children: values.map((v, ci) => createCell(v, colWidths2[ci], {})),
}));
});
children.push(new Table({
rows: table2Rows,
columnWidths: colWidths2,
borders: tableBorders,
margins: tableMargins,
}));
// 分页符
children.push(new Paragraph({
children: [new PageBreak()],
}));
// P25: "三、药店拜访小结" - 仿宋 14pt 加粗 左对齐 keepNext keepLines
children.push(new Paragraph({
alignment: AlignmentType.LEFT,
spacing: { line: 360, after: 0 },
keepNext: true,
keepLines: true,
children: [
new TextRun({ text: '三、药店拜访小结', font: FONT_FANGSONG, size: 28, bold: true }),
],
}));
// 标题下方空行
children.push(new Paragraph({
spacing: { line: 360, before: 0, after: 0 },
keepNext: true,
}));
// P26-P29: 小结内容 - 仿宋 12pt 两端对齐 首行缩进 24.0pt 行距 1.5 keepNext keepLines
const summaryParagraphs = generateSummaryParagraphs(yearMonth, totalCount, totalPeople, monthStr, dayStr, product, region);
for (const s of summaryParagraphs) {
children.push(new Paragraph({
alignment: AlignmentType.JUSTIFIED,
indent: { firstLine: 480 },
spacing: { line: 360, before: 0, after: 0 },
keepNext: true,
keepLines: true,
children: [
new TextRun({ text: s, font: FONT_FANGSONG, size: 24 }),
],
}));
}
// 落款前空行
children.push(new Paragraph({
spacing: { line: 360, before: 0, after: 0 },
keepNext: true,
}));
// P31-P33: 签名区 - 仿宋 12pt 右对齐 右缩进 720twips(约1cm) keepNext keepLines
const SIGNATURE_RIGHT_MARGIN = 720;
children.push(new Paragraph({
alignment: AlignmentType.RIGHT,
indent: { firstLine: 0, right: SIGNATURE_RIGHT_MARGIN },
spacing: { line: 360, before: 0, after: 0 },
keepNext: true,
keepLines: true,
children: [
new TextRun({ text: `服务公司:${companyStamp}(盖章)`, font: FONT_FANGSONG, size: 24, bold: false }),
],
}));
children.push(new Paragraph({
alignment: AlignmentType.RIGHT,
indent: { firstLine: 0, right: SIGNATURE_RIGHT_MARGIN },
spacing: { line: 360, before: 0, after: 0 },
keepNext: true,
keepLines: true,
children: [
new TextRun({ text: '活动负责人:', font: FONT_FANGSONG, size: 24, bold: false }),
new TextRun({ text: person, font: FONT_FANGSONG, size: 24, bold: false }),
],
}));
children.push(new Paragraph({
alignment: AlignmentType.RIGHT,
indent: { firstLine: 0, right: SIGNATURE_RIGHT_MARGIN },
spacing: { line: 360, before: 0, after: 0 },
keepLines: true,
children: [
new TextRun({ text: `日期:${visitDateDisplay}`, font: FONT_FANGSONG, size: 24, bold: false }),
],
}));
// ===== 照片表前分页 =====
children.push(new Paragraph({
pageBreakBefore: true,
spacing: { before: 0, after: 0, line: 240 },
}));
// ===== 表3: 照片表 (3行 x 5列) 无边框 =====
const colWidths3 = [2592, 2592, 2592, 2592, 2592];
const photoPositions = [
[0, 0], [0, 1], [0, 2], [0, 3], [0, 4],
[1, 0], [1, 1], [1, 2], [1, 4],
[2, 0], [2, 1], [2, 2], [2, 3], [2, 4],
];
const photoMap = {};
photos.forEach((photo, idx) => {
if (idx < photoPositions.length) {
const [r, c] = photoPositions[idx];
if (!photoMap[r]) photoMap[r] = {};
photoMap[r][c] = photo;
}
});
const table3Rows = [];
for (let ri = 0; ri < 3; ri++) {
const cells = [];
for (let ci = 0; ci < 5; ci++) {
const photo = photoMap[ri] && photoMap[ri][ci];
if (photo) {
try {
const imgData = base64ToUint8Array(photo);
if (imgData.length === 0) {
cells.push(createCell('', colWidths3[ci], {}));
} else {
cells.push(new TableCell({
children: [new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 0, after: 0, line: 240 },
children: [
new ImageRun({
data: imgData,
transformation: { width: 108, height: 81 },
}),
],
})],
width: { size: colWidths3[ci], type: WidthType.DXA },
verticalAlign: 'center',
}));
}
} catch (e) {
addLog('照片渲染', '跳过损坏照片: ' + e.message, 'fail');
cells.push(createCell('', colWidths3[ci], {}));
}
} else {
cells.push(createCell('', colWidths3[ci], {}));
}
}
table3Rows.push(new TableRow({ children: cells }));
}
children.push(new Table({
rows: table3Rows,
columnWidths: colWidths3,
borders: noBorders,
margins: tableMargins,
}));
// ===== 创建文档 =====
const doc = new Document({
styles: {
default: {
document: {
run: {
font: { ascii: FONT_FANGSONG, hAnsi: FONT_FANGSONG, eastAsia: FONT_FANGSONG },
size: 24,
},
paragraph: {
spacing: {
line: 360,
after: 0,
},
},
},
},
},
sections: [{
properties: {
page: {
size: {
width: 12240, // docx.js orientation='landscape' 会交换宽高,最终 XML: 15840×12240 (Letter横向,与模板一致)
height: 15840,
orientation: 'landscape',
},
margin: {
top: 1440,
bottom: 1440,
left: 1440,
right: 1440,
},
},
},
children: children,
}],
});
return doc;
};
const exportWord = async (id) => {
const visit = getVisit(id);
if (!visit) return;
const btn = document.getElementById('export-btn');
btn.disabled = true;
btn.textContent = '正在加载导出组件...';
// 等待docx库加载完成(最多等30秒)
let waitCount = 0;
while (typeof docx === 'undefined' && waitCount < 60) {
await new Promise(r => setTimeout(r, 500));
waitCount++;
btn.textContent = `正在加载导出组件...(${waitCount*0.5}秒)`;
}
if (typeof docx === 'undefined') {
btn.disabled = false;
btn.textContent = '导出Word文档(.docx)';
alert('Word导出组件加载超时,请检查网络后刷新页面重试');
return;
}
btn.textContent = '生成中...';
try {
const doc = generateDocx(visit);
const blob = await docx.Packer.toBlob(doc);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const dateStr = visit.visitDateDisplay.replace(/\./g, '-');
a.download = `药店拜访清单${dateStr}-${visit.totalCount}家 ${visit.personInCharge}.docx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
addLog('导出Word', `已导出.docx: ${a.download}`, 'pass');
} catch (e) {
addLog('导出Word', `导出失败: ${e.message}`, 'fail');
alert('导出失败: ' + e.message);
}
btn.disabled = false;
btn.textContent = '导出Word文档(.docx)';
};
// ============ 页面加载 ============
window.addEventListener('DOMContentLoaded', ()=>{
const logDiv = document.createElement('div');
logDiv.className = 'card log-section';
logDiv.style.margin = '16px';
logDiv.innerHTML = '<div class="card-title">操作日志</div><div id="test-logs"></div>';
document.body.appendChild(logDiv);
renderHome();
addLog('初始化', '药店拜访助手加载完成', 'info');
});
</script>
</body>
</html>