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;
+ }
+}