commit 6ca0770bf6c49de667b9b5e5408f1a89fdb1b0cb Author: Jafeng <2998840497@qq.com> Date: Thu Jun 4 16:02:30 2026 +0800 first commit diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..8b1c6ce --- /dev/null +++ b/.env.development @@ -0,0 +1 @@ +api_url=http://localhost:8080 diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..d825bfc --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +api_url=https://ykt.youcan365.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57f46ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.zip +*.log +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..79dd69e --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# YKT Friend Bridge H5 + +短信好友邀请中转页。发布包名为 `sms.zip`,默认短路径为 `/sms/{recordId}`。 + +## Local Dev + +```bash +npm run dev +``` + +本地默认读取 `.env.development`: + +```text +api_url=http://localhost:8080 +``` + +默认地址: + +```text +http://localhost:5174/sms/{recordId} +``` + +本地服务端默认读取: + +```text +http://localhost:8080/sms/friend-link/{recordId}/data +``` + +## Build + +```bash +npm run build +npm run package +``` + +生产默认读取 `.env.production`: + +```text +api_url=https://ykt.youcan365.com +``` + +产物: + +```text +dist/sms/ +sms.zip +``` + +`sms.zip` 内部自带 `sms/` 目录,上传后解压到站点根目录即可: + +```bash +unzip -o sms.zip -d /var/www +``` + +`api_url` 必须配置,页面内部接口只请求: + +```text +/sms/friend-link/{recordId}/data +``` + +如需覆盖服务端域名,打包前传入: + +```bash +api_url=https://your-api-domain.com npm run package +``` + +后端必须配置 H5 域名,生成短信链接时会使用短路径: + +```text +CONFIG_SMS_FRIEND_LINK_BASE_URL=https://your-h5-domain.com +生成链接:https://your-h5-domain.com/sms/{recordId} +``` diff --git a/assets/background.png b/assets/background.png new file mode 100644 index 0000000..030d4e3 Binary files /dev/null and b/assets/background.png differ diff --git a/assets/default-avatar.png b/assets/default-avatar.png new file mode 100644 index 0000000..4eb5879 Binary files /dev/null and b/assets/default-avatar.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..48d2221 --- /dev/null +++ b/index.html @@ -0,0 +1,46 @@ + + + + + + + 添加好友 + + + +
+ + +
+
+
+

医生服务好友

+

患者精益管理服务平台

+

+ +
+
+
+ +
+
+ +
+ +
+

正在准备好友链接

+
+ + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..ad06aaf --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "ykt-sms", + "version": "1.0.0", + "private": true, + "description": "YKT friend invite H5 bridge page", + "scripts": { + "build": "node scripts/build.js", + "build:dev": "node scripts/build.js development", + "build:prod": "node scripts/build.js production", + "package": "node scripts/build.js production && powershell -NoProfile -Command \"Compress-Archive -Path dist\\sms -DestinationPath sms.zip -Force\"", + "dev": "node scripts/dev-server.js", + "start": "node scripts/dev-server.js", + "check": "node --check scripts/dev-server.js && node --check src/main.js" + } +} diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000..ed47d5f --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,102 @@ +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); +const dist = path.join(root, "dist"); +const appDist = path.join(dist, "sms"); + +function normalizeText(value) { + return typeof value === "string" ? value.trim() : value ? String(value).trim() : ""; +} + +const mode = normalizeText(process.argv[2] || process.env.NODE_ENV || "production") || "production"; + +function assertNoLocalhost(name, value) { + if (!value) return; + if (/^(https?:\/\/)?(localhost|127\.0\.0\.1)(?::|\/|$)/i.test(value)) { + throw new Error(`${name} must not be localhost when building release package`); + } +} + +function assertRequired(name, value) { + if (!value) { + throw new Error(`${name} is required`); + } +} + +function copyDir(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyDir(srcPath, destPath); + } else if (entry.isFile()) { + fs.copyFileSync(srcPath, destPath); + } + } +} + +function writeFile(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +function parseEnvValue(value) { + const text = normalizeText(value); + if ( + (text.startsWith('"') && text.endsWith('"')) || + (text.startsWith("'") && text.endsWith("'")) + ) { + return text.slice(1, -1); + } + return text; +} + +function loadEnvFile(fileName) { + const filePath = path.join(root, fileName); + if (!fs.existsSync(filePath)) return {}; + const env = {}; + const content = fs.readFileSync(filePath, "utf8"); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const index = trimmed.indexOf("="); + if (index <= 0) continue; + const key = trimmed.slice(0, index).trim(); + const value = parseEnvValue(trimmed.slice(index + 1)); + env[key] = value; + } + return env; +} + +const fileEnv = { + ...loadEnvFile(".env"), + ...loadEnvFile(`.env.${mode}`), +}; + +const apiBase = normalizeText( + process.env.api_url || + fileEnv.api_url || + "", +).replace(/\/+$/, ""); + +assertRequired("api_url", apiBase); +if (mode === "production") { + assertNoLocalhost("api_url", apiBase); +} + +fs.rmSync(dist, { recursive: true, force: true }); +fs.mkdirSync(appDist, { recursive: true }); +fs.copyFileSync(path.join(root, "index.html"), path.join(appDist, "index.html")); +copyDir(path.join(root, "src"), path.join(appDist, "src")); +copyDir(path.join(root, "assets"), path.join(appDist, "assets")); +writeFile( + path.join(appDist, "config.js"), + `window.api_url=${JSON.stringify(apiBase)};\n`, +); + +console.log(`Built ykt-sms to ${appDist}`); +console.log(`Route: /sms/{id}`); +console.log(`Mode: ${mode}`); +console.log(`API base: ${apiBase}`); diff --git a/scripts/dev-server.js b/scripts/dev-server.js new file mode 100644 index 0000000..7875241 --- /dev/null +++ b/scripts/dev-server.js @@ -0,0 +1,103 @@ +const fs = require("fs"); +const http = require("http"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); +const port = Number(process.env.PORT || process.env.CONFIG_SMS_FRIEND_BRIDGE_PORT || 5174); + +function normalizeText(value) { + return typeof value === "string" ? value.trim() : value ? String(value).trim() : ""; +} + +function parseEnvValue(value) { + const text = normalizeText(value); + if ( + (text.startsWith('"') && text.endsWith('"')) || + (text.startsWith("'") && text.endsWith("'")) + ) { + return text.slice(1, -1); + } + return text; +} + +function loadEnvFile(fileName) { + const filePath = path.join(root, fileName); + if (!fs.existsSync(filePath)) return {}; + const env = {}; + const content = fs.readFileSync(filePath, "utf8"); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const index = trimmed.indexOf("="); + if (index <= 0) continue; + env[trimmed.slice(0, index).trim()] = parseEnvValue(trimmed.slice(index + 1)); + } + return env; +} + +const fileEnv = { + ...loadEnvFile(".env"), + ...loadEnvFile(".env.development"), +}; + +const mimeTypes = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", +}; + +function sendText(res, text, contentType) { + res.writeHead(200, { + "Content-Type": contentType, + }); + res.end(text); +} + +function sendFile(res, filePath) { + const ext = path.extname(filePath); + res.writeHead(200, { + "Content-Type": mimeTypes[ext] || "application/octet-stream", + }); + fs.createReadStream(filePath).pipe(res); +} + +function resolveAssetPath(urlPath) { + const cleanPath = decodeURIComponent(urlPath.split("?")[0]); + const assetPath = cleanPath.replace(/^\/sms\//, "/"); + const candidate = path.resolve(root, assetPath.replace(/^\/+/, "")); + if (!candidate.startsWith(root)) return ""; + return candidate; +} + +const server = http.createServer((req, res) => { + const urlPath = (req.url || "/").split("?")[0]; + if (urlPath === "/config.js" || urlPath === "/sms/config.js") { + const apiBase = + process.env.api_url || + fileEnv.api_url || + ""; + sendText( + res, + `window.api_url=${JSON.stringify(apiBase)};\n`, + "application/javascript; charset=utf-8", + ); + return; + } + + const assetPath = resolveAssetPath(req.url || "/"); + if (assetPath && fs.existsSync(assetPath) && fs.statSync(assetPath).isFile()) { + sendFile(res, assetPath); + return; + } + + sendFile(res, path.join(root, "index.html")); +}); + +server.listen(port, () => { + console.log(`Friend bridge H5 running at http://localhost:${port}/sms/{id}`); +}); diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..9511bbb --- /dev/null +++ b/src/main.js @@ -0,0 +1,130 @@ +(function () { + const elements = { + title: document.getElementById("mainTitle"), + corpName: document.getElementById("corpName"), + hintText: document.getElementById("hintText"), + openButton: document.getElementById("openButton"), + statusText: document.getElementById("statusText"), + primaryInitial: document.getElementById("primaryInitial"), + primaryAvatar: document.getElementById("primaryAvatar"), + primaryAvatarFallback: document.getElementById("primaryAvatarFallback"), + secondaryAvatar: document.getElementById("secondaryAvatar"), + secondaryAvatarFallback: document.getElementById("secondaryAvatarFallback"), + friendMeta: document.getElementById("friendMeta"), + }; + + let scheme = ""; + + function normalizeText(value) { + return typeof value === "string" ? value.trim() : value ? String(value).trim() : ""; + } + + function getRecordId() { + const params = new URLSearchParams(window.location.search); + const queryId = normalizeText(params.get("id")); + if (queryId) return queryId; + + const match = window.location.pathname.match(/\/sms\/([^/?#]+)/); + return match ? decodeURIComponent(match[1]) : ""; + } + + function getApiBase() { + const apiUrl = normalizeText(window.api_url); + return apiUrl ? apiUrl.replace(/\/+$/, "") : ""; + } + + function setStatus(text) { + elements.statusText.textContent = text; + } + + function setError(message) { + elements.title.textContent = "链接暂不可用"; + elements.hintText.textContent = message || "请联系工作人员重新获取好友链接"; + elements.friendMeta.textContent = ""; + elements.openButton.textContent = "无法打开"; + elements.openButton.disabled = true; + setStatus(""); + } + + function setAvatar(src, friendName, imageEl, fallbackEl) { + const avatar = normalizeText(src); + if (fallbackEl === elements.primaryAvatarFallback) { + elements.primaryInitial.textContent = friendName.slice(0, 1) || "医"; + } + fallbackEl.classList.remove("has-image"); + imageEl.removeAttribute("src"); + + if (!avatar) return; + imageEl.onload = function () { + fallbackEl.classList.add("has-image"); + }; + imageEl.onerror = function () { + fallbackEl.classList.remove("has-image"); + imageEl.removeAttribute("src"); + }; + imageEl.src = avatar; + } + + function fillPage(data) { + const friendName = normalizeText(data.friendName) || "医生"; + const corpName = normalizeText(data.corpName) || "患者精益管理服务平台"; + const friendJob = normalizeText(data.friendJob || data.job); + const friendIntro = normalizeText(data.friendIntro || data.memberTroduce); + const friendAvatar = normalizeText(data.friendAvatar || data.avatar); + const avatarList = Array.isArray(data.avatarList) ? data.avatarList.map(normalizeText).filter(Boolean) : []; + const secondaryAvatar = normalizeText(data.patientAvatar || data.customerAvatar || avatarList[1]); + const title = friendName.endsWith("医生") ? `${friendName}服务好友` : `${friendName}医生服务好友`; + + document.title = normalizeText(data.title) || "添加好友"; + elements.title.textContent = title; + elements.corpName.textContent = corpName; + elements.friendMeta.textContent = friendJob || friendIntro || ""; + elements.friendMeta.hidden = !elements.friendMeta.textContent; + elements.hintText.textContent = normalizeText(data.hintText) || "点击下方按钮,添加企业微信好友"; + elements.openButton.textContent = normalizeText(data.actionText) || "去 加 好 友"; + setAvatar(friendAvatar || avatarList[0], friendName, elements.primaryAvatar, elements.primaryAvatarFallback); + setAvatar(secondaryAvatar, "患", elements.secondaryAvatar, elements.secondaryAvatarFallback); + elements.openButton.disabled = false; + scheme = normalizeText(data.scheme); + setStatus(""); + } + + async function loadLink() { + const recordId = getRecordId(); + if (!recordId) { + setError("好友链接参数缺失"); + return; + } + + try { + const apiBase = getApiBase(); + if (!apiBase) { + setError("接口地址未配置"); + return; + } + const response = await fetch(`${apiBase}/sms/friend-link/${encodeURIComponent(recordId)}/data`, { + method: "GET", + }); + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("application/json")) { + setError("好友链接不存在或已失效"); + return; + } + const result = await response.json(); + if (!response.ok || !result.success || !result.data?.scheme) { + setError(result.message || "好友链接不存在或已失效"); + return; + } + fillPage(result.data); + } catch (error) { + setError("好友链接加载失败,请稍后重试"); + } + } + + elements.openButton.addEventListener("click", function () { + if (!scheme) return; + window.location.href = scheme; + }); + + loadLink(); +})(); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..868b7c5 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,281 @@ +:root { + color-scheme: light; + --blue: #1677ff; + --blue-deep: #1265d8; + --text: #171923; + --muted: #7a8292; + --surface: #ffffff; + --shadow: 0 18px 44px rgba(34, 88, 164, 0.14); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", sans-serif; + background: #fff; + color: var(--text); +} + +.login-bg { + position: relative; + display: flex; + min-height: 100vh; + flex-direction: column; + align-items: center; + overflow: hidden; + background: #fff; + padding-bottom: 38px; +} + +.bg-image { + position: absolute; + top: 0; + left: 0; + z-index: 0; + width: 100%; + height: 250px; + object-fit: cover; +} + +.doctor-card-wrap { + position: relative; + z-index: 1; + width: calc(100vw - 48px); + max-width: 300px; + margin: 160px auto 0; +} + +.doctor-card { + position: relative; + z-index: 1; + display: flex; + min-height: 210px; + flex-direction: column; + align-items: center; + padding: 50px 12px 30px; + background: linear-gradient(180deg, #dbe8ff 0%, #fff 50.25%); + border-radius: 12px; + box-shadow: 0 4px 16px rgba(59, 124, 255, 0.08); +} + +.doctor-info { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.doctor-avatar { + position: absolute; + top: 0; + left: 50%; + z-index: 2; + transform: translate(-50%, -50%); + border: 3px solid #fff; + border-radius: 8px; + background: #f2f2f2; + box-shadow: 0 1px 4px rgba(59, 124, 255, 0.08); +} + +.avatar-board { + position: relative; + width: 72px; + height: 72px; + overflow: hidden; + border-radius: 8px; + background: #f5f5f5; +} + +.avatar { + position: absolute; + display: grid; + place-items: center; + overflow: hidden; + color: #fff; + font-weight: 700; + letter-spacing: 0; + background: linear-gradient(160deg, #b9d8ff, #1d79ed); +} + +.avatar::before { + content: ""; + position: absolute; + top: 14%; + width: 34%; + aspect-ratio: 1; + border-radius: 50%; + background: rgba(255, 255, 255, 0.78); +} + +.avatar::after { + content: ""; + position: absolute; + bottom: -15%; + width: 68%; + aspect-ratio: 1.1; + border-radius: 48% 48% 0 0; + background: rgba(255, 255, 255, 0.82); +} + +.avatar span { + position: relative; + z-index: 1; + margin-top: 28px; + font-size: 16px; +} + +.avatar img { + position: absolute; + inset: 0; + z-index: 2; + display: none; + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatar.has-image img { + display: block; +} + +.avatar.has-image span, +.avatar.has-image::before, +.avatar.has-image::after { + display: none; +} + +.avatar-large { + left: 0; + top: 0; + width: 36px; + height: 36px; + border-radius: 2px; +} + +.avatar-small { + right: 0; + bottom: 0; + width: 36px; + height: 36px; + border-radius: 2px; + background: linear-gradient(160deg, #d5e8ff, #48a0ff); +} + +.doctor-name { + margin: 20px 0 0; + color: #1d2129; + font-size: 20px; + line-height: 1.35; + font-weight: 600; + letter-spacing: 0; +} + +.doctor-hospital { + margin: 10px 0 0; + color: #78808f; + font-size: 14px; + line-height: 1.45; + font-weight: 400; + letter-spacing: 0; +} + +.doctor-dept { + display: inline-block; + margin: 9px 0 0; + padding: 0 5px; + border: 1px solid rgba(26, 62, 132, 0.2); + border-radius: 2px; + color: #213e80; + font-size: 11px; + line-height: 20px; + font-weight: 400; +} + +.doctor-dept:empty, +.doctor-dept[hidden] { + display: none; +} + +.login-tip { + display: block; + margin: 20px 0 0; + color: #000; + font-size: 16px; + line-height: 1.45; + font-weight: 500; + letter-spacing: 0; +} + +.login-btn-wrap { + position: relative; + z-index: 1; + display: flex; + width: 100vw; + justify-content: center; + margin-top: 68px; +} + +.login-btn { + width: calc(100vw - 56px); + max-width: 300px; + height: 48px; + line-height: 48px; + border: 0; + border-radius: 24px; + color: #fff; + background: linear-gradient(270deg, #1b5cc8 2.26%, #0877f1 94.33%); + box-shadow: 0 2px 8px rgba(59, 124, 255, 0.08); + font-size: 16px; + font-weight: 600; + letter-spacing: 0.18em; + text-indent: 0.18em; +} + +.login-btn:disabled { + background: #c7d3e4; + box-shadow: none; +} + +.status-text { + position: relative; + z-index: 1; + min-height: 20px; + margin: 16px 0 0; + color: #8b96a8; + text-align: center; + font-size: 13px; + line-height: 1.5; +} + +@media (max-width: 360px) { + .doctor-card-wrap { + width: calc(100vw - 36px); + margin-top: 150px; + } + + .doctor-card { + min-height: 198px; + padding-right: 12px; + padding-left: 12px; + } + + .doctor-name { + font-size: 19px; + } + + .login-tip { + font-size: 15px; + } + + .login-btn-wrap { + margin-top: 58px; + } +}