demo-crm-invoice-paste-recognize.html 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <meta name="viewport" content="width=device-width, initial-scale=1" />
  6. <title>开票信息 · 快速识别 Demo</title>
  7. <style>
  8. :root {
  9. --primary: #10b981;
  10. --primary-dark: #059669;
  11. --primary-bg: #ecfdf5;
  12. --text: #1e293b;
  13. --muted: #64748b;
  14. --border: #e2e8f0;
  15. --panel: #f8fafc;
  16. }
  17. * { box-sizing: border-box; }
  18. body {
  19. margin: 0;
  20. font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
  21. color: var(--text);
  22. background: linear-gradient(160deg, #ecfdf5 0%, #f8fafc 40%, #eef2ff 100%);
  23. min-height: 100vh;
  24. padding: 32px 20px 48px;
  25. }
  26. .page { max-width: 1100px; margin: 0 auto; }
  27. h1 { font-size: 22px; margin: 0 0 6px; }
  28. .lead { color: var(--muted); margin: 0 0 24px; font-size: 14px; line-height: 1.6; }
  29. .tabs {
  30. display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px;
  31. }
  32. .tab {
  33. border: 1px solid var(--border);
  34. background: #fff;
  35. border-radius: 999px;
  36. padding: 8px 14px;
  37. font-size: 13px;
  38. cursor: pointer;
  39. color: var(--text);
  40. }
  41. .tab.active {
  42. background: var(--primary);
  43. border-color: var(--primary);
  44. color: #fff;
  45. font-weight: 600;
  46. }
  47. .stage {
  48. background: #fff;
  49. border: 1px solid var(--border);
  50. border-radius: 12px;
  51. box-shadow: 0 8px 28px rgba(15, 23, 42, 0.06);
  52. overflow: hidden;
  53. }
  54. .stage-head {
  55. padding: 12px 16px;
  56. border-bottom: 1px solid var(--border);
  57. background: var(--panel);
  58. font-size: 13px;
  59. color: var(--muted);
  60. }
  61. .stage-body { padding: 20px; background: var(--panel); }
  62. .dialog {
  63. max-width: 720px;
  64. margin: 0 auto;
  65. background: #fff;
  66. border-radius: 10px;
  67. border: 1px solid var(--border);
  68. box-shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
  69. overflow: hidden;
  70. }
  71. .dialog-hd {
  72. display: flex; justify-content: space-between; align-items: center;
  73. padding: 14px 18px; border-bottom: 1px solid var(--border);
  74. font-weight: 600; font-size: 15px;
  75. }
  76. .dialog-bd { padding: 16px 18px; }
  77. .dialog-ft {
  78. display: flex; justify-content: flex-end; gap: 8px;
  79. padding: 12px 18px; border-top: 1px solid var(--border);
  80. }
  81. .paste {
  82. border: 1px dashed #6ee7b7;
  83. background: var(--primary-bg);
  84. border-radius: 8px;
  85. padding: 12px;
  86. margin-bottom: 14px;
  87. }
  88. .paste-hd {
  89. display: flex; justify-content: space-between; align-items: center;
  90. margin-bottom: 8px;
  91. }
  92. .paste-title { font-weight: 600; color: var(--primary-dark); }
  93. .paste-tip { font-size: 11px; color: var(--muted); }
  94. textarea {
  95. width: 100%;
  96. min-height: 120px;
  97. border: 1px solid #a7f3d0;
  98. border-radius: 6px;
  99. padding: 10px;
  100. font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
  101. resize: vertical;
  102. color: var(--text);
  103. background: #fff;
  104. }
  105. .row-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 10px; }
  106. .btn {
  107. height: 32px; padding: 0 14px; border-radius: 8px;
  108. border: 1px solid #cbd5e1; background: #fff; color: #475569;
  109. font-size: 13px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px;
  110. }
  111. .btn-primary {
  112. background: var(--primary); border-color: var(--primary); color: #fff; font-weight: 600;
  113. }
  114. .btn-primary:hover { background: var(--primary-dark); }
  115. .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
  116. .span2 { grid-column: 1 / -1; }
  117. label { display: block; font-size: 11px; color: var(--muted); margin-bottom: 4px; }
  118. input, .fake-input {
  119. width: 100%; height: 36px; border: 1px solid var(--border); border-radius: 8px;
  120. padding: 0 10px; font-size: 13px; background: #fff; color: var(--text);
  121. }
  122. input.filled { border-color: var(--primary); background: #f0fdf4; }
  123. .msg {
  124. margin: 10px 0 0; font-size: 12px; color: var(--primary-dark);
  125. min-height: 18px;
  126. }
  127. .msg.warn { color: #d97706; }
  128. .toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
  129. .panel-title {
  130. font-weight: 600; margin-bottom: 12px; padding-left: 8px;
  131. border-left: 3px solid var(--primary);
  132. }
  133. .table {
  134. background: #fff; border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
  135. }
  136. .thead, .trow {
  137. display: grid; grid-template-columns: 48px 1.4fr 1.2fr 1.6fr 72px;
  138. gap: 8px; padding: 10px 12px; font-size: 12px;
  139. }
  140. .thead { background: #f1f5f9; color: var(--muted); font-weight: 600; }
  141. .empty { padding: 28px; text-align: center; color: #94a3b8; font-size: 13px; }
  142. .note {
  143. margin-top: 16px; padding: 12px 14px; border-radius: 8px;
  144. background: #fff; border: 1px solid var(--border); font-size: 13px; color: var(--muted); line-height: 1.6;
  145. }
  146. .hidden { display: none !important; }
  147. @media (max-width: 720px) {
  148. .grid { grid-template-columns: 1fr; }
  149. .thead, .trow { grid-template-columns: 1fr; }
  150. }
  151. </style>
  152. </head>
  153. <body>
  154. <div class="page">
  155. <h1>客户管理 · 开票信息「快速识别」交互 Demo</h1>
  156. <p class="lead">
  157. 粘贴客户发来的开票资料文本,一键解析并填入表单字段。
  158. 下方可切换三种入口方案;方案 A 可直接点「识别并填入」试效果。
  159. </p>
  160. <div class="tabs">
  161. <button class="tab active" data-tab="a">A · 表单顶部折叠区(推荐)</button>
  162. <button class="tab" data-tab="b">B · 工具栏「快速识别新增」</button>
  163. <button class="tab" data-tab="c">C · 二级小弹窗</button>
  164. </div>
  165. <!-- A -->
  166. <div class="stage" id="pane-a">
  167. <div class="stage-head">方案 A:新增/编辑弹窗内顶部粘贴区 → 识别后回填下方输入框</div>
  168. <div class="stage-body">
  169. <div class="dialog">
  170. <div class="dialog-hd">
  171. <span>添加开票信息</span>
  172. <span style="color:#94a3b8;font-weight:400;">✕</span>
  173. </div>
  174. <div class="dialog-bd">
  175. <div class="paste">
  176. <div class="paste-hd">
  177. <span class="paste-title">⚡ 粘贴识别</span>
  178. <span class="paste-tip">支持常见「开票资料」文本格式</span>
  179. </div>
  180. <textarea id="pasteA" placeholder="在此粘贴开票资料…"></textarea>
  181. <div class="row-actions">
  182. <button class="btn" type="button" id="clearA">清空</button>
  183. <button class="btn" type="button" id="sampleA">填入样例</button>
  184. <button class="btn btn-primary" type="button" id="recognizeA">识别并填入</button>
  185. </div>
  186. <p class="msg" id="msgA"></p>
  187. </div>
  188. <div class="grid">
  189. <div class="span2">
  190. <label>单位名称(章)</label>
  191. <input id="f-companyName" placeholder="请输入单位名称" />
  192. </div>
  193. <div class="span2">
  194. <label>税号</label>
  195. <input id="f-taxNo" placeholder="请输入税号" />
  196. </div>
  197. <div class="span2">
  198. <label>单位地址</label>
  199. <input id="f-companyAddress" placeholder="请输入单位地址" />
  200. </div>
  201. <div>
  202. <label>电话/传真</label>
  203. <input id="f-phoneFax" placeholder="请输入电话/传真" />
  204. </div>
  205. <div>
  206. <label>开户银行</label>
  207. <input id="f-bankName" placeholder="请输入开户银行" />
  208. </div>
  209. <div class="span2">
  210. <label>账号</label>
  211. <input id="f-bankAccount" placeholder="请输入账号" />
  212. </div>
  213. <div class="span2">
  214. <label>备注(未映射字段可写入)</label>
  215. <input id="f-remark" placeholder="银行行号 / 邮编 / 邮寄信息等" />
  216. </div>
  217. </div>
  218. </div>
  219. <div class="dialog-ft">
  220. <button class="btn" type="button">取消</button>
  221. <button class="btn btn-primary" type="button">确定</button>
  222. </div>
  223. </div>
  224. </div>
  225. </div>
  226. <!-- B -->
  227. <div class="stage hidden" id="pane-b">
  228. <div class="stage-head">方案 B:开票信息列表工具栏增加「快速识别新增」</div>
  229. <div class="stage-body">
  230. <div class="panel-title">开票信息</div>
  231. <div class="toolbar">
  232. <button class="btn btn-primary" type="button">+ 添加开票信息</button>
  233. <button class="btn" type="button" id="openB">📋 快速识别新增</button>
  234. </div>
  235. <div class="table">
  236. <div class="thead"><span>#</span><span>单位名称</span><span>税号</span><span>开户银行</span><span>默认</span></div>
  237. <div class="empty">暂无开票信息,请点击「添加开票信息」或「快速识别新增」</div>
  238. </div>
  239. <div class="dialog hidden" id="dialogB" style="margin-top:16px;">
  240. <div class="dialog-hd"><span>快速识别新增</span><span style="color:#94a3b8;font-weight:400;">✕</span></div>
  241. <div class="dialog-bd">
  242. <textarea id="pasteB" style="min-height:140px;" placeholder="粘贴开票资料后点识别…"></textarea>
  243. <div class="row-actions">
  244. <button class="btn" type="button" id="sampleB">填入样例</button>
  245. <button class="btn btn-primary" type="button" id="recognizeB">识别并打开表单</button>
  246. </div>
  247. <p class="msg" id="msgB"></p>
  248. </div>
  249. </div>
  250. </div>
  251. </div>
  252. <!-- C -->
  253. <div class="stage hidden" id="pane-c">
  254. <div class="stage-head">方案 C:新增弹窗标题旁「粘贴识别」→ 二级小窗</div>
  255. <div class="stage-body">
  256. <div class="dialog" style="position:relative;">
  257. <div class="dialog-hd">
  258. <span>添加开票信息</span>
  259. <button class="btn" type="button" id="openC" style="height:28px;font-size:12px;color:var(--primary-dark);border-color:#a7f3d0;background:var(--primary-bg);">⚡ 粘贴识别</button>
  260. </div>
  261. <div class="dialog-bd" style="opacity:.55;">
  262. <div class="grid">
  263. <div class="span2"><label>单位名称(章)</label><input placeholder="请输入单位名称" disabled /></div>
  264. <div class="span2"><label>税号</label><input placeholder="请输入税号" disabled /></div>
  265. </div>
  266. </div>
  267. <div class="dialog hidden" id="dialogC" style="position:absolute;left:50%;top:58%;transform:translate(-50%,-50%);width:86%;z-index:3;box-shadow:0 16px 48px rgba(15,23,42,.2);">
  268. <div class="dialog-hd"><span>粘贴开票资料</span></div>
  269. <div class="dialog-bd">
  270. <textarea id="pasteC" style="min-height:100px;"></textarea>
  271. <div class="row-actions">
  272. <button class="btn" type="button" id="sampleC">填入样例</button>
  273. <button class="btn btn-primary" type="button" id="recognizeC">识别</button>
  274. </div>
  275. <p class="msg" id="msgC"></p>
  276. </div>
  277. </div>
  278. </div>
  279. </div>
  280. </div>
  281. <div class="note">
  282. <strong style="color:var(--text);">字段映射(与现表单一致):</strong>
  283. 公司名称/单位名 → 单位名称;纳税人识别号/税号 → 税号;地址 → 单位地址;
  284. 电话/传真 → 电话/传真;开户行/银行开户行 → 开户银行;帐号/账号 → 账号(自动去空格)。
  285. 「地址及电话」合并行会拆分。银行行号、邮编、发票邮寄地址无独立字段时写入备注。
  286. </div>
  287. </div>
  288. <script>
  289. const SAMPLE_1 = `开票资料:
  290. 公司名称:安庆市山峡化工有限公司
  291. 纳税人识别号:9134080006084212XH
  292. 开户行:中国建设银行安庆分行城中支行
  293. 帐号:34050168860800000121
  294. 银行行号:105368000056
  295. 地址:安徽省安庆市高新区霞虹路5号
  296. 电话/传真:0556-5348918
  297. 邮编:246000
  298. 发票邮寄地址:
  299. 姓名:汪璇
  300. 电话:18895395317
  301. 地址:安徽省安庆市高新区霞虹路5号`
  302. const SAMPLE_2 = `单位名:安徽江峰生物科技有限公司
  303. 税 号:91340200MA8PYJNB8X
  304. 地址及电话:安徽省芜湖市皖江江北新兴产业集中区沈巷片区起步区皖跃路与和睦路交叉口西南角福州路4号
  305. 银行开户行:中国建设银行芜湖经开区支行
  306. 账 号:3405 0167 8808 0000 2481
  307. 银行行号:1053 6205 0066
  308. 电话:0553-5201019`
  309. function normalizeSpaces(s) {
  310. return (s || '').replace(/\s+/g, ' ').trim()
  311. }
  312. function pick(text, patterns) {
  313. for (const re of patterns) {
  314. const m = text.match(re)
  315. if (m && m[1]) return normalizeSpaces(m[1])
  316. }
  317. return ''
  318. }
  319. function parseInvoicePaste(raw) {
  320. const text = (raw || '').replace(/\r\n/g, '\n')
  321. const warnings = []
  322. const result = {
  323. companyName: '',
  324. taxNo: '',
  325. companyAddress: '',
  326. phoneFax: '',
  327. bankName: '',
  328. bankAccount: '',
  329. remarkExtra: []
  330. }
  331. result.companyName = pick(text, [
  332. /(?:公司名称|单位名称|单位名|名称)\s*[::]\s*(.+)/,
  333. ])
  334. result.taxNo = pick(text, [
  335. /(?:纳税人识别号|纳税识别号|统一社会信用代码|税\s*号|税号)\s*[::]\s*([0-9A-Za-z]+)/,
  336. ])
  337. result.bankName = pick(text, [
  338. /(?:银行开户行|开户银行|开户行)\s*[::]\s*(.+)/,
  339. ])
  340. let account = pick(text, [
  341. /(?:银行账号|账\s*号|帐\s*号|账号|帐号)\s*[::]\s*([0-9\s]+)/,
  342. ])
  343. if (account) result.bankAccount = account.replace(/\s+/g, '')
  344. const addrPhone = pick(text, [/(?:地址及电话)\s*[::]\s*(.+)/])
  345. if (addrPhone) {
  346. const phoneInAddr = addrPhone.match(/(0\d{2,3}-?\d{7,8}|1\d{10})$/)
  347. if (phoneInAddr) {
  348. result.phoneFax = phoneInAddr[1]
  349. result.companyAddress = normalizeSpaces(addrPhone.slice(0, phoneInAddr.index))
  350. } else {
  351. result.companyAddress = addrPhone
  352. }
  353. } else {
  354. // 优先取「开票资料」段的地址,避开「发票邮寄地址」块内的地址
  355. const beforeMail = text.split(/发票邮寄地址/)[0] || text
  356. result.companyAddress = pick(beforeMail, [/(?:单位地址|地\s*址|地址)\s*[::]\s*(.+)/])
  357. }
  358. result.phoneFax = result.phoneFax || pick(text, [
  359. /(?:电话\/传真|电话\/传貞|电话传真|传真)\s*[::]\s*(.+)/,
  360. /(?:^|\n)\s*电\s*话\s*[::]\s*(.+)/,
  361. ])
  362. const bankCode = pick(text, [/(?:银行行号|联行号|行号)\s*[::]\s*([0-9\s]+)/])
  363. if (bankCode) result.remarkExtra.push('银行行号:' + bankCode.replace(/\s+/g, ''))
  364. const zip = pick(text, [/(?:邮\s*编|邮编)\s*[::]\s*(\d{6})/])
  365. if (zip) result.remarkExtra.push('邮编:' + zip)
  366. const mailBlock = text.split(/发票邮寄地址\s*[::]?/)[1]
  367. if (mailBlock) {
  368. const name = pick(mailBlock, [/(?:姓\s*名|姓名)\s*[::]\s*(.+)/])
  369. const phone = pick(mailBlock, [/(?:电\s*话|电话|手机)\s*[::]\s*(.+)/])
  370. const addr = pick(mailBlock, [/(?:地\s*址|地址)\s*[::]\s*(.+)/])
  371. const parts = []
  372. if (name) parts.push('收件人 ' + name)
  373. if (phone) parts.push(phone)
  374. if (addr) parts.push(addr)
  375. if (parts.length) result.remarkExtra.push('邮寄:' + parts.join(' / '))
  376. }
  377. if (!result.companyName) warnings.push('未识别到单位名称')
  378. if (!result.taxNo) warnings.push('未识别到税号')
  379. return { result, warnings }
  380. }
  381. function applyToForm(parsed) {
  382. const map = {
  383. companyName: 'f-companyName',
  384. taxNo: 'f-taxNo',
  385. companyAddress: 'f-companyAddress',
  386. phoneFax: 'f-phoneFax',
  387. bankName: 'f-bankName',
  388. bankAccount: 'f-bankAccount',
  389. }
  390. Object.keys(map).forEach((k) => {
  391. const el = document.getElementById(map[k])
  392. const v = parsed.result[k] || ''
  393. el.value = v
  394. el.classList.toggle('filled', !!v)
  395. })
  396. const remark = document.getElementById('f-remark')
  397. remark.value = parsed.result.remarkExtra.join(';')
  398. remark.classList.toggle('filled', !!remark.value)
  399. }
  400. function runRecognize(textareaId, msgId, apply) {
  401. const raw = document.getElementById(textareaId).value
  402. const parsed = parseInvoicePaste(raw)
  403. if (apply) applyToForm(parsed)
  404. const msg = document.getElementById(msgId)
  405. if (!raw.trim()) {
  406. msg.className = 'msg warn'
  407. msg.textContent = '请先粘贴开票资料文本'
  408. return
  409. }
  410. const filled = ['companyName','taxNo','companyAddress','phoneFax','bankName','bankAccount']
  411. .filter((k) => parsed.result[k]).length
  412. msg.className = 'msg' + (parsed.warnings.length ? ' warn' : '')
  413. msg.textContent = `已识别 ${filled} 个字段` +
  414. (parsed.result.remarkExtra.length ? `,另有 ${parsed.result.remarkExtra.length} 项写入备注` : '') +
  415. (parsed.warnings.length ? ';' + parsed.warnings.join(';') : '')
  416. }
  417. document.querySelectorAll('.tab').forEach((tab) => {
  418. tab.addEventListener('click', () => {
  419. document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'))
  420. tab.classList.add('active')
  421. ;['a','b','c'].forEach((k) => {
  422. document.getElementById('pane-' + k).classList.toggle('hidden', k !== tab.dataset.tab)
  423. })
  424. })
  425. })
  426. document.getElementById('pasteA').value = SAMPLE_1
  427. document.getElementById('sampleA').onclick = () => {
  428. document.getElementById('pasteA').value =
  429. document.getElementById('pasteA').value.includes('安庆市山峡') ? SAMPLE_2 : SAMPLE_1
  430. }
  431. document.getElementById('clearA').onclick = () => {
  432. document.getElementById('pasteA').value = ''
  433. document.getElementById('msgA').textContent = ''
  434. }
  435. document.getElementById('recognizeA').onclick = () => runRecognize('pasteA', 'msgA', true)
  436. document.getElementById('openB').onclick = () => {
  437. document.getElementById('dialogB').classList.remove('hidden')
  438. document.getElementById('pasteB').value = SAMPLE_2
  439. }
  440. document.getElementById('sampleB').onclick = () => {
  441. document.getElementById('pasteB').value = SAMPLE_1
  442. }
  443. document.getElementById('recognizeB').onclick = () => {
  444. runRecognize('pasteB', 'msgB', true)
  445. document.querySelector('[data-tab="a"]').click()
  446. document.getElementById('pasteA').value = document.getElementById('pasteB').value
  447. }
  448. document.getElementById('openC').onclick = () => {
  449. document.getElementById('dialogC').classList.toggle('hidden')
  450. if (!document.getElementById('pasteC').value) document.getElementById('pasteC').value = SAMPLE_1
  451. }
  452. document.getElementById('sampleC').onclick = () => {
  453. document.getElementById('pasteC').value = SAMPLE_2
  454. }
  455. document.getElementById('recognizeC').onclick = () => {
  456. runRecognize('pasteC', 'msgC', true)
  457. document.getElementById('dialogC').classList.add('hidden')
  458. document.querySelector('[data-tab="a"]').click()
  459. document.getElementById('pasteA').value = document.getElementById('pasteC').value
  460. }
  461. // 首次自动演示一次
  462. runRecognize('pasteA', 'msgA', true)
  463. </script>
  464. </body>
  465. </html>