Spaces:
Running
Running
Download index.html from data-learner/pharmacy: direct link, hf CLI and curl.
- Browser
- Download file 71.5 kB
-
https://huggingface.co/spaces/data-learner/pharmacy/resolve/main/index.html
- Command line
-
hf download hf://spaces/data-learner/pharmacy/index.html
-
curl -L -o index.html https://huggingface.co/spaces/data-learner/pharmacy/resolve/main/index.html
71.5 kB
| <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&¤tIsNameContinuation)||(prevHasUnmatchedParen&¤tStartsWithCloseParen)||(prevEndsWithPharmacy&¤tLooksLikeRemainder))&&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,'<').replace(/>/g,'>') + '</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> | |