first commit

This commit is contained in:
Jafeng 2026-06-04 16:02:30 +08:00
commit 6ca0770bf6
12 changed files with 757 additions and 0 deletions

1
.env.development Normal file
View File

@ -0,0 +1 @@
api_url=http://localhost:8080

1
.env.production Normal file
View File

@ -0,0 +1 @@
api_url=https://ykt.youcan365.com

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
dist/
*.zip
*.log
.DS_Store
Thumbs.db

72
README.md Normal file
View File

@ -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}
```

BIN
assets/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
assets/default-avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

46
index.html Normal file
View File

@ -0,0 +1,46 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="format-detection" content="telephone=no" />
<title>添加好友</title>
<link rel="stylesheet" href="/sms/src/styles.css" />
</head>
<body>
<main class="login-bg">
<img class="bg-image" src="/sms/assets/background.png" alt="" />
<section class="doctor-card-wrap" aria-live="polite">
<div class="doctor-card">
<div class="doctor-info">
<h1 id="mainTitle" class="doctor-name">医生服务好友</h1>
<p id="corpName" class="doctor-hospital">患者精益管理服务平台</p>
<p id="friendMeta" class="doctor-dept"></p>
<strong id="hintText" class="login-tip">点击下方按钮,添加企业微信好友</strong>
</div>
</div>
<div class="doctor-avatar">
<div class="avatar-board" aria-hidden="true">
<div class="avatar avatar-large" id="primaryAvatarFallback">
<img id="primaryAvatar" alt="" />
<span id="primaryInitial"></span>
</div>
<div class="avatar avatar-small" id="secondaryAvatarFallback">
<img id="secondaryAvatar" alt="" />
<span></span>
</div>
</div>
</div>
</section>
<section class="login-btn-wrap">
<button id="openButton" class="login-btn" type="button" disabled>加载中</button>
</section>
<p id="statusText" class="status-text">正在准备好友链接</p>
</main>
<script src="/sms/config.js"></script>
<script src="/sms/src/main.js"></script>
</body>
</html>

15
package.json Normal file
View File

@ -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"
}
}

102
scripts/build.js Normal file
View File

@ -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}`);

103
scripts/dev-server.js Normal file
View File

@ -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}`);
});

130
src/main.js Normal file
View File

@ -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();
})();

281
src/styles.css Normal file
View File

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