市场夺宝奇兵
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

481 lines
14 KiB

3 months ago
3 months ago
  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>管理后台</title>
  7. <style>
  8. body {
  9. font-family: Arial, sans-serif;
  10. padding: 24px;
  11. background: #f7f8fb;
  12. color: #222;
  13. }
  14. .card {
  15. background: #fff;
  16. padding: 16px;
  17. border-radius: 8px;
  18. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
  19. margin: auto;
  20. }
  21. table {
  22. width: 100%;
  23. border-collapse: collapse;
  24. margin-top: 12px;
  25. }
  26. th,
  27. td {
  28. padding: 10px 12px;
  29. border-bottom: 1px solid #eee;
  30. text-align: left;
  31. font-size: 14px;
  32. }
  33. th {
  34. background: #fafafa;
  35. font-weight: 600;
  36. }
  37. .controls {
  38. display: flex;
  39. gap: 12px;
  40. align-items: center;
  41. flex-wrap: wrap;
  42. }
  43. .pagination {
  44. display: flex;
  45. gap: 6px;
  46. align-items: center;
  47. margin-left: auto;
  48. flex-wrap: wrap;
  49. }
  50. .btn {
  51. padding: 6px 10px;
  52. border-radius: 6px;
  53. border: 1px solid #ddd;
  54. background: #fff;
  55. cursor: pointer;
  56. user-select: none;
  57. }
  58. .btn:disabled {
  59. opacity: 0.5;
  60. cursor: default;
  61. }
  62. .btn.primary {
  63. background: #007bff;
  64. color: #fff;
  65. border-color: #007bff;
  66. }
  67. .status-btn {
  68. padding: 4px 8px;
  69. border-radius: 6px;
  70. border: 1px solid #ccc;
  71. cursor: pointer;
  72. }
  73. .status-0 {
  74. background: red;
  75. color: #fff;
  76. }
  77. .status-1 {
  78. background: #2f9e44;
  79. color: #fff;
  80. border-color: #2f9e44;
  81. }
  82. .btn.active {
  83. background: #007bff;
  84. color: #fff;
  85. border-color: #007bff;
  86. }
  87. select,
  88. input[type="number"] {
  89. padding: 6px;
  90. border-radius: 6px;
  91. border: 1px solid #ddd;
  92. }
  93. .small {
  94. font-size: 13px;
  95. color: #666;
  96. }
  97. @media (max-width: 640px) {
  98. .controls {
  99. flex-direction: column;
  100. align-items: flex-start;
  101. }
  102. .pagination {
  103. margin-left: 0;
  104. }
  105. }
  106. #noteModal {
  107. display: none;
  108. position: fixed;
  109. inset: 0;
  110. background: rgba(0, 0, 0, 0.45);
  111. align-items: center;
  112. justify-content: center;
  113. z-index: 9999;
  114. }
  115. #noteModal .dialog {
  116. background: #fff;
  117. padding: 16px;
  118. border-radius: 8px;
  119. width: 90%;
  120. max-width: 520px;
  121. box-sizing: border-box;
  122. }
  123. #noteModal textarea {
  124. width: 100%;
  125. min-width: 60%;
  126. max-width: 100%;
  127. min-height: 150px;
  128. box-sizing: border-box;
  129. padding: 8px;
  130. border-radius: 6px;
  131. border: 1px solid #ddd;
  132. font-size: 14px;
  133. }
  134. .toast {
  135. position: fixed;
  136. top: -20px;
  137. left: 50%;
  138. transform: translateX(-50%);
  139. background: #4caf50;
  140. color: #fff;
  141. padding: 10px 16px;
  142. border-radius: 6px;
  143. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  144. font-size: 16px;
  145. opacity: 0;
  146. transition: all 0.3s ease;
  147. z-index: 10000;
  148. pointer-events: none;
  149. }
  150. .toast.show {
  151. opacity: 1;
  152. top: 20px;
  153. }
  154. table th,
  155. table td {
  156. text-align: center;
  157. }
  158. </style>
  159. </head>
  160. <body>
  161. <div class="card">
  162. <table aria-describedby="tableDesc">
  163. <thead>
  164. <tr>
  165. <th style="width: 40px">#</th>
  166. <th style="width: 100px">姓名</th>
  167. <th style="width: 90px">国家/地区代码</th>
  168. <th style="width: 120px">电话号码</th>
  169. <th style="width: 120px">微信ID</th>
  170. <th style="width: 150px">邮箱</th>
  171. <th style="width: 160px">添加时间</th>
  172. <th style="width: 100px">是否联系</th>
  173. <th style="width: 160px">备注</th>
  174. <th style="width: 100px">操作</th>
  175. </tr>
  176. </thead>
  177. <tbody id="tableBody"></tbody>
  178. </table>
  179. <div class="controls" style="margin-bottom: 8px; margin-top: 12px">
  180. <div class="small">
  181. 每页显示
  182. <select id="pageSizeSelect">
  183. <option value="20" selected>20</option>
  184. <option value="50">50</option>
  185. <option value="100">100</option>
  186. <option value="200">200</option>
  187. </select>
  188. </div>
  189. <div class="small"><span id="totalCount">0</span></div>
  190. <div class="pagination" id="pagination"></div>
  191. </div>
  192. <!-- 备注编辑模态 -->
  193. <div id="noteModal">
  194. <div class="dialog">
  195. <h3 style="margin: 0 0 8px">编辑备注</h3>
  196. <textarea
  197. id="noteTextarea"
  198. rows="6"
  199. placeholder="输入备注..."
  200. ></textarea>
  201. <div
  202. style="
  203. margin-top: 10px;
  204. display: flex;
  205. justify-content: flex-end;
  206. gap: 8px;
  207. "
  208. >
  209. <button class="btn" id="noteCancelBtn">取消</button>
  210. <button class="btn primary" id="noteSaveBtn">保存</button>
  211. </div>
  212. </div>
  213. </div>
  214. </div>
  215. <div id="toast" class="toast"></div>
  216. <script type="module">
  217. import { getMemberListApi,updateMemberStateApi,editMemberNoteApi }from './src/api/member.js'
  218. let state = {
  219. pageSize: 20,
  220. currentPage: 1,
  221. total: 0,
  222. items:[]
  223. };
  224. // DOM
  225. const tableBody = document.getElementById("tableBody");
  226. const paginationEl = document.getElementById("pagination");
  227. const totalCountEl = document.getElementById("totalCount");
  228. const currentPageEl = document.getElementById("currentPage");
  229. const showingRangeEl = document.getElementById("showingRange");
  230. const pageSizeSelect = document.getElementById("pageSizeSelect");
  231. const gotoInput = document.getElementById("gotoInput");
  232. const noteModal = document.getElementById("noteModal");
  233. const noteTextarea = document.getElementById("noteTextarea");
  234. const noteCancelBtn = document.getElementById("noteCancelBtn");
  235. const noteSaveBtn = document.getElementById("noteSaveBtn");
  236. const toastEl = document.getElementById("toast");
  237. let editingRowId = null;
  238. function showToast(msg) {
  239. toastEl.textContent = msg;
  240. toastEl.classList.add("show");
  241. setTimeout(() => toastEl.classList.remove("show"), 1000);
  242. }
  243. async function fetchPage(page, pageSize) {
  244. try {
  245. const res = await getMemberListApi({
  246. page: page,
  247. page_size: pageSize,
  248. })
  249. if (typeof res.code !== "undefined" && res.code !== 200) {
  250. throw new Error(res.msg || "接口返回错误");
  251. }
  252. console.log(res)
  253. const payload = res.data || {};
  254. state.items = payload.list;
  255. state.total = payload.total;
  256. } catch (err) {
  257. alert("获取数据失败");
  258. state.total = 0;
  259. }
  260. }
  261. // 渲染表格行
  262. function renderTable() {
  263. const startIndex = (state.currentPage - 1) * state.pageSize;
  264. const items = state.items || [];
  265. tableBody.innerHTML = items
  266. .map((item, idx) => {
  267. const serial = startIndex + idx + 1;
  268. const statusClass = item.isRelated ? "status-1" : "status-0";
  269. const statusText = item.isRelated ? "已联系" : "未联系";
  270. return `
  271. <tr>
  272. <td>${serial}</td>
  273. <td>${escapeHtml(item.name || "")}</td>
  274. <td>${escapeHtml(item.code || "")}</td>
  275. <td>${escapeHtml(item.telephone || "")}</td>
  276. <td>${escapeHtml(item.wechat || "")}</td>
  277. <td>${escapeHtml(item.email || "")}</td>
  278. <td>${escapeHtml(item.createdAt || "")}</td>
  279. <td>
  280. <button class="status-btn ${statusClass}" data-action="toggle" data-id="${
  281. item.id
  282. }">${statusText}</button>
  283. </td>
  284. <td>${escapeHtml(item.note || "")}</td>
  285. <td>
  286. <button class="btn" data-action="editNote" data-id="${
  287. item.id
  288. }">编辑备注</button>
  289. </td>
  290. </tr>
  291. `;
  292. })
  293. .join("");
  294. totalCountEl.textContent = state.total;
  295. }
  296. tableBody.addEventListener("click", async (e) => {
  297. // 切换状态
  298. const toggler = e.target.closest('[data-action="toggle"]');
  299. if (toggler) {
  300. const id = toggler.getAttribute("data-id");
  301. await handleToggle(id, toggler);
  302. return;
  303. }
  304. // 编辑备注
  305. const editBtn = e.target.closest('[data-action="editNote"]');
  306. if (editBtn) {
  307. const id = editBtn.getAttribute("data-id");
  308. openNoteModal(id);
  309. return;
  310. }
  311. });
  312. // ---------- 切换联系状态 ----------
  313. async function handleToggle(id, btnEl) {
  314. const item = state.items.find((it) => String(it.id) === String(id));
  315. if (!item) return;
  316. const prev = item.isRelated;
  317. const newVal = prev ? 0 : 1;
  318. item.isRelated = newVal;
  319. btnEl.textContent = newVal ? "已联系" : "未联系";
  320. btnEl.classList.toggle("status-1", !!newVal);
  321. btnEl.classList.toggle("status-0", !newVal);
  322. try {
  323. const res = await updateMemberStateApi({ id: item.id, state: newVal })
  324. if (typeof res.code !== "undefined" && res.code !== 200) {
  325. throw new Error(res.msg || "更新失败");
  326. }
  327. showToast("状态修改成功");
  328. } catch (err) {
  329. item.isRelated = prev;
  330. btnEl.textContent = prev ? "已联系" : "未联系";
  331. btnEl.classList.toggle("status-1", !!prev);
  332. btnEl.classList.toggle("status-0", !prev);
  333. alert("更新失败");
  334. }
  335. }
  336. // ---------- 备注模态相关 ----------
  337. function openNoteModal(id) {
  338. const item = state.items.find((it) => String(it.id) === String(id));
  339. if (!item) return;
  340. editingRowId = id;
  341. noteTextarea.value = item.note || "";
  342. noteModal.style.display = "flex";
  343. noteTextarea.focus();
  344. }
  345. function closeNoteModal() {
  346. editingRowId = null;
  347. noteTextarea.value = "";
  348. noteModal.style.display = "none";
  349. }
  350. noteCancelBtn.addEventListener("click", closeNoteModal);
  351. noteSaveBtn.addEventListener("click", async () => {
  352. if (!editingRowId) return closeNoteModal();
  353. const newNote = noteTextarea.value.trim();
  354. const item = state.items.find((it) => String(it.id) === String(editingRowId));
  355. if (!item) return closeNoteModal();
  356. try {
  357. const res = await editMemberNoteApi({ id: item.id, note: newNote })
  358. if (typeof res.code !== "undefined" && res.code !== 200) {
  359. throw new Error(res.msg || "保存失败");
  360. }
  361. const prevNote = item.note;
  362. item.note = newNote;
  363. renderTable();
  364. showToast("备注保存成功");
  365. } catch (err) {
  366. alert("保存备注失败");
  367. } finally {
  368. closeNoteModal();
  369. }
  370. });
  371. // 渲染分页控件(页码、上一页、下一页)
  372. function renderPagination() {
  373. const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize));
  374. const current = Math.min(Math.max(1, state.currentPage), totalPages);
  375. state.currentPage = current;
  376. const pages = buildPageList(current, totalPages, 2);
  377. let html = "";
  378. html += `<button class="btn" data-action="prev" ${
  379. current === 1 ? "disabled" : ""
  380. }>上一页</button>`;
  381. pages.forEach((p) => {
  382. if (p === "...") {
  383. html += `<span class="small" style="padding:6px 8px">...</span>`;
  384. } else {
  385. html += `<button class="btn ${
  386. p === current ? "active" : ""
  387. }" data-page="${p}">${p}</button>`;
  388. }
  389. });
  390. html += `<button class="btn" data-action="next" ${
  391. current === totalPages ? "disabled" : ""
  392. }>下一页</button>`;
  393. paginationEl.innerHTML = html;
  394. }
  395. function buildPageList(current, total, delta) {
  396. const pages = [];
  397. const left = Math.max(1, current - delta);
  398. const right = Math.min(total, current + delta);
  399. if (left > 1) {
  400. pages.push(1);
  401. if (left > 2) pages.push("...");
  402. }
  403. for (let i = left; i <= right; i++) pages.push(i);
  404. if (right < total) {
  405. if (right < total - 1) pages.push("...");
  406. pages.push(total);
  407. }
  408. return pages;
  409. }
  410. paginationEl.addEventListener("click", (e) => {
  411. const btn = e.target.closest("button");
  412. if (!btn) return;
  413. const action = btn.getAttribute("data-action");
  414. if (action === "prev") {
  415. if (state.currentPage > 1) state.currentPage--;
  416. } else if (action === "next") {
  417. const totalPages = Math.max(
  418. 1,
  419. Math.ceil(state.total / state.pageSize)
  420. );
  421. if (state.currentPage < totalPages) state.currentPage++;
  422. } else {
  423. const p = Number(btn.getAttribute("data-page"));
  424. if (!isNaN(p)) state.currentPage = p;
  425. }
  426. update();
  427. });
  428. // 每页条数变更
  429. pageSizeSelect.addEventListener("change", () => {
  430. const newSize = parseInt(pageSizeSelect.value, 10);
  431. state.pageSize = newSize;
  432. state.currentPage = 1;
  433. update();
  434. gotoInput.value = "";
  435. });
  436. async function update() {
  437. const p = Math.max(1, state.currentPage);
  438. state.currentPage = p;
  439. await fetchPage(state.currentPage, state.pageSize);
  440. renderTable();
  441. renderPagination();
  442. }
  443. // 简单安全的文本转义(防 XSS)
  444. function escapeHtml(s) {
  445. return String(s)
  446. .replace(/&/g, "&amp;")
  447. .replace(/</g, "&lt;")
  448. .replace(/>/g, "&gt;")
  449. .replace(/"/g, "&quot;")
  450. .replace(/'/g, "&#39;");
  451. }
  452. // 首次渲染
  453. update();
  454. </script>
  455. </body>
  456. </html>