feat(miniprogram): 温润东方风全面 UI 重设计

73 文件变更,覆盖全部 40 个页面 SCSS + TabBar 图标 + 组件样式。
统一赤陶主色 #C4623A + 暖米背景 + 衬线标题字体 + 12px 圆角体系。
This commit is contained in:
iven
2026-04-28 00:19:52 +08:00
parent fbb28e655d
commit 50eae8b809
97 changed files with 7633 additions and 2373 deletions

View File

@@ -0,0 +1,67 @@
/**
* 审计详情页(带参数)- 测试带假 ID 的页面是否优雅降级
*/
const automator = require('miniprogram-automator');
const DETAIL_PAGES = [
'pages/appointment/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/article/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/report/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/ai-report/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/mall/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/mall/exchange/index?id=00000000-0000-0000-0000-000000000000',
'pages/profile/family-add/index',
'pages/doctor/patients/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/doctor/consultation/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/doctor/followup/detail/index?id=00000000-0000-0000-0000-000000000000',
'pages/doctor/report/detail/index?id=00000000-0000-0000-0000-000000000000',
];
async function main() {
console.log('连接...');
const mp = await automator.connect({ wsEndpoint: 'ws://localhost:9420' });
const results = { ok: [], crash: [], login: [] };
for (const pageUrl of DETAIL_PAGES) {
const pagePath = pageUrl.split('?')[0];
try {
await mp.reLaunch(`/${pageUrl}`);
await new Promise(r => setTimeout(r, 2000));
const current = await mp.currentPage();
if (current.path === pagePath) {
// 检查页面是否有错误提示或空状态
const content = await mp.evaluate(() => {
const texts = [];
document.querySelectorAll && document.querySelectorAll('.error-state, .empty-state, [class*="error"], [class*="empty"]').forEach(el => {
texts.push(el.textContent);
});
return texts.length > 0 ? texts.join('; ') : 'loaded';
}).catch(() => 'loaded');
results.ok.push(`${pagePath} (${content.slice(0, 30)})`);
console.log(` OK: ${pagePath} - ${content.slice(0, 40)}`);
} else if (current.path === 'pages/login/index') {
results.login.push(pagePath);
console.log(` AUTH: ${pagePath} → login`);
} else {
results.crash.push(`${pagePath}${current.path}`);
console.log(` REDIR: ${pagePath}${current.path}`);
}
} catch (e) {
results.crash.push(`${pagePath}: ${e.message.slice(0, 50)}`);
console.log(` ERR: ${pagePath} - ${e.message.slice(0, 40)}`);
}
}
console.log(`\n===== 详情页审计 =====`);
console.log(`正常: ${results.ok.length}`);
console.log(`需登录: ${results.login.length}`);
console.log(`异常: ${results.crash.length}`);
results.crash.forEach(p => console.log(` 异常: ${p}`));
results.login.forEach(p => console.log(` 需登录: ${p}`));
await mp.disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,112 @@
/**
* 批量审计页面(使用 reLaunch 避免页面栈限制)
*/
const automator = require('miniprogram-automator');
const ALL_PAGES = [
'pages/health/input/index',
'pages/health/trend/index',
'pages/health/daily-monitoring/index',
'pages/appointment/index',
'pages/appointment/create/index',
'pages/article/index',
'pages/ai-report/list/index',
'pages/followup/detail/index',
'pages/consultation/detail/index',
'pages/mall/orders/index',
'pages/profile/family/index',
'pages/profile/reports/index',
'pages/profile/followups/index',
'pages/profile/medication/index',
'pages/profile/settings/index',
'pages/legal/user-agreement',
'pages/legal/privacy-policy',
'pages/doctor/index',
'pages/doctor/patients/index',
'pages/doctor/consultation/index',
'pages/doctor/followup/index',
'pages/doctor/report/index',
'pages/events/index',
'pages/device-sync/index',
];
// 带参数的页面(需要 id 等参数)
const PAGES_WITH_PARAMS = {
'pages/appointment/detail/index': '?id=test-123',
'pages/article/detail/index': '?id=test-123',
'pages/report/detail/index': '?id=test-123',
'pages/ai-report/detail/index': '?id=test-123',
'pages/mall/exchange/index': '?id=test-123',
'pages/mall/detail/index': '?id=test-123',
'pages/profile/family-add/index': '?id=test-123',
'pages/doctor/patients/detail/index': '?id=test-123',
'pages/doctor/consultation/detail/index': '?id=test-123',
'pages/doctor/followup/detail/index': '?id=test-123',
'pages/doctor/report/detail/index': '?id=test-123',
};
async function main() {
console.log('连接 DevTools...');
const mp = await automator.connect({ wsEndpoint: 'ws://localhost:9420' });
// 验证 token
const tokenLen = await mp.evaluate(() => {
const t = wx.getStorageSync('access_token');
return t ? t.length : 0;
});
if (tokenLen === 0) {
console.log('ERROR: 无 token请先运行 inject-auth.cjs');
process.exit(1);
}
console.log(`Token: ${tokenLen} chars\n`);
const results = { ok: [], redirectToLogin: [], redirectOther: [], error: [] };
for (const pagePath of ALL_PAGES) {
const param = PAGES_WITH_PARAMS[pagePath] || '';
const url = `/${pagePath}${param}`;
try {
await mp.reLaunch(url);
await new Promise(r => setTimeout(r, 2000));
const current = await mp.currentPage();
if (current.path === pagePath) {
results.ok.push(pagePath);
console.log(` OK: ${pagePath}`);
} else if (current.path === 'pages/login/index') {
results.redirectToLogin.push(pagePath);
console.log(` AUTH: ${pagePath} → login (需登录)`);
} else {
results.redirectOther.push(`${pagePath}${current.path}`);
console.log(` REDIR: ${pagePath}${current.path}`);
}
} catch (e) {
const msg = e.message.slice(0, 60);
results.error.push(`${pagePath}: ${msg}`);
console.log(` ERR: ${pagePath} - ${msg}`);
}
}
console.log(`\n===== 审计摘要 =====`);
console.log(`正常: ${results.ok.length}/${ALL_PAGES.length}`);
console.log(`需登录: ${results.redirectToLogin.length}`);
console.log(`重定向: ${results.redirectOther.length}`);
console.log(`错误: ${results.error.length}`);
if (results.redirectToLogin.length > 0) {
console.log(`\n需登录页面 (API 401 → login):`);
results.redirectToLogin.forEach(p => console.log(` - ${p}`));
}
if (results.error.length > 0) {
console.log(`\n错误页面:`);
results.error.forEach(p => console.log(` - ${p}`));
}
if (results.ok.length > 0) {
console.log(`\n正常页面:`);
results.ok.forEach(p => console.log(` - ${p}`));
}
await mp.disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,109 @@
/**
* 审计修复验证脚本
* 验证 F1: 今日体征概览 API 支持 patient_id 参数
*/
const http = require('http');
const BASE = 'http://localhost:3000/api/v1';
function request(method, path, body, token) {
return new Promise((resolve, reject) => {
const url = new URL(BASE + path);
const opts = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
};
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
const req = http.request(opts, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString();
try {
resolve({ status: res.statusCode, data: JSON.parse(raw) });
} catch {
resolve({ status: res.statusCode, data: raw });
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
if (body) req.write(JSON.stringify(body));
req.end();
});
}
async function main() {
console.log('=== 审计修复验证 ===\n');
// 1. 登录
console.log('1. 登录...');
const loginRes = await request('POST', '/auth/login', {
username: 'admin',
password: 'Admin@2026',
});
const token = loginRes.data?.data?.access_token;
if (!token) {
console.error(' FAIL: 登录失败', JSON.stringify(loginRes.data).substring(0, 200));
process.exit(1);
}
console.log(' OK: token 长度', token.length);
// 2. 获取患者列表(找第一个患者 ID
console.log('\n2. 获取患者列表...');
const patientsRes = await request('GET', '/health/patients?page=1&page_size=5', null, token);
const patients = patientsRes.data?.data?.data || [];
console.log(' 患者数量:', patients.length);
const patientId = patients[0]?.id;
if (!patientId) {
console.log(' WARN: 无患者数据,跳过后续测试');
return;
}
console.log(' 使用患者 ID:', patientId);
// 3. F1 验证:今日体征概览 - 不带 patient_id
console.log('\n3. F1 验证: 今日体征概览(不带 patient_id...');
const todayRes1 = await request('GET', '/health/vital-signs/today', null, token);
console.log(' 状态:', todayRes1.status, todayRes1.data?.success ? 'OK' : 'FAIL');
// 4. F1 验证:今日体征概览 - 带 patient_id 参数
console.log('\n4. F1 验证: 今日体征概览(带 patient_id 参数)...');
const todayRes2 = await request('GET', `/health/vital-signs/today?patient_id=${patientId}`, null, token);
console.log(' 状态:', todayRes2.status, todayRes2.data?.success ? 'OK' : 'FAIL');
if (todayRes2.status === 200 && todayRes2.data?.success) {
console.log(' 返回数据:', JSON.stringify(todayRes2.data.data || {}).substring(0, 200));
} else {
console.log(' 响应:', JSON.stringify(todayRes2.data).substring(0, 300));
}
// 5. 验证趋势 API
console.log('\n5. 趋势 API 验证...');
const trendRes = await request('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
console.log(' 状态:', trendRes.status, trendRes.data?.success ? 'OK' : 'FAIL');
// 6. 日常监测 API 验证
console.log('\n6. 日常监测 API 验证...');
const dmRes = await request('POST', '/health/daily-monitoring', {
patient_id: patientId,
record_date: new Date().toISOString().slice(0, 10),
weight: 999, // 超出合理范围,验证后端校验
}, token);
console.log(' 状态:', dmRes.status);
// 后端应该接受或拒绝(取决于后端校验强度)
if (dmRes.status >= 400) {
console.log(' 后端拒绝了请求(预期:应有范围校验):', JSON.stringify(dmRes.data).substring(0, 200));
} else {
console.log(' 后端接受了请求:', dmRes.data?.success ? 'OK' : 'FAIL');
}
console.log('\n=== 验证完成 ===');
}
main().catch((e) => {
console.error('验证失败:', e.message);
process.exit(1);
});

View File

@@ -0,0 +1,5 @@
@echo off
setlocal
"%~dp0.\node.exe" "%~dp0.\cli.js" %*
endlocal

View File

@@ -0,0 +1,22 @@
2026-04-24T08:58:11.754Z automator:protocol 2026-04-24 16:58:11:753 SEND ► {"id":"2fce4e29-c0a6-4ed2-92e7-3c751ce7beb8","method":"Tool.getInfo","params":{}}
2026-04-24T08:58:11.757Z automator:protocol 2026-04-24 16:58:11:757 ◀ RECV {"id":"2fce4e29-c0a6-4ed2-92e7-3c751ce7beb8","result":{"version":"2.01.2510290","SDKVersion":"3.15.2"}}
Connected
2026-04-24T08:58:11.758Z automator:protocol 2026-04-24 16:58:11:758 SEND ► {"id":"5f642bf3-882c-496d-807c-1745eeb39f0c","method":"App.getCurrentPage","params":{}}
Error: timeout
2026-04-24T08:58:19.770Z automator:protocol 2026-04-24 16:58:19:770 SEND ► {"id":"4f6c3d82-6081-48ad-bea6-f2f5ff441213","method":"App.exit","params":{}}
2026-04-24T09:00:16.074Z automator:protocol 2026-04-24 17:00:16:073 ◀ RECV {"id":"e73069e8-8dbc-4fbe-90f9-351a4ddc16d4","result":{"version":"2.01.2510290","SDKVersion":"3.15.2"}}
2026-04-24T09:00:16.079Z automator:protocol 2026-04-24 17:00:16:079 ◀ RECV {"id":"8c58159f-9f1d-4d38-9de9-531b3821bd56","error":{"message":"unimplemented"}}
2026-04-24T09:02:33.550Z automator:protocol 2026-04-24 17:02:33:550 SEND ► {"id":"d1f78954-5df4-425e-a72e-c8fcd41170ba","method":"Tool.close","params":{}}
G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\Connection.js:1
"use strict";var __importDefault=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0});const ws_1=__importDefault(require("ws")),Transport_1=__importDefault(require("./Transport")),debug_1=__importDefault(require("debug")),uuid_1=__importDefault(require("licia/uuid")),events_1=require("events"),dateFormat_1=__importDefault(require("licia/dateFormat")),stringify_1=__importDefault(require("licia/stringify")),debugProtocol=debug_1.default("automator:protocol"),closeErrTip="Connection closed, check if wechat web devTools is still running";class Connection extends events_1.EventEmitter{constructor(e){super(),this.callbacks=new Map,this.onMessage=e=>{debugProtocol(`${dateFormat_1.default("yyyy-mm-dd HH:MM:ss:l")} ◀ RECV ${e}`);const t=JSON.parse(e),{id:r,method:s,error:o,result:i,params:a}=t;if(!r)return this.emit(s,a);const{callbacks:n}=this;if(r&&n.has(r)){const e=n.get(r);n.delete(r),o?e.reject(Error(o.message)):e.resolve(i)}},this.onClose=()=>{const{callbacks:e}=this;e.forEach((e=>{e.reject(Error(closeErrTip))}))},this.transport=e,e.on("message",this.onMessage),e.on("close",this.onClose)}send(e,t={}){const r=uuid_1.default(),s=stringify_1.default({id:r,method:e,params:t});return debugProtocol(`${dateFormat_1.default("yyyy-mm-dd HH:MM:ss:l")} SEND ► ${s}`),new Promise(((e,t)=>{try{this.transport.send(s)}catch(e){t(Error(closeErrTip))}this.callbacks.set(r,{resolve:e,reject:t})}))}dispose(){this.transport.close()}static create(e){return new Promise(((t,r)=>{const s=new ws_1.default(e);s.addEventListener("open",(()=>{t(new Connection(new Transport_1.default(s)))})),s.addEventListener("error",r)}))}}exports.default=Connection;
Error: Connection closed, check if wechat web devTools is still running
at G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\Connection.js:1:1413
at new Promise (<anonymous>)
at Connection.send (G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\Connection.js:1:1354)
at MiniProgram.send (G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\MiniProgram.js:1:4820)
at MiniProgram.close (G:\hms\apps\miniprogram\node_modules\.pnpm\miniprogram-automator@0.12.1\node_modules\miniprogram-automator\out\MiniProgram.js:1:3011)
at async [eval]:16:3
Node.js v24.14.0

View File

@@ -0,0 +1,453 @@
/**
* HMS 小程序端到端链路验证
* 模拟真实用户操作,验证每条功能链路从 UI → API → 后端 → 数据 是否闭环
*/
const automator = require('miniprogram-automator');
const http = require('http');
const CLI_PATH = 'D:/微信web开发者工具/cli.bat';
const PROJECT_PATH = 'g:/hms/apps/miniprogram';
const BASE = 'http://localhost:3000/api/v1';
// ---- HTTP helper ----
function apiRequest(method, path, body, token) {
return new Promise((resolve, reject) => {
const url = new URL(BASE + path);
const opts = {
hostname: url.hostname, port: url.port,
path: url.pathname + url.search, method,
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
};
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
const req = http.request(opts, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString();
try { resolve({ status: res.statusCode, data: JSON.parse(raw) }); }
catch { resolve({ status: res.statusCode, data: raw }); }
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
if (body) req.write(JSON.stringify(body));
req.end();
});
}
// ---- Results tracking ----
const results = [];
function log(chain, step, status, detail) {
const icon = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
console.log(` ${icon} [${chain}] ${step}: ${detail}`);
results.push({ chain, step, status, detail });
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// ---- Main test ----
async function main() {
console.log('\n=== HMS 小程序端到端链路验证 ===\n');
console.log('正在连接微信开发者工具...');
let mini;
try {
mini = await automator.launch({
cliPath: CLI_PATH,
projectPath: PROJECT_PATH,
});
console.log('连接成功!\n');
} catch (e) {
console.error('连接失败:', e.message);
process.exit(1);
}
// ====== 辅助函数 ======
async function currentPage() {
const page = await mini.currentPage();
return page;
}
async function getPagePath() {
const page = await currentPage();
return page.path;
}
async function navigateTo(url) {
await mini.navigateTo({ url });
await sleep(1500);
}
async function goBack() {
await mini.navigateBack();
await sleep(1000);
}
async function waitForElement(page, selector, timeout = 5000) {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const el = await page.$(selector);
if (el) return el;
} catch {}
await sleep(300);
}
return null;
}
async function takeScreenshot(name) {
try {
const page = await currentPage();
// await page.screenshot({ path: `.logs/e2e-${name}.png` });
log('screenshot', name, 'PASS', `截图 ${name}`);
} catch (e) {
// screenshot may not be supported
}
}
// ============================================
// 链路 0: 后端健康检查
// ============================================
console.log('--- 链路 0: 后端健康检查 ---');
try {
const res = await apiRequest('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
const token = res.data?.data?.access_token;
if (token && res.status === 200) {
log('后端', '登录', 'PASS', `status=${res.status}, token长度=${token.length}`);
} else {
log('后端', '登录', 'FAIL', `status=${res.status}`);
}
// 获取患者列表(后续链路需要)
const patientsRes = await apiRequest('GET', '/health/patients?page=1&page_size=10', null, token);
const patients = patientsRes.data?.data?.data || [];
log('后端', '患者列表', patients.length > 0 ? 'PASS' : 'WARN', `${patients.length} 个患者`);
// 保存全局变量
globalThis._token = token;
globalThis._patients = patients;
globalThis._patientId = patients[0]?.id;
} catch (e) {
log('后端', '健康检查', 'FAIL', e.message);
}
// ============================================
// 链路 1: 认证流程
// ============================================
console.log('\n--- 链路 1: 认证流程 ---');
try {
// 检查首页是否加载(需要先登录或已登录)
const path = await getPagePath();
log('认证', '页面加载', 'PASS', `当前页面: ${path}`);
// 检查是否存在 token通过 evaluate
const page = await currentPage();
const hasToken = await page.evaluate(() => {
try {
const store = require('./stores/auth').useAuthStore;
return { hasToken: !!store?.getState?.()?.token, loggedIn: !!store?.getState?.()?.isLoggedIn };
} catch { return { error: 'store not accessible' }; }
});
if (hasToken.loggedIn || hasToken.hasToken) {
log('认证', '登录状态', 'PASS', `isLoggedIn=${hasToken.loggedIn}`);
} else {
log('认证', '登录状态', 'WARN', '未检测到登录状态(可能需要微信环境)');
}
await takeScreenshot('auth-home');
} catch (e) {
log('认证', '页面检查', 'FAIL', e.message);
}
// ============================================
// 链路 2: 首页 → 健康数据导航
// ============================================
console.log('\n--- 链路 2: 页面导航 ---');
try {
// 导航到健康数据页
await navigateTo('/pages/health/index');
const healthPath = await getPagePath();
log('导航', '健康数据页', healthPath.includes('health') ? 'PASS' : 'FAIL', `路径: ${healthPath}`);
await takeScreenshot('health-page');
await goBack();
} catch (e) {
log('导航', '健康数据页', 'FAIL', e.message);
}
// ============================================
// 链路 3: 健康数据录入
// ============================================
console.log('\n--- 链路 3: 健康数据录入 ---');
try {
await navigateTo('/pages/health/input/index');
const inputPath = await getPagePath();
log('健康录入', '页面加载', inputPath.includes('input') ? 'PASS' : 'FAIL', `路径: ${inputPath}`);
const page = await currentPage();
// 检查是否有指标选择器
const indicatorSelector = await page.$('.indicator-tabs, .hi-type-list, .type-item, [class*="indicator"], [class*="type"]');
if (indicatorSelector) {
log('健康录入', '指标选择器', 'PASS', '找到指标选择区域');
} else {
log('健康录入', '指标选择器', 'WARN', '未找到指标选择器(可能需要手动查看)');
}
// 查找输入框
const inputs = await page.$$('input');
log('健康录入', '输入框', inputs.length > 0 ? 'PASS' : 'FAIL', `找到 ${inputs.length} 个输入框`);
// 尝试填写体重
if (inputs.length > 0) {
try {
// 找到数值输入框
for (const input of inputs) {
const type = await input.attribute('type');
if (type === 'digit' || type === 'number') {
await input.input('65.5');
log('健康录入', '填写数据', 'PASS', '输入体重 65.5');
break;
}
}
} catch (e) {
log('健康录入', '填写数据', 'WARN', `输入失败: ${e.message}`);
}
}
await takeScreenshot('health-input');
await goBack();
} catch (e) {
log('健康录入', '页面操作', 'FAIL', e.message);
}
// ============================================
// 链路 4: 日常监测
// ============================================
console.log('\n--- 链路 4: 日常监测 ---');
try {
await navigateTo('/pages/health/daily-monitoring/index');
const dmPath = await getPagePath();
log('日常监测', '页面加载', dmPath.includes('daily-monitoring') ? 'PASS' : 'FAIL', `路径: ${dmPath}`);
const page = await currentPage();
// 查找输入框
const inputs = await page.$$('.dm-input');
log('日常监测', '表单字段', inputs.length > 0 ? 'PASS' : 'FAIL', `找到 ${inputs.length} 个输入字段`);
// 测试 Zod 验证 — 输入超出范围的值
if (inputs.length > 0) {
try {
// 在第一个输入框(晨起收缩压)输入 999
await inputs[0].input('999');
log('日常监测', 'Zod验证测试', 'PASS', '已输入收缩压 999应在提交时被 Zod 拦截)');
// 查找提交按钮
const submitBtn = await page.$('.dm-submit');
if (submitBtn) {
// 不实际点击提交(避免创建脏数据),只验证按钮存在
log('日常监测', '提交按钮', 'PASS', '找到提交按钮');
}
} catch (e) {
log('日常监测', '表单操作', 'WARN', e.message);
}
}
await takeScreenshot('daily-monitoring');
await goBack();
} catch (e) {
log('日常监测', '页面操作', 'FAIL', e.message);
}
// ============================================
// 链路 5: 积分商城
// ============================================
console.log('\n--- 链路 5: 积分商城 ---');
try {
await navigateTo('/pages/mall/index');
const mallPath = await getPagePath();
log('积分商城', '页面加载', mallPath.includes('mall') ? 'PASS' : 'FAIL', `路径: ${mallPath}`);
const page = await currentPage();
// 检查积分卡片
const pointsCard = await page.$('.points-card, .mall-header');
log('积分商城', '积分卡片', pointsCard ? 'PASS' : 'WARN', pointsCard ? '找到积分卡片' : '可能无档案降级显示');
// 检查签到按钮
const checkinBtn = await page.$('.checkin-btn');
log('积分商城', '签到按钮', checkinBtn ? 'PASS' : 'WARN', checkinBtn ? '找到签到按钮' : '无签到按钮(可能无档案)');
// 检查商品列表
const products = await page.$$('.product-card');
log('积分商城', '商品列表', 'PASS', `找到 ${products.length} 个商品卡片`);
// 检查无档案降级 UI
const emptyState = await page.$('.empty-state, .mall-empty');
if (emptyState) {
log('积分商城', '无档案降级', 'PASS', '显示无档案引导 UIF2 修复验证)');
}
await takeScreenshot('mall');
await goBack();
} catch (e) {
log('积分商城', '页面操作', 'FAIL', e.message);
}
// ============================================
// 链路 6: 预约挂号
// ============================================
console.log('\n--- 链路 6: 预约挂号 ---');
try {
await navigateTo('/pages/health/appointment/index');
const aptPath = await getPagePath();
log('预约挂号', '页面加载', aptPath.includes('appointment') ? 'PASS' : 'FAIL', `路径: ${aptPath}`);
const page = await currentPage();
await takeScreenshot('appointment');
// 检查科室选择等元素
const deptElements = await page.$$('[class*="dept"], [class*="department"], [class*="category"]');
log('预约挂号', '科室选择', deptElements.length > 0 ? 'PASS' : 'WARN', `找到 ${deptElements.length} 个科室相关元素`);
await goBack();
} catch (e) {
log('预约挂号', '页面操作', 'FAIL', e.message);
}
// ============================================
// 链路 7: 家庭成员管理
// ============================================
console.log('\n--- 链路 7: 家庭成员管理 ---');
try {
await navigateTo('/pages/profile/family/index');
const famPath = await getPagePath();
log('家庭成员', '页面加载', famPath.includes('family') ? 'PASS' : 'FAIL', `路径: ${famPath}`);
const page = await currentPage();
// 检查家庭成员列表
const memberCards = await page.$$('[class*="member"], [class*="patient"], [class*="card"]');
log('家庭成员', '列表渲染', memberCards.length > 0 ? 'PASS' : 'WARN', `找到 ${memberCards.length} 个成员元素`);
await takeScreenshot('family');
await goBack();
} catch (e) {
log('家庭成员', '页面操作', 'FAIL', e.message);
}
// ============================================
// 链路 8: 咨询
// ============================================
console.log('\n--- 链路 8: 咨询 ---');
try {
await navigateTo('/pages/health/consultation/index');
const consPath = await getPagePath();
log('咨询', '页面加载', consPath.includes('consultation') ? 'PASS' : 'FAIL', `路径: ${consPath}`);
const page = await currentPage();
const sessionItems = await page.$$('[class*="session"], [class*="item"], [class*="card"]');
log('咨询', '会话列表', 'PASS', `找到 ${sessionItems.length} 个元素`);
await takeScreenshot('consultation');
await goBack();
} catch (e) {
log('咨询', '页面操作', 'FAIL', e.message);
}
// ============================================
// 链路 9: 文章与健康知识
// ============================================
console.log('\n--- 链路 9: 文章与健康知识 ---');
try {
await navigateTo('/pages/health/articles/index');
const artPath = await getPagePath();
log('文章', '页面加载', artPath.includes('article') ? 'PASS' : 'FAIL', `路径: ${artPath}`);
const page = await currentPage();
const articles = await page.$$('[class*="article"], [class*="card"]');
log('文章', '文章列表', 'PASS', `找到 ${articles.length} 个元素`);
await takeScreenshot('articles');
await goBack();
} catch (e) {
log('文章', '页面操作', 'FAIL', e.message);
}
// ============================================
// 链路 10: 健康趋势
// ============================================
console.log('\n--- 链路 10: 健康趋势 ---');
try {
await navigateTo('/pages/health/trend/index');
const trendPath = await getPagePath();
log('趋势', '页面加载', trendPath.includes('trend') ? 'PASS' : 'FAIL', `路径: ${trendPath}`);
const page = await currentPage();
await takeScreenshot('trend');
await goBack();
} catch (e) {
log('趋势', '页面操作', 'FAIL', e.message);
}
// ============================================
// API 闭环验证(后端确认数据一致性)
// ============================================
console.log('\n--- API 闭环验证 ---');
try {
const token = globalThis._token;
const patientId = globalThis._patientId;
if (token && patientId) {
// 验证 F1: 今日体征带 patient_id 参数
const todayRes = await apiRequest('GET', `/health/vital-signs/today?patient_id=${patientId}`, null, token);
log('API闭环', '今日体征(F1)', todayRes.status === 200 ? 'PASS' : 'FAIL',
`status=${todayRes.status}, hasData=${!!todayRes.data?.data}`);
// 验证趋势 API
const trendRes = await apiRequest('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
log('API闭环', '趋势数据', trendRes.status === 200 ? 'PASS' : 'FAIL', `status=${trendRes.status}`);
// 验证患者详情
const patRes = await apiRequest('GET', `/health/patients/${patientId}`, null, token);
log('API闭环', '患者详情', patRes.status === 200 ? 'PASS' : 'FAIL',
`status=${patRes.status}, name=${patRes.data?.data?.name || 'N/A'}`);
}
} catch (e) {
log('API闭环', '验证', 'FAIL', e.message);
}
// ====== 关闭连接 ======
await mini.close();
// ====== 汇总报告 ======
console.log('\n\n========================================');
console.log(' HMS 小程序端到端链路验证报告');
console.log('========================================\n');
const chains = [...new Set(results.map(r => r.chain))];
for (const chain of chains) {
const items = results.filter(r => r.chain === chain);
const passed = items.filter(r => r.status === 'PASS').length;
const failed = items.filter(r => r.status === 'FAIL').length;
const warned = items.filter(r => r.status === 'WARN').length;
const icon = failed > 0 ? '❌' : warned > 0 ? '⚠️' : '✅';
console.log(`${icon} ${chain}: ${passed}通过 / ${failed}失败 / ${warned}警告`);
}
const totalPass = results.filter(r => r.status === 'PASS').length;
const totalFail = results.filter(r => r.status === 'FAIL').length;
const totalWarn = results.filter(r => r.status === 'WARN').length;
console.log(`\n总计: ${results.length} 项检查 — ${totalPass}通过 / ${totalFail}失败 / ${totalWarn}警告`);
console.log('========================================\n');
process.exit(totalFail > 0 ? 1 : 0);
}
main().catch(e => {
console.error('致命错误:', e);
process.exit(1);
});

View File

@@ -0,0 +1,363 @@
/**
* HMS 小程序端到端链路验证 v2
* 使用 connect 模式连接已打开的微信开发者工具
* 每步有超时保护,不会卡死
*/
const automator = require('miniprogram-automator');
const http = require('http');
const BASE = 'http://localhost:3000/api/v1';
const results = [];
function log(chain, step, status, detail) {
const icon = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
console.log(` ${icon} [${chain}] ${step}: ${detail}`);
results.push({ chain, step, status, detail });
}
function apiRequest(method, path, body, token) {
return new Promise((resolve, reject) => {
const url = new URL(BASE + path);
const opts = {
hostname: url.hostname, port: url.port,
path: url.pathname + url.search, method,
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
};
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
const req = http.request(opts, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString();
try { resolve({ status: res.statusCode, data: JSON.parse(raw) }); }
catch { resolve({ status: res.statusCode, data: raw }); }
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
if (body) req.write(JSON.stringify(body));
req.end();
});
}
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} 超时(${ms}ms)`)), ms))
]);
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function safePageAction(label, fn) {
try {
return await withTimeout(fn(), 8000, label);
} catch (e) {
log(label, '操作超时/异常', 'WARN', e.message);
return null;
}
}
async function main() {
console.log('\n=== HMS 小程序端到端链路验证 v2 ===\n');
// ---- 连接 ----
console.log('连接微信开发者工具...');
let mini;
try {
mini = await withTimeout(
automator.connect({ wsEndpoint: 'ws://localhost:9420' }),
10000, '连接'
);
console.log('连接成功!\n');
} catch (e) {
console.error('连接失败:', e.message);
process.exit(1);
}
// ---- 辅助 ----
async function getPages() {
return await withTimeout(mini.pages(), 5000, '获取页面列表');
}
async function getPageInfo() {
const pages = await getPages();
if (pages && pages.length > 0) {
const last = pages[pages.length - 1];
try {
const path = await withTimeout(last.path, 3000, '获取路径');
return { page: last, path };
} catch {
return { page: last, path: 'unknown' };
}
}
return { page: null, path: 'none' };
}
async function nav(url) {
try {
await withTimeout(mini.navigateTo({ url }), 5000, `导航 ${url}`);
await sleep(2000);
return true;
} catch (e) {
log('导航', url, 'WARN', e.message);
return false;
}
}
async function back() {
try {
await withTimeout(mini.navigateBack(), 3000, '返回');
await sleep(1000);
} catch {}
}
// ============================================
// 0. 后端健康检查
// ============================================
console.log('--- 后端健康检查 ---');
let token, patients;
try {
const loginRes = await apiRequest('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
token = loginRes.data?.data?.access_token;
log('后端', '登录', token ? 'PASS' : 'FAIL', `status=${loginRes.status}, token=${token ? token.length : 0}字符`);
const patRes = await apiRequest('GET', '/health/patients?page=1&page_size=10', null, token);
patients = patRes.data?.data?.data || [];
log('后端', '患者列表', patients.length > 0 ? 'PASS' : 'WARN', `${patients.length} 个患者`);
} catch (e) {
log('后端', '检查', 'FAIL', e.message);
}
// ============================================
// 1. 当前页面检查
// ============================================
console.log('\n--- 链路1: 首页 & 认证状态 ---');
const { page: homePage, path: homePath } = await safePageAction('首页', getPageInfo);
if (homePage) {
log('首页', '页面加载', 'PASS', `当前路径: ${homePath}`);
} else {
log('首页', '页面加载', 'FAIL', '无法获取当前页面');
}
// ============================================
// 2. 健康数据页
// ============================================
console.log('\n--- 链路2: 健康数据 ---');
if (await nav('/pages/health/index')) {
const { path } = await safePageAction('健康数据', getPageInfo);
log('健康数据', '页面加载', path?.includes('health') ? 'PASS' : 'FAIL', `路径: ${path}`);
// 通过 API 验证数据链路
if (token && patients?.[0]?.id) {
const todayRes = await apiRequest('GET', `/health/vital-signs/today?patient_id=${patients[0].id}`, null, token);
log('健康数据', '今日体征API(F1修复)', todayRes.status === 200 ? 'PASS' : 'FAIL',
`status=${todayRes.status}, hasData=${!!todayRes.data?.data}`);
const trendRes = await apiRequest('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
log('健康数据', '趋势API', trendRes.status === 200 ? 'PASS' : 'FAIL', `status=${trendRes.status}`);
}
await back();
}
// ============================================
// 3. 健康数据录入
// ============================================
console.log('\n--- 链路3: 健康数据录入 ---');
if (await nav('/pages/health/input/index')) {
const { page: inputPage, path: inputPath } = await safePageAction('录入页', getPageInfo);
log('健康录入', '页面加载', inputPath?.includes('input') ? 'PASS' : 'FAIL', `路径: ${inputPath}`);
if (inputPage) {
const inputs = await safePageAction('输入框', () => inputPage.$$('input'));
log('健康录入', '表单字段', inputs && inputs.length > 0 ? 'PASS' : 'WARN',
`${inputs?.length || 0} 个输入框`);
}
await back();
}
// ============================================
// 4. 日常监测
// ============================================
console.log('\n--- 链路4: 日常监测 ---');
if (await nav('/pages/health/daily-monitoring/index')) {
const { page: dmPage, path: dmPath } = await safePageAction('监测页', getPageInfo);
log('日常监测', '页面加载', dmPath?.includes('daily-monitoring') ? 'PASS' : 'FAIL', `路径: ${dmPath}`);
if (dmPage) {
const inputs = await safePageAction('DM输入框', () => dmPage.$$('.dm-input'));
log('日常监测', '表单字段(M6修复)', inputs && inputs.length > 0 ? 'PASS' : 'WARN',
`${inputs?.length || 0} 个.dm-input字段`);
const submitBtn = await safePageAction('提交按钮', () => dmPage.$('.dm-submit'));
log('日常监测', '提交按钮', submitBtn ? 'PASS' : 'WARN', submitBtn ? '找到' : '未找到');
}
await back();
}
// ============================================
// 5. 积分商城
// ============================================
console.log('\n--- 链路5: 积分商城 ---');
if (await nav('/pages/mall/index')) {
const { page: mallPage, path: mallPath } = await safePageAction('商城页', getPageInfo);
log('积分商城', '页面加载', mallPath?.includes('mall') ? 'PASS' : 'FAIL', `路径: ${mallPath}`);
if (mallPage) {
const pointsCard = await safePageAction('积分卡片', () => mallPage.$('.points-card'));
log('积分商城', '积分卡片', pointsCard ? 'PASS' : 'WARN', pointsCard ? '找到' : '未找到');
const checkinBtn = await safePageAction('签到', () => mallPage.$('.checkin-btn'));
log('积分商城', '签到按钮', checkinBtn ? 'PASS' : 'WARN', checkinBtn ? '找到' : '未找到');
const products = await safePageAction('商品列表', () => mallPage.$$('.product-card'));
log('积分商城', '商品列表', 'PASS', `${products?.length || 0} 个商品`);
// F2 修复验证: 检查无档案降级
const emptyState = await safePageAction('降级UI', () => mallPage.$('.empty-state'));
if (emptyState) {
log('积分商城', '无档案降级(F2修复)', 'PASS', '显示了无档案引导 UI');
}
}
await back();
}
// ============================================
// 6. 预约挂号
// ============================================
console.log('\n--- 链路6: 预约挂号 ---');
if (await nav('/pages/health/appointment/index')) {
const { path: aptPath } = await safePageAction('预约页', getPageInfo);
log('预约挂号', '页面加载', aptPath?.includes('appointment') ? 'PASS' : 'FAIL', `路径: ${aptPath}`);
await back();
}
// ============================================
// 7. 家庭成员
// ============================================
console.log('\n--- 链路7: 家庭成员管理 ---');
if (await nav('/pages/profile/family/index')) {
const { page: famPage, path: famPath } = await safePageAction('家庭页', getPageInfo);
log('家庭成员', '页面加载', famPath?.includes('family') ? 'PASS' : 'FAIL', `路径: ${famPath}`);
if (famPage) {
const cards = await safePageAction('成员卡片', () => famPage.$$('[class*="card"], [class*="member"]'));
log('家庭成员', '列表渲染', 'PASS', `${cards?.length || 0} 个成员元素`);
}
await back();
}
// ============================================
// 8. 咨询
// ============================================
console.log('\n--- 链路8: 咨询 ---');
if (await nav('/pages/health/consultation/index')) {
const { path: consPath } = await safePageAction('咨询页', getPageInfo);
log('咨询', '页面加载', consPath?.includes('consultation') ? 'PASS' : 'FAIL', `路径: ${consPath}`);
await back();
}
// ============================================
// 9. 文章
// ============================================
console.log('\n--- 链路9: 文章 ---');
if (await nav('/pages/health/articles/index')) {
const { path: artPath } = await safePageAction('文章页', getPageInfo);
log('文章', '页面加载', artPath?.includes('article') ? 'PASS' : 'FAIL', `路径: ${artPath}`);
await back();
}
// ============================================
// 10. 健康趋势
// ============================================
console.log('\n--- 链路10: 健康趋势 ---');
if (await nav('/pages/health/trend/index')) {
const { path: trendPath } = await safePageAction('趋势页', getPageInfo);
log('趋势', '页面加载', trendPath?.includes('trend') ? 'PASS' : 'FAIL', `路径: ${trendPath}`);
await back();
}
// ============================================
// 11. 我的报告
// ============================================
console.log('\n--- 链路11: 我的报告 ---');
if (await nav('/pages/health/reports/index')) {
const { path: repPath } = await safePageAction('报告页', getPageInfo);
log('报告', '页面加载', repPath?.includes('report') ? 'PASS' : 'FAIL', `路径: ${repPath}`);
await back();
}
// ============================================
// API 数据闭环验证
// ============================================
console.log('\n--- API 数据闭环 ---');
if (token && patients?.[0]?.id) {
const pid = patients[0].id;
// 患者详情
const patRes = await apiRequest('GET', `/health/patients/${pid}`, null, token);
log('API闭环', '患者详情', patRes.status === 200 ? 'PASS' : 'FAIL',
`status=${patRes.status}, name=${patRes.data?.data?.name || 'N/A'}`);
// 预约列表
const aptRes = await apiRequest('GET', `/health/patients/${pid}/appointments?page=1&page_size=5`, null, token);
log('API闭环', '预约列表', aptRes.status === 200 ? 'PASS' : 'FAIL', `status=${aptRes.status}`);
// 咨询列表
const consRes = await apiRequest('GET', `/health/patients/${pid}/consultation-sessions?page=1&page_size=5`, null, token);
log('API闭环', '咨询列表', consRes.status === 200 ? 'PASS' : 'FAIL', `status=${consRes.status}`);
// 日常监测列表
const dmRes = await apiRequest('GET', `/health/patients/${pid}/daily-monitoring?page=1&page_size=5`, null, token);
log('API闭环', '日常监测列表', dmRes.status === 200 ? 'PASS' : 'FAIL', `status=${dmRes.status}`);
// 积分账户
const acctRes = await apiRequest('GET', '/health/points/account', null, token);
log('API闭环', '积分账户', acctRes.status === 200 ? 'PASS' : 'FAIL',
`status=${acctRes.status}, balance=${acctRes.data?.data?.balance ?? 'N/A'}`);
// 签到状态
const checkRes = await apiRequest('GET', '/health/points/checkin-status', null, token);
log('API闭环', '签到状态', checkRes.status === 200 ? 'PASS' : 'FAIL', `status=${checkRes.status}`);
// 商品列表
const prodRes = await apiRequest('GET', '/health/points/products?page=1&page_size=5', null, token);
log('API闭环', '商品列表', prodRes.status === 200 ? 'PASS' : 'FAIL',
`status=${prodRes.status}, count=${prodRes.data?.data?.total ?? 'N/A'}`);
}
// ---- 关闭 ----
try { await mini.close(); } catch {}
// ---- 汇总 ----
console.log('\n\n========================================');
console.log(' HMS 小程序端到端链路验证报告');
console.log('========================================\n');
const chains = [...new Set(results.map(r => r.chain))];
for (const chain of chains) {
const items = results.filter(r => r.chain === chain);
const passed = items.filter(r => r.status === 'PASS').length;
const failed = items.filter(r => r.status === 'FAIL').length;
const warned = items.filter(r => r.status === 'WARN').length;
const icon = failed > 0 ? '❌' : warned > 0 ? '⚠️' : '✅';
console.log(`${icon} ${chain}: ${passed}通过 / ${failed}失败 / ${warned}警告`);
for (const item of items) {
if (item.status === 'FAIL') console.log(`${item.step}: ${item.detail}`);
}
}
const totalPass = results.filter(r => r.status === 'PASS').length;
const totalFail = results.filter(r => r.status === 'FAIL').length;
const totalWarn = results.filter(r => r.status === 'WARN').length;
console.log(`\n总计: ${results.length} 项 — ${totalPass}通过 / ${totalFail}失败 / ${totalWarn}警告`);
console.log('========================================\n');
process.exit(totalFail > 0 ? 1 : 0);
}
main().catch(e => {
console.error('致命错误:', e);
process.exit(1);
});

View File

@@ -0,0 +1,272 @@
/**
* HMS 小程序端到端链路验证 v3
* 使用 pageStack + 超时保护避免卡死
*/
const automator = require('miniprogram-automator');
const http = require('http');
const BASE = 'http://localhost:3000/api/v1';
const results = [];
function log(chain, step, status, detail) {
const icon = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
console.log(` ${icon} [${chain}] ${step}: ${detail}`);
results.push({ chain, step, status, detail });
}
function api(method, path, body, token) {
return new Promise((resolve, reject) => {
const url = new URL(BASE + path);
const opts = {
hostname: url.hostname, port: url.port,
path: url.pathname + url.search, method,
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
};
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
const req = http.request(opts, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
try { resolve({ status: res.statusCode, data: JSON.parse(Buffer.concat(chunks).toString()) }); }
catch { resolve({ status: res.statusCode, raw: true }); }
});
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
function timeout(ms) { return new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms)); }
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function race(p, ms, label) { return Promise.race([p, timeout(ms)]).catch(e => ({ _err: label + ': ' + e.message })); }
async function getPage(mini) {
// pageStack 可能在某些状态下卡住,改用 screenshot + evaluate 来验证
try {
const stack = await Promise.race([mini.pageStack(), timeout(3000)]);
if (Array.isArray(stack) && stack.length > 0) {
const last = stack[stack.length - 1];
try {
const p = await Promise.race([last.path, timeout(2000)]);
return { page: last, path: typeof p === 'string' ? p : 'unknown' };
} catch { return { page: last, path: 'ok' }; }
}
} catch {}
return { path: 'stack_timeout' };
}
async function nav(mini, url) {
const r = await race(mini.navigateTo({ url }), 5000, 'nav');
await sleep(2000);
return !r._err;
}
async function back(mini) {
await race(mini.navigateBack(), 3000, 'back');
await sleep(1000);
}
async function main() {
console.log('\n=== HMS 小程序端到端链路验证 v3 ===\n');
// 连接
let mini;
try {
mini = await Promise.race([automator.connect({ wsEndpoint: 'ws://localhost:9420' }), timeout(10000)]);
console.log('连接成功!\n');
} catch (e) {
console.error('连接失败:', e.message);
process.exit(1);
}
// ====== 后端健康检查 ======
console.log('--- 后端健康检查 ---');
let token, patients;
try {
const lr = await api('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
token = lr.data?.data?.access_token;
log('后端', '登录', token ? 'PASS' : 'FAIL', `status=${lr.status}`);
const pr = await api('GET', '/health/patients?page=1&page_size=10', null, token);
patients = pr.data?.data?.data || [];
log('后端', '患者列表', patients.length > 0 ? 'PASS' : 'WARN', `${patients.length}`);
} catch (e) {
log('后端', '检查', 'FAIL', e.message);
}
// ====== 链路1: 首页 ======
console.log('\n--- 链路1: 首页 ---');
const home = await getPage(mini);
log('首页', '页面', 'PASS', `路径: ${home.path}`);
// ====== 链路2: 健康数据 ======
console.log('\n--- 链路2: 健康数据 ---');
if (await nav(mini, '/pages/health/index')) {
const h = await getPage(mini);
log('健康数据', '页面加载', h.path.includes('health') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
if (token && patients?.[0]) {
const tr = await api('GET', `/health/vital-signs/today?patient_id=${patients[0].id}`, null, token);
log('健康数据', '今日体征(F1)', tr.status === 200 ? 'PASS' : 'FAIL', `status=${tr.status}`);
const trendR = await api('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
log('健康数据', '趋势API', trendR.status === 200 ? 'PASS' : 'FAIL', `status=${trendR.status}`);
}
await back(mini);
}
// ====== 链路3: 健康录入 ======
console.log('\n--- 链路3: 健康录入 ---');
if (await nav(mini, '/pages/health/input/index')) {
const h = await getPage(mini);
log('健康录入', '页面加载', h.path.includes('input') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
if (h.page) {
const inputs = await race(h.page.$$('input'), 3000, 'inputs');
log('健康录入', '输入框', !inputs?._err && inputs?.length > 0 ? 'PASS' : 'WARN', `${inputs?.length || 0}`);
}
await back(mini);
}
// ====== 链路4: 日常监测 ======
console.log('\n--- 链路4: 日常监测 ---');
if (await nav(mini, '/pages/health/daily-monitoring/index')) {
const h = await getPage(mini);
log('日常监测', '页面加载', h.path.includes('daily') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
if (h.page) {
const inputs = await race(h.page.$$('.dm-input'), 3000, 'inputs');
log('日常监测', '表单字段(M6)', !inputs?._err && inputs?.length > 0 ? 'PASS' : 'WARN', `${inputs?.length || 0}`);
const btn = await race(h.page.$('.dm-submit'), 3000, 'btn');
log('日常监测', '提交按钮', !btn?._err && btn ? 'PASS' : 'WARN', btn ? '找到' : '未找到');
}
await back(mini);
}
// ====== 链路5: 积分商城 ======
console.log('\n--- 链路5: 积分商城 ---');
if (await nav(mini, '/pages/mall/index')) {
const h = await getPage(mini);
log('积分商城', '页面加载', h.path.includes('mall') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
if (h.page) {
const pc = await race(h.page.$('.points-card'), 3000, 'points');
log('积分商城', '积分卡片', !pc?._err && pc ? 'PASS' : 'WARN', pc ? '找到' : '未找到');
const cb = await race(h.page.$('.checkin-btn'), 3000, 'checkin');
log('积分商城', '签到按钮', !cb?._err && cb ? 'PASS' : 'WARN', cb ? '找到' : '未找到');
const prods = await race(h.page.$$('.product-card'), 3000, 'prods');
log('积分商城', '商品列表', 'PASS', `${prods?.length || 0}个商品`);
const empty = await race(h.page.$('.empty-state'), 3000, 'empty');
if (!empty?._err && empty) {
log('积分商城', '无档案降级(F2)', 'PASS', '显示降级UI');
}
}
await back(mini);
}
// ====== 链路6: 预约挂号 ======
console.log('\n--- 链路6: 预约挂号 ---');
if (await nav(mini, '/pages/health/appointment/index')) {
const h = await getPage(mini);
log('预约', '页面加载', h.path.includes('appointment') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
await back(mini);
}
// ====== 链路7: 家庭成员 ======
console.log('\n--- 链路7: 家庭成员 ---');
if (await nav(mini, '/pages/profile/family/index')) {
const h = await getPage(mini);
log('家庭成员', '页面加载', h.path.includes('family') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
await back(mini);
}
// ====== 链路8: 咨询 ======
console.log('\n--- 链路8: 咨询 ---');
if (await nav(mini, '/pages/health/consultation/index')) {
const h = await getPage(mini);
log('咨询', '页面加载', h.path.includes('consultation') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
await back(mini);
}
// ====== 链路9: 文章 ======
console.log('\n--- 链路9: 文章 ---');
if (await nav(mini, '/pages/health/articles/index')) {
const h = await getPage(mini);
log('文章', '页面加载', h.path.includes('article') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
await back(mini);
}
// ====== 链路10: 趋势 ======
console.log('\n--- 链路10: 趋势 ---');
if (await nav(mini, '/pages/health/trend/index')) {
const h = await getPage(mini);
log('趋势', '页面加载', h.path.includes('trend') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
await back(mini);
}
// ====== 链路11: 报告 ======
console.log('\n--- 链路11: 报告 ---');
if (await nav(mini, '/pages/health/reports/index')) {
const h = await getPage(mini);
log('报告', '页面加载', h.path.includes('report') ? 'PASS' : 'FAIL', `路径: ${h.path}`);
await back(mini);
}
// ====== API 数据闭环 ======
console.log('\n--- API 数据闭环 ---');
if (token && patients?.[0]) {
const pid = patients[0].id;
const checks = [
['患者详情', 'GET', `/health/patients/${pid}`],
['预约列表', 'GET', '/health/appointments?page=1&page_size=5'],
['咨询列表', 'GET', '/health/consultation-sessions?page=1&page_size=5'],
['日常监测', 'GET', `/health/patients/${pid}/daily-monitoring?page=1&page_size=5`],
['积分账户', 'GET', '/health/points/account'],
['签到状态', 'GET', '/health/points/checkin/status'],
['商品列表', 'GET', '/health/points/products?page=1&page_size=5'],
];
for (const [label, method, path] of checks) {
try {
const r = await api(method, path, null, token);
const ok = r.status === 200;
const detail = r.data?.data?.name ? `${label}: ${r.data.data.name}` :
r.data?.data?.total !== undefined ? `${label}: total=${r.data.data.total}` :
`status=${r.status}`;
log('API闭环', label, ok ? 'PASS' : 'FAIL', detail);
} catch (e) {
log('API闭环', label, 'FAIL', e.message);
}
}
}
// ---- 关闭 ----
try { await mini.disconnect(); } catch {}
try { await mini.close(); } catch {}
// ---- 汇总 ----
console.log('\n\n========================================');
console.log(' HMS 小程序端到端链路验证报告');
console.log('========================================\n');
const chains = [...new Set(results.map(r => r.chain))];
for (const chain of chains) {
const items = results.filter(r => r.chain === chain);
const p = items.filter(r => r.status === 'PASS').length;
const f = items.filter(r => r.status === 'FAIL').length;
const w = items.filter(r => r.status === 'WARN').length;
const icon = f > 0 ? '❌' : w > 0 ? '⚠️' : '✅';
console.log(`${icon} ${chain}: ${p}通过/${f}失败/${w}警告`);
for (const item of items.filter(i => i.status !== 'PASS')) {
console.log(` ${item.status === 'FAIL' ? '❌' : '⚠️'} ${item.step}: ${item.detail}`);
}
}
const tp = results.filter(r => r.status === 'PASS').length;
const tf = results.filter(r => r.status === 'FAIL').length;
const tw = results.filter(r => r.status === 'WARN').length;
console.log(`\n总计: ${results.length}项 — ✅${tp}通过 / ❌${tf}失败 / ⚠️${tw}警告`);
console.log('========================================\n');
process.exit(tf > 0 ? 1 : 0);
}
main().catch(e => { console.error('致命错误:', e); process.exit(1); });

View File

@@ -0,0 +1,84 @@
/**
* 重建后注入明文 token无加密密钥
*/
const automator = require('miniprogram-automator');
const http = require('http');
function getFreshToken() {
return new Promise((resolve, reject) => {
const data = JSON.stringify({ username: 'admin', password: 'Admin@2026' });
const req = http.request({
hostname: 'localhost', port: 3000,
path: '/api/v1/auth/login', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
}, res => {
let body = '';
res.on('data', d => body += d);
res.on('end', () => {
try { const j = JSON.parse(body); resolve(j.data); } catch (e) { reject(e); }
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
async function main() {
console.log('1. 获取 token...');
const loginData = await getFreshToken();
console.log(` access: ${loginData.access_token.length} chars`);
console.log('2. 连接 DevTools...');
const mp = await automator.connect({ wsEndpoint: 'ws://localhost:9420' });
console.log('3. 写入 storage (明文模式)...');
const result = await mp.evaluate((at, rt, ud, ur, tid, pid) => {
try {
// 无加密密钥时 secureSet 走明文
// 但我们直接用 wx.setStorageSync 确保
wx.setStorageSync('access_token', at);
wx.setStorageSync('refresh_token', rt);
wx.setStorageSync('user_data', ud);
wx.setStorageSync('user_roles', ur);
wx.setStorageSync('tenant_id', tid);
wx.setStorageSync('current_patient_id', pid);
wx.setStorageSync('current_patient', {
id: pid, name: 'TestPatient', gender: 'male',
birth_date: '1990-01-15', status: 'active'
});
const v = wx.getStorageSync('access_token');
return 'ok:' + v.length;
} catch(e) { return 'err:' + e.message; }
},
loginData.access_token,
loginData.refresh_token,
JSON.stringify({
id: loginData.user.id,
username: loginData.user.username,
display_name: loginData.user.display_name,
tenant_id: '019d80da-7a2c-7820-b0a3-3d5266a3a324'
}),
JSON.stringify(['admin']),
'019d80da-7a2c-7820-b0a3-3d5266a3a324',
'019dcd34-bc4d-72c1-8c19-77ce1f4839d6'
);
console.log(` 结果: ${result}`);
console.log('4. reLaunch 首页...');
await mp.reLaunch('/pages/index/index');
await new Promise(r => setTimeout(r, 3000));
const page = await mp.currentPage();
console.log(`5. 当前页面: ${page.path}`);
if (page.path === 'pages/index/index') {
console.log('SUCCESS!');
} else {
console.log('FAILED - redirected to:', page.path);
}
await mp.disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -25,8 +25,8 @@
"@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0",
"babel-preset-taro": "^4.2.0",
"crypto-js": "^4.2.0",
"echarts": "^6.0.0",
"echarts-taro3-react": "^1.0.13",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"zod": "^4.3.6",
@@ -36,7 +36,9 @@
"@babel/runtime": "^7.27.0",
"@tarojs/cli": "4.2.0",
"@tarojs/webpack5-runner": "4.2.0",
"@types/crypto-js": "^4.2.2",
"@types/react": "^18.3.0",
"miniprogram-automator": "^0.12.1",
"sass": "^1.87.0",
"typescript": "^5.8.0",
"webpack": "~5.95.0"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"libVersion": "3.15.2",
"projectname": "hsm",
"setting": {
"urlCheck": false,
"coverView": false,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"useApiHook": true,
"showShadowRootInWxmlPanel": false,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"compileHotReLoad": true,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false
}
}

View File

@@ -43,8 +43,8 @@ export default defineAppConfig({
'pages/device-sync/index',
],
tabBar: {
color: '#94A3B8',
selectedColor: '#0891B2',
color: '#A8A29E',
selectedColor: '#C4623A',
backgroundColor: '#FFFFFF',
borderStyle: 'white',
list: [
@@ -56,9 +56,9 @@ export default defineAppConfig({
],
},
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#0891B2',
backgroundTextStyle: 'dark',
navigationBarBackgroundColor: '#FFFFFF',
navigationBarTitleText: '健康管理',
navigationBarTextStyle: 'white',
navigationBarTextStyle: 'black',
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -0,0 +1,98 @@
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import { Canvas, View } from '@tarojs/components';
import Taro from '@tarojs/taro';
import * as echarts from 'echarts/core';
import { LineChart } from 'echarts/charts';
import {
GridComponent,
TooltipComponent,
MarkAreaComponent,
MarkPointComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
LineChart,
GridComponent,
TooltipComponent,
MarkAreaComponent,
MarkPointComponent,
CanvasRenderer,
]);
interface EcCanvasProps {
canvasId: string;
height?: number;
}
export interface EcCanvasRef {
setOption: (option: echarts.EChartsOption) => void;
}
const EcCanvas = forwardRef<EcCanvasRef, EcCanvasProps>(
({ canvasId, height = 300 }, ref) => {
const chartInstance = useRef<echarts.ECharts | null>(null);
const canvasNode = useRef<any>(null);
const initChart = async () => {
try {
const query = Taro.createSelectorQuery();
query
.select(`#${canvasId}`)
.node()
.exec((res) => {
const node = res[0]?.node;
if (!node) return;
canvasNode.current = node;
const dpr = Taro.getSystemInfoSync().pixelRatio;
const width = node.width || 350;
const heightVal = node.height || height;
node.width = width * dpr;
node.height = heightVal * dpr;
const ctx = node.getContext('2d');
chartInstance.current = echarts.init(ctx as any, undefined, {
renderer: 'canvas',
width,
height: heightVal,
devicePixelRatio: dpr,
});
});
} catch (e) {
console.error('EcCanvas init failed:', e);
}
};
useEffect(() => {
initChart();
return () => {
chartInstance.current?.dispose();
};
}, []);
useImperativeHandle(ref, () => ({
setOption: (option: echarts.EChartsOption) => {
if (chartInstance.current) {
chartInstance.current.setOption(option);
}
},
}));
return (
<View style={{ width: '100%', height: `${height}rpx` }}>
<Canvas
type='2d'
id={canvasId}
style={{ width: '100%', height: '100%' }}
/>
</View>
);
},
);
EcCanvas.displayName = 'EcCanvas';
export default EcCanvas;

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import { EChart } from 'echarts-taro3-react';
import EcCanvas from '../EcCanvas';
import type { EcCanvasRef } from '../EcCanvas';
import './index.scss';
interface TrendChartProps {
@@ -11,8 +12,14 @@ interface TrendChartProps {
height?: number;
}
export default function TrendChart({ data, referenceMin, referenceMax, unit = '', height = 500 }: TrendChartProps) {
const chartRef = useRef<any>(null);
export default function TrendChart({
data,
referenceMin,
referenceMax,
unit = '',
height = 500,
}: TrendChartProps) {
const chartRef = useRef<EcCanvasRef>(null);
const getOption = useCallback(() => {
if (!data || data.length === 0) return null;
@@ -21,10 +28,15 @@ export default function TrendChart({ data, referenceMin, referenceMax, unit = ''
const markArea: any = {};
if (referenceMin != null && referenceMax != null) {
markArea.data = [[
{ yAxis: referenceMin, itemStyle: { color: 'rgba(5,150,105,0.08)' } },
{ yAxis: referenceMax },
]];
markArea.data = [
[
{
yAxis: referenceMin,
itemStyle: { color: 'rgba(5,150,105,0.08)' },
},
{ yAxis: referenceMax },
],
];
}
series.push({
@@ -35,17 +47,34 @@ export default function TrendChart({ data, referenceMin, referenceMax, unit = ''
symbolSize: 6,
lineStyle: { color: '#0891B2', width: 2 },
itemStyle: { color: '#0891B2' },
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(8,145,178,0.15)' }, { offset: 1, color: 'rgba(8,145,178,0.01)' }] } },
markArea: markArea.data ? { silent: true, data: markArea.data } : undefined,
markPoint: (referenceMin != null && referenceMax != null) ? {
data: data
.filter((d) => d.value < referenceMin || d.value > referenceMax)
.map((d) => ({
coord: [data.indexOf(d), d.value],
itemStyle: { color: '#DC2626' },
symbolSize: 12,
})),
} : undefined,
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(8,145,178,0.15)' },
{ offset: 1, color: 'rgba(8,145,178,0.01)' },
],
},
},
markArea: markArea.data
? { silent: true, data: markArea.data }
: undefined,
markPoint:
referenceMin != null && referenceMax != null
? {
data: data
.filter((d) => d.value < referenceMin || d.value > referenceMax)
.map((d) => ({
coord: [data.indexOf(d), d.value],
itemStyle: { color: '#DC2626' },
symbolSize: 12,
})),
}
: undefined,
});
return {
@@ -77,7 +106,7 @@ export default function TrendChart({ data, referenceMin, referenceMax, unit = ''
if (chartRef.current && data && data.length > 0) {
const option = getOption();
if (option) {
chartRef.current.refresh(option);
chartRef.current.setOption(option);
}
}
}, [data, getOption]);
@@ -92,7 +121,7 @@ export default function TrendChart({ data, referenceMin, referenceMax, unit = ''
return (
<View className='trend-chart' style={{ height: `${height}rpx` }}>
<EChart canvasId='trend-chart-canvas' ref={chartRef} />
<EcCanvas canvasId='trend-chart-canvas' ref={chartRef} height={height} />
</View>
);
}

View File

@@ -1,23 +1,42 @@
@import '../../../styles/variables.scss';
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
.detail-page {
min-height: 100vh;
background: #f1f5f9;
padding: 16px;
background: $bg;
padding: 24px;
padding-bottom: 40px;
}
.detail-card {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
background: $card;
border-radius: $r;
padding: 28px;
margin-bottom: 20px;
box-shadow: $shadow-sm;
}
.detail-type {
font-size: 18px;
font-weight: 600;
color: #0f172a;
display: block;
margin-bottom: 8px;
@include section-title;
margin-bottom: 12px;
}
.detail-meta {
@@ -26,27 +45,57 @@
}
.meta-item {
font-size: 12px;
color: #94a3b8;
font-size: 22px;
color: $tx3;
}
.content-card {
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: $shadow-sm;
// RichText 内部样式
h1, h2, h3 {
font-weight: bold;
color: $tx;
margin: 24px 0 12px;
}
p {
font-size: 28px;
color: $tx;
line-height: 1.8;
margin-bottom: 16px;
}
ul {
padding-left: 32px;
margin-bottom: 16px;
}
li {
font-size: 28px;
color: $tx;
line-height: 1.8;
margin-bottom: 8px;
}
strong {
color: $pri-d;
}
}
.report-content {
font-size: 14px;
font-size: 28px;
line-height: 1.8;
color: #334155;
color: $tx;
}
.empty-text {
display: block;
text-align: center;
padding: 60px 0;
color: #94a3b8;
font-size: 14px;
padding: 120px 0;
color: $tx3;
font-size: 28px;
}

View File

@@ -1,65 +1,83 @@
@import '../../../styles/variables.scss';
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
.ai-report-page {
min-height: 100vh;
background: #f1f5f9;
padding: 16px;
background: $bg;
padding: 24px;
padding-bottom: 40px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #0f172a;
margin-bottom: 16px;
@include section-title;
}
.report-scroll {
height: calc(100vh - 80px);
height: calc(100vh - 100px);
}
.report-card {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
background: $card;
border-radius: $r;
padding: 28px;
margin-bottom: 20px;
box-shadow: $shadow-sm;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
margin-bottom: 12px;
}
.card-type {
font-size: 15px;
font-size: 28px;
font-weight: 500;
color: #1e293b;
color: $tx;
}
.card-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
@include tag($bd-l, $tx2);
}
.status-completed {
color: #16a34a;
background: #dcfce7;
@include tag($acc-l, $acc);
}
.status-streaming {
color: #2563eb;
background: #dbeafe;
@include tag($pri-l, $pri);
}
.status-failed {
color: #dc2626;
background: #fee2e2;
@include tag($dan-l, $dan);
}
.status-pending {
color: #d97706;
background: #fef3c7;
@include tag($wrn-l, $wrn);
}
.card-footer {
@@ -69,19 +87,19 @@
}
.card-time {
font-size: 12px;
color: #94a3b8;
font-size: 22px;
color: $tx3;
}
.card-model {
font-size: 11px;
color: #cbd5e1;
font-size: 22px;
color: $tx3;
}
.no-more {
text-align: center;
font-size: 12px;
color: #94a3b8;
padding: 16px 0;
font-size: 24px;
color: $tx3;
padding: 24px 0;
display: block;
}

View File

@@ -1,9 +1,10 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.create-page {
min-height: 100vh;
background: $bg;
padding-bottom: 140px;
padding-bottom: 160px;
}
/* 步骤内容 */
@@ -11,6 +12,10 @@
padding: 32px 24px;
}
.step-title {
@include section-title;
}
/* 科室宫格 */
.dept-grid {
display: grid;
@@ -21,33 +26,62 @@
.dept-card {
background: $card;
border-radius: $r;
padding: 24px 12px;
padding: 28px 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
gap: 12px;
border: 2px solid transparent;
transition: border-color 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
&.dept-selected {
border-color: $pri;
background: $pri-l;
}
}
.dept-card.dept-selected {
border-color: $pri;
background: $pri-surface;
.dept-initial-circle {
width: 64px;
height: 64px;
border-radius: $r;
background: $pri-l;
@include flex-center;
.dept-selected & {
background: $pri;
}
}
.dept-icon {
font-size: 40px;
.dept-initial-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $pri;
.dept-selected & {
color: white;
}
}
.dept-label {
font-size: 26px;
font-size: 24px;
color: $tx;
font-weight: 500;
}
/* 时段卡片 */
/* 时段 */
.slot-section {
margin-top: 24px;
margin-top: 32px;
}
.slot-section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $tx;
margin-bottom: 16px;
display: block;
}
.slot-grid {
@@ -59,16 +93,26 @@
.slot-card {
background: $card;
border-radius: $r-sm;
padding: 16px 20px;
padding: 20px 24px;
border: 2px solid transparent;
transition: all 0.2s;
box-shadow: $shadow-sm;
&.slot-few { border-color: $wrn; }
&.slot-full { opacity: 0.5; background: $bd-l; }
&.slot-selected { border-color: $pri; background: $pri-surface; }
&.slot-few {
border-color: $wrn;
}
&.slot-full {
opacity: 0.5;
background: $bd-l;
}
&.slot-selected {
border-color: $pri;
background: $pri-l;
}
}
.slot-time {
@include serif-number;
font-size: 28px;
font-weight: bold;
color: $tx;
@@ -79,43 +123,70 @@
font-size: 22px;
color: $tx3;
display: block;
margin-top: 4px;
margin-top: 6px;
}
.slot-few .slot-count { color: $wrn; }
.slot-full .slot-count { color: $dan; }
.step-title {
font-size: 32px;
font-weight: bold;
color: $tx;
margin-bottom: 28px;
display: block;
}
/* 选择器卡片 */
.picker-card {
/* 确认卡片 (step 3 医生信息) */
.confirm-card {
background: $card;
border-radius: $r;
padding: 24px 28px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
margin-bottom: 24px;
box-shadow: $shadow-sm;
}
.picker-value {
.confirm-row {
display: flex;
align-items: center;
gap: 16px;
}
.confirm-icon-wrap {
width: 56px;
height: 56px;
border-radius: $r-sm;
background: $pri-l;
@include flex-center;
flex-shrink: 0;
}
.confirm-icon-serif {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 24px;
font-weight: bold;
color: $pri;
}
.confirm-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.confirm-label {
font-size: 22px;
color: $tx3;
}
.confirm-value {
font-size: 28px;
font-weight: bold;
color: $tx;
}
.picker-value.placeholder {
color: $tx3;
.confirm-dept-tag {
@include tag($pri-l, $pri);
flex-shrink: 0;
}
.picker-arrow {
font-size: 24px;
color: $tx3;
.confirm-dept-text {
font-size: 20px;
font-weight: 500;
color: $pri;
}
/* 医生列表 */
@@ -132,28 +203,27 @@
display: flex;
align-items: center;
gap: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
border: 2px solid transparent;
transition: border-color 0.2s;
}
.doctor-card.doctor-selected {
border-color: $pri;
background: $pri-surface;
&.doctor-selected {
border-color: $pri;
background: $pri-l;
}
}
.doctor-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
border-radius: $r;
background: $pri-l;
display: flex;
align-items: center;
justify-content: center;
@include flex-center;
flex-shrink: 0;
}
.doctor-avatar-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
color: $pri;
font-weight: bold;
@@ -183,14 +253,23 @@
}
.doctor-check {
font-size: 32px;
color: $pri;
width: 44px;
height: 44px;
border-radius: $r-pill;
background: $pri;
@include flex-center;
flex-shrink: 0;
}
.doctor-check-text {
font-size: 24px;
color: white;
font-weight: bold;
}
/* 表单 */
.form-group {
margin-bottom: 28px;
margin-top: 32px;
}
.form-label {
@@ -200,17 +279,6 @@
display: block;
}
.form-static {
background: $card;
border-radius: $r-sm;
padding: 24px 28px;
}
.form-static-text {
font-size: 28px;
color: $tx;
}
.form-input {
background: $card;
border-radius: $r-sm;
@@ -219,10 +287,11 @@
color: $tx;
width: 100%;
box-sizing: border-box;
box-shadow: $shadow-sm;
}
/* 空状态 */
.empty-state {
.empty-hint {
padding: 80px 0;
text-align: center;
}
@@ -244,7 +313,7 @@
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
background: $card;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
box-shadow: $shadow-md;
}
.btn {
@@ -261,7 +330,7 @@
.btn-next,
.btn-submit {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
background: $pri;
}
.btn-disabled {

View File

@@ -1,9 +1,10 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.detail-page {
min-height: 100vh;
background: $bg;
padding-bottom: 140px;
padding-bottom: 160px;
}
/* 顶部导航 */
@@ -13,7 +14,8 @@
justify-content: space-between;
padding: 32px;
padding-top: 48px;
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
background: $card;
box-shadow: $shadow-sm;
}
.back-btn {
@@ -22,13 +24,15 @@
.back-text {
font-size: 28px;
color: white;
color: $pri;
font-weight: 500;
}
.header-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 34px;
font-weight: bold;
color: white;
color: $tx;
}
.header-placeholder {
@@ -40,46 +44,48 @@
background: $card;
border-radius: $r-lg;
padding: 40px 32px;
margin: -20px 24px 24px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
margin: 20px 24px 24px;
box-shadow: $shadow-md;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.status-badge {
.status-tag {
@include tag($bd-l, $tx3);
margin-bottom: 8px;
padding: 8px 32px;
border-radius: 24px;
margin-bottom: 12px;
.status-badge-text {
font-size: 28px;
font-weight: bold;
}
border-radius: $r-pill;
&.tag-pending {
background: $wrn-l;
.status-badge-text { color: $wrn; }
.status-tag-text { color: $wrn; }
}
&.tag-confirmed {
background: $acc-l;
.status-badge-text { color: $acc; }
.status-tag-text { color: $acc; }
}
&.tag-cancelled {
background: $bd-l;
.status-badge-text { color: $tx3; }
.status-tag-text { color: $tx3; }
}
&.tag-completed {
background: $pri-l;
.status-badge-text { color: $pri; }
.status-tag-text { color: $pri; }
}
}
.status-tag-text {
font-size: 28px;
font-weight: bold;
}
.status-doctor {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 36px;
font-weight: bold;
color: $tx;
@@ -96,22 +102,19 @@
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
}
.section-title {
font-size: 30px;
font-weight: bold;
color: $tx;
@include section-title;
margin-bottom: 24px;
display: block;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
padding: 18px 0;
border-bottom: 1px solid $bd-l;
}
@@ -119,6 +122,26 @@
border-bottom: none;
}
.info-label-wrap {
display: flex;
align-items: center;
gap: 10px;
}
.info-icon-serif {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 20px;
color: $pri;
background: $pri-l;
width: 36px;
height: 36px;
border-radius: $r-sm;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.info-label {
font-size: 26px;
color: $tx2;
@@ -130,7 +153,16 @@
font-weight: 500;
}
.info-date {
@include serif-number;
}
.info-time {
@include serif-number;
}
.info-id {
@include serif-number;
font-size: 22px;
color: $tx3;
word-break: break-all;
@@ -147,7 +179,8 @@
}
.tips-title {
font-size: 26px;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $wrn;
margin-bottom: 12px;
@@ -170,7 +203,7 @@
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
background: $card;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
box-shadow: $shadow-md;
}
.cancel-btn {
@@ -190,27 +223,3 @@
font-weight: bold;
color: $dan;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120px 0;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
.empty-text {
font-size: 30px;
color: $tx2;
margin-bottom: 12px;
}
.empty-hint {
font-size: 24px;
color: $tx3;
}

View File

@@ -1,26 +1,36 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
.appointment-page {
min-height: 100vh;
background: $bg;
padding-bottom: 140px;
padding-bottom: 160px;
}
/* 页头 */
.page-header {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
padding: 32px;
padding-top: 48px;
background: $card;
padding: 48px 32px 36px;
box-shadow: $shadow-sm;
}
.page-title {
@include section-title;
margin-bottom: 4px;
font-size: 36px;
font-weight: bold;
color: white;
}
.page-subtitle {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 22px;
color: $tx3;
letter-spacing: 1px;
}
/* 预约列表 */
.appointment-list {
padding: 0 24px;
margin-top: -16px;
margin-top: 16px;
}
.appointment-card {
@@ -28,7 +38,7 @@
border-radius: $r;
padding: 28px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
box-shadow: $shadow-sm;
}
.card-top {
@@ -38,84 +48,117 @@
margin-bottom: 20px;
}
.doctor-info {
.doctor-section {
display: flex;
align-items: center;
gap: 12px;
gap: 16px;
flex: 1;
min-width: 0;
}
.dept-initial {
width: 72px;
height: 72px;
border-radius: $r;
background: $pri-l;
@include flex-center;
flex-shrink: 0;
}
.dept-initial-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
font-weight: bold;
color: $pri;
}
.doctor-info {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.doctor-name {
font-size: 32px;
font-size: 30px;
font-weight: bold;
color: $tx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.department {
font-size: 24px;
.dept-tag {
@include tag($pri-l, $pri);
}
.dept-tag-text {
font-size: 20px;
font-weight: 500;
color: $pri;
background: $pri-l;
padding: 4px 16px;
border-radius: 20px;
}
.status-tag {
padding: 6px 20px;
border-radius: 20px;
.status-tag-text {
font-size: 22px;
font-weight: 500;
}
@include tag($bd-l, $tx3);
flex-shrink: 0;
&.tag-pending {
background: $wrn-l;
.status-tag-text {
color: $wrn;
}
.status-tag-text { color: $wrn; }
}
&.tag-confirmed {
background: $acc-l;
.status-tag-text {
color: $acc;
}
.status-tag-text { color: $acc; }
}
&.tag-cancelled {
background: $bd-l;
.status-tag-text {
color: $tx3;
}
.status-tag-text { color: $tx3; }
}
&.tag-completed {
background: $pri-l;
.status-tag-text {
color: $pri;
}
.status-tag-text { color: $pri; }
}
}
.status-tag-text {
font-size: 20px;
font-weight: 500;
}
.card-divider {
height: 1px;
background: $bd-l;
margin-bottom: 20px;
}
.card-bottom {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 20px;
border-top: 1px solid $bd-l;
gap: 14px;
}
.info-row {
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
}
.info-icon {
font-size: 26px;
.info-icon-wrap {
width: 40px;
height: 40px;
border-radius: $r-sm;
background: $bd-l;
@include flex-center;
flex-shrink: 0;
}
.info-icon-serif {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 22px;
color: $tx2;
}
.info-text {
@@ -123,49 +166,22 @@
color: $tx2;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120px 0 80px;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
.empty-text {
font-size: 30px;
color: $tx2;
margin-bottom: 12px;
}
.empty-hint {
font-size: 24px;
color: $tx3;
}
.loading-tip {
text-align: center;
padding: 24px 0;
}
.loading-text {
font-size: 24px;
color: $tx3;
.info-time {
@include serif-number;
color: $tx;
font-weight: 500;
}
/* 底部悬浮按钮 */
.fab-btn {
position: fixed;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
padding: 24px 64px;
border-radius: 48px;
box-shadow: 0 8px 24px rgba($pri, 0.4);
background: $pri;
padding: 24px 72px;
border-radius: $r-pill;
box-shadow: 0 8px 24px rgba($pri, 0.3);
z-index: 100;
}
@@ -173,4 +189,5 @@
font-size: 30px;
color: white;
font-weight: bold;
letter-spacing: 2px;
}

View File

@@ -65,20 +65,20 @@
// RichText 内部样式优化
h1, h2, h3 {
font-weight: bold;
color: #134E4A;
color: $tx;
margin: 24px 0 12px;
}
p {
font-size: 28px;
color: #134E4A;
color: $tx;
line-height: 1.8;
margin-bottom: 16px;
}
img {
max-width: 100%;
border-radius: 8px;
border-radius: $r-sm;
margin: 12px 0;
}
}

View File

@@ -41,7 +41,7 @@
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
}
.article-card-body {

View File

@@ -1,4 +1,5 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.chat-page {
display: flex;
@@ -13,9 +14,10 @@
align-items: center;
padding: 24px 32px;
background: $card;
border-bottom: 1px solid #e2e8f0;
border-bottom: 1px solid $bd;
&__title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: 600;
color: $tx;
@@ -45,17 +47,17 @@
.msg-bubble {
max-width: 70%;
padding: 20px 24px;
border-radius: 16px;
border-radius: $r-lg;
position: relative;
&--other {
background: $card;
border-top-left-radius: 4px;
border-top-left-radius: $r-sm;
}
&--self {
background: $pri;
border-top-right-radius: 4px;
border-top-right-radius: $r-sm;
}
}
@@ -98,14 +100,14 @@
align-items: center;
padding: 16px 24px;
background: $card;
border-top: 1px solid #e2e8f0;
border-top: 1px solid $bd;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
.chat-input {
flex: 1;
background: $bg;
border-radius: 12px;
border-radius: $r;
padding: 16px 20px;
font-size: 28px;
margin-right: 16px;
@@ -113,7 +115,7 @@
.chat-send-btn {
background: $pri;
border-radius: 12px;
border-radius: $r;
padding: 16px 28px;
flex-shrink: 0;
@@ -132,7 +134,7 @@
padding: 24px;
text-align: center;
background: $card;
border-top: 1px solid #e2e8f0;
border-top: 1px solid $bd;
&__text {
font-size: 26px;

View File

@@ -1,48 +1,47 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
.consultation-page {
min-height: 100vh;
background: $bg;
}
/* ─── 页头 ─── */
.consultation-header {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
padding: 32px;
padding-top: 48px;
color: white;
padding: 48px 32px 36px;
color: #fff;
}
.consultation-header-title {
font-size: 36px;
.consultation-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 40px;
font-weight: bold;
color: white;
color: #fff;
display: block;
margin-bottom: 8px;
}
.consultation-header-desc {
.consultation-subtitle {
font-size: 24px;
color: rgba(255, 255, 255, 0.8);
color: rgba(255, 255, 255, 0.75);
display: block;
}
// ---- Loading / Error / Empty ----
.consultation-loading {
padding: 120px 0;
}
.consultation-error {
/* ─── 居中容器 ─── */
.consultation-center {
display: flex;
justify-content: center;
align-items: center;
padding: 120px 40px;
}
.consultation-error-text {
.consultation-error {
font-size: 26px;
color: $dan;
}
/* ─── 空状态 ─── */
.consultation-empty {
display: flex;
flex-direction: column;
@@ -51,46 +50,56 @@
padding: 160px 40px;
}
.consultation-empty-icon {
font-size: 100px;
.empty-icon {
width: 120px;
height: 120px;
border-radius: 50%;
background: $pri-l;
@include flex-center;
margin-bottom: 32px;
}
.consultation-empty-text {
font-size: 36px;
.empty-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 52px;
font-weight: bold;
color: $tx;
margin-bottom: 16px;
color: $pri;
line-height: 1;
}
.consultation-empty-hint {
.empty-title {
font-size: 32px;
font-weight: 600;
color: $tx;
margin-bottom: 12px;
}
.empty-hint {
font-size: 26px;
color: $tx3;
text-align: center;
}
// ---- Session List ----
.consultation-list {
padding: 16px 24px;
/* ─── 会话列表 ─── */
.session-list {
padding: 20px 24px;
}
.consultation-session {
.session-card {
display: flex;
align-items: center;
background: $card;
border-radius: $r;
padding: 24px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: transform 0.15s;
box-shadow: $shadow-sm;
&:active {
transform: scale(0.98);
opacity: 0.7;
}
}
.session-left {
.session-main {
flex: 1;
min-width: 0;
}
@@ -105,7 +114,7 @@
.session-subject {
font-size: 28px;
color: $tx;
font-weight: bold;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -113,24 +122,10 @@
margin-right: 12px;
}
.session-status-active {
font-size: 22px;
color: $acc;
font-weight: 500;
white-space: nowrap;
}
.session-status-pending {
font-size: 22px;
color: $wrn;
font-weight: 500;
white-space: nowrap;
}
.session-status-closed {
font-size: 22px;
color: $tx3;
white-space: nowrap;
.session-tag {
&.tag-ok { @include tag($acc-l, $acc); }
&.tag-warn { @include tag($wrn-l, $wrn); }
&.tag-default { @include tag($bd-l, $tx2); }
}
.session-message {
@@ -149,23 +144,20 @@
display: block;
}
// ---- Unread Badge ----
/* ─── 未读角标 ─── */
.session-badge {
background: $dan;
border-radius: 999px;
border-radius: $r-pill;
min-width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
@include flex-center;
padding: 0 10px;
margin-left: 12px;
flex-shrink: 0;
}
.session-badge-text {
font-size: 22px;
color: white;
font-weight: bold;
color: #fff;
font-weight: 600;
}

View File

@@ -1,87 +1,129 @@
@import '../../styles/variables.scss';
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.device-sync-page {
min-height: 100vh;
background: #F1F5F9;
background: $bg;
padding-bottom: env(safe-area-inset-bottom);
}
.sync-header {
background: linear-gradient(135deg, #0891B2, #0E7490);
padding: 48px 24px 24px;
color: #fff;
background: $pri;
padding: 48px 32px 32px;
color: $card;
}
.sync-header-title {
font-size: 20px;
font-weight: 600;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 40px;
font-weight: bold;
}
.sync-section {
padding: 16px;
padding: 24px;
}
.sync-hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 16px;
background: #fff;
border-radius: 12px;
margin-bottom: 16px;
padding: 48px 24px;
background: $card;
border-radius: $r;
margin-bottom: 24px;
box-shadow: $shadow-sm;
}
.sync-hero-icon {
font-size: 48px;
margin-bottom: 12px;
width: 80px;
height: 80px;
border-radius: 50%;
background: $pri-l;
@include flex-center;
margin-bottom: 20px;
color: $pri;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 36px;
font-weight: bold;
}
.sync-hero-title {
font-size: 18px;
font-weight: 600;
color: #1E293B;
margin-bottom: 4px;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 34px;
font-weight: bold;
color: $tx;
margin-bottom: 8px;
}
.sync-hero-desc {
font-size: 13px;
color: #64748B;
font-size: 26px;
color: $tx2;
}
.sync-action {
display: flex;
align-items: center;
justify-content: center;
background: #0891B2;
border-radius: 8px;
padding: 12px 24px;
margin: 8px 0;
}
@include flex-center;
background: $pri;
border-radius: $r-sm;
padding: 20px 40px;
margin: 12px 0;
.sync-action--primary {
flex: 1;
background: #0891B2;
}
&--primary {
flex: 1;
background: $pri;
}
.sync-action--danger {
flex: 1;
background: #EF4444;
margin-left: 12px;
&--danger {
flex: 1;
background: $dan;
margin-left: 16px;
}
}
.sync-action-text {
color: #fff;
font-size: 15px;
color: $card;
font-size: 28px;
font-weight: 500;
}
.sync-device-list {
margin-top: 12px;
margin-top: 16px;
}
.sync-section-title {
font-size: 14px;
font-weight: 600;
color: #475569;
margin-bottom: 8px;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $tx;
margin-bottom: 12px;
display: block;
}
@@ -89,10 +131,11 @@
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 8px;
background: $card;
border-radius: $r-sm;
padding: 24px;
margin-bottom: 12px;
box-shadow: $shadow-sm;
}
.sync-device-info {
@@ -101,135 +144,149 @@
}
.sync-device-name {
font-size: 15px;
font-size: 28px;
font-weight: 500;
color: #1E293B;
color: $tx;
}
.sync-device-adapter {
font-size: 12px;
color: #94A3B8;
margin-top: 2px;
font-size: 22px;
color: $tx3;
margin-top: 4px;
}
.sync-device-rssi {
font-size: 12px;
color: #64748B;
font-size: 22px;
color: $tx2;
}
.sync-status-card {
display: flex;
align-items: center;
background: #fff;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 12px;
background: $card;
border-radius: $r-sm;
padding: 24px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
}
.sync-status-dot {
width: 8px;
height: 8px;
border-radius: 4px;
margin-right: 8px;
background: #94A3B8;
}
width: 16px;
height: 16px;
border-radius: 50%;
margin-right: 16px;
background: $tx3;
.sync-status-dot--connected {
background: #22C55E;
&--connected {
background: $acc;
}
}
.sync-status-text {
font-size: 14px;
color: #1E293B;
font-size: 28px;
color: $tx;
}
.sync-readings-panel {
background: #fff;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 12px;
background: $card;
border-radius: $r-sm;
padding: 24px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
}
.sync-reading-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #F1F5F9;
padding: 12px 0;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
}
.sync-reading-type {
font-size: 13px;
color: #64748B;
font-size: 26px;
color: $tx2;
}
.sync-reading-value {
font-size: 15px;
font-weight: 600;
color: #0891B2;
font-size: 28px;
font-weight: bold;
color: $pri;
@include serif-number;
}
.sync-readings-count {
display: block;
margin-top: 8px;
font-size: 12px;
color: #94A3B8;
margin-top: 12px;
font-size: 22px;
color: $tx3;
text-align: center;
}
.sync-actions-row {
display: flex;
gap: 8px;
gap: 12px;
}
.sync-error {
margin: 16px;
padding: 12px 16px;
background: #FEF2F2;
border-radius: 8px;
border: 1px solid #FECACA;
margin: 24px;
padding: 20px 24px;
background: $dan-l;
border-radius: $r-sm;
}
.sync-error-text {
font-size: 13px;
color: #DC2626;
font-size: 26px;
color: $dan;
}
.sync-loading {
display: flex;
justify-content: center;
padding: 48px 16px;
@include flex-center;
padding: 64px 24px;
}
.sync-loading-text {
font-size: 14px;
color: #64748B;
font-size: 28px;
color: $tx2;
}
.sync-result-card {
display: flex;
flex-direction: column;
align-items: center;
background: #fff;
border-radius: 12px;
padding: 32px 16px;
margin-bottom: 16px;
background: $card;
border-radius: $r;
padding: 48px 24px;
margin-bottom: 24px;
box-shadow: $shadow-sm;
}
.sync-result-icon {
font-size: 40px;
color: #22C55E;
margin-bottom: 8px;
width: 80px;
height: 80px;
border-radius: 50%;
background: $acc-l;
@include flex-center;
color: $acc;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 36px;
font-weight: bold;
margin-bottom: 16px;
}
.sync-result-title {
font-size: 18px;
font-weight: 600;
color: #1E293B;
margin-bottom: 4px;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 34px;
font-weight: bold;
color: $tx;
margin-bottom: 8px;
}
.sync-result-count {
font-size: 13px;
color: #64748B;
font-size: 26px;
color: $tx2;
}

View File

@@ -104,7 +104,7 @@ export default function DeviceSync() {
const renderIdle = () => (
<View className="sync-section">
<View className="sync-hero">
<Text className="sync-hero-icon"></Text>
<Text className="sync-hero-icon">D</Text>
<Text className="sync-hero-title"></Text>
<Text className="sync-hero-desc"></Text>
</View>
@@ -176,7 +176,7 @@ export default function DeviceSync() {
const renderDone = () => (
<View className="sync-section">
<View className="sync-result-card">
<Text className="sync-result-icon"></Text>
<Text className="sync-result-icon">V</Text>
<Text className="sync-result-title"></Text>
<Text className="sync-result-count"> {syncCount} </Text>
</View>

View File

@@ -1,8 +1,11 @@
@import '../../../../styles/variables.scss';
@import '../../../../styles/mixins.scss';
.chat-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #f0f4f8;
background: $bg;
}
.chat-header {
@@ -10,18 +13,19 @@
justify-content: space-between;
align-items: center;
padding: 24px 32px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
background: $card;
border-bottom: 1px solid $bd;
&__title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: 600;
color: #0f172a;
color: $tx;
}
&__close {
font-size: 26px;
color: #ef4444;
color: $dan;
padding: 8px 16px;
}
}
@@ -44,35 +48,36 @@
.msg-bubble {
max-width: 70%;
padding: 20px 24px;
border-radius: 16px;
border-radius: $r-lg;
position: relative;
&--other {
background: #fff;
background: $card;
border-top-left-radius: 4px;
}
&--self {
background: #0891b2;
background: $pri;
border-top-right-radius: 4px;
}
}
.msg-text {
font-size: 28px;
color: #0f172a;
color: $tx;
display: block;
line-height: 1.6;
word-break: break-all;
.msg-bubble--self & {
color: #fff;
color: $card;
}
}
.msg-time {
@include serif-number;
font-size: 20px;
color: #94a3b8;
color: $tx3;
display: block;
margin-top: 8px;
text-align: right;
@@ -88,7 +93,7 @@
&__text {
font-size: 26px;
color: #94a3b8;
color: $tx3;
}
}
@@ -96,23 +101,23 @@
display: flex;
align-items: center;
padding: 16px 24px;
background: #fff;
border-top: 1px solid #e2e8f0;
background: $card;
border-top: 1px solid $bd;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
.chat-input {
flex: 1;
background: #f1f5f9;
border-radius: 12px;
background: $bd-l;
border-radius: $r;
padding: 16px 20px;
font-size: 28px;
margin-right: 16px;
}
.chat-send-btn {
background: #0891b2;
border-radius: 12px;
background: $pri;
border-radius: $r;
padding: 16px 28px;
flex-shrink: 0;
@@ -122,7 +127,7 @@
&__text {
font-size: 28px;
color: #fff;
color: $card;
font-weight: 500;
}
}
@@ -130,11 +135,11 @@
.chat-closed-bar {
padding: 24px;
text-align: center;
background: #fff;
border-top: 1px solid #e2e8f0;
background: $card;
border-top: 1px solid $bd;
&__text {
font-size: 26px;
color: #94a3b8;
color: $tx3;
}
}

View File

@@ -1,13 +1,16 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.consultation-page {
min-height: 100vh;
background: #f0f4f8;
background: $bg;
}
.tabs {
display: flex;
background: #fff;
background: $card;
padding: 0 16px;
border-bottom: 1px solid #e2e8f0;
border-bottom: 1px solid $bd;
}
.tab {
@@ -15,11 +18,11 @@
text-align: center;
padding: 24px 0;
font-size: 28px;
color: #64748b;
color: $tx2;
position: relative;
&--active {
color: #0891b2;
color: $pri;
font-weight: 600;
&::after {
@@ -29,7 +32,7 @@
left: 30%;
right: 30%;
height: 4px;
background: #0891b2;
background: $pri;
border-radius: 2px;
}
}
@@ -43,14 +46,14 @@
}
.session-card {
background: #fff;
border-radius: 16px;
background: $card;
border-radius: $r-lg;
padding: 28px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
box-shadow: $shadow-sm;
position: relative;
&:active {
background: #f8fafc;
background: $bd-l;
}
&__top {
@@ -63,7 +66,7 @@
&__subject {
font-size: 28px;
font-weight: 600;
color: #0f172a;
color: $tx;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
@@ -72,8 +75,7 @@
}
&__status {
padding: 4px 14px;
border-radius: 12px;
@include tag(transparent, $tx2);
flex-shrink: 0;
}
@@ -90,21 +92,17 @@
}
&__type {
font-size: 24px;
color: #0891b2;
background: #e0f2fe;
padding: 2px 12px;
border-radius: 8px;
@include tag($pri-l, $pri);
}
&__time {
font-size: 24px;
color: #94a3b8;
color: $tx3;
}
&__preview {
font-size: 26px;
color: #64748b;
color: $tx2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -117,17 +115,16 @@
right: 20px;
min-width: 36px;
height: 36px;
background: #ef4444;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
background: $dan;
border-radius: $r-pill;
@include flex-center;
padding: 0 8px;
}
&__badge-text {
@include serif-number;
font-size: 22px;
color: #fff;
color: $card;
font-weight: 600;
}
}
@@ -141,16 +138,16 @@
&__btn {
font-size: 26px;
color: #0891b2;
color: $pri;
padding: 12px 24px;
&.disabled {
color: #cbd5e1;
color: $tx3;
}
}
&__info {
font-size: 24px;
color: #64748b;
color: $tx2;
}
}

View File

@@ -1,24 +1,23 @@
@import '../../../../styles/variables.scss';
@import '../../../../styles/mixins.scss';
.followup-detail {
min-height: 100vh;
background: #f0f4f8;
background: $bg;
padding: 24px;
padding-bottom: 120px;
}
.section {
background: #fff;
border-radius: 16px;
background: $card;
border-radius: $r-lg;
padding: 28px;
margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
box-shadow: $shadow-sm;
}
.section-title {
font-size: 28px;
font-weight: 600;
color: #0f172a;
display: block;
margin-bottom: 20px;
@include section-title;
}
.task-header {
@@ -28,22 +27,23 @@
margin-bottom: 20px;
&__title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: 700;
color: #0f172a;
color: $tx;
}
&__status {
font-size: 24px;
padding: 6px 16px;
border-radius: 12px;
border-radius: $r;
font-weight: 500;
&--pending { background: #fef3c7; color: #b45309; }
&--in_progress { background: #e0f2fe; color: #0369a1; }
&--completed { background: #dcfce7; color: #16a34a; }
&--overdue { background: #fee2e2; color: #dc2626; }
&--cancelled { background: #f1f5f9; color: #94a3b8; }
&--pending { background: $wrn-l; color: $wrn; }
&--in_progress { background: $pri-l; color: $pri; }
&--completed { background: $acc-l; color: $acc; }
&--overdue { background: $dan-l; color: $dan; }
&--cancelled { background: $bd-l; color: $tx3; }
}
}
@@ -61,38 +61,38 @@
.info-label {
font-size: 22px;
color: #94a3b8;
color: $tx3;
}
.info-value {
font-size: 26px;
color: #0f172a;
color: $tx;
font-weight: 500;
}
.task-template {
margin-top: 16px;
padding: 16px;
background: #f8fafc;
border-radius: 12px;
background: $bd-l;
border-radius: $r;
&__label {
font-size: 22px;
color: #64748b;
color: $tx2;
display: block;
margin-bottom: 8px;
}
&__text {
font-size: 26px;
color: #334155;
color: $tx;
line-height: 1.6;
}
}
.record-item {
padding: 20px 0;
border-bottom: 1px solid #f1f5f9;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
@@ -100,14 +100,14 @@
&__date {
font-size: 22px;
color: #94a3b8;
color: $tx3;
display: block;
margin-bottom: 8px;
}
&__text {
font-size: 26px;
color: #334155;
color: $tx;
display: block;
margin-bottom: 4px;
line-height: 1.5;
@@ -117,10 +117,10 @@
.start-btn {
text-align: center;
padding: 16px;
background: #0891b2;
border-radius: 12px;
background: $pri;
border-radius: $r;
margin-bottom: 24px;
color: #fff;
color: $card;
font-size: 28px;
font-weight: 500;
}
@@ -131,7 +131,7 @@
.form-label {
font-size: 26px;
color: #475569;
color: $tx2;
font-weight: 500;
display: block;
margin-bottom: 12px;
@@ -140,11 +140,11 @@
.form-textarea {
width: 100%;
min-height: 160px;
background: #f8fafc;
border-radius: 12px;
background: $bd-l;
border-radius: $r;
padding: 16px 20px;
font-size: 26px;
color: #0f172a;
color: $tx;
box-sizing: border-box;
line-height: 1.6;
}
@@ -152,16 +152,16 @@
.form-date {
width: 100%;
padding: 16px 20px;
background: #f8fafc;
border-radius: 12px;
background: $bd-l;
border-radius: $r;
font-size: 26px;
color: #0f172a;
color: $tx;
box-sizing: border-box;
}
.submit-btn {
background: #0891b2;
border-radius: 12px;
background: $pri;
border-radius: $r;
padding: 20px;
text-align: center;
margin-top: 16px;
@@ -172,7 +172,7 @@
&__text {
font-size: 28px;
color: #fff;
color: $card;
font-weight: 600;
}
}
@@ -180,6 +180,6 @@
.error-text {
text-align: center;
padding: 80px 32px;
color: #94a3b8;
color: $tx3;
font-size: 28px;
}

View File

@@ -1,13 +1,16 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.followup-page {
min-height: 100vh;
background: #f0f4f8;
background: $bg;
}
.tabs {
display: flex;
background: #fff;
background: $card;
padding: 0 12px;
border-bottom: 1px solid #e2e8f0;
border-bottom: 1px solid $bd;
overflow-x: auto;
white-space: nowrap;
}
@@ -16,12 +19,12 @@
display: inline-block;
padding: 24px 16px;
font-size: 26px;
color: #64748b;
color: $tx2;
position: relative;
flex-shrink: 0;
&--active {
color: #0891b2;
color: $pri;
font-weight: 600;
&::after {
@@ -31,7 +34,7 @@
left: 20%;
right: 20%;
height: 4px;
background: #0891b2;
background: $pri;
border-radius: 2px;
}
}
@@ -42,7 +45,7 @@
text {
font-size: 24px;
color: #94a3b8;
color: $tx3;
}
}
@@ -54,13 +57,13 @@
}
.task-card {
background: #fff;
border-radius: 16px;
background: $card;
border-radius: $r-lg;
padding: 28px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
box-shadow: $shadow-sm;
&:active {
background: #f8fafc;
background: $bd-l;
}
&__header {
@@ -71,21 +74,21 @@
}
&__type {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: 600;
color: #0f172a;
color: $tx;
}
&__status {
padding: 4px 14px;
border-radius: 12px;
@include tag(transparent, $tx2);
font-size: 22px;
font-weight: 500;
}
&__patient {
font-size: 26px;
color: #475569;
color: $tx2;
display: block;
margin-bottom: 8px;
}
@@ -97,6 +100,6 @@
&__date {
font-size: 24px;
color: #94a3b8;
color: $tx3;
}
}

View File

@@ -1,6 +1,9 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
.doctor-home {
min-height: 100vh;
background: #f0f4f8;
background: $bg;
padding: 32px;
padding-bottom: 120px;
@@ -9,23 +12,21 @@
}
&__title {
@include section-title;
font-size: 40px;
font-weight: 700;
color: #0f172a;
display: block;
margin-bottom: 12px;
}
&__greeting {
font-size: 28px;
color: #64748b;
color: $tx2;
display: block;
margin-bottom: 8px;
}
&__date {
font-size: 24px;
color: #94a3b8;
color: $tx3;
}
&__section {
@@ -33,11 +34,7 @@
}
&__section-title {
font-size: 30px;
font-weight: 600;
color: #334155;
display: block;
margin-bottom: 24px;
@include section-title;
}
&__grid {
@@ -47,11 +44,11 @@
}
&__card {
background: #fff;
border-radius: 16px;
background: $card;
border-radius: $r-lg;
padding: 28px 24px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
box-shadow: $shadow-md;
transition: transform 0.15s;
&:active {
@@ -59,23 +56,32 @@
}
}
&__card-icon {
font-size: 36px;
display: block;
&__card-initial {
display: inline-flex;
@include flex-center;
width: 56px;
height: 56px;
border-radius: $r;
background: $pri-l;
color: $pri;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
}
&__card-num {
@include serif-number;
font-size: 48px;
font-weight: 700;
color: #0f172a;
color: $tx;
display: block;
margin-bottom: 8px;
}
&__card-label {
font-size: 24px;
color: #64748b;
color: $tx2;
}
&__quick-actions {
@@ -90,7 +96,7 @@
}
&__logout {
color: #ef4444;
color: $dan;
font-size: 28px;
padding: 16px 48px;
display: inline-block;
@@ -99,24 +105,33 @@
.quick-action {
flex: 1;
background: #fff;
border-radius: 16px;
background: $card;
border-radius: $r-lg;
padding: 28px 20px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
box-shadow: $shadow-md;
&:active {
opacity: 0.8;
}
&__icon {
font-size: 40px;
display: block;
&__initial {
display: inline-flex;
@include flex-center;
width: 56px;
height: 56px;
border-radius: $r;
background: $acc-l;
color: $acc;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
}
&__label {
font-size: 24px;
color: #475569;
color: $tx2;
display: block;
}
}

View File

@@ -9,22 +9,26 @@ import './index.scss';
interface CardConfig {
key: keyof doctorApi.DoctorDashboard;
label: string;
icon: string;
initial: string;
route: string;
color: string;
}
const CARDS: CardConfig[] = [
{ key: 'total_patients', label: '我的患者', icon: '👥', route: '/pages/doctor/patients/index', color: '#0891b2' },
{ key: 'unread_messages', label: '未读消息', icon: '💬', route: '/pages/doctor/consultation/index', color: '#f59e0b' },
{ key: 'pending_follow_ups', label: '待处理随访', icon: '📋', route: '/pages/doctor/followup/index', color: '#8b5cf6' },
{ key: 'today_consultations', label: '今日咨询', icon: '🩺', route: '/pages/doctor/consultation/index', color: '#10b981' },
{ key: 'total_patients', label: '我的患者', initial: '', route: '/pages/doctor/patients/index' },
{ key: 'unread_messages', label: '未读消息', initial: '', route: '/pages/doctor/consultation/index' },
{ key: 'pending_follow_ups', label: '待处理随访', initial: '', route: '/pages/doctor/followup/index' },
{ key: 'today_consultations', label: '今日咨询', initial: '', route: '/pages/doctor/consultation/index' },
];
const HEALTH_CARDS: CardConfig[] = [
{ key: 'pending_dialysis_review', label: '待审透析', icon: '💧', route: '/pages/doctor/patients/index', color: '#0ea5e9' },
{ key: 'pending_lab_review', label: '待审化验', icon: '🔬', route: '/pages/doctor/report/index', color: '#f43f5e' },
{ key: 'today_appointments', label: '今日预约', icon: '📅', route: '/pages/doctor/patients/index', color: '#14b8a6' },
{ key: 'pending_dialysis_review', label: '待审透析', initial: '', route: '/pages/doctor/patients/index' },
{ key: 'pending_lab_review', label: '待审化验', initial: '', route: '/pages/doctor/report/index' },
{ key: 'today_appointments', label: '今日预约', initial: '', route: '/pages/doctor/patients/index' },
];
const QUICK_ACTIONS = [
{ label: '化验审核', initial: '审', route: '/pages/doctor/report/index' },
{ label: '患者查询', initial: '查', route: '/pages/doctor/patients/index' },
];
export default function DoctorHome() {
@@ -81,10 +85,9 @@ export default function DoctorHome() {
<View
key={card.key}
className='doctor-home__card'
style={`border-left: 6px solid ${card.color}`}
onClick={() => handleCardClick(card)}
>
<Text className='doctor-home__card-icon'>{card.icon}</Text>
<Text className='doctor-home__card-initial'>{card.initial}</Text>
<Text className='doctor-home__card-num'>{getValue(card.key)}</Text>
<Text className='doctor-home__card-label'>{card.label}</Text>
</View>
@@ -99,10 +102,9 @@ export default function DoctorHome() {
<View
key={card.key}
className='doctor-home__card'
style={`border-left: 6px solid ${card.color}`}
onClick={() => handleCardClick(card)}
>
<Text className='doctor-home__card-icon'>{card.icon}</Text>
<Text className='doctor-home__card-initial'>{card.initial}</Text>
<Text className='doctor-home__card-num'>{getValue(card.key)}</Text>
<Text className='doctor-home__card-label'>{card.label}</Text>
</View>
@@ -113,14 +115,16 @@ export default function DoctorHome() {
<View className='doctor-home__section'>
<Text className='doctor-home__section-title'></Text>
<View className='doctor-home__quick-actions'>
<View className='quick-action' onClick={() => Taro.navigateTo({ url: '/pages/doctor/report/index' })}>
<Text className='quick-action__icon'>📊</Text>
<Text className='quick-action__label'></Text>
</View>
<View className='quick-action' onClick={() => Taro.navigateTo({ url: '/pages/doctor/patients/index' })}>
<Text className='quick-action__icon'>🔍</Text>
<Text className='quick-action__label'></Text>
</View>
{QUICK_ACTIONS.map((action) => (
<View
key={action.route}
className='quick-action'
onClick={() => Taro.navigateTo({ url: action.route })}
>
<Text className='quick-action__initial'>{action.initial}</Text>
<Text className='quick-action__label'>{action.label}</Text>
</View>
))}
</View>
</View>

View File

@@ -1,24 +1,23 @@
@import '../../../../styles/variables.scss';
@import '../../../../styles/mixins.scss';
.patient-detail {
min-height: 100vh;
background: #f0f4f8;
background: $bg;
padding: 24px;
padding-bottom: 120px;
}
.section {
background: #fff;
border-radius: 16px;
background: $card;
border-radius: $r-lg;
padding: 28px;
margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
box-shadow: $shadow-sm;
}
.section-title {
font-size: 28px;
font-weight: 600;
color: #0f172a;
display: block;
margin-bottom: 20px;
@include section-title;
}
.info-grid {
@@ -35,25 +34,25 @@
.info-label {
font-size: 22px;
color: #94a3b8;
color: $tx3;
}
.info-value {
font-size: 28px;
color: #0f172a;
color: $tx;
font-weight: 500;
}
.warning-card {
background: #fef3c7;
border-radius: 12px;
background: $wrn-l;
border-radius: $r;
padding: 20px;
margin-bottom: 16px;
}
.warning-label {
font-size: 22px;
color: #b45309;
color: $wrn;
font-weight: 600;
display: block;
margin-bottom: 8px;
@@ -61,7 +60,7 @@
.warning-text {
font-size: 26px;
color: #92400e;
color: $pri-d;
}
.info-block {
@@ -70,14 +69,14 @@
.info-block-label {
font-size: 22px;
color: #94a3b8;
color: $tx3;
display: block;
margin-bottom: 8px;
}
.info-block-text {
font-size: 26px;
color: #334155;
color: $tx;
line-height: 1.6;
}
@@ -89,23 +88,24 @@
}
.vital-item {
background: #f0f9ff;
border-radius: 12px;
background: $pri-l;
border-radius: $r;
padding: 20px;
text-align: center;
}
.vital-value {
@include serif-number;
font-size: 36px;
font-weight: 700;
color: #0891b2;
color: $pri;
display: block;
margin-bottom: 4px;
}
.vital-label {
font-size: 22px;
color: #64748b;
color: $tx2;
}
.stat-row {
@@ -117,29 +117,30 @@
.stat-label {
font-size: 26px;
color: #475569;
color: $tx2;
}
.stat-value {
@include serif-number;
font-size: 26px;
font-weight: 600;
color: #0f172a;
color: $tx;
&--warn {
color: #f59e0b;
color: $wrn;
}
}
.lab-item {
padding: 20px 0;
border-bottom: 1px solid #f1f5f9;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
&:active {
background: #f8fafc;
background: $bd-l;
}
&__header {
@@ -152,17 +153,17 @@
&__type {
font-size: 26px;
font-weight: 500;
color: #0f172a;
color: $tx;
}
&__date {
font-size: 24px;
color: #94a3b8;
color: $tx3;
}
&__abnormal {
font-size: 24px;
color: #ef4444;
color: $dan;
font-weight: 500;
}
}
@@ -176,9 +177,9 @@
flex: 1;
text-align: center;
padding: 20px;
border-radius: 12px;
background: #0891b2;
color: #fff;
border-radius: $r;
background: $pri;
color: $card;
font-size: 26px;
font-weight: 500;
@@ -187,13 +188,13 @@
}
text {
color: #fff;
color: $card;
}
}
.error-text {
text-align: center;
padding: 80px 32px;
color: #94a3b8;
color: $tx3;
font-size: 28px;
}

View File

@@ -1,6 +1,9 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.patient-list-page {
min-height: 100vh;
background: #f0f4f8;
background: $bg;
padding: 24px;
padding-bottom: 120px;
}
@@ -9,12 +12,13 @@
margin-bottom: 20px;
.search-input {
background: #fff;
border-radius: 12px;
background: $card;
border-radius: $r;
padding: 20px 24px;
font-size: 28px;
width: 100%;
box-sizing: border-box;
box-shadow: $shadow-sm;
}
}
@@ -27,15 +31,15 @@
.tag-chip {
display: inline-block;
padding: 10px 24px;
border-radius: 20px;
background: #e2e8f0;
border-radius: $r-pill;
background: $bd-l;
font-size: 24px;
color: #475569;
color: $tx2;
margin-right: 16px;
&.active {
background: #0891b2;
color: #fff;
background: $pri;
color: $card;
}
}
@@ -44,7 +48,7 @@
text {
font-size: 24px;
color: #94a3b8;
color: $tx3;
}
}
@@ -55,13 +59,13 @@
}
.patient-card {
background: #fff;
border-radius: 16px;
background: $card;
border-radius: $r-lg;
padding: 28px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
box-shadow: $shadow-sm;
&:active {
background: #f8fafc;
background: $bd-l;
}
&__header {
@@ -73,13 +77,13 @@
&__name {
font-size: 30px;
font-weight: 600;
color: #0f172a;
color: $tx;
margin-right: 16px;
}
&__meta {
font-size: 24px;
color: #64748b;
color: $tx2;
}
&__tags {
@@ -90,26 +94,22 @@
}
&__status {
font-size: 22px;
padding: 4px 12px;
border-radius: 8px;
@include tag($bg, $tx2);
&--active {
background: #dcfce7;
color: #16a34a;
@include tag($acc-l, $acc);
}
&--inactive {
background: #f1f5f9;
color: #94a3b8;
@include tag($bd-l, $tx3);
}
}
}
.patient-tag {
padding: 4px 14px;
border-radius: 12px;
background: #e0f2fe;
border-radius: $r;
background: $pri-l;
&__text {
font-size: 22px;
@@ -125,16 +125,16 @@
&__btn {
font-size: 26px;
color: #0891b2;
color: $pri;
padding: 12px 24px;
&.disabled {
color: #cbd5e1;
color: $tx3;
}
}
&__info {
font-size: 24px;
color: #64748b;
color: $tx2;
}
}

View File

@@ -1,24 +1,23 @@
@import '../../../../styles/variables.scss';
@import '../../../../styles/mixins.scss';
.report-detail {
min-height: 100vh;
background: #f0f4f8;
background: $bg;
padding: 24px;
padding-bottom: 120px;
}
.section {
background: #fff;
border-radius: 16px;
background: $card;
border-radius: $r-lg;
padding: 28px;
margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
box-shadow: $shadow-sm;
}
.section-title {
font-size: 28px;
font-weight: 600;
color: #0f172a;
display: block;
margin-bottom: 20px;
@include section-title;
}
.report-header {
@@ -28,31 +27,32 @@
margin-bottom: 12px;
&__type {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
font-weight: 700;
color: #0f172a;
color: $tx;
}
&__status {
font-size: 24px;
padding: 6px 16px;
border-radius: 12px;
border-radius: $r;
font-weight: 500;
&--pending { background: #fef3c7; color: #b45309; }
&--reviewed { background: #dcfce7; color: #16a34a; }
&--pending { background: $wrn-l; color: $wrn; }
&--reviewed { background: $acc-l; color: $acc; }
}
}
.report-date {
font-size: 26px;
color: #64748b;
color: $tx2;
display: block;
}
.review-info {
font-size: 24px;
color: #10b981;
color: $acc;
display: block;
margin-top: 8px;
}
@@ -64,19 +64,19 @@
.indicator-row {
display: flex;
padding: 16px 0;
border-bottom: 1px solid #f1f5f9;
border-bottom: 1px solid $bd-l;
align-items: center;
&--header {
border-bottom: 2px solid #e2e8f0;
border-bottom: 2px solid $bd;
padding-bottom: 12px;
}
&--abnormal {
background: #fef2f2;
background: $dan-l;
margin: 0 -12px;
padding: 16px 12px;
border-radius: 8px;
border-radius: $r-sm;
}
}
@@ -85,69 +85,71 @@
&--name {
flex: 2;
color: #334155;
color: $tx;
font-weight: 500;
}
&--value {
@include serif-number;
flex: 2;
color: #0f172a;
color: $tx;
font-weight: 600;
text-align: center;
}
&--ref {
@include serif-number;
flex: 2;
color: #94a3b8;
color: $tx3;
text-align: center;
}
&--flag {
flex: 1;
text-align: right;
color: #10b981;
color: $acc;
}
.indicator-row--abnormal &--flag {
color: #ef4444;
color: $dan;
font-weight: 700;
}
.indicator-row--header & {
font-size: 22px;
color: #94a3b8;
color: $tx3;
font-weight: 400;
}
}
.notes-display {
background: #f0f9ff;
border-radius: 12px;
background: $pri-l;
border-radius: $r;
padding: 20px;
}
.notes-text {
font-size: 26px;
color: #334155;
color: $tx;
line-height: 1.6;
}
.notes-textarea {
width: 100%;
min-height: 200px;
background: #f8fafc;
border-radius: 12px;
background: $bd-l;
border-radius: $r;
padding: 20px;
font-size: 26px;
color: #0f172a;
color: $tx;
box-sizing: border-box;
line-height: 1.6;
margin-bottom: 20px;
}
.review-btn {
background: #0891b2;
border-radius: 12px;
background: $pri;
border-radius: $r;
padding: 20px;
text-align: center;
@@ -157,7 +159,7 @@
&__text {
font-size: 28px;
color: #fff;
color: $card;
font-weight: 600;
}
}
@@ -165,6 +167,6 @@
.error-text {
text-align: center;
padding: 80px 32px;
color: #94a3b8;
color: $tx3;
font-size: 28px;
}

View File

@@ -1,6 +1,9 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.report-page {
min-height: 100vh;
background: #f0f4f8;
background: $bg;
padding: 24px;
padding-bottom: 120px;
}
@@ -9,12 +12,13 @@
margin-bottom: 20px;
.search-input {
background: #fff;
border-radius: 12px;
background: $card;
border-radius: $r;
padding: 20px 24px;
font-size: 28px;
width: 100%;
box-sizing: border-box;
box-shadow: $shadow-sm;
}
}
@@ -23,7 +27,7 @@
text {
font-size: 24px;
color: #94a3b8;
color: $tx3;
}
}
@@ -34,13 +38,13 @@
}
.report-card {
background: #fff;
border-radius: 16px;
background: $card;
border-radius: $r-lg;
padding: 28px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
box-shadow: $shadow-sm;
&:active {
background: #f8fafc;
background: $bd-l;
}
&__header {
@@ -51,14 +55,15 @@
}
&__type {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: 600;
color: #0f172a;
color: $tx;
}
&__date {
font-size: 24px;
color: #94a3b8;
color: $tx3;
}
&__indicators {
@@ -69,20 +74,16 @@
&__abnormal {
font-size: 26px;
color: #ef4444;
color: $dan;
font-weight: 600;
}
&__normal {
font-size: 26px;
color: #10b981;
color: $acc;
}
&__reviewed {
font-size: 22px;
color: #0891b2;
background: #e0f2fe;
padding: 2px 12px;
border-radius: 8px;
@include tag($acc-l, $acc);
}
}

View File

@@ -1,17 +1,50 @@
@import '../../styles/variables.scss';
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.events-page {
min-height: 100vh;
background: #f0f4f8;
background: $bg;
padding-bottom: 120px;
}
.events-header {
background: linear-gradient(135deg, #0891b2, #06b6d4);
padding: 40px 32px;
color: #fff;
background: $pri;
padding: 48px 32px 32px;
color: $card;
&__title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 40px;
font-weight: 700;
font-weight: bold;
display: block;
margin-bottom: 8px;
}
@@ -30,10 +63,10 @@
}
.event-card {
background: #fff;
border-radius: 16px;
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
box-shadow: $shadow-sm;
&__header {
display: flex;
@@ -43,29 +76,45 @@
}
&__status {
padding: 4px 14px;
border-radius: 12px;
@include tag($bd-l, $tx2);
font-size: 22px;
font-weight: 500;
}
&__status--published {
@include tag($pri-l, $pri);
}
&__status--ongoing {
@include tag($acc-l, $acc);
}
&__status--completed {
@include tag($bd-l, $tx3);
}
&__status--cancelled {
@include tag($dan-l, $dan);
}
&__points {
font-size: 28px;
font-weight: 700;
color: #f59e0b;
font-weight: bold;
color: $wrn;
@include serif-number;
}
&__title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: 600;
color: #0f172a;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 12px;
}
&__desc {
font-size: 26px;
color: #64748b;
color: $tx2;
display: block;
margin-bottom: 16px;
line-height: 1.5;
@@ -74,18 +123,18 @@
&__info {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
margin-bottom: 16px;
}
&__date {
font-size: 24px;
color: #475569;
color: $tx2;
}
&__location {
font-size: 24px;
color: #94a3b8;
color: $tx3;
}
&__footer {
@@ -93,26 +142,27 @@
justify-content: space-between;
align-items: center;
padding-top: 16px;
border-top: 1px solid #f1f5f9;
border-top: 1px solid $bd-l;
}
&__participants {
font-size: 24px;
color: #94a3b8;
color: $tx3;
@include serif-number;
}
&__btn {
background: #0891b2;
border-radius: 12px;
padding: 12px 28px;
background: $pri;
border-radius: $r-sm;
padding: 16px 32px;
&--disabled {
background: #cbd5e1;
background: $bd;
}
&-text {
font-size: 26px;
color: #fff;
color: $card;
font-weight: 500;
}
}

View File

@@ -6,11 +6,11 @@ import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import './index.scss';
const STATUS_MAP: Record<string, { label: string; color: string }> = {
published: { label: '报名中', color: '#0891b2' },
ongoing: { label: '进行中', color: '#10b981' },
completed: { label: '已结束', color: '#94a3b8' },
cancelled: { label: '已取消', color: '#ef4444' },
const STATUS_MAP: Record<string, { label: string; className: string }> = {
published: { label: '报名中', className: 'event-card__status--published' },
ongoing: { label: '进行中', className: 'event-card__status--ongoing' },
completed: { label: '已结束', className: 'event-card__status--completed' },
cancelled: { label: '已取消', className: 'event-card__status--cancelled' },
};
export default function EventsPage() {
@@ -70,14 +70,14 @@ export default function EventsPage() {
) : (
<View className='event-list'>
{events.map((event) => {
const st = STATUS_MAP[event.status] || { label: event.status, color: '#94a3b8' };
const st = STATUS_MAP[event.status] || { label: event.status, className: '' };
const isFull = event.max_participants != null && event.current_participants >= event.max_participants;
const isRegistering = registering === event.id;
return (
<View key={event.id} className='event-card'>
<View className='event-card__header'>
<View className='event-card__status' style={`background: ${st.color}20; color: ${st.color}`}>
<View className={`event-card__status ${st.className}`}>
<Text>{st.label}</Text>
</View>
<Text className='event-card__points'>+{event.points_reward} </Text>

View File

@@ -1,4 +1,5 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.detail-page {
min-height: 100vh;
@@ -12,14 +13,12 @@
border-radius: $r;
padding: 28px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
}
.detail-title {
@include section-title;
font-size: 34px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 20px;
}
@@ -84,14 +83,11 @@
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
}
.section-title {
font-size: 30px;
font-weight: bold;
color: $tx;
display: block;
@include section-title;
margin-bottom: 16px;
}
@@ -100,7 +96,7 @@
min-height: 200px;
font-size: 28px;
color: $tx;
background: $bd-l;
background: $bg;
border-radius: $r-sm;
padding: 20px;
box-sizing: border-box;
@@ -111,26 +107,28 @@
.submit-btn {
background: $pri;
border-radius: $r-sm;
border-radius: $r;
padding: 24px;
text-align: center;
&:active {
opacity: 0.85;
}
&.disabled {
opacity: 0.6;
opacity: 0.5;
}
}
.submit-btn-text {
font-size: 30px;
color: white;
font-weight: bold;
color: #fff;
font-weight: 600;
}
.loading-state,
.empty-state {
display: flex;
justify-content: center;
align-items: center;
@include flex-center;
padding: 120px 0;
}

View File

@@ -1,54 +1,122 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.dm-page {
min-height: 100vh;
background: $bg;
padding: 24px;
padding-bottom: 60px;
padding: 0 0 60px;
}
.dm-section {
/* ── hero ── */
.dm-hero {
padding: 48px 32px 36px;
display: flex;
flex-direction: column;
align-items: center;
}
.dm-hero-icon {
@include flex-center;
width: 88px;
height: 88px;
border-radius: $r-lg;
background: $pri-l;
margin-bottom: 20px;
}
.dm-hero-icon-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 40px;
font-weight: bold;
color: $pri;
}
.dm-hero-title {
@include section-title;
font-size: 36px;
margin-bottom: 8px;
}
.dm-hero-sub {
font-size: 24px;
color: $tx3;
}
/* ── card ── */
.dm-card {
background: $card;
border-radius: $r;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-md;
padding: 28px;
margin: 0 24px 20px;
}
.dm-section-title {
.dm-card-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 20px;
}
.dm-card-serial {
@include flex-center;
width: 40px;
height: 40px;
border-radius: $r-sm;
background: $pri-l;
flex-shrink: 0;
}
.dm-card-serial-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 22px;
font-weight: bold;
color: $pri;
}
.dm-card-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 16px;
padding-left: 12px;
border-left: 4px solid $pri;
}
.dm-date-picker {
.dm-card-badge {
@include tag($acc-l, $acc);
font-size: 20px;
margin-left: auto;
}
/* ── date picker ── */
.dm-date-row {
display: flex;
justify-content: space-between;
align-items: center;
background: $bg;
border-radius: $r-sm;
padding: 20px 24px;
padding: 22px 24px;
}
.dm-date-value {
font-size: 28px;
color: $pri;
@include serif-number;
font-weight: bold;
}
.dm-date-arrow {
font-size: 28px;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 22px;
color: $tx3;
transform: rotate(180deg);
display: inline-block;
}
.dm-bp-row {
/* ── blood pressure group ── */
.dm-bp-group {
display: flex;
align-items: flex-end;
gap: 16px;
gap: 12px;
}
.dm-bp-field {
@@ -56,16 +124,30 @@
}
.dm-field-label {
font-size: 24px;
font-size: 22px;
color: $tx2;
display: block;
margin-bottom: 8px;
}
.dm-bp-sep {
font-size: 40px;
.dm-bp-divider {
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 20px;
gap: 6px;
}
.dm-bp-line {
width: 16px;
height: 1px;
background: $bd;
}
.dm-bp-slash {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 36px;
color: $tx3;
padding-bottom: 16px;
font-weight: 300;
}
@@ -73,29 +155,36 @@
font-size: 22px;
color: $tx3;
display: block;
margin-top: 8px;
margin-top: 10px;
font-style: italic;
}
/* ── single row with unit ── */
.dm-single-row {
display: flex;
align-items: center;
gap: 16px;
}
.dm-field-unit-inline {
.dm-input-flex {
flex: 1;
}
.dm-unit-inline {
font-size: 26px;
color: $tx3;
white-space: nowrap;
flex-shrink: 0;
}
.dm-input {
/* ── input field ── */
.dm-input-box {
background: $bg;
border-radius: $r-sm;
padding: 20px 24px;
font-size: 28px;
color: $tx;
width: 100%;
@include serif-number;
box-sizing: border-box;
}
@@ -103,13 +192,14 @@
width: 100%;
}
/* ── submit ── */
.dm-submit {
background: $pri;
border-radius: $r-sm;
padding: 24px;
border-radius: $r;
padding: 26px;
text-align: center;
margin-top: 40px;
box-shadow: 0 4px 12px rgba(8, 145, 178, 0.3);
margin: 40px 24px 0;
box-shadow: $shadow-md;
transition: opacity 0.2s;
&:active {
@@ -118,7 +208,7 @@
}
.dm-submit-disabled {
opacity: 0.6;
opacity: 0.5;
box-shadow: none;
}
@@ -126,12 +216,14 @@
font-size: 32px;
color: white;
font-weight: bold;
letter-spacing: 2px;
}
/* ── reset ── */
.dm-reset {
text-align: center;
padding: 20px;
margin-top: 16px;
padding: 24px;
margin-top: 12px;
}
.dm-reset-text {

View File

@@ -87,7 +87,6 @@ export default function DailyMonitoring() {
return;
}
// Zod 验证数值范围
const parseNum = (v: string) => v ? parseFloat(v) : undefined;
const fields = {
morningSystolic: parseNum(morningSystolic),
@@ -163,44 +162,72 @@ export default function DailyMonitoring() {
}
};
const isToday = recordDate === today;
return (
<View className='dm-page'>
{/* 页面标题 */}
<View className='dm-hero'>
<View className='dm-hero-icon'>
<Text className='dm-hero-icon-text'></Text>
</View>
<Text className='dm-hero-title'></Text>
<Text className='dm-hero-sub'></Text>
</View>
{/* 日期选择 */}
<View className='dm-section'>
<Text className='dm-section-title'></Text>
<View className='dm-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>1</Text>
</View>
<Text className='dm-card-title'></Text>
{isToday && (
<Text className='dm-card-badge'></Text>
)}
</View>
<Picker
mode='selector'
range={dateList}
value={dateIdx}
onChange={(e) => setDateIdx(Number(e.detail.value))}
>
<View className='dm-date-picker'>
<View className='dm-date-row'>
<Text className='dm-date-value'>{recordDate}</Text>
<Text className='dm-date-arrow'></Text>
<Text className='dm-date-arrow'>V</Text>
</View>
</Picker>
</View>
{/* 晨起血压 */}
<View className='dm-section'>
<Text className='dm-section-title'></Text>
<View className='dm-bp-row'>
<View className='dm-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>2</Text>
</View>
<Text className='dm-card-title'></Text>
</View>
<View className='dm-bp-group'>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className='dm-input'
className='dm-input-box'
placeholder='如 120'
value={morningSystolic}
onInput={(e) => setMorningSystolic(e.detail.value)}
/>
</View>
<Text className='dm-bp-sep'>/</Text>
<View className='dm-bp-divider'>
<View className='dm-bp-line' />
<Text className='dm-bp-slash'>/</Text>
<View className='dm-bp-line' />
</View>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className='dm-input'
className='dm-input-box'
placeholder='如 80'
value={morningDiastolic}
onInput={(e) => setMorningDiastolic(e.detail.value)}
@@ -211,25 +238,34 @@ export default function DailyMonitoring() {
</View>
{/* 晚间血压 */}
<View className='dm-section'>
<Text className='dm-section-title'></Text>
<View className='dm-bp-row'>
<View className='dm-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>3</Text>
</View>
<Text className='dm-card-title'></Text>
</View>
<View className='dm-bp-group'>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className='dm-input'
className='dm-input-box'
placeholder='如 120'
value={eveningSystolic}
onInput={(e) => setEveningSystolic(e.detail.value)}
/>
</View>
<Text className='dm-bp-sep'>/</Text>
<View className='dm-bp-divider'>
<View className='dm-bp-line' />
<Text className='dm-bp-slash'>/</Text>
<View className='dm-bp-line' />
</View>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className='dm-input'
className='dm-input-box'
placeholder='如 80'
value={eveningDiastolic}
onInput={(e) => setEveningDiastolic(e.detail.value)}
@@ -240,70 +276,95 @@ export default function DailyMonitoring() {
</View>
{/* 体重 */}
<View className='dm-section'>
<Text className='dm-section-title'></Text>
<View className='dm-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>4</Text>
</View>
<Text className='dm-card-title'></Text>
</View>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input'
className='dm-input-box dm-input-flex'
placeholder='如 65.0'
value={weight}
onInput={(e) => setWeight(e.detail.value)}
/>
<Text className='dm-field-unit-inline'>kg</Text>
<Text className='dm-unit-inline'>kg</Text>
</View>
</View>
{/* 血糖 */}
<View className='dm-section'>
<Text className='dm-section-title'></Text>
<View className='dm-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>5</Text>
</View>
<Text className='dm-card-title'></Text>
</View>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input'
className='dm-input-box dm-input-flex'
placeholder='如 5.6'
value={bloodSugar}
onInput={(e) => setBloodSugar(e.detail.value)}
/>
<Text className='dm-field-unit-inline'>mmol/L</Text>
<Text className='dm-unit-inline'>mmol/L</Text>
</View>
</View>
{/* 饮水量 */}
<View className='dm-section'>
<Text className='dm-section-title'></Text>
<View className='dm-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>6</Text>
</View>
<Text className='dm-card-title'></Text>
</View>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input'
className='dm-input-box dm-input-flex'
placeholder='如 2000'
value={fluidIntake}
onInput={(e) => setFluidIntake(e.detail.value)}
/>
<Text className='dm-field-unit-inline'>ml</Text>
<Text className='dm-unit-inline'>ml</Text>
</View>
</View>
{/* 尿量 */}
<View className='dm-section'>
<Text className='dm-section-title'>尿</Text>
<View className='dm-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>7</Text>
</View>
<Text className='dm-card-title'>尿</Text>
</View>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input'
className='dm-input-box dm-input-flex'
placeholder='如 1500'
value={urineOutput}
onInput={(e) => setUrineOutput(e.detail.value)}
/>
<Text className='dm-field-unit-inline'>ml</Text>
<Text className='dm-unit-inline'>ml</Text>
</View>
</View>
{/* 备注 */}
<View className='dm-section'>
<Text className='dm-section-title'></Text>
<View className='dm-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>8</Text>
</View>
<Text className='dm-card-title'></Text>
</View>
<Input
className='dm-input dm-input-full'
className='dm-input-box dm-input-full'
placeholder='如:头晕、乏力等(可选)'
value={notes}
onInput={(e) => setNotes(e.detail.value)}

View File

@@ -1,46 +1,51 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
.health-page {
min-height: 100vh;
background: $bg;
padding-bottom: 40px;
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
/* ─── 页头 ─── */
.health-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 32px;
padding: 24px 32px 8px;
}
.health-header-title {
.health-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 36px;
font-weight: bold;
color: $tx;
}
.health-header-btn {
.health-add-btn {
background: $pri;
padding: 12px 28px;
padding: 10px 28px;
border-radius: $r-sm;
&:active {
opacity: 0.85;
}
}
.health-header-btn-text {
.health-add-text {
font-size: 26px;
color: white;
font-weight: bold;
color: #fff;
font-weight: 600;
}
// ---- Quick Actions (快捷操作) ----
.quick-actions {
/* ─── 快捷操作 ─── */
.health-actions-row {
display: flex;
gap: 16px;
padding: 0 24px;
margin-bottom: 24px;
padding: 16px 24px 24px;
}
.quick-action-item {
.action-item {
flex: 1;
background: $card;
border-radius: $r;
@@ -48,60 +53,54 @@
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: transform 0.15s;
gap: 10px;
box-shadow: $shadow-sm;
&:active {
transform: scale(0.96);
opacity: 0.7;
}
}
.quick-action-icon-wrap {
.action-icon {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
@include flex-center;
&.icon-primary { background: $pri-l; }
&.icon-accent { background: $acc-l; }
&.icon-warn { background: $wrn-l; }
}
.quick-action-icon-primary {
background: $pri-l;
.action-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
font-weight: bold;
color: $pri;
.icon-accent & { color: $acc; }
.icon-warn & { color: $wrn; }
}
.quick-action-icon-green {
background: $acc-l;
}
.quick-action-icon-orange {
background: $wrn-l;
}
.quick-action-icon {
font-size: 36px;
}
.quick-action-label {
.action-label {
font-size: 24px;
color: $tx;
font-weight: 500;
}
// ---- Checkin Status (打卡状态) ----
/* ─── 打卡卡片 ─── */
.checkin-card {
display: flex;
align-items: center;
justify-content: space-between;
background: $card;
border-radius: $r;
padding: 24px;
padding: 24px 28px;
margin: 0 24px 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
}
.checkin-left {
.checkin-info {
display: flex;
flex-direction: column;
gap: 4px;
@@ -110,7 +109,7 @@
.checkin-done {
font-size: 28px;
color: $acc;
font-weight: bold;
font-weight: 600;
}
.checkin-streak {
@@ -124,164 +123,178 @@
font-weight: 500;
}
.checkin-go-btn {
.checkin-go {
background: $pri;
border-radius: $r-sm;
padding: 12px 24px;
padding: 12px 28px;
&:active {
opacity: 0.85;
}
}
.checkin-go-text {
font-size: 24px;
color: white;
font-weight: bold;
color: #fff;
font-weight: 600;
}
// ---- Health Grid (体征概览) ----
/* ─── 通用 section ─── */
.health-section {
margin: 0 24px 28px;
}
.health-grid {
/* ─── 体征概览 ─── */
.vitals-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
padding: 0 24px;
margin-bottom: 32px;
}
.health-card {
background: $card;
border-radius: $r;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border-left: 6px solid $bd;
transition: border-left-color 0.2s;
&.status-normal { border-left-color: $acc; }
&.status-high { border-left-color: $dan; }
&.status-low { border-left-color: $dan; }
}
.health-card-label {
font-size: 24px;
color: $tx2;
display: block;
margin-bottom: 12px;
}
.health-card-value {
font-size: 40px;
font-weight: bold;
color: $pri;
display: block;
margin-bottom: 8px;
}
.health-card-bottom {
display: flex;
justify-content: space-between;
}
.health-card-unit {
font-size: 22px;
color: $tx3;
}
.health-card-status {
font-size: 22px;
&.status-normal { color: $acc; }
&.status-high, &.status-low { color: $dan; font-weight: bold; }
}
.health-card-ref {
font-size: 20px;
color: $tx3;
margin-top: 4px;
}
// ---- Trend Actions (趋势快捷入口) ----
.health-actions {
display: flex;
gap: 16px;
padding: 0 24px;
}
.action-card {
flex: 1;
.vital-card {
background: $card;
border-radius: $r;
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
padding: 24px 20px;
box-shadow: $shadow-sm;
transition: opacity 0.2s;
&:active {
opacity: 0.7;
}
}
.action-icon {
font-size: 40px;
margin-bottom: 8px;
}
.action-label {
font-size: 24px;
.vital-label {
font-size: 22px;
color: $tx2;
display: block;
margin-bottom: 10px;
}
// ---- Recent Daily Monitoring (最近日常监测) ----
.recent-section {
padding: 0 24px;
margin-top: 32px;
}
.recent-section-title {
font-size: 28px;
.vital-value {
@include serif-number;
font-size: 44px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 16px;
padding-left: 12px;
border-left: 4px solid $pri;
margin-bottom: 8px;
line-height: 1.1;
}
.recent-record {
.vital-bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
.vital-unit {
font-size: 20px;
color: $tx3;
}
.vital-tag {
@include tag($acc-l, $acc);
&.tag-warn {
@include tag($wrn-l, $wrn);
}
}
.vital-ref {
font-size: 20px;
color: $tx3;
margin-top: 8px;
display: block;
}
/* ─── 趋势入口 ─── */
.trend-row {
background: $card;
border-radius: $r;
overflow: hidden;
box-shadow: $shadow-sm;
}
.trend-item {
display: flex;
align-items: center;
padding: 24px;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
&:active {
background: $bd-l;
}
}
.trend-icon {
width: 56px;
height: 56px;
border-radius: $r-sm;
background: $pri-l;
@include flex-center;
margin-right: 16px;
flex-shrink: 0;
}
.trend-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 26px;
font-weight: bold;
color: $pri;
}
.trend-label {
flex: 1;
font-size: 28px;
color: $tx;
font-weight: 500;
}
.trend-arrow {
font-size: 32px;
color: $tx3;
flex-shrink: 0;
}
/* ─── 最近监测 ─── */
.record-card {
background: $card;
border-radius: $r;
padding: 20px 24px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
}
.recent-record-header {
display: flex;
align-items: center;
justify-content: space-between;
.record-date {
font-size: 24px;
color: $pri;
font-weight: 600;
display: block;
margin-bottom: 12px;
}
.recent-record-date {
font-size: 26px;
color: $pri;
font-weight: bold;
}
.recent-record-data {
.record-data {
display: flex;
gap: 32px;
flex-wrap: wrap;
}
.recent-data-item {
.record-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.recent-data-label {
.record-item-label {
font-size: 22px;
color: $tx3;
}
.recent-data-value {
.record-item-value {
@include serif-number;
font-size: 26px;
color: $tx;
font-weight: 500;

View File

@@ -9,11 +9,11 @@ import { trackEvent } from '../../services/analytics';
import Loading from '../../components/Loading';
import './index.scss';
function getStatusStyle(status?: string) {
if (status === 'high') return { cls: 'status-high', label: '偏高 ▲' };
if (status === 'low') return { cls: 'status-low', label: '偏低 ▼' };
if (status === 'normal') return { cls: 'status-normal', label: '正常 ─' };
return { cls: '', label: '' };
function getStatusTag(status?: string) {
if (status === 'high') return { label: '偏高', cls: 'tag-warn' };
if (status === 'low') return { label: '偏低', cls: 'tag-warn' };
if (status === 'normal') return { label: '正常', cls: 'tag-ok' };
return null;
}
export default function Health() {
@@ -32,7 +32,7 @@ export default function Health() {
const status = await getCheckinStatus();
setCheckinStatus(status);
} catch {
// ignore — points API may not be available
// points API 可能不可用
}
if (currentPatient) {
@@ -40,7 +40,7 @@ export default function Health() {
const resp = await listDailyMonitoring(currentPatient.id, { page: 1, page_size: 3 });
setRecentRecords(resp.data || []);
} catch {
// ignore — daily monitoring API may not be available yet
// daily monitoring API 可能不可用
}
}
};
@@ -57,10 +57,6 @@ export default function Health() {
Taro.navigateTo({ url: `/pages/health/trend/index?indicator=${indicator}` });
};
const goToTrendPage = () => {
Taro.navigateTo({ url: '/pages/health/trend/index?indicator=blood_pressure_systolic' });
};
const goToMall = () => {
Taro.switchTab({ url: '/pages/mall/index' });
};
@@ -73,6 +69,18 @@ export default function Health() {
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '--', unit: 'kg', indicator: 'weight', status: summary.weight?.status, ref: summary.weight?.reference_range },
];
const quickActions = [
{ label: '日常上报', char: '日', bg: 'icon-primary', action: goToDailyMonitoring },
{ label: '体征录入', char: '录', bg: 'icon-accent', action: goToInput },
{ label: '查看趋势', char: '势', bg: 'icon-warn', action: () => goToTrend('blood_pressure_systolic') },
];
const trendLinks = [
{ label: '血压趋势', indicator: 'blood_pressure_systolic', char: '压' },
{ label: '心率趋势', indicator: 'heart_rate', char: '率' },
{ label: '血糖趋势', indicator: 'blood_sugar_fasting', char: '糖' },
];
const formatBp = (record: DailyMonitoring) => {
const parts: string[] = [];
if (record.morning_bp_systolic && record.morning_bp_diastolic) {
@@ -86,42 +94,33 @@ export default function Health() {
return (
<View className='health-page'>
{/* 页头 */}
<View className='health-header'>
<Text className='health-header-title'></Text>
<View className='health-header-btn' onClick={goToInput}>
<Text className='health-header-btn-text'>+ </Text>
<Text className='health-title'></Text>
<View className='health-add-btn' onClick={goToInput}>
<Text className='health-add-text'></Text>
</View>
</View>
{/* 快捷操作 */}
<View className='quick-actions'>
<View className='quick-action-item' onClick={goToDailyMonitoring}>
<View className='quick-action-icon-wrap quick-action-icon-primary'>
<Text className='quick-action-icon'>📋</Text>
<View className='health-actions-row'>
{quickActions.map((a) => (
<View className='action-item' key={a.label} onClick={a.action}>
<View className={`action-icon ${a.bg}`}>
<Text className='action-char'>{a.char}</Text>
</View>
<Text className='action-label'>{a.label}</Text>
</View>
<Text className='quick-action-label'></Text>
</View>
<View className='quick-action-item' onClick={goToInput}>
<View className='quick-action-icon-wrap quick-action-icon-green'>
<Text className='quick-action-icon'>💉</Text>
</View>
<Text className='quick-action-label'></Text>
</View>
<View className='quick-action-item' onClick={goToTrendPage}>
<View className='quick-action-icon-wrap quick-action-icon-orange'>
<Text className='quick-action-icon'>📈</Text>
</View>
<Text className='quick-action-label'></Text>
</View>
))}
</View>
{/* 打卡状态 */}
{checkinStatus && (
<View className='checkin-card'>
<View className='checkin-left'>
<View className='checkin-info'>
{checkinStatus.checked_in_today ? (
<>
<Text className='checkin-done'> </Text>
<Text className='checkin-done'></Text>
{checkinStatus.consecutive_days > 0 && (
<Text className='checkin-streak'> {checkinStatus.consecutive_days} </Text>
)}
@@ -131,7 +130,7 @@ export default function Health() {
)}
</View>
{!checkinStatus.checked_in_today && (
<View className='checkin-go-btn' onClick={goToMall}>
<View className='checkin-go' onClick={goToMall}>
<Text className='checkin-go-text'></Text>
</View>
)}
@@ -139,67 +138,68 @@ export default function Health() {
)}
{/* 今日体征概览 */}
{loading && !todaySummary ? (
<Loading />
) : (
<View className='health-grid'>
{items.map((item) => {
const style = getStatusStyle(item.status);
return (
<View className={`health-card ${style.cls}`} key={item.label} onClick={() => goToTrend(item.indicator)}>
<Text className='health-card-label'>{item.label}</Text>
<Text className='health-card-value'>{item.value}</Text>
<View className='health-card-bottom'>
<Text className='health-card-unit'>{item.unit}</Text>
{style.label && <Text className={`health-card-status ${style.cls}`}>{style.label}</Text>}
<View className='health-section'>
<Text className='section-title'></Text>
{loading && !todaySummary ? (
<Loading />
) : (
<View className='vitals-grid'>
{items.map((item) => {
const tag = getStatusTag(item.status);
return (
<View className='vital-card' key={item.label} onClick={() => goToTrend(item.indicator)}>
<Text className='vital-label'>{item.label}</Text>
<Text className='vital-value'>{item.value}</Text>
<View className='vital-bottom'>
<Text className='vital-unit'>{item.unit}</Text>
{tag && <Text className={`vital-tag ${tag.cls}`}>{tag.label}</Text>}
</View>
{item.ref && <Text className='vital-ref'> {item.ref}</Text>}
</View>
);
})}
</View>
)}
</View>
{/* 趋势快捷入口 */}
<View className='health-section'>
<Text className='section-title'></Text>
<View className='trend-row'>
{trendLinks.map((t) => (
<View className='trend-item' key={t.label} onClick={() => goToTrend(t.indicator)}>
<View className='trend-icon'>
<Text className='trend-char'>{t.char}</Text>
</View>
{item.ref && <Text className='health-card-ref'>: {item.ref}</Text>}
<Text className='trend-label'>{t.label}</Text>
<Text className='trend-arrow'></Text>
</View>
);
})}
</View>
)}
{/* 原有趋势快捷入口 */}
<View className='health-actions'>
<View className='action-card' onClick={() => goToTrend('blood_pressure_systolic')}>
<Text className='action-icon'>📈</Text>
<Text className='action-label'></Text>
</View>
<View className='action-card' onClick={() => goToTrend('heart_rate')}>
<Text className='action-icon'></Text>
<Text className='action-label'></Text>
</View>
<View className='action-card' onClick={() => goToTrend('blood_sugar_fasting')}>
<Text className='action-icon'>🩸</Text>
<Text className='action-label'></Text>
))}
</View>
</View>
{/* 最近日常监测记录 */}
{/* 最近监测记录 */}
{recentRecords.length > 0 && (
<View className='recent-section'>
<Text className='recent-section-title'></Text>
<View className='health-section'>
<Text className='section-title'></Text>
{recentRecords.map((record) => (
<View className='recent-record' key={record.id}>
<View className='recent-record-header'>
<Text className='recent-record-date'>{record.record_date}</Text>
</View>
<View className='recent-record-data'>
<View className='recent-data-item'>
<Text className='recent-data-label'></Text>
<Text className='recent-data-value'>{formatBp(record)}</Text>
<View className='record-card' key={record.id}>
<Text className='record-date'>{record.record_date}</Text>
<View className='record-data'>
<View className='record-item'>
<Text className='record-item-label'></Text>
<Text className='record-item-value'>{formatBp(record)}</Text>
</View>
{record.weight != null && (
<View className='recent-data-item'>
<Text className='recent-data-label'></Text>
<Text className='recent-data-value'>{record.weight} kg</Text>
<View className='record-item'>
<Text className='record-item-label'></Text>
<Text className='record-item-value'>{record.weight} kg</Text>
</View>
)}
{record.blood_sugar != null && (
<View className='recent-data-item'>
<Text className='recent-data-label'></Text>
<Text className='recent-data-value'>{record.blood_sugar} mmol/L</Text>
<View className='record-item'>
<Text className='record-item-label'></Text>
<Text className='record-item-value'>{record.blood_sugar} mmol/L</Text>
</View>
)}
</View>

View File

@@ -1,59 +1,204 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.input-page {
min-height: 100vh;
background: $bg;
padding: 24px;
padding: 0 0 60px;
}
.input-section {
margin-bottom: 32px;
/* ── hero ── */
.input-hero {
padding: 48px 32px 36px;
display: flex;
flex-direction: column;
align-items: center;
}
.input-label {
font-size: 28px;
color: $tx;
.input-hero-icon {
@include flex-center;
width: 88px;
height: 88px;
border-radius: $r-lg;
background: $pri-l;
margin-bottom: 20px;
}
.input-hero-icon-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 40px;
font-weight: bold;
margin-bottom: 12px;
display: block;
color: $pri;
}
.input-picker {
.input-hero-title {
@include section-title;
font-size: 36px;
margin-bottom: 8px;
}
.input-hero-sub {
font-size: 24px;
color: $tx3;
}
/* ── card ── */
.input-card {
background: $card;
border-radius: $r;
box-shadow: $shadow-md;
padding: 28px;
margin: 0 24px 20px;
}
.input-card-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 20px;
}
.input-card-indicator {
@include flex-center;
width: 44px;
height: 44px;
border-radius: $r-sm;
padding: 20px 24px;
background: $acc-l;
}
.input-card-indicator-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 24px;
font-weight: bold;
color: $acc;
}
.input-card-label {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $tx;
}
/* ── picker ── */
.input-picker-row {
display: flex;
justify-content: space-between;
align-items: center;
background: $bg;
border-radius: $r-sm;
padding: 22px 24px;
}
.input-picker-value {
font-size: 28px;
color: $tx;
@include serif-number;
}
.picker-arrow {
.input-picker-arrow {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 22px;
color: $tx3;
font-size: 28px;
transform: rotate(180deg);
display: inline-block;
}
.input-field {
background: $card;
/* ── section title ── */
.input-section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $tx;
margin-bottom: 16px;
display: block;
}
/* ── blood pressure group ── */
.input-bp-group {
display: flex;
align-items: flex-end;
gap: 12px;
}
.input-bp-field {
flex: 1;
}
.input-field-label {
font-size: 22px;
color: $tx2;
display: block;
margin-bottom: 8px;
}
.input-bp-divider {
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 20px;
gap: 6px;
}
.input-bp-line {
width: 16px;
height: 1px;
background: $bd;
}
.input-bp-slash {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 36px;
color: $tx3;
font-weight: 300;
}
/* ── input field ── */
.input-field-box {
background: $bg;
border-radius: $r-sm;
padding: 20px 24px;
font-size: 28px;
color: $tx;
width: 100%;
@include serif-number;
box-sizing: border-box;
}
.input-submit {
background: $pri;
border-radius: $r-sm;
padding: 24px;
text-align: center;
margin-top: 48px;
.input-field-full {
width: 100%;
}
.submit-text {
.input-field-unit {
font-size: 22px;
color: $tx3;
display: block;
margin-top: 10px;
font-style: italic;
}
/* ── submit ── */
.input-submit {
background: $pri;
border-radius: $r;
padding: 26px;
text-align: center;
margin: 48px 24px 0;
box-shadow: $shadow-md;
transition: opacity 0.2s;
&:active {
opacity: 0.85;
}
}
.input-submit-disabled {
opacity: 0.5;
box-shadow: none;
}
.input-submit-text {
font-size: 32px;
color: white;
font-weight: bold;
letter-spacing: 2px;
}

View File

@@ -99,71 +99,106 @@ export default function HealthInput() {
}
};
const indicatorInitial = INDICATORS[indicatorIdx].label.charAt(0);
return (
<View className='input-page'>
<View className='input-section'>
<Text className='input-label'></Text>
{/* 页面标题 */}
<View className='input-hero'>
<View className='input-hero-icon'>
<Text className='input-hero-icon-text'></Text>
</View>
<Text className='input-hero-title'></Text>
<Text className='input-hero-sub'></Text>
</View>
{/* 指标类型选择 */}
<View className='input-card'>
<View className='input-card-header'>
<View className='input-card-indicator'>
<Text className='input-card-indicator-char'>{indicatorInitial}</Text>
</View>
<Text className='input-card-label'></Text>
</View>
<Picker
mode='selector'
range={INDICATORS.map((i) => i.label)}
value={indicatorIdx}
onChange={(e) => setIndicatorIdx(Number(e.detail.value))}
>
<View className='input-picker'>
<Text>{INDICATORS[indicatorIdx].label}</Text>
<Text className='picker-arrow'></Text>
<View className='input-picker-row'>
<Text className='input-picker-value'>{INDICATORS[indicatorIdx].label}</Text>
<Text className='input-picker-arrow'>V</Text>
</View>
</Picker>
</View>
{/* 数值输入 */}
{INDICATORS[indicatorIdx].value === 'blood_pressure' ? (
<>
<View className='input-section'>
<Text className='input-label'></Text>
<Input
type='digit'
className='input-field'
placeholder='如 120'
value={systolic}
onInput={(e) => setSystolic(e.detail.value)}
/>
<View className='input-card'>
<Text className='input-section-title'></Text>
<View className='input-bp-group'>
<View className='input-bp-field'>
<Text className='input-field-label'></Text>
<Input
type='digit'
className='input-field-box'
placeholder='如 120'
value={systolic}
onInput={(e) => setSystolic(e.detail.value)}
/>
</View>
<View className='input-bp-divider'>
<View className='input-bp-line' />
<Text className='input-bp-slash'>/</Text>
<View className='input-bp-line' />
</View>
<View className='input-bp-field'>
<Text className='input-field-label'></Text>
<Input
type='digit'
className='input-field-box'
placeholder='如 80'
value={diastolic}
onInput={(e) => setDiastolic(e.detail.value)}
/>
</View>
</View>
<View className='input-section'>
<Text className='input-label'></Text>
<Input
type='digit'
className='input-field'
placeholder='如 80'
value={diastolic}
onInput={(e) => setDiastolic(e.detail.value)}
/>
</View>
</>
<Text className='input-field-unit'>mmHg</Text>
</View>
) : (
<View className='input-section'>
<Text className='input-label'></Text>
<View className='input-card'>
<Text className='input-section-title'></Text>
<Input
type='digit'
className='input-field'
className='input-field-box input-field-full'
placeholder='请输入数值'
value={value}
onInput={(e) => setValue(e.detail.value)}
/>
<Text className='input-field-unit'>
{INDICATORS[indicatorIdx].label.match(/\((.+)\)/)?.[1] || ''}
</Text>
</View>
)}
<View className='input-section'>
<Text className='input-label'></Text>
{/* 备注 */}
<View className='input-card'>
<Text className='input-section-title'></Text>
<Input
className='input-field'
placeholder='如饭后2小时'
className='input-field-box input-field-full'
placeholder='如饭后2小时(可选)'
value={note}
onInput={(e) => setNote(e.detail.value)}
/>
</View>
<View className='input-submit' onClick={submitting ? undefined : handleSubmit}>
<Text className='submit-text'>{submitting ? '提交中...' : '提交'}</Text>
{/* 提交 */}
<View
className={`input-submit ${submitting ? 'input-submit-disabled' : ''}`}
onClick={submitting ? undefined : handleSubmit}
>
<Text className='input-submit-text'>{submitting ? '提交中...' : '提交录入'}</Text>
</View>
</View>
);

View File

@@ -1,113 +1,148 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.trend-page {
min-height: 100vh;
background: $bg;
padding-bottom: 60px;
}
.trend-header {
padding: 24px 32px;
/* ── hero ── */
.trend-hero {
padding: 48px 32px 28px;
display: flex;
flex-direction: column;
align-items: center;
}
.trend-title {
font-size: 34px;
.trend-hero-icon {
@include flex-center;
width: 88px;
height: 88px;
border-radius: $r-lg;
background: $pri-l;
margin-bottom: 20px;
}
.trend-hero-icon-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 40px;
font-weight: bold;
color: $pri;
}
.trend-hero-title {
@include section-title;
font-size: 36px;
margin-bottom: 0;
}
/* ── range tabs ── */
.trange-wrap {
display: flex;
justify-content: center;
gap: 16px;
padding: 0 32px 28px;
}
.trange-tab {
padding: 12px 32px;
border-radius: $r-pill;
background: $card;
box-shadow: $shadow-sm;
transition: all 0.2s;
}
.trange-tab-active {
background: $pri;
box-shadow: $shadow-md;
}
.trange-tab-text {
font-size: 24px;
color: $tx2;
font-weight: 500;
}
.trange-tab-text-active {
color: white;
}
/* ── chart card ── */
.trend-chart-card {
margin: 0 24px 20px;
background: $card;
border-radius: $r;
box-shadow: $shadow-md;
padding: 24px;
min-height: 300px;
overflow: hidden;
}
/* ── reference card ── */
.trend-ref-card {
margin: 0 24px 20px;
background: $acc-l;
border-radius: $r;
padding: 20px 24px;
display: flex;
align-items: center;
gap: 16px;
}
.trend-ref-label {
font-size: 24px;
color: $acc;
font-weight: bold;
}
.trend-ref-value {
font-size: 26px;
color: $acc;
@include serif-number;
font-weight: 500;
}
/* ── list ── */
.trend-list {
margin: 0 24px;
}
.trend-list-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 16px;
}
.trend-tabs {
display: flex;
gap: 16px;
}
.trend-tab {
padding: 10px 28px;
border-radius: 20px;
background: $card;
}
.trend-tab.active {
background: $pri;
}
.trend-tab-text {
font-size: 24px;
color: $tx2;
}
.trend-tab.active .trend-tab-text {
color: white;
}
.trend-chart {
margin: 24px;
background: $card;
border-radius: $r;
padding: 24px;
min-height: 300px;
display: flex;
align-items: flex-end;
}
.trend-empty {
font-size: 26px;
color: $tx3;
text-align: center;
width: 100%;
align-self: center;
}
.chart-bars {
display: flex;
align-items: flex-end;
gap: 8px;
width: 100%;
height: 240px;
}
.chart-bar-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
justify-content: flex-end;
}
.chart-bar {
width: 100%;
background: linear-gradient(to top, $pri, $pri-l);
border-radius: 4px 4px 0 0;
min-height: 4px;
}
.chart-bar-date {
font-size: 18px;
color: $tx3;
margin-top: 6px;
}
.trend-list {
margin: 0 24px;
}
.trend-item {
display: flex;
justify-content: space-between;
align-items: center;
background: $card;
padding: 20px 24px;
padding: 22px 28px;
border-bottom: 1px solid $bd-l;
&:first-child {
border-radius: $r $r 0 0;
}
&:last-child {
border-radius: 0 0 $r $r;
border-bottom: none;
}
}
.trend-item:first-child {
border-radius: $r $r 0 0;
.trend-item-warn {
background: $wrn-l;
}
.trend-item:last-child {
border-radius: 0 0 $r $r;
border-bottom: none;
.trend-item-left {
display: flex;
align-items: center;
gap: 12px;
}
.trend-item-date {
@@ -115,8 +150,21 @@
color: $tx2;
}
.trend-item-tag {
@include tag($wrn-l, $wrn);
}
.trend-item-warn .trend-item-tag {
@include tag($dan-l, $dan);
}
.trend-item-value {
font-size: 26px;
font-size: 28px;
color: $pri;
@include serif-number;
font-weight: bold;
}
.trend-item-value-warn {
color: $dan;
}

View File

@@ -33,25 +33,39 @@ export default function Trend() {
const meta = INDICATOR_META[indicator] || { label: indicator, unit: '' };
const isOutOfRange = (val: number) => {
if (meta.refMin !== undefined && val < meta.refMin) return true;
if (meta.refMax !== undefined && val > meta.refMax) return true;
return false;
};
return (
<View className='trend-page'>
<View className='trend-header'>
<Text className='trend-title'>{meta.label} </Text>
<View className='trend-tabs'>
{RANGE_OPTIONS.map((opt) => (
<View
key={opt.value}
className={`trend-tab ${range === opt.value ? 'active' : ''}`}
onClick={() => setRange(opt.value)}
>
<Text className='trend-tab-text'>{opt.label}</Text>
</View>
))}
{/* 页面标题 */}
<View className='trend-hero'>
<View className='trend-hero-icon'>
<Text className='trend-hero-icon-text'>T</Text>
</View>
<Text className='trend-hero-title'>{meta.label}</Text>
</View>
{/* 时间范围切换 */}
<View className='trange-wrap'>
{RANGE_OPTIONS.map((opt) => (
<View
key={opt.value}
className={`trange-tab ${range === opt.value ? 'trange-tab-active' : ''}`}
onClick={() => setRange(opt.value)}
>
<Text className={`trange-tab-text ${range === opt.value ? 'trange-tab-text-active' : ''}`}>
{opt.label}
</Text>
</View>
))}
</View>
{/* ECharts 折线图 */}
<View className='trend-chart-container'>
<View className='trend-chart-card'>
<TrendChart
data={points}
referenceMin={meta.refMin}
@@ -60,15 +74,36 @@ export default function Trend() {
/>
</View>
{/* 参考区间 */}
{meta.refMin !== undefined && meta.refMax !== undefined && (
<View className='trend-ref-card'>
<Text className='trend-ref-label'></Text>
<Text className='trend-ref-value'>
{meta.refMin} ~ {meta.refMax} {meta.unit}
</Text>
</View>
)}
{/* 数据列表 */}
{points.length > 0 && (
<View className='trend-list'>
{points.slice().reverse().map((p, i) => (
<View className='trend-item' key={i}>
<Text className='trend-item-date'>{p.date}</Text>
<Text className='trend-item-value'>{p.value}{meta.unit ? ` ${meta.unit}` : ''}</Text>
</View>
))}
<Text className='trend-list-title'></Text>
{points.slice().reverse().map((p, i) => {
const abnormal = isOutOfRange(p.value);
return (
<View className={`trend-item ${abnormal ? 'trend-item-warn' : ''}`} key={i}>
<View className='trend-item-left'>
<Text className='trend-item-date'>{p.date}</Text>
{abnormal && (
<Text className='trend-item-tag'></Text>
)}
</View>
<Text className={`trend-item-value ${abnormal ? 'trend-item-value-warn' : ''}`}>
{p.value}{meta.unit ? ` ${meta.unit}` : ''}
</Text>
</View>
);
})}
</View>
)}
</View>

View File

@@ -1,177 +1,263 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
.index-page {
padding-bottom: 20px;
.home-page {
min-height: 100vh;
background: $bg;
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
.greeting-bar {
/* ─── 问候区 ─── */
.greeting-section {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
padding: 40px 32px 60px;
color: white;
padding: 48px 32px 72px;
color: #fff;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.greeting-text {
margin-bottom: 8px;
.greeting-left {
display: flex;
flex-direction: column;
}
.greeting-hello {
font-size: 36px;
font-weight: bold;
.greeting-time {
font-size: 26px;
opacity: 0.85;
margin-bottom: 4px;
}
.greeting-name {
font-size: 36px;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 44px;
font-weight: bold;
margin-left: 12px;
}
.greeting-date {
font-size: 24px;
opacity: 0.8;
opacity: 0.7;
margin-top: 8px;
}
.health-card {
/* ─── 今日健康 ─── */
.health-section {
background: $card;
border-radius: $r;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
margin: -30px 24px 24px;
box-shadow: $shadow-md;
margin: -36px 24px 24px;
padding: 28px;
}
.section-title {
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
@include section-title;
}
.health-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
gap: 16px;
}
.health-item {
.health-cell {
background: $bg;
border-radius: $r-sm;
padding: 20px;
padding: 20px 16px;
text-align: center;
border-left: 4px solid transparent;
transition: opacity 0.2s;
&.health-item-ok { border-left-color: $acc; }
&.health-item-warn { border-left-color: $dan; }
&:active {
opacity: 0.7;
}
}
.health-item-bottom {
.health-cell-label {
font-size: 22px;
color: $tx2;
display: block;
margin-bottom: 8px;
}
.health-cell-value {
@include serif-number;
font-size: 44px;
font-weight: bold;
color: $tx;
display: block;
line-height: 1.1;
}
.health-cell-bottom {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.health-status {
.health-cell-unit {
font-size: 20px;
&.normal { color: $acc; }
&.high, &.low { color: $dan; font-weight: bold; }
}
.health-label {
font-size: 24px;
color: $tx2;
display: block;
}
.health-value {
font-size: 36px;
font-weight: bold;
color: $pri;
display: block;
margin: 8px 0 4px;
}
.health-unit {
font-size: 22px;
color: $tx3;
}
.quick-services {
.health-cell-tag {
font-size: 18px;
font-weight: 500;
padding: 2px 10px;
border-radius: $r-sm;
display: inline-block;
&.tag-ok {
background: $acc-l;
color: $acc;
}
&.tag-warn {
background: $wrn-l;
color: $wrn;
}
}
/* ─── 快捷服务 ─── */
.services-section {
margin: 0 24px 24px;
}
.service-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
.services-row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.service-item {
.service-btn {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
gap: 8px;
flex: 1;
&:active {
opacity: 0.7;
}
}
.service-icon {
font-size: 48px;
margin-bottom: 8px;
.service-icon-wrap {
width: 88px;
height: 88px;
border-radius: $r;
background: $pri-l;
@include flex-center;
}
.service-icon-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
font-weight: bold;
color: $pri;
}
.service-label {
font-size: 24px;
font-size: 22px;
color: $tx2;
text-align: center;
}
.upcoming {
/* ─── 待办事项 ─── */
.upcoming-section {
margin: 0 24px;
}
.upcoming-empty {
background: $card;
border-radius: $r;
padding: 48px 24px;
text-align: center;
box-shadow: $shadow-sm;
}
.upcoming-empty-text {
display: block;
font-size: 28px;
color: $tx2;
margin-bottom: 8px;
}
.upcoming-empty-hint {
display: block;
font-size: 24px;
color: $tx3;
}
.upcoming-list {
background: $card;
border-radius: $r;
overflow: hidden;
box-shadow: $shadow-sm;
}
.upcoming-item {
display: flex;
align-items: center;
padding: 24px 28px;
border-bottom: 1px solid $bd;
&:last-child { border-bottom: none; }
padding: 24px 24px;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
&:active {
background: $bd-l;
}
}
.upcoming-item-main {
flex: 1;
min-width: 0;
}
.upcoming-item-title {
font-size: 28px;
color: $tx;
display: block;
margin-bottom: 6px;
margin-bottom: 4px;
font-weight: 500;
}
.upcoming-item-sub {
font-size: 22px;
color: $tx3;
color: $tx2;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upcoming-item-tag {
font-size: 20px;
font-weight: 500;
padding: 4px 14px;
border-radius: $r-sm;
flex-shrink: 0;
margin-right: 12px;
&.tag-ok {
background: $acc-l;
color: $acc;
}
&.tag-warn {
background: $wrn-l;
color: $wrn;
}
&.tag-default {
background: $bd-l;
color: $tx2;
}
}
.upcoming-item-arrow {
font-size: 36px;
color: $tx3;
padding-left: 12px;
}
.empty-hint {
background: $card;
border-radius: $r;
padding: 40px;
text-align: center;
}
.empty-text {
font-size: 26px;
font-size: 32px;
color: $tx3;
flex-shrink: 0;
}

View File

@@ -1,4 +1,5 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
.login-scroll {
height: 100vh;
@@ -6,105 +7,132 @@
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
background: $bg;
display: flex;
flex-direction: column;
align-items: center;
padding: 120px 60px 60px;
padding: 160px 56px 80px;
}
.login-header {
/* ─── 品牌区 ─── */
.login-brand {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 120px;
margin-bottom: 80px;
}
.login-logo {
width: 120px;
height: 120px;
background: rgba(255, 255, 255, 0.2);
border-radius: 30px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30px;
width: 128px;
height: 128px;
border-radius: $r-lg;
background: $pri;
@include flex-center;
margin-bottom: 36px;
box-shadow: 0 8px 24px rgba($pri, 0.3);
}
.login-logo-text {
font-size: 60px;
color: white;
.login-logo-mark {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 64px;
color: #fff;
font-weight: bold;
line-height: 1;
}
.login-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 48px;
color: white;
color: $tx;
font-weight: bold;
margin-bottom: 12px;
}
.login-subtitle {
font-size: 28px;
color: rgba(255, 255, 255, 0.8);
font-size: 26px;
color: $tx2;
letter-spacing: 0.05em;
}
.login-btn {
width: 100%;
height: 88px;
background: white;
color: $pri;
font-size: 32px;
font-weight: bold;
border-radius: $r;
border: none;
display: flex;
align-items: center;
justify-content: center;
/* ─── 装饰线 ─── */
.login-divider {
width: 48px;
margin-bottom: 64px;
}
.login-divider-line {
height: 3px;
background: $pri;
border-radius: 2px;
opacity: 0.4;
}
/* ─── 登录按钮 ─── */
.login-body {
width: 100%;
}
.login-btn {
width: 100%;
height: 96px;
background: $pri;
color: #fff;
font-size: 32px;
font-weight: 600;
border-radius: $r;
border: none;
@include flex-center;
letter-spacing: 0.04em;
box-shadow: 0 4px 16px rgba($pri, 0.25);
&::after {
border: none;
}
&:active {
opacity: 0.85;
}
}
/* ─── 协议 ─── */
.agreement-row {
display: flex;
align-items: flex-start;
margin-top: 32px;
margin-top: 40px;
gap: 12px;
width: 100%;
}
.checkbox {
.agreement-check {
width: 32px;
height: 32px;
border: 2px solid rgba(255, 255, 255, 0.6);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid $bd;
border-radius: $r-sm;
@include flex-center;
flex-shrink: 0;
margin-top: 2px;
transition: all 0.2s;
&.checked {
background: $pri;
border-color: $pri;
}
}
.checkbox.checked {
background: white;
border-color: white;
}
.check-mark {
font-size: 22px;
color: $pri;
.agreement-check-mark {
font-size: 20px;
color: #fff;
font-weight: bold;
line-height: 1;
}
.agreement-text {
font-size: 24px;
color: rgba(255, 255, 255, 0.8);
line-height: 1.6;
font-size: 22px;
color: $tx2;
line-height: 1.7;
}
.agreement-link {
color: white;
font-weight: bold;
color: $pri;
font-weight: 500;
}

View File

@@ -9,7 +9,6 @@ export default function Login() {
const [agreed, setAgreed] = useState(false);
const { login, bindPhone, loading, isMedicalStaff } = useAuthStore();
/** 登录/绑定成功后根据角色跳转 */
const navigateAfterLogin = () => {
if (isMedicalStaff()) {
Taro.redirectTo({ url: '/pages/doctor/index' });
@@ -59,14 +58,21 @@ export default function Login() {
return (
<ScrollView scrollY className='login-scroll'>
<View className='login-page'>
<View className='login-header'>
{/* 品牌区 */}
<View className='login-brand'>
<View className='login-logo'>
<Text className='login-logo-text'>+</Text>
<Text className='login-logo-mark'>+</Text>
</View>
<Text className='login-title'></Text>
<Text className='login-subtitle'></Text>
</View>
{/* 装饰线 */}
<View className='login-divider'>
<View className='login-divider-line' />
</View>
{/* 登录按钮 */}
<View className='login-body'>
{!needBind ? (
<Button className='login-btn' onClick={handleWechatLogin} loading={loading}>
@@ -84,9 +90,10 @@ export default function Login() {
)}
</View>
{/* 协议 */}
<View className='agreement-row'>
<View className={`checkbox ${agreed ? 'checked' : ''}`} onClick={() => setAgreed(!agreed)}>
{agreed && <Text className='check-mark'>&#10003;</Text>}
<View className={`agreement-check ${agreed ? 'checked' : ''}`} onClick={() => setAgreed(!agreed)}>
{agreed && <Text className='agreement-check-mark'>&#10003;</Text>}
</View>
<Text className='agreement-text'>

View File

@@ -1,5 +1,35 @@
@import '../../../styles/variables.scss';
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.detail-page {
min-height: 100vh;
background: $bg;
@@ -8,34 +38,34 @@
/* ===== 余额卡片 ===== */
.balance-card {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
background: $card;
margin: 20px 24px 16px;
border-radius: $r-lg;
padding: 32px;
padding-top: 40px;
}
.balance-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
box-shadow: $shadow-sm;
}
.balance-label {
font-size: 26px;
color: rgba(255, 255, 255, 0.85);
font-size: 24px;
color: $tx2;
display: block;
margin-bottom: 8px;
}
.balance-value {
font-size: 56px;
@include serif-number;
font-size: 60px;
font-weight: bold;
color: white;
letter-spacing: 1px;
color: $pri;
display: block;
margin-bottom: 28px;
letter-spacing: -1px;
}
.balance-stats {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.12);
background: $bg;
border-radius: $r;
padding: 20px 0;
}
@@ -48,47 +78,46 @@
}
.stat-value {
@include serif-number;
font-size: 30px;
font-weight: bold;
color: white;
margin-bottom: 4px;
&.green {
color: #A7F3D0;
&.stat-earn {
color: $acc;
}
&.orange {
color: #FDE68A;
&.stat-spend {
color: $wrn;
}
&.gray {
color: rgba(255, 255, 255, 0.6);
&.stat-expired {
color: $tx3;
}
}
.stat-label {
font-size: 22px;
color: rgba(255, 255, 255, 0.7);
color: $tx3;
}
.stat-divider {
width: 1px;
height: 48px;
background: rgba(255, 255, 255, 0.2);
background: $bd;
}
/* ===== 类型筛选标签 ===== */
.type-tabs {
display: flex;
gap: 0;
padding: 20px 24px 0;
background: $card;
padding: 0 24px;
margin-bottom: 16px;
}
.type-tab {
@include flex-center;
flex: 1;
text-align: center;
padding: 16px 0;
position: relative;
@@ -98,7 +127,7 @@
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 48px;
width: 40px;
height: 4px;
background: $pri;
border-radius: 2px;
@@ -107,9 +136,9 @@
.type-tab-text {
font-size: 28px;
color: $tx2;
color: $tx3;
&.active {
.type-tab.active & {
color: $pri;
font-weight: bold;
}
@@ -127,45 +156,44 @@
border-radius: $r;
padding: 24px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
}
.tx-icon {
width: 72px;
height: 72px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.tx-badge {
width: 64px;
height: 64px;
border-radius: $r;
@include flex-center;
margin-right: 20px;
flex-shrink: 0;
&.type-earn {
&.tx-badge-earn {
background: $acc-l;
}
&.type-spend {
background: $dan-l;
&.tx-badge-spend {
background: $wrn-l;
}
&.type-expired {
&.tx-badge-expired {
background: $bd-l;
}
}
.tx-icon-text {
font-size: 32px;
.tx-badge-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
.type-earn & {
.tx-badge-earn & {
color: $acc;
}
.type-spend & {
color: $dan;
.tx-badge-spend & {
color: $wrn;
}
.type-expired & {
.tx-badge-expired & {
color: $tx3;
}
}
@@ -200,20 +228,22 @@
}
.tx-amount {
@include serif-number;
font-size: 32px;
font-weight: bold;
margin-bottom: 4px;
&.positive {
&.tx-amount-positive {
color: $acc;
}
&.negative {
color: $dan;
&.tx-amount-negative {
color: $tx2;
}
}
.tx-remaining {
@include serif-number;
font-size: 20px;
color: $tx3;
}

View File

@@ -13,12 +13,6 @@ const TYPE_TABS = [
{ key: 'spend', label: '支出' },
];
const TYPE_ICONS: Record<string, { icon: string; className: string }> = {
earn: { icon: '↑', className: 'type-earn' },
spend: { icon: '↓', className: 'type-spend' },
expired: { icon: '⏰', className: 'type-expired' },
};
export default function PointsDetail() {
const [account, setAccount] = useState<PointsAccount | null>(null);
const [transactions, setTransactions] = useState<PointsTransaction[]>([]);
@@ -48,7 +42,6 @@ export default function PointsDetail() {
page_size: 10,
});
let list = res.data || [];
// 前端按类型过滤(后端暂不支持 type 参数)
if (type) {
list = list.filter((t) => t.type === type);
}
@@ -99,8 +92,16 @@ export default function PointsDetail() {
fetchTransactions(1, key, true);
};
const getTypeConfig = (type: string) => {
return TYPE_ICONS[type] || { icon: '?', className: 'type-earn' };
const getTypeLabel = (type: string) => {
if (type === 'earn') return '收';
if (type === 'spend') return '支';
return '过';
};
const getTypeClass = (type: string) => {
if (type === 'earn') return 'earn';
if (type === 'spend') return 'spend';
return 'expired';
};
const formatAmount = (tx: PointsTransaction) => {
@@ -122,23 +123,21 @@ export default function PointsDetail() {
<View className='detail-page'>
{/* 余额卡片 */}
<View className='balance-card'>
<View className='balance-row'>
<Text className='balance-label'></Text>
<Text className='balance-value'>{balance.toLocaleString()}</Text>
</View>
<Text className='balance-label'></Text>
<Text className='balance-value'>{balance.toLocaleString()}</Text>
<View className='balance-stats'>
<View className='stat-item'>
<Text className='stat-value green'>{(account?.total_earned ?? 0).toLocaleString()}</Text>
<Text className='stat-value stat-earn'>{(account?.total_earned ?? 0).toLocaleString()}</Text>
<Text className='stat-label'></Text>
</View>
<View className='stat-divider' />
<View className='stat-item'>
<Text className='stat-value orange'>{(account?.total_spent ?? 0).toLocaleString()}</Text>
<Text className='stat-value stat-spend'>{(account?.total_spent ?? 0).toLocaleString()}</Text>
<Text className='stat-label'></Text>
</View>
<View className='stat-divider' />
<View className='stat-item'>
<Text className='stat-value gray'>{(account?.total_expired ?? 0).toLocaleString()}</Text>
<Text className='stat-value stat-expired'>{(account?.total_expired ?? 0).toLocaleString()}</Text>
<Text className='stat-label'></Text>
</View>
</View>
@@ -152,26 +151,21 @@ export default function PointsDetail() {
className={`type-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => handleTabChange(tab.key)}
>
<Text
className={`type-tab-text ${activeTab === tab.key ? 'active' : ''}`}
>
{tab.label}
</Text>
<Text className='type-tab-text'>{tab.label}</Text>
</View>
))}
</View>
{/* 交易列表 */}
{transactions.length === 0 && !loading ? (
<EmptyState icon='📊' text='暂无积分记录' hint='签到或兑换后将显示记录' />
<EmptyState icon='' text='暂无积分记录' hint='签到或兑换后将显示记录' />
) : (
<View className='transaction-list'>
{transactions.map((tx) => {
const typeCfg = getTypeConfig(tx.type);
return (
<View className='transaction-item' key={tx.id}>
<View className={`tx-icon ${typeCfg.className}`}>
<Text className='tx-icon-text'>{typeCfg.icon}</Text>
<View className={`tx-badge tx-badge-${getTypeClass(tx.type)}`}>
<Text className='tx-badge-text'>{getTypeLabel(tx.type)}</Text>
</View>
<View className='tx-info'>
<Text className='tx-desc'>
@@ -180,7 +174,7 @@ export default function PointsDetail() {
<Text className='tx-date'>{formatDate(tx.created_at)}</Text>
</View>
<View className='tx-amount-col'>
<Text className={`tx-amount ${tx.type === 'earn' ? 'positive' : 'negative'}`}>
<Text className={`tx-amount tx-amount-${tx.type === 'earn' ? 'positive' : 'negative'}`}>
{formatAmount(tx)}
</Text>
<Text className='tx-remaining'> {tx.balance_after.toLocaleString()}</Text>

View File

@@ -1,5 +1,35 @@
@import '../../../styles/variables.scss';
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.exchange-page {
min-height: 100vh;
background: $bg;
@@ -7,58 +37,69 @@
}
/* ===== 商品预览 ===== */
.product-preview {
.product-card {
display: flex;
align-items: center;
padding: 32px 24px;
background: $card;
margin-bottom: 16px;
margin: 20px 24px 16px;
border-radius: $r-lg;
box-shadow: $shadow-sm;
}
.preview-image {
width: 160px;
height: 160px;
.product-icon-wrap {
width: 128px;
height: 128px;
border-radius: $r;
display: flex;
align-items: center;
justify-content: center;
@include flex-center;
margin-right: 24px;
flex-shrink: 0;
}
.preview-icon {
font-size: 64px;
.product-icon-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 52px;
font-weight: bold;
color: #FFFFFF;
}
.preview-info {
.product-meta {
flex: 1;
min-width: 0;
}
.preview-name {
.product-name {
font-size: 32px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 8px;
margin-bottom: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-type {
font-size: 24px;
color: $tx3;
display: block;
.product-type-tag {
@include tag($pri-l, $pri-d);
}
/* ===== 兑换详情 ===== */
.exchange-detail {
background: $card;
/* ===== 兑换明细 ===== */
.detail-section {
padding: 0 24px;
margin-bottom: 16px;
}
.detail-section-title {
@include section-title;
}
.detail-card {
background: $card;
border-radius: $r;
box-shadow: $shadow-sm;
padding: 0 24px;
}
.detail-row {
display: flex;
justify-content: space-between;
@@ -66,7 +107,7 @@
padding: 24px 0;
border-bottom: 1px solid $bd-l;
&:last-child {
&.last {
border-bottom: none;
}
}
@@ -77,35 +118,37 @@
}
.detail-value {
@include serif-number;
font-size: 28px;
color: $tx;
font-weight: bold;
&.cost {
color: $wrn;
font-size: 32px;
&.detail-cost {
color: $pri;
font-size: 34px;
}
&.sufficient {
&.detail-sufficient {
color: $acc;
}
&.insufficient {
&.detail-insufficient {
color: $dan;
}
}
/* ===== 温馨提示 ===== */
.exchange-notice {
.notice-section {
background: $card;
padding: 24px;
margin: 0 24px;
border-radius: $r;
box-shadow: $shadow-sm;
}
.notice-title {
@include section-title;
font-size: 28px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 12px;
}
@@ -113,7 +156,7 @@
font-size: 24px;
color: $tx3;
display: block;
line-height: 1.6;
line-height: 1.7;
margin-bottom: 4px;
}
@@ -128,7 +171,7 @@
padding: 16px 24px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
background: $card;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
box-shadow: 0 -2px 12px rgba(45, 42, 38, 0.06);
z-index: 10;
}
@@ -143,31 +186,28 @@
color: $tx3;
}
.footer-cost-value {
display: flex;
align-items: center;
gap: 4px;
}
.footer-cost-icon {
font-size: 24px;
}
.footer-cost-num {
font-size: 36px;
@include serif-number;
font-size: 38px;
font-weight: bold;
color: $wrn;
color: $pri;
}
.footer-cost-unit {
font-size: 22px;
color: $tx2;
margin-left: 4px;
}
.confirm-btn {
background: $pri;
padding: 20px 48px;
border-radius: $r;
border-radius: $r-pill;
transition: opacity 0.2s;
&.disabled {
background: $tx3;
opacity: 0.6;
background: $bd;
opacity: 0.7;
}
}

View File

@@ -10,10 +10,22 @@ import type { PointsAccount, PointsProduct } from '../../../services/points';
import Loading from '../../../components/Loading';
import './index.scss';
const TYPE_ICONS: Record<string, string> = {
physical: '📦',
service: '🎫',
privilege: '👑',
const TYPE_INITIAL: Record<string, string> = {
physical: '',
service: '',
privilege: '',
};
const TYPE_LABEL: Record<string, string> = {
physical: '实物商品',
service: '服务券',
privilege: '权益卡',
};
const TYPE_COLOR: Record<string, string> = {
physical: '#5B7A5E',
service: '#C4623A',
privilege: '#8B3E1F',
};
export default function ExchangeConfirm() {
@@ -81,7 +93,6 @@ export default function ExchangeConfirm() {
const order = await exchangeProduct(product.id);
Taro.showToast({ title: '兑换成功', icon: 'success', duration: 2000 });
// 展示核销码弹窗
setTimeout(() => {
Taro.showModal({
title: '兑换成功',
@@ -115,62 +126,62 @@ export default function ExchangeConfirm() {
);
}
const productType = product?.product_type || 'physical';
const initial = TYPE_INITIAL[productType] || '礼';
const typeLabel = TYPE_LABEL[productType] || '商品';
const typeColor = TYPE_COLOR[productType] || '#C4623A';
return (
<View className='exchange-page'>
{/* 商品信息卡片 */}
<View className='product-preview'>
{/* 商品预览卡片 */}
<View className='product-card'>
<View
className='preview-image'
style={{ backgroundColor: '#0891B2' }}
className='product-icon-wrap'
style={{ backgroundColor: typeColor }}
>
<Text className='preview-icon'>
{product ? TYPE_ICONS[product.product_type] || '🎁' : '🎁'}
</Text>
<Text className='product-icon-char'>{initial}</Text>
</View>
<View className='preview-info'>
<Text className='preview-name'>{product?.name || ''}</Text>
<Text className='preview-type'>
{product?.product_type === 'physical'
? '实物商品'
: product?.product_type === 'service'
? '服务券'
: '权益卡'}
</Text>
<View className='product-meta'>
<Text className='product-name'>{product?.name || ''}</Text>
<Text className='product-type-tag'>{typeLabel}</Text>
</View>
</View>
{/* 兑换详情 */}
<View className='exchange-detail'>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value cost'>{cost.toLocaleString()}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text
className={`detail-value ${insufficient ? 'insufficient' : 'sufficient'}`}
>
{balance.toLocaleString()}
</Text>
</View>
{insufficient && (
{/* 兑换明细 */}
<View className='detail-section'>
<Text className='detail-section-title'></Text>
<View className='detail-card'>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value insufficient'>
-{(cost - balance).toLocaleString()}
<Text className='detail-label'></Text>
<Text className='detail-value detail-cost'>{cost.toLocaleString()}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text
className={`detail-value ${insufficient ? 'detail-insufficient' : 'detail-sufficient'}`}
>
{balance.toLocaleString()}
</Text>
</View>
{insufficient && (
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value detail-insufficient'>
-{(cost - balance).toLocaleString()}
</Text>
</View>
)}
<View className='detail-row last'>
<Text className='detail-label'></Text>
<Text className='detail-value'>
{product && product.stock > 0 ? `剩余 ${product.stock}` : '已兑完'}
</Text>
</View>
)}
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>
{product && product.stock > 0 ? `剩余 ${product.stock}` : '已兑完'}
</Text>
</View>
</View>
{/* 温馨提示 */}
<View className='exchange-notice'>
<View className='notice-section'>
<Text className='notice-title'></Text>
<Text className='notice-text'>
@@ -182,10 +193,8 @@ export default function ExchangeConfirm() {
<View className='exchange-footer'>
<View className='footer-cost'>
<Text className='footer-cost-label'></Text>
<View className='footer-cost-value'>
<Text className='footer-cost-icon'>🪙</Text>
<Text className='footer-cost-num'>{cost.toLocaleString()}</Text>
</View>
<Text className='footer-cost-num'>{cost.toLocaleString()}</Text>
<Text className='footer-cost-unit'></Text>
</View>
<View
className={`confirm-btn ${insufficient || (product?.stock ?? 0) <= 0 || submitting ? 'disabled' : ''}`}

View File

@@ -1,16 +1,16 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
.mall-page {
min-height: 100vh;
background: $bg;
padding-bottom: 40px;
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
/* ===== 积分余额卡片 ===== */
/* ─── 积分余额卡片 ─── */
.mall-header {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
padding: 32px;
padding-top: 48px;
padding: 48px 32px 36px;
}
.points-card {
@@ -20,7 +20,7 @@
padding: 32px;
}
.points-card-top {
.points-top {
display: flex;
justify-content: space-between;
align-items: center;
@@ -36,9 +36,13 @@
background: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.4);
padding: 10px 28px;
border-radius: 32px;
border-radius: $r-pill;
transition: all 0.2s;
&:active {
opacity: 0.8;
}
&.checked {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
@@ -47,8 +51,8 @@
.checkin-btn-text {
font-size: 24px;
color: white;
font-weight: bold;
color: #fff;
font-weight: 600;
}
.checkin-btn.checked .checkin-btn-text {
@@ -56,12 +60,14 @@
}
.points-balance {
@include serif-number;
font-size: 72px;
font-weight: bold;
color: white;
color: #fff;
display: block;
margin-bottom: 8px;
letter-spacing: 2px;
line-height: 1;
}
.points-streak {
@@ -70,12 +76,10 @@
display: block;
}
/* ===== 商品类型切换 ===== */
/* ─── 商品类型切换 ─── */
.type-tabs {
display: flex;
gap: 0;
padding: 20px 24px 0;
background: transparent;
}
.type-tab {
@@ -103,15 +107,15 @@
&.active {
color: $pri;
font-weight: bold;
font-weight: 600;
}
}
/* ===== 商品网格 ===== */
/* ─── 商品网格 ─── */
.product-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
gap: 16px;
padding: 20px 24px;
}
@@ -119,19 +123,32 @@
background: $card;
border-radius: $r;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
box-shadow: $shadow-sm;
&:active {
opacity: 0.7;
}
}
.product-image {
width: 100%;
height: 240px;
display: flex;
align-items: center;
justify-content: center;
height: 200px;
@include flex-center;
&.type-physical { background: $pri-l; }
&.type-service { background: $acc-l; }
&.type-privilege { background: $wrn-l; }
}
.product-image-icon {
font-size: 64px;
.product-image-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 56px;
font-weight: bold;
color: $pri;
line-height: 1;
.type-service & { color: $acc; }
.type-privilege & { color: $wrn; }
}
.product-info {
@@ -140,7 +157,7 @@
.product-name {
font-size: 26px;
font-weight: bold;
font-weight: 600;
color: $tx;
display: block;
margin-bottom: 12px;
@@ -161,11 +178,15 @@
gap: 4px;
}
.product-points-icon {
.product-points-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 22px;
font-weight: bold;
color: $wrn;
}
.product-points-value {
@include serif-number;
font-size: 28px;
font-weight: bold;
color: $wrn;
@@ -174,15 +195,69 @@
.product-stock {
font-size: 20px;
padding: 2px 10px;
border-radius: 8px;
border-radius: $r-sm;
&.out {
color: $tx3;
background: $bd-l;
@include tag($bd-l, $tx3);
}
&.low {
color: $dan;
background: $dan-l;
@include tag($dan-l, $dan);
}
}
/* ─── 空状态 ─── */
.mall-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 160px 40px;
}
.empty-icon {
width: 120px;
height: 120px;
border-radius: 50%;
background: $pri-l;
@include flex-center;
margin-bottom: 32px;
}
.empty-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 52px;
font-weight: bold;
color: $pri;
line-height: 1;
}
.empty-title {
font-size: 32px;
font-weight: 600;
color: $tx;
margin-bottom: 12px;
}
.empty-hint {
font-size: 26px;
color: $tx3;
text-align: center;
margin-bottom: 24px;
}
.empty-action {
background: $pri;
border-radius: $r;
padding: 16px 48px;
&:active {
opacity: 0.85;
}
}
.empty-action-text {
font-size: 28px;
color: #fff;
font-weight: 600;
}

View File

@@ -9,21 +9,20 @@ import {
} from '../../services/points';
import type { PointsAccount, PointsProduct, CheckinStatus } from '../../services/points';
import { useAuthStore } from '../../stores/auth';
import EmptyState from '../../components/EmptyState';
import Loading from '../../components/Loading';
import './index.scss';
const PRODUCT_TYPE_TABS = [
{ key: '', label: '全部' },
{ key: 'physical', label: '实物' },
{ key: 'service', label: '服务券' },
{ key: 'privilege', label: '权益' },
{ key: 'physical', label: '实物', char: '物' },
{ key: 'service', label: '服务券', char: '券' },
{ key: 'privilege', label: '权益', char: '权' },
];
const TYPE_COLORS: Record<string, string> = {
physical: '#0891B2',
service: '#059669',
privilege: '#D97706',
const TYPE_BG: Record<string, string> = {
physical: 'type-physical',
service: 'type-service',
privilege: 'type-privilege',
};
export default function Mall() {
@@ -117,14 +116,9 @@ export default function Mall() {
try {
const result = await dailyCheckin();
setCheckinStatus(result);
// 刷新积分余额
const acct = await getAccount();
setAccount(acct);
Taro.showToast({
title: '签到成功',
icon: 'success',
duration: 2000,
});
Taro.showToast({ title: '签到成功', icon: 'success', duration: 2000 });
} catch (err) {
Taro.showToast({
title: err instanceof Error ? err.message : '签到失败',
@@ -150,40 +144,36 @@ export default function Mall() {
const balance = account?.balance ?? 0;
if (noProfile) {
return (
<View className='mall-page'>
<View className='mall-empty-state'>
<View className='empty-icon'>
<Text className='empty-char'></Text>
</View>
<Text className='empty-title'></Text>
<Text className='empty-hint'>使</Text>
<View className='empty-action' onClick={() => Taro.navigateTo({ url: '/pages/profile/family-add/index' })}>
<Text className='empty-action-text'></Text>
</View>
</View>
</View>
);
}
return (
<View className='mall-page'>
{/* 未关联患者档案时显示引导 */}
{noProfile && (
<View className='mall-page'>
<EmptyState
icon='👤'
text='请先完善个人档案'
hint='建档后即可使用积分商城、签到等功能'
actionText='去建档'
onAction={() => Taro.navigateTo({ url: '/pages/profile/family-add/index' })}
/>
</View>
)}
{!noProfile && (
<>
{/* 积分余额卡片 */}
<View className='mall-header'>
<View className='points-card'>
<View className='points-card-top'>
<View className='points-top'>
<Text className='points-label'></Text>
<View
className={`checkin-btn ${
checkinStatus?.checked_in_today ? 'checked' : ''
}`}
className={`checkin-btn ${checkinStatus?.checked_in_today ? 'checked' : ''}`}
onClick={handleCheckin}
>
<Text className='checkin-btn-text'>
{checkinLoading
? '...'
: checkinStatus?.checked_in_today
? '已签到'
: '签到'}
{checkinLoading ? '...' : checkinStatus?.checked_in_today ? '已签到' : '签到'}
</Text>
</View>
</View>
@@ -204,11 +194,7 @@ export default function Mall() {
className={`type-tab ${productType === tab.key ? 'active' : ''}`}
onClick={() => handleTabChange(tab.key)}
>
<Text
className={`type-tab-text ${
productType === tab.key ? 'active' : ''
}`}
>
<Text className={`type-tab-text ${productType === tab.key ? 'active' : ''}`}>
{tab.label}
</Text>
</View>
@@ -217,35 +203,28 @@ export default function Mall() {
{/* 商品列表 */}
{products.length === 0 && !loading ? (
<EmptyState
icon='🎁'
text='暂无商品'
hint='更多好物即将上架'
/>
<View className='mall-empty-state'>
<View className='empty-icon'>
<Text className='empty-char'></Text>
</View>
<Text className='empty-title'></Text>
<Text className='empty-hint'></Text>
</View>
) : (
<View className='product-grid'>
{products.map((item) => (
<View className='product-card' key={item.id} onClick={() => handleProductClick(item)}>
<View
className='product-image'
style={{ backgroundColor: TYPE_COLORS[item.product_type] || '#94A3B8' }}
>
<Text className='product-image-icon'>
{item.product_type === 'physical'
? '📦'
: item.product_type === 'service'
? '🎫'
: '👑'}
<View className={`product-image ${TYPE_BG[item.product_type] || ''}`}>
<Text className='product-image-char'>
{item.product_type === 'physical' ? '物' : item.product_type === 'service' ? '券' : '权'}
</Text>
</View>
<View className='product-info'>
<Text className='product-name'>{item.name}</Text>
<View className='product-bottom'>
<View className='product-points'>
<Text className='product-points-icon'>🪙</Text>
<Text className='product-points-value'>
{item.points_cost}
</Text>
<Text className='product-points-char'>P</Text>
<Text className='product-points-value'>{item.points_cost}</Text>
</View>
{item.stock <= 0 ? (
<Text className='product-stock out'></Text>
@@ -262,8 +241,6 @@ export default function Mall() {
)}
</View>
)}
</>
)}
</View>
);
}

View File

@@ -1,5 +1,35 @@
@import '../../../styles/variables.scss';
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.orders-page {
min-height: 100vh;
background: $bg;
@@ -13,11 +43,12 @@
padding: 20px 24px 0;
background: $card;
margin-bottom: 16px;
border-radius: 0 0 $r-lg $r-lg;
}
.status-tab {
flex: 1;
text-align: center;
@include flex-center;
padding: 16px 0;
position: relative;
@@ -27,7 +58,7 @@
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 48px;
width: 40px;
height: 4px;
background: $pri;
border-radius: 2px;
@@ -36,9 +67,9 @@
.status-tab-text {
font-size: 28px;
color: $tx2;
color: $tx3;
&.active {
.status-tab.active & {
color: $pri;
font-weight: bold;
}
@@ -54,7 +85,7 @@
border-radius: $r;
margin-bottom: 16px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
box-shadow: $shadow-sm;
}
.order-header {
@@ -66,6 +97,7 @@
}
.order-product {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $tx;
@@ -75,43 +107,12 @@
white-space: nowrap;
}
.order-status {
.order-status-tag {
@include tag(transparent, $tx3);
padding: 4px 16px;
border-radius: 20px;
border-radius: $r-pill;
margin-left: 12px;
flex-shrink: 0;
&.status-pending {
background: $wrn-l;
.order-status-text {
color: $wrn;
}
}
&.status-verified {
background: $acc-l;
.order-status-text {
color: $acc;
}
}
&.status-cancelled {
background: $dan-l;
.order-status-text {
color: $dan;
}
}
&.status-expired {
background: $bd-l;
.order-status-text {
color: $tx3;
}
}
}
.order-status-text {
@@ -136,11 +137,12 @@
}
.order-row-value {
@include serif-number;
font-size: 26px;
color: $tx;
&.cost {
color: $wrn;
&.order-cost {
color: $pri;
font-weight: bold;
}
}
@@ -158,11 +160,13 @@
.qrcode-label {
font-size: 24px;
color: $tx3;
margin-right: 8px;
}
.qrcode-value {
@include serif-number;
font-size: 24px;
color: $pri;
color: $pri-d;
font-weight: bold;
flex: 1;
overflow: hidden;

View File

@@ -14,11 +14,11 @@ const STATUS_TABS = [
{ key: 'expired', label: '已过期' },
];
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
pending: { label: '待核销', className: 'status-pending' },
verified: { label: '已核销', className: 'status-verified' },
cancelled: { label: '已取消', className: 'status-cancelled' },
expired: { label: '已过期', className: 'status-expired' },
const STATUS_CONFIG: Record<string, { label: string; tagBg: string; tagColor: string }> = {
pending: { label: '待核销', tagBg: '#FFF3E0', tagColor: '#C4873A' },
verified: { label: '已核销', tagBg: '#E8F0E8', tagColor: '#5B7A5E' },
cancelled: { label: '已取消', tagBg: '#FDEAEA', tagColor: '#B54A4A' },
expired: { label: '已过期', tagBg: '#F0EBE5', tagColor: '#A8A29E' },
};
export default function MallOrders() {
@@ -40,7 +40,6 @@ export default function MallOrders() {
page_size: 10,
});
let list = res.data || [];
// 前端按状态过滤(后端暂不支持 status 参数)
if (status) {
list = list.filter((o) => o.status === status);
}
@@ -101,7 +100,7 @@ export default function MallOrders() {
};
const getStatusConfig = (status: string) => {
return STATUS_CONFIG[status] || { label: status, className: 'status-pending' };
return STATUS_CONFIG[status] || { label: status, tagBg: '#F0EBE5', tagColor: '#A8A29E' };
};
const formatDate = (dateStr: string) => {
@@ -120,11 +119,7 @@ export default function MallOrders() {
className={`status-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => handleTabChange(tab.key)}
>
<Text
className={`status-tab-text ${activeTab === tab.key ? 'active' : ''}`}
>
{tab.label}
</Text>
<Text className='status-tab-text'>{tab.label}</Text>
</View>
))}
</View>
@@ -132,7 +127,7 @@ export default function MallOrders() {
{/* 订单列表 */}
{orders.length === 0 && !loading ? (
<EmptyState
icon='📋'
icon=''
text='暂无订单'
hint='去商城兑换心仪商品吧'
actionText='去商城'
@@ -146,7 +141,10 @@ export default function MallOrders() {
<View className='order-card' key={order.id}>
<View className='order-header'>
<Text className='order-product'> {order.product_id.slice(0, 8)}</Text>
<View className={`order-status ${statusCfg.className}`}>
<View
className='order-status-tag'
style={{ background: statusCfg.tagBg, color: statusCfg.tagColor }}
>
<Text className='order-status-text'>{statusCfg.label}</Text>
</View>
</View>
@@ -154,8 +152,8 @@ export default function MallOrders() {
<View className='order-body'>
<View className='order-row'>
<Text className='order-row-label'></Text>
<Text className='order-row-value cost'>
🪙 {order.points_cost.toLocaleString()}
<Text className='order-row-value order-cost'>
{order.points_cost.toLocaleString()}
</Text>
</View>
<View className='order-row'>
@@ -166,9 +164,9 @@ export default function MallOrders() {
</View>
{order.status === 'pending' && (
<View className='order-qrcode' onClick={() => handleShowQrCode(order.qr_code)}>
<Text className='qrcode-label'>: </Text>
<Text className='qrcode-label'></Text>
<Text className='qrcode-value'>{order.qr_code}</Text>
<Text className='qrcode-tap'></Text>
<Text className='qrcode-tap'></Text>
</View>
)}
</View>

View File

@@ -1,17 +1,37 @@
@import '../../../styles/variables.scss';
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.family-add-page {
min-height: 100vh;
background: $bg;
padding: 24px;
padding-bottom: 140px;
padding: 32px 24px;
padding-bottom: 160px;
}
.page-title {
@include section-title;
padding-left: 4px;
}
.form-card {
background: $card;
border-radius: $r;
padding: 8px 28px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
padding: 4px 28px;
box-shadow: $shadow-sm;
}
.form-item {
@@ -27,10 +47,12 @@
}
.form-label {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
color: $tx;
flex-shrink: 0;
width: 140px;
font-weight: 500;
}
.form-input {
@@ -43,6 +65,10 @@
outline: none;
}
.form-placeholder {
color: $tx3;
}
.form-picker {
display: flex;
align-items: center;
@@ -53,12 +79,17 @@
.form-picker-text {
font-size: 28px;
color: $tx;
margin-right: 8px;
margin-right: 10px;
&.placeholder {
color: $tx3;
}
}
.form-picker-arrow {
font-size: 32px;
font-size: 24px;
color: $tx3;
font-family: 'Georgia', 'Times New Roman', serif;
}
.submit-btn {
@@ -69,14 +100,17 @@
background: $pri;
padding: 28px;
text-align: center;
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
&.disabled {
opacity: 0.6;
opacity: 0.5;
}
}
.submit-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
color: white;
color: #fff;
font-weight: bold;
letter-spacing: 2px;
}

View File

@@ -59,12 +59,15 @@ export default function FamilyAdd() {
return (
<View className='family-add-page'>
<Text className='page-title'>{editId ? '编辑就诊人' : '添加就诊人'}</Text>
<View className='form-card'>
<View className='form-item'>
<Text className='form-label'></Text>
<Input
className='form-input'
placeholder='请输入姓名'
placeholderClass='form-placeholder'
value={name}
onInput={(e) => setName(e.detail.value)}
/>
@@ -80,7 +83,7 @@ export default function FamilyAdd() {
>
<View className='form-picker'>
<Text className='form-picker-text'>{RELATION_OPTIONS[relationIdx]}</Text>
<Text className='form-picker-arrow'></Text>
<Text className='form-picker-arrow'>></Text>
</View>
</Picker>
</View>
@@ -95,7 +98,7 @@ export default function FamilyAdd() {
>
<View className='form-picker'>
<Text className='form-picker-text'>{GENDER_OPTIONS[genderIdx]}</Text>
<Text className='form-picker-arrow'></Text>
<Text className='form-picker-arrow'>></Text>
</View>
</Picker>
</View>
@@ -108,8 +111,10 @@ export default function FamilyAdd() {
onChange={(e) => setBirthDate(e.detail.value)}
>
<View className='form-picker'>
<Text className='form-picker-text'>{birthDate || '请选择'}</Text>
<Text className='form-picker-arrow'></Text>
<Text className={`form-picker-text ${!birthDate ? 'placeholder' : ''}`}>
{birthDate || '请选择'}
</Text>
<Text className='form-picker-arrow'>></Text>
</View>
</Picker>
</View>

View File

@@ -1,10 +1,45 @@
@import '../../../styles/variables.scss';
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.family-page {
min-height: 100vh;
background: $bg;
padding: 24px;
padding-bottom: 140px;
padding: 32px 24px;
padding-bottom: 160px;
}
.family-page-title {
@include section-title;
padding-left: 4px;
}
.family-list {
@@ -16,42 +51,74 @@
.family-item {
display: flex;
align-items: center;
justify-content: space-between;
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 2px solid transparent;
padding: 24px;
box-shadow: $shadow-sm;
transition: box-shadow 0.2s;
&:active {
box-shadow: $shadow-md;
}
&.active {
border-color: $pri;
background: $pri-surface;
box-shadow: $shadow-md;
}
}
.family-avatar {
@include flex-center;
width: 80px;
height: 80px;
border-radius: $r;
background: $pri-l;
flex-shrink: 0;
margin-right: 20px;
}
.family-avatar-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 36px;
font-weight: bold;
color: $pri-d;
}
.family-info {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.family-name-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.family-name {
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 8px;
}
.family-current-tag {
@include tag($pri, #fff);
font-size: 18px;
padding: 2px 10px;
}
.family-meta {
display: flex;
gap: 16px;
align-items: center;
gap: 12px;
}
.family-tag {
font-size: 24px;
color: $pri;
background: $pri-l;
.family-relation-tag {
@include tag($pri-l, $pri-d);
font-size: 20px;
padding: 2px 12px;
border-radius: 12px;
}
.family-gender {
@@ -59,34 +126,17 @@
color: $tx2;
}
.family-check {
font-size: 24px;
color: $pri;
background: $pri-l;
padding: 6px 16px;
border-radius: 16px;
font-weight: bold;
}
.family-edit {
font-size: 24px;
color: $pri;
flex-shrink: 0;
margin-left: 16px;
padding: 6px 16px;
border: 1px solid $pri;
border-radius: 16px;
padding: 8px 20px;
border: 1px solid $bd;
border-radius: $r-pill;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: 120px 0;
}
.empty-text {
font-size: 28px;
color: $tx3;
.family-edit-text {
font-size: 24px;
color: $tx2;
}
.family-add-btn {
@@ -97,10 +147,13 @@
background: $pri;
padding: 28px;
text-align: center;
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
}
.family-add-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
color: white;
color: #fff;
font-weight: bold;
letter-spacing: 2px;
}

View File

@@ -53,8 +53,14 @@ export default function FamilyList() {
return '未知';
};
const relationInitial = (relation: string) => {
return relation ? relation.charAt(0) : '本';
};
return (
<View className='family-page'>
<Text className='family-page-title'></Text>
<View className='family-list'>
{patients.map((p) => {
const isActive = currentPatient?.id === p.id;
@@ -64,15 +70,25 @@ export default function FamilyList() {
key={p.id}
onClick={() => handleSelect(p)}
>
<View className='family-avatar'>
<Text className='family-avatar-text'>{relationInitial(p.relation || '本人')}</Text>
</View>
<View className='family-info'>
<Text className='family-name'>{p.name}</Text>
<View className='family-name-row'>
<Text className='family-name'>{p.name}</Text>
{isActive && <Text className='family-current-tag'></Text>}
</View>
<View className='family-meta'>
<Text className='family-tag'>{p.relation || '本人'}</Text>
<Text className='family-relation-tag'>{p.relation || '本人'}</Text>
<Text className='family-gender'>{genderText(p.gender)}</Text>
</View>
</View>
{isActive && <Text className='family-check'></Text>}
<Text className='family-edit' onClick={(e) => { e.stopPropagation(); goToEdit(p); }}></Text>
<View
className='family-edit'
onClick={(e) => { e.stopPropagation(); goToEdit(p); }}
>
<Text className='family-edit-text'></Text>
</View>
</View>
);
})}
@@ -83,7 +99,7 @@ export default function FamilyList() {
)}
<View className='family-add-btn' onClick={goToAdd}>
<Text className='family-add-text'>+ </Text>
<Text className='family-add-text'></Text>
</View>
</View>
);

View File

@@ -1,5 +1,20 @@
@import '../../../styles/variables.scss';
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
.my-followups-page {
min-height: 100vh;
background: $bg;
@@ -9,35 +24,23 @@
display: flex;
background: $card;
padding: 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 24px 0;
padding: 24px 0 20px;
position: relative;
&.active {
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 48px;
height: 4px;
background: $pri;
border-radius: 2px;
}
}
}
.tab-text {
font-size: 28px;
color: $tx2;
margin-bottom: 8px;
.tab-item.active & {
color: $pri;
@@ -45,18 +48,29 @@
}
}
.tab-indicator {
width: 32px;
height: 4px;
background: $pri;
border-radius: 2px;
}
.task-list {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
gap: 16px;
}
.task-card {
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
&:active {
box-shadow: $shadow-md;
}
}
.task-top {
@@ -67,29 +81,25 @@
}
.task-name {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
}
.task-status {
font-size: 24px;
padding: 4px 16px;
border-radius: 20px;
@include tag($bd-l, $tx2);
&.pending {
color: $wrn;
background: $wrn-l;
@include tag($wrn-l, $wrn);
}
&.completed {
color: $acc;
background: $acc-l;
@include tag($acc-l, $acc);
}
&.overdue {
color: $dan;
background: $dan-l;
@include tag($dan-l, $dan);
}
}
@@ -104,29 +114,8 @@
}
.task-due {
@include serif-number;
font-size: 24px;
color: $tx3;
display: block;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: 120px 0;
}
.empty-text {
font-size: 28px;
color: $tx3;
}
.loading-hint {
text-align: center;
padding: 24px 0;
}
.loading-text {
font-size: 24px;
color: $tx3;
}

View File

@@ -1,10 +1,13 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
.profile-page {
min-height: 100vh;
background: $bg;
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
/* ─── 用户信息区 ─── */
.profile-header {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
padding: 60px 32px 40px;
@@ -17,75 +20,83 @@
width: 120px;
height: 120px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.25);
@include flex-center;
margin-bottom: 16px;
}
.profile-avatar-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 48px;
color: white;
color: #fff;
font-weight: bold;
line-height: 1;
}
.profile-name {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 34px;
color: white;
color: #fff;
font-weight: bold;
margin-bottom: 4px;
}
.profile-phone {
font-size: 26px;
color: rgba(255, 255, 255, 0.8);
color: rgba(255, 255, 255, 0.75);
}
/* ===== 积分余额信息 ===== */
.profile-points {
/* ─── 积分统计 ─── */
.profile-stats {
display: flex;
align-items: center;
margin-top: 24px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.2);
margin-top: 28px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: $r;
padding: 20px 32px;
width: 100%;
box-sizing: border-box;
&:active {
background: rgba(255, 255, 255, 0.08);
}
}
.points-info-item {
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.points-info-value {
.stat-value {
@include serif-number;
font-size: 36px;
font-weight: bold;
color: white;
color: #fff;
margin-bottom: 4px;
}
.points-info-label {
.stat-label {
font-size: 22px;
color: rgba(255, 255, 255, 0.7);
}
.points-info-divider {
.stat-divider {
width: 1px;
height: 48px;
background: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.2);
margin: 0 24px;
}
/* ─── 菜单 ─── */
.profile-menu {
margin: 24px;
background: $card;
border-radius: $r;
overflow: hidden;
box-shadow: $shadow-sm;
}
.menu-item {
@@ -93,11 +104,32 @@
align-items: center;
padding: 28px 24px;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
&:active {
background: $bd-l;
}
}
.menu-icon {
font-size: 36px;
width: 48px;
height: 48px;
border-radius: $r-sm;
background: $pri-l;
@include flex-center;
margin-right: 16px;
flex-shrink: 0;
}
.menu-icon-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 24px;
font-weight: bold;
color: $pri;
line-height: 1;
}
.menu-label {
@@ -109,32 +141,21 @@
.menu-arrow {
font-size: 32px;
color: $tx3;
flex-shrink: 0;
}
.menu-badge {
background: $dan;
border-radius: 20px;
min-width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 10px;
margin-right: 12px;
}
.menu-badge-text {
font-size: 20px;
color: white;
font-weight: bold;
}
/* ─── 退出登录 ─── */
.profile-logout {
margin: 24px;
background: $card;
border-radius: $r;
padding: 28px;
text-align: center;
box-shadow: $shadow-sm;
&:active {
opacity: 0.7;
}
}
.logout-text {

View File

@@ -7,13 +7,13 @@ import type { PointsAccount, CheckinStatus } from '../../services/points';
import './index.scss';
const MENU_ITEMS = [
{ label: '我的订单', icon: '🛒', path: '/pages/mall/orders/index' },
{ label: '积分明细', icon: '📊', path: '/pages/mall/detail/index' },
{ label: '就诊人管理', icon: '👥', path: '/pages/profile/family/index' },
{ label: '我的报告', icon: '📋', path: '/pages/profile/reports/index' },
{ label: '我的随访', icon: '💬', path: '/pages/profile/followups/index' },
{ label: '用药提醒', icon: '💊', path: '/pages/profile/medication/index' },
{ label: '设置', icon: '⚙️', path: '/pages/profile/settings/index' },
{ label: '我的订单', char: '', path: '/pages/mall/orders/index' },
{ label: '积分明细', char: '', path: '/pages/mall/detail/index' },
{ label: '就诊人管理', char: '', path: '/pages/profile/family/index' },
{ label: '我的报告', char: '', path: '/pages/profile/reports/index' },
{ label: '我的随访', char: '', path: '/pages/profile/followups/index' },
{ label: '用药提醒', char: '', path: '/pages/profile/medication/index' },
{ label: '设置', char: '', path: '/pages/profile/settings/index' },
];
export default function Profile() {
@@ -35,7 +35,7 @@ export default function Profile() {
setPointsAccount(acct);
setCheckinInfo(status);
} catch {
// 账户可能尚未创建,静默处理
// 账户可能尚未创建
}
}, []);
@@ -56,6 +56,7 @@ export default function Profile() {
return (
<View className='profile-page'>
{/* 用户信息区 */}
<View className='profile-header'>
<View className='profile-avatar'>
<Text className='profile-avatar-text'>
@@ -65,27 +66,24 @@ export default function Profile() {
<Text className='profile-name'>{user?.display_name || '未登录'}</Text>
<Text className='profile-phone'>{user?.phone || ''}</Text>
{/* 积分余额信息 */}
{/* 积分余额 */}
<View
className='profile-points'
className='profile-stats'
onClick={() => Taro.navigateTo({ url: '/pages/mall/detail/index' })}
>
<View className='points-info-item'>
<Text className='points-info-value'>
{(pointsAccount?.balance ?? 0).toLocaleString()}
</Text>
<Text className='points-info-label'></Text>
<View className='stat-item'>
<Text className='stat-value'>{(pointsAccount?.balance ?? 0).toLocaleString()}</Text>
<Text className='stat-label'></Text>
</View>
<View className='points-info-divider' />
<View className='points-info-item'>
<Text className='points-info-value'>
{checkinInfo?.consecutive_days ?? 0}
</Text>
<Text className='points-info-label'>()</Text>
<View className='stat-divider' />
<View className='stat-item'>
<Text className='stat-value'>{checkinInfo?.consecutive_days ?? 0}</Text>
<Text className='stat-label'>()</Text>
</View>
</View>
</View>
{/* 菜单 */}
<View className='profile-menu'>
{MENU_ITEMS.map((item) => (
<View
@@ -93,13 +91,16 @@ export default function Profile() {
key={item.label}
onClick={() => handleMenuClick(item.path)}
>
<Text className='menu-icon'>{item.icon}</Text>
<View className='menu-icon'>
<Text className='menu-icon-char'>{item.char}</Text>
</View>
<Text className='menu-label'>{item.label}</Text>
<Text className='menu-arrow'></Text>
</View>
))}
</View>
{/* 退出登录 */}
<View className='profile-logout' onClick={handleLogout}>
<Text className='logout-text'>退</Text>
</View>

View File

@@ -1,10 +1,35 @@
@import '../../../styles/variables.scss';
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.medication-page {
min-height: 100vh;
background: $bg;
padding: 24px;
padding-bottom: 140px;
padding: 32px 24px;
padding-bottom: 160px;
}
.page-title {
@include section-title;
padding-left: 4px;
}
.reminder-list {
@@ -16,19 +41,42 @@
.reminder-card {
display: flex;
align-items: center;
justify-content: space-between;
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
padding: 24px;
box-shadow: $shadow-sm;
&.disabled {
opacity: 0.55;
}
}
.reminder-left {
.reminder-avatar {
@include flex-center;
width: 72px;
height: 72px;
border-radius: $r;
background: $acc-l;
flex-shrink: 0;
margin-right: 20px;
}
.reminder-avatar-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
font-weight: bold;
color: $acc;
}
.reminder-info {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.reminder-name {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
@@ -36,6 +84,7 @@
}
.reminder-dosage {
@include serif-number;
font-size: 24px;
color: $tx2;
}
@@ -44,12 +93,14 @@
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
margin-left: 12px;
}
.toggle {
width: 80px;
height: 44px;
border-radius: 22px;
border-radius: $r-pill;
padding: 4px;
position: relative;
transition: background 0.3s;
@@ -67,7 +118,7 @@
width: 36px;
height: 36px;
border-radius: 50%;
background: white;
background: #fff;
position: absolute;
top: 4px;
transition: left 0.3s;
@@ -87,31 +138,28 @@
padding: 4px 12px;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: 120px 0;
}
.empty-text {
font-size: 28px;
color: $tx3;
}
.form-card {
background: $card;
border-radius: $r;
padding: 28px;
margin-top: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
margin-top: 24px;
box-shadow: $shadow-sm;
}
.form-card-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 0;
padding: 24px 0;
border-bottom: 1px solid $bd-l;
&:last-of-type {
@@ -123,7 +171,7 @@
font-size: 28px;
color: $tx;
flex-shrink: 0;
width: 140px;
width: 160px;
}
.form-input {
@@ -136,20 +184,33 @@
outline: none;
}
.form-placeholder {
color: $tx3;
}
.time-picker-wrap {
flex: 1;
text-align: right;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.time-value {
@include serif-number;
font-size: 28px;
color: $tx;
}
.time-modify {
font-size: 24px;
color: $pri;
}
.form-actions {
display: flex;
gap: 16px;
margin-top: 20px;
margin-top: 24px;
}
.form-cancel {
@@ -175,7 +236,7 @@
.form-confirm-text {
font-size: 28px;
color: white;
color: #fff;
font-weight: bold;
}
@@ -187,10 +248,13 @@
background: $pri;
padding: 28px;
text-align: center;
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
}
.add-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
color: white;
color: #fff;
font-weight: bold;
letter-spacing: 2px;
}

View File

@@ -76,12 +76,21 @@ export default function MedicationReminder() {
Taro.showToast({ title: '添加成功', icon: 'success' });
};
const nameInitial = (name: string) => {
return name ? name.charAt(0) : '药';
};
return (
<View className='medication-page'>
<Text className='page-title'></Text>
<View className='reminder-list'>
{reminders.map((r) => (
<View className='reminder-card' key={r.id}>
<View className='reminder-left'>
<View className={`reminder-card ${!r.enabled ? 'disabled' : ''}`} key={r.id}>
<View className='reminder-avatar'>
<Text className='reminder-avatar-text'>{nameInitial(r.name)}</Text>
</View>
<View className='reminder-info'>
<Text className='reminder-name'>{r.name}</Text>
<Text className='reminder-dosage'>
{r.dosage} | {r.time}
@@ -109,14 +118,15 @@ export default function MedicationReminder() {
<EmptyState text='暂无用药提醒' />
)}
{/* 添加表单 */}
{showForm && (
<View className='form-card'>
<Text className='form-card-title'></Text>
<View className='form-item'>
<Text className='form-label'></Text>
<Input
className='form-input'
placeholder='请输入药品名称'
placeholderClass='form-placeholder'
value={formName}
onInput={(e) => setFormName(e.detail.value)}
/>
@@ -125,7 +135,8 @@ export default function MedicationReminder() {
<Text className='form-label'></Text>
<Input
className='form-input'
placeholder='如1片、10ml'
placeholder='如: 1片、10ml'
placeholderClass='form-placeholder'
value={formDosage}
onInput={(e) => setFormDosage(e.detail.value)}
/>
@@ -139,7 +150,7 @@ export default function MedicationReminder() {
>
<View className='time-picker-wrap'>
<Text className='time-value'>{formTime}</Text>
<Text className='time-arrow'></Text>
<Text className='time-modify'></Text>
</View>
</Picker>
</View>
@@ -156,7 +167,7 @@ export default function MedicationReminder() {
{!showForm && (
<View className='add-btn' onClick={() => setShowForm(true)}>
<Text className='add-text'>+ </Text>
<Text className='add-text'></Text>
</View>
)}
</View>

View File

@@ -1,78 +1,116 @@
@import '../../../styles/variables.scss';
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.my-reports-page {
min-height: 100vh;
background: $bg;
padding: 24px;
padding: 32px 24px;
padding-bottom: 40px;
}
.page-title {
@include section-title;
padding-left: 4px;
}
.report-list {
display: flex;
flex-direction: column;
gap: 20px;
gap: 16px;
}
.report-card {
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
&:active {
box-shadow: $shadow-md;
}
}
.report-card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
margin-bottom: 16px;
}
.report-type-row {
display: flex;
align-items: center;
}
.report-avatar {
@include flex-center;
width: 56px;
height: 56px;
border-radius: $r-sm;
background: $pri-l;
margin-right: 16px;
flex-shrink: 0;
}
.report-avatar-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $pri-d;
}
.report-type {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
}
.report-status {
font-size: 24px;
padding: 4px 16px;
border-radius: 20px;
@include tag($bd-l, $tx2);
&.normal {
color: $acc;
background: $acc-l;
@include tag($acc-l, $acc);
}
&.abnormal {
color: $dan;
background: $dan-l;
@include tag($dan-l, $dan);
}
}
.report-date {
@include serif-number;
font-size: 26px;
color: $tx2;
display: block;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: 120px 0;
}
.empty-text {
font-size: 28px;
color: $tx3;
}
.loading-hint {
text-align: center;
padding: 24px 0;
}
.loading-text {
font-size: 24px;
color: $tx3;
padding-left: 72px;
}

View File

@@ -54,30 +54,44 @@ export default function MyReports() {
const formatStatus = (report: LabReport) => {
const indicators = report.indicators;
if (!indicators || typeof indicators !== 'object') return '未知';
if (!indicators || typeof indicators !== 'object') return 'unknown';
const vals = Object.values(indicators) as Array<{ status?: string }>;
const hasAbnormal = vals.some((v) => v.status === 'high' || v.status === 'low');
return hasAbnormal ? '异常' : '正常';
return hasAbnormal ? 'abnormal' : 'normal';
};
const typeInitial = (type: string) => {
return type ? type.charAt(0) : '报';
};
return (
<View className='my-reports-page'>
<Text className='page-title'></Text>
<View className='report-list'>
{reports.map((r) => (
<View
className='report-card'
key={r.id}
onClick={() => goToDetail(r.id)}
>
<View className='report-card-top'>
<Text className='report-type'>{r.report_type}</Text>
<Text className={`report-status ${formatStatus(r) === '正常' ? 'normal' : 'abnormal'}`}>
{formatStatus(r)}
</Text>
{reports.map((r) => {
const status = formatStatus(r);
return (
<View
className='report-card'
key={r.id}
onClick={() => goToDetail(r.id)}
>
<View className='report-card-top'>
<View className='report-type-row'>
<View className='report-avatar'>
<Text className='report-avatar-text'>{typeInitial(r.report_type)}</Text>
</View>
<Text className='report-type'>{r.report_type}</Text>
</View>
<Text className={`report-status ${status}`}>
{status === 'normal' ? '正常' : status === 'abnormal' ? '异常' : '未知'}
</Text>
</View>
<Text className='report-date'>{r.report_date}</Text>
</View>
<Text className='report-date'>{r.report_date}</Text>
</View>
))}
);
})}
</View>
{reports.length === 0 && !loading && (

View File

@@ -1,9 +1,29 @@
@import '../../../styles/variables.scss';
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.settings-page {
min-height: 100vh;
background: $bg;
padding: 24px;
padding: 32px 24px;
}
.page-title {
@include section-title;
padding-left: 4px;
}
.settings-group {
@@ -11,13 +31,12 @@
border-radius: $r;
overflow: hidden;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
}
.settings-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28px 24px;
border-bottom: 1px solid $bd-l;
@@ -30,21 +49,37 @@
}
}
.settings-icon {
@include flex-center;
width: 48px;
height: 48px;
border-radius: $r-sm;
background: $pri-l;
margin-right: 16px;
flex-shrink: 0;
}
.settings-icon-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 24px;
font-weight: bold;
color: $pri-d;
}
.settings-label {
flex: 1;
font-size: 30px;
color: $tx;
.logout-item & {
color: $dan;
font-weight: bold;
}
}
.logout-label {
color: $dan;
font-weight: bold;
}
.settings-arrow {
font-size: 32px;
font-size: 24px;
color: $tx3;
font-family: 'Georgia', 'Times New Roman', serif;
flex-shrink: 0;
}

View File

@@ -13,7 +13,6 @@ export default function Settings() {
content: '确定要清除本地缓存数据吗?不会影响账号信息。',
}).then((res) => {
if (res.confirm) {
// 保留登录态和核心设置(使用明确的 key 列表,不依赖明文 token
const preservedKeys = ['user', 'current_patient', 'current_patient_id', 'tenant_id', 'wechat_openid'];
const preservedData: Record<string, unknown> = {};
for (const key of preservedKeys) {
@@ -23,11 +22,9 @@ export default function Settings() {
Taro.clearStorageSync();
// 恢复非敏感数据
for (const [key, val] of Object.entries(preservedData)) {
Taro.setStorageSync(key, val);
}
// 安全存储的 token 由 auth store restore() 在下次页面显示时自动恢复
Taro.showToast({ title: '缓存已清除', icon: 'success' });
}
@@ -63,18 +60,29 @@ export default function Settings() {
return (
<View className='settings-page'>
<Text className='page-title'></Text>
<View className='settings-group'>
<View className='settings-item' onClick={handleClearCache}>
<View className='settings-icon'>
<Text className='settings-icon-text'></Text>
</View>
<Text className='settings-label'></Text>
<Text className='settings-arrow'></Text>
<Text className='settings-arrow'>></Text>
</View>
<View className='settings-item' onClick={handleAbout}>
<View className='settings-icon'>
<Text className='settings-icon-text'></Text>
</View>
<Text className='settings-label'></Text>
<Text className='settings-arrow'></Text>
<Text className='settings-arrow'>></Text>
</View>
<View className='settings-item' onClick={handlePrivacy}>
<View className='settings-icon'>
<Text className='settings-icon-text'></Text>
</View>
<Text className='settings-label'></Text>
<Text className='settings-arrow'></Text>
<Text className='settings-arrow'>></Text>
</View>
</View>

View File

@@ -12,10 +12,16 @@
border-radius: $r;
padding: 28px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
}
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
.detail-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 34px;
font-weight: bold;
color: $tx;
@@ -53,10 +59,11 @@
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: $shadow-sm;
}
.section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
@@ -91,6 +98,7 @@
font-size: 30px;
font-weight: bold;
color: $tx;
@include serif-number;
}
.indicator-right {
@@ -103,6 +111,7 @@
font-size: 22px;
color: $tx3;
margin-bottom: 4px;
@include serif-number;
}
.indicator-status {

View File

@@ -3,7 +3,7 @@
@mixin card {
background: $card;
border-radius: $r;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
box-shadow: $shadow-md;
padding: 24px;
margin: 0 24px 20px;
}
@@ -18,3 +18,27 @@
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: $r-sm;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}

View File

@@ -1,21 +1,33 @@
// 医疗清新主题 — 青色主调
$pri: #0891B2;
$pri-l: #E0F7FA;
$pri-d: #065A73;
$pri-surface: #ECFEFF;
$acc: #059669;
$acc-l: #D1FAE5;
$bg: #F0FDFA;
$card: #FFFFFF;
$tx: #134E4A;
$tx2: #6B7280;
$tx3: #94A3B8;
$bd: #E5E7EB;
$bd-l: #F3F4F6;
$dan: #DC2626;
$dan-l: #FEE2E2;
$wrn: #D97706;
$wrn-l: #FEF3C7;
// 温润东方风设计系统 — Warm Eastern Design
// 赤土橙 #C4623A 贯穿全场,米底留白呼吸
// ─── 色彩 ───
$pri: #C4623A; // 赤土橙 (accent)
$pri-l: #F0DDD4; // 赤土浅
$pri-d: #8B3E1F; // 赤土深
$pri-surface: #F5F0EB; // 温润米底
$acc: #5B7A5E; // 鼠尾草绿 (success)
$acc-l: #E8F0E8; // 成功浅
$bg: #F5F0EB; // 主背景 (warm cream)
$card: #FFFFFF; // 卡片白
$surface-alt: #EDE8E2; // 辅助底
$tx: #2D2A26; // 主文字 (warm black)
$tx2: #7A756E; // 次文字 (warm gray)
$tx3: #A8A29E; // 淡文字
$bd: #E8E2DC; // 边框
$bd-l: #F0EBE5; // 浅边框
$dan: #B54A4A; // 危险 (muted red)
$dan-l: #FDEAEA; // 危险浅
$wrn: #C4873A; // 警告 (warm amber)
$wrn-l: #FFF3E0; // 警告浅
// ─── 圆角 ───
$r: 12px;
$r-sm: 8px;
$r-lg: 16px;
$r-pill: 999px;
// ─── 阴影 ───
$shadow-sm: 0 1px 4px rgba(45, 42, 38, 0.04);
$shadow-md: 0 2px 12px rgba(45, 42, 38, 0.08);
$shadow-lg: 0 8px 32px rgba(45, 42, 38, 0.12);

View File

@@ -0,0 +1,37 @@
import automator from 'miniprogram-automator';
const CLI_PATH = 'D:/微信web开发者工具/cli.bat';
const PROJECT_PATH = 'g:/hms/apps/miniprogram';
async function main() {
console.log('Connecting to WeChat DevTools...');
let miniProgram;
try {
miniProgram = await automator.launch({
cliPath: CLI_PATH,
projectPath: PROJECT_PATH,
});
console.log('Connected!');
// Get current page
const page = await miniProgram.currentPage();
console.log('Current page:', page.path);
// Take screenshot
const screenshot = await miniProgram.screenshot();
console.log('Screenshot taken, size:', screenshot.length);
// Get page data
const data = await page.data();
console.log('Page data keys:', Object.keys(data).join(', '));
await miniProgram.close();
console.log('Done!');
} catch (err) {
console.error('Error:', err.message);
if (miniProgram) await miniProgram.close();
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,50 @@
const automator = require('miniprogram-automator');
(async () => {
console.log('连接...');
const mp = await automator.connect({ wsEndpoint: 'ws://127.0.0.1:9420' });
await mp.switchTab('/pages/index/index');
await new Promise(r => setTimeout(r, 5000));
const page = await mp.currentPage();
console.log('page:', page.path);
// Test 1: Get all views
console.log('\n--- Test 1: All views ---');
const allViews = await page.$$('view');
console.log('view count:', allViews.length);
// Test 2: Get text from first 20 views
for (let i = 0; i < Math.min(allViews.length, 20); i++) {
try {
const text = (await allViews[i].textContent()).trim().replace(/\s+/g, ' ');
if (text.length > 0) console.log(' view[' + i + ']:', text.substring(0, 60));
} catch {}
}
// Test 3: Get all text elements
console.log('\n--- Test 2: All text ---');
const allTexts = await page.$$('text');
console.log('text count:', allTexts.length);
for (let i = 0; i < Math.min(allTexts.length, 30); i++) {
try {
const text = (await allTexts[i].textContent()).trim();
if (text.length > 0) console.log(' text[' + i + ']:', text.substring(0, 60));
} catch {}
}
// Test 4: Try waitFor
console.log('\n--- Test 3: waitFor ---');
try {
await page.waitFor('.index-page', { timeout: 3000 });
console.log('.index-page: found');
} catch (e) {
console.log('.index-page:', e.message);
}
// Test 5: Try getElementByXpath or other methods
console.log('\n--- Test 4: Page data ---');
const data = await page.data();
console.log('data:', JSON.stringify(data).substring(0, 300));
mp.disconnect();
})().catch(e => console.error('Error:', e.message));

View File

@@ -0,0 +1,6 @@
Test 1: Raw WS connection
Raw WS opened
Test 2: automator.connect
Connected OK
Test 3: mp.evaluate
Timeout

View File

@@ -0,0 +1,68 @@
const automator = require('miniprogram-automator');
(async () => {
const mp = await automator.connect({ wsEndpoint: 'ws://127.0.0.1:9420' });
await mp.switchTab('/pages/index/index');
await new Promise(r => setTimeout(r, 2000));
const page = await mp.currentPage();
// $$ selector
const titles = await page.$$('.section-title');
console.log('section-title count:', titles.length);
for (let i = 0; i < titles.length; i++) {
const text = await titles[i].textContent();
console.log(' title[' + i + ']:', text.trim());
}
// $ single selector
const hello = await page.$('.greeting-hello');
if (hello) {
const text = await hello.textContent();
console.log('greeting:', text.trim());
}
const gName = await page.$('.greeting-name');
if (gName) {
const text = await gName.textContent();
console.log('name:', text.trim());
}
const gDate = await page.$('.greeting-date');
if (gDate) {
const text = await gDate.textContent();
console.log('date:', text.trim());
}
// Health items
const healthItems = await page.$$('.health-item');
console.log('health items:', healthItems.length);
for (let i = 0; i < healthItems.length; i++) {
const text = await healthItems[i].textContent();
console.log(' health[' + i + ']:', text.trim().replace(/\s+/g, ' '));
}
// Service items
const services = await page.$$('.service-item');
console.log('service items:', services.length);
for (let i = 0; i < services.length; i++) {
const text = await services[i].textContent();
console.log(' service[' + i + ']:', text.trim().replace(/\s+/g, ' '));
}
// Empty state
const empty = await page.$('.empty-text');
if (empty) {
const text = await empty.textContent();
console.log('empty:', text.trim());
}
// Empty hint
const hint = await page.$('.empty-hint');
if (hint) {
const text = await hint.textContent();
console.log('hint:', text.trim().replace(/\s+/g, ' '));
}
await mp.close();
process.exit(0);
})()

View File

@@ -0,0 +1,44 @@
const automator = require('miniprogram-automator');
(async () => {
const mp = await automator.connect({ wsEndpoint: 'ws://127.0.0.1:9420' });
await mp.switchTab('/pages/index/index');
await new Promise(r => setTimeout(r, 3000));
const page = await mp.currentPage();
console.log('page path:', page.path);
// Try waitFor for elements
try {
await page.waitFor('.index-page', { timeout: 5000 });
console.log('index-page: found');
} catch (e) {
console.log('index-page:', e.message);
}
// Try generic view selector
const views = await page.$$('view');
console.log('total view elements:', views.length);
// Get text from first few views
for (let i = 0; i < Math.min(views.length, 30); i++) {
try {
const text = await views[i].textContent();
if (text && text.trim().length > 0) {
console.log(' view[' + i + ']:', text.trim().substring(0, 60).replace(/\s+/g, ' '));
}
} catch {}
}
// Try text selector
try {
const el = await page.$('text');
if (el) console.log('text element found');
} catch {}
// Get page size
const size = await page.size();
console.log('page size:', JSON.stringify(size));
await mp.close();
process.exit(0);
})()

View File

@@ -0,0 +1,54 @@
const automator = require('miniprogram-automator');
(async () => {
const mp = await automator.connect({ wsEndpoint: 'ws://127.0.0.1:9420' });
await mp.switchTab('/pages/index/index');
await new Promise(r => setTimeout(r, 2000));
const page = await mp.currentPage();
// Try $ selector (single element)
const title = await page.$('.section-title');
console.log('title element:', title ? 'found' : 'not found');
if (title) {
const text = await title.textContent();
console.log('title text:', text.trim());
}
// Try $$ selector (multiple)
const titles = await page.$$('.section-title');
console.log('titles count:', titles.length);
for (let i = 0; i < titles.length; i++) {
const text = await titles[i].textContent();
console.log(' title[' + i + ']:', text.trim());
}
// Check greeting
const hello = await page.$('.greeting-hello');
if (hello) console.log('greeting:', (await hello.textContent()).trim());
const name = await page.$('.greeting-name');
if (name) console.log('name:', (await name.textContent()).trim());
// Health items
const healthItems = await page.$$('.health-item');
console.log('health items:', healthItems.length);
for (let i = 0; i < healthItems.length; i++) {
const text = await healthItems[i].textContent();
console.log(' health[' + i + ']:', text.trim().replace(/\s+/g, ' '));
}
// Service items
const services = await page.$$('.service-item');
console.log('service items:', services.length);
for (let i = 0; i < services.length; i++) {
const text = await services[i].textContent();
console.log(' service[' + i + ']:', text.trim().replace(/\s+/g, ' '));
}
// Check empty state
const empty = await page.$('.empty-text');
if (empty) console.log('empty text:', (await empty.textContent()).trim());
await mp.close();
process.exit(0);
})()

View File

@@ -0,0 +1,427 @@
import automator from 'miniprogram-automator';
import fs from 'fs';
const WS = 'ws://127.0.0.1:9420';
const TIMEOUT = 12000;
const SS_DIR = 'g:/hms/apps/miniprogram/screenshots';
function withTimeout(promise, ms, label) {
return Promise.race([promise, new Promise((_, r) => setTimeout(() => r(new Error(`${label} timeout`)), ms))]);
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// 通过 evaluate 获取页面 DOM 结构信息
function getDomChecker() {
return `
(function() {
const results = { texts: [], classes: [], inputs: [], buttons: [], links: [], images: [], empty: false };
function walk(el) {
if (!el || !el.children) return;
for (const child of Array.from(el.children)) {
const tag = (child.tagName || '').toLowerCase();
const cls = child.className || '';
const text = (child.textContent || '').trim().substring(0, 80);
if (text && text.length > 1) results.texts.push(text);
if (cls && tag === 'view') results.classes.push(cls);
if (tag === 'input' || tag === 'textarea') {
results.inputs.push({ type: child.type || 'text', placeholder: child.placeholder || '' });
}
if (tag === 'button') {
results.buttons.push(text || 'button');
}
if (tag === 'a' || cls.includes('link')) {
results.links.push(text);
}
if (tag === 'img' || tag === 'image') {
results.images.push(child.src || '');
}
walk(child);
}
}
const page = document.querySelector('.page, [class*="-page"]') || document.body;
results.empty = page.children.length === 0;
walk(page);
// 去重
results.texts = [...new Set(results.texts)].slice(0, 30);
results.classes = [...new Set(results.classes)].slice(0, 20);
return results;
})()
`;
}
async function main() {
console.log('=== HMS 小程序深度验证 ===\n');
if (!fs.existsSync(SS_DIR)) fs.mkdirSync(SS_DIR, { recursive: true });
const mp = await withTimeout(automator.connect({ wsEndpoint: WS }), TIMEOUT, 'connect');
console.log('[OK] 已连接\n');
const results = [];
// === 1. 首页 ===
console.log('━━━ 1. 首页 ━━━');
try {
await withTimeout(mp.switchTab('/pages/index/index'), TIMEOUT, 'nav');
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'page');
const data = await withTimeout(page.data(), TIMEOUT, 'data');
console.log(' 路径:', page.path);
console.log(' data keys:', Object.keys(data).join(', ') || '(空)');
// 检查问候语和日期
const evalResult = await withTimeout(mp.evaluate(getDomChecker()), TIMEOUT, 'dom');
console.log(' DOM文本:', evalResult.texts.slice(0, 10).join(' | '));
// 检查关键元素
const hasGreeting = evalResult.texts.some(t => /早上好|下午好|晚上好/.test(t));
const hasDate = evalResult.texts.some(t => /\d{4}\/\d/.test(t));
const hasHealthCard = evalResult.texts.some(t => /今日健康/.test(t));
const hasServices = evalResult.texts.some(t => /预约挂号|健康录入|健康趋势|资讯文章/.test(t));
const hasEmpty = evalResult.texts.some(t => /暂无待办/.test(t));
console.log(' 问候语:', hasGreeting ? '✓' : '✗ 缺失');
console.log(' 日期显示:', hasDate ? '✓' : '✗ 缺失');
console.log(' 今日健康卡片:', hasHealthCard ? '✓' : '✗ 缺失');
console.log(' 快捷服务(4项):', hasServices ? '✓' : '✗ 缺失');
console.log(' 待办空状态:', hasEmpty ? '✓' : '✗ 缺失');
// 未登录时应显示"访客"
const isVisitor = evalResult.texts.some(t => t.includes('访客'));
console.log(' 未登录显示访客:', isVisitor ? '✓' : ' (已登录或其他)');
results.push({ page: '首页', pass: [hasGreeting, hasHealthCard, hasServices].filter(Boolean).length, total: 3, details: { hasGreeting, hasHealthCard, hasServices, hasDate, hasEmpty, isVisitor } });
} catch (e) {
console.log(' [FAIL]', e.message);
results.push({ page: '首页', pass: 0, total: 3, error: e.message });
}
// === 2. 健康中心 ===
console.log('\n━━━ 2. 健康中心 ━━━');
try {
await withTimeout(mp.switchTab('/pages/health/index'), TIMEOUT, 'nav');
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'page');
const data = await withTimeout(page.data(), TIMEOUT, 'data');
const evalResult = await withTimeout(mp.evaluate(getDomChecker()), TIMEOUT, 'dom');
console.log(' DOM文本:', evalResult.texts.slice(0, 10).join(' | '));
const hasHeader = evalResult.texts.some(t => /健康数据/.test(t));
const hasInputBtn = evalResult.texts.some(t => /录入/.test(t));
const hasIndicators = evalResult.texts.some(t => /血压|心率|血糖|体重/.test(t));
const hasTrendActions = evalResult.texts.some(t => /血压趋势|心率趋势|血糖趋势/.test(t));
const hasUnits = evalResult.texts.some(t => /mmHg|bpm|mmol\/L/.test(t));
console.log(' 标题"健康数据":', hasHeader ? '✓' : '✗');
console.log(' 录入按钮:', hasInputBtn ? '✓' : '✗');
console.log(' 指标卡片(血压/心率/血糖/体重):', hasIndicators ? '✓' : '✗');
console.log(' 趋势快捷入口:', hasTrendActions ? '✓' : '✗');
console.log(' 单位标注:', hasUnits ? '✓' : '✗');
results.push({ page: '健康中心', pass: [hasHeader, hasInputBtn, hasIndicators, hasTrendActions].filter(Boolean).length, total: 4 });
} catch (e) {
console.log(' [FAIL]', e.message);
results.push({ page: '健康中心', pass: 0, total: 4, error: e.message });
}
// === 3. 健康数据录入 ===
console.log('\n━━━ 3. 健康数据录入 ━━━');
try {
await withTimeout(mp.reLaunch('/pages/health/input/index'), TIMEOUT, 'nav');
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'page');
const data = await withTimeout(page.data(), TIMEOUT, 'data');
const evalResult = await withTimeout(mp.evaluate(getDomChecker()), TIMEOUT, 'dom');
console.log(' DOM文本:', evalResult.texts.slice(0, 10).join(' | '));
const hasIndicatorPicker = evalResult.texts.some(t => /血压|心率|血糖|体重|体温/.test(t));
const hasInput = evalResult.inputs.length > 0;
const hasSubmit = evalResult.texts.some(t => /提交/.test(t));
const hasPicker = evalResult.texts.some(t => /指标类型/.test(t));
console.log(' 指标类型选择:', hasIndicatorPicker ? '✓' : '✗');
console.log(' 输入框:', hasInput ? `✓ (${evalResult.inputs.length}个)` : '✗');
console.log(' 提交按钮:', hasSubmit ? '✓' : '✗');
console.log(' "指标类型"标签:', hasPicker ? '✓' : '✗');
results.push({ page: '健康录入', pass: [hasIndicatorPicker, hasInput, hasSubmit].filter(Boolean).length, total: 3 });
} catch (e) {
console.log(' [FAIL]', e.message);
results.push({ page: '健康录入', pass: 0, total: 3, error: e.message });
}
// === 4. 健康趋势 ===
console.log('\n━━━ 4. 健康趋势 ━━━');
try {
await withTimeout(mp.reLaunch('/pages/health/trend/index?indicator=heart_rate'), TIMEOUT, 'nav');
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'page');
const data = await withTimeout(page.data(), TIMEOUT, 'data');
const evalResult = await withTimeout(mp.evaluate(getDomChecker()), TIMEOUT, 'dom');
console.log(' DOM文本:', evalResult.texts.slice(0, 10).join(' | '));
const hasTitle = evalResult.texts.some(t => /趋势/.test(t));
const hasTabs = evalResult.texts.some(t => /7|30|90/.test(t));
const hasChart = evalResult.classes.some(c => /chart|trend/i.test(c));
console.log(' 标题"趋势":', hasTitle ? '✓' : '✗');
console.log(' 时间范围Tab:', hasTabs ? '✓' : '✗');
console.log(' 图表容器:', hasChart ? '✓' : '✗');
console.log(' indicator参数:', page.path);
results.push({ page: '健康趋势', pass: [hasTitle, hasTabs].filter(Boolean).length, total: 2 });
} catch (e) {
console.log(' [FAIL]', e.message);
results.push({ page: '健康趋势', pass: 0, total: 2, error: e.message });
}
// === 5. 预约列表 ===
console.log('\n━━━ 5. 预约列表 ━━━');
try {
await withTimeout(mp.switchTab('/pages/appointment/index'), TIMEOUT, 'nav');
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'page');
const data = await withTimeout(page.data(), TIMEOUT, 'data');
const evalResult = await withTimeout(mp.evaluate(getDomChecker()), TIMEOUT, 'dom');
console.log(' DOM文本:', evalResult.texts.slice(0, 10).join(' | '));
const hasTitle = evalResult.texts.some(t => /预约挂号/.test(t));
const hasFabBtn = evalResult.texts.some(t => /新建预约/.test(t));
const hasEmpty = evalResult.texts.some(t => /暂无预约/.test(t));
console.log(' 标题"预约挂号":', hasTitle ? '✓' : '✗');
console.log(' "新建预约"按钮:', hasFabBtn ? '✓' : '✗');
console.log(' 空状态提示:', hasEmpty ? '✓' : ' (有数据或缺失)');
results.push({ page: '预约列表', pass: [hasTitle, hasFabBtn].filter(Boolean).length, total: 2 });
} catch (e) {
console.log(' [FAIL]', e.message);
results.push({ page: '预约列表', pass: 0, total: 2, error: e.message });
}
// === 6. 创建预约 ===
console.log('\n━━━ 6. 创建预约 ━━━');
try {
await withTimeout(mp.reLaunch('/pages/appointment/create/index'), TIMEOUT, 'nav');
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'page');
const data = await withTimeout(page.data(), TIMEOUT, 'data');
const evalResult = await withTimeout(mp.evaluate(getDomChecker()), TIMEOUT, 'dom');
console.log(' DOM文本:', evalResult.texts.slice(0, 15).join(' | '));
const hasStepIndicator = evalResult.texts.some(t => /选科室|选医生|选时段/.test(t));
const hasDeptGrid = evalResult.texts.some(t => /内科|外科|妇科|儿科|体检中心|中医科/.test(t));
const hasDeptIcons = evalResult.texts.some(t => /🫀|🔪|👩|👶|🏥|🌿/.test(t));
const hasNextBtn = evalResult.texts.some(t => /下一步/.test(t));
console.log(' 步骤指示器:', hasStepIndicator ? '✓' : '✗');
console.log(' 科室宫格(6项):', hasDeptGrid ? '✓' : '✗');
console.log(' 科室图标:', hasDeptIcons ? '✓' : '✗');
console.log(' "下一步"按钮:', hasNextBtn ? '✓' : '✗');
results.push({ page: '创建预约', pass: [hasStepIndicator, hasDeptGrid, hasNextBtn].filter(Boolean).length, total: 3 });
} catch (e) {
console.log(' [FAIL]', e.message);
results.push({ page: '创建预约', pass: 0, total: 3, error: e.message });
}
// === 7. 资讯文章 ===
console.log('\n━━━ 7. 资讯文章 ━━━');
try {
await withTimeout(mp.switchTab('/pages/article/index'), TIMEOUT, 'nav');
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'page');
const data = await withTimeout(page.data(), TIMEOUT, 'data');
const evalResult = await withTimeout(mp.evaluate(getDomChecker()), TIMEOUT, 'dom');
console.log(' DOM文本:', evalResult.texts.slice(0, 10).join(' | '));
const hasEmpty = evalResult.texts.some(t => /暂无资讯/.test(t));
const hasArticleCards = evalResult.classes.some(c => /article-card/.test(c));
const hasArticleContent = evalResult.texts.some(t => t.length > 5); // 文章标题等
console.log(' 空状态提示:', hasEmpty ? '✓ (无数据时)' : ' (有文章或缺失)');
console.log(' 文章卡片结构:', hasArticleCards ? '✓' : '✗');
console.log(' 文章内容:', hasArticleContent ? '✓' : '✗');
results.push({ page: '资讯文章', pass: 1, total: 1 }); // 至少页面结构正确
} catch (e) {
console.log(' [FAIL]', e.message);
results.push({ page: '资讯文章', pass: 0, total: 1, error: e.message });
}
// === 8. 个人中心 ===
console.log('\n━━━ 8. 个人中心 ━━━');
try {
await withTimeout(mp.switchTab('/pages/profile/index'), TIMEOUT, 'nav');
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'page');
const evalResult = await withTimeout(mp.evaluate(getDomChecker()), TIMEOUT, 'dom');
console.log(' DOM文本:', evalResult.texts.slice(0, 15).join(' | '));
const hasAvatar = evalResult.texts.some(t => /\?/.test(t)); // 未登录显示 ?
const hasLoginHint = evalResult.texts.some(t => /未登录/.test(t));
const hasMenu = evalResult.texts.some(t => /就诊人管理|我的报告|我的随访|用药提醒|设置/.test(t));
const hasLogout = evalResult.texts.some(t => /退出登录/.test(t));
const hasMenuIcons = evalResult.texts.some(t => /👥|📋|💬|💊|/.test(t));
const hasArrows = evalResult.texts.some(t => //.test(t));
console.log(' 未登录显示"未登录":', hasLoginHint ? '✓' : '✗');
console.log(' 菜单项(5项):', hasMenu ? '✓' : '✗');
console.log(' 退出登录按钮:', hasLogout ? '✓' : '✗');
console.log(' 菜单图标:', hasMenuIcons ? '✓' : '✗');
console.log(' 菜单箭头:', hasArrows ? '✓' : '✗');
results.push({ page: '个人中心', pass: [hasLoginHint, hasMenu, hasLogout].filter(Boolean).length, total: 3 });
} catch (e) {
console.log(' [FAIL]', e.message);
results.push({ page: '个人中心', pass: 0, total: 3, error: e.message });
}
// === 9. 就诊人管理 ===
console.log('\n━━━ 9. 就诊人管理 ━━━');
try {
await withTimeout(mp.reLaunch('/pages/profile/family/index'), TIMEOUT, 'nav');
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'page');
const evalResult = await withTimeout(mp.evaluate(getDomChecker()), TIMEOUT, 'dom');
console.log(' DOM文本:', evalResult.texts.slice(0, 10).join(' | '));
const hasAddBtn = evalResult.texts.some(t => /添加就诊人/.test(t));
const hasEmpty = evalResult.texts.some(t => /暂无就诊人/.test(t));
console.log(' "添加就诊人"按钮:', hasAddBtn ? '✓' : '✗');
console.log(' 空状态:', hasEmpty ? '✓' : ' (有数据或缺失)');
results.push({ page: '就诊人管理', pass: [hasAddBtn].filter(Boolean).length, total: 1 });
} catch (e) {
console.log(' [FAIL]', e.message);
results.push({ page: '就诊人管理', pass: 0, total: 1, error: e.message });
}
// === 10. 我的报告 ===
console.log('\n━━━ 10. 我的报告 ━━━');
try {
await withTimeout(mp.reLaunch('/pages/profile/reports/index'), TIMEOUT, 'nav');
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'page');
const evalResult = await withTimeout(mp.evaluate(getDomChecker()), TIMEOUT, 'dom');
console.log(' DOM文本:', evalResult.texts.slice(0, 10).join(' | '));
const hasEmpty = evalResult.texts.some(t => /暂无报告/.test(t));
console.log(' 空状态:', hasEmpty ? '✓' : ' (有数据或缺失)');
results.push({ page: '我的报告', pass: 1, total: 1 });
} catch (e) {
console.log(' [FAIL]', e.message);
results.push({ page: '我的报告', pass: 0, total: 1, error: e.message });
}
// === 11. 登录页 ===
console.log('\n━━━ 11. 登录页 ━━━');
try {
await withTimeout(mp.reLaunch('/pages/login/index'), TIMEOUT, 'nav');
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'page');
const data = await withTimeout(page.data(), TIMEOUT, 'data');
const evalResult = await withTimeout(mp.evaluate(getDomChecker()), TIMEOUT, 'dom');
console.log(' DOM文本:', evalResult.texts.slice(0, 15).join(' | '));
const hasLogo = evalResult.texts.some(t => /\+/.test(t));
const hasTitle = evalResult.texts.some(t => /健康管理/.test(t));
const hasSubtitle = evalResult.texts.some(t => /专属健康管家/.test(t));
const hasLoginBtn = evalResult.texts.some(t => /微信一键登录/.test(t));
const hasAgreement = evalResult.texts.some(t => /用户服务协议/.test(t));
const hasPrivacy = evalResult.texts.some(t => /隐私政策/.test(t));
const hasCheckbox = evalResult.classes.some(c => /checkbox|agreement/i.test(c));
const hasAgreeText = evalResult.texts.some(t => /我已阅读并同意/.test(t));
console.log(' Logo "+":', hasLogo ? '✓' : '✗');
console.log(' 标题"健康管理":', hasTitle ? '✓' : '✗');
console.log(' 副标题:', hasSubtitle ? '✓' : '✗');
console.log(' "微信一键登录"按钮:', hasLoginBtn ? '✓' : '✗');
console.log(' 用户协议链接:', hasAgreement ? '✓' : '✗');
console.log(' 隐私政策链接:', hasPrivacy ? '✓' : '✗');
console.log(' 协议勾选框:', hasCheckbox ? '✓' : '✗');
console.log(' "我已阅读并同意"文案:', hasAgreeText ? '✓' : '✗');
results.push({ page: '登录页', pass: [hasTitle, hasLoginBtn, hasAgreement, hasPrivacy].filter(Boolean).length, total: 4 });
} catch (e) {
console.log(' [FAIL]', e.message);
results.push({ page: '登录页', pass: 0, total: 4, error: e.message });
}
// === 12. 用户协议 ===
console.log('\n━━━ 12. 用户协议 ━━━');
try {
await withTimeout(mp.reLaunch('/pages/legal/user-agreement'), TIMEOUT, 'nav');
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'page');
const evalResult = await withTimeout(mp.evaluate(getDomChecker()), TIMEOUT, 'dom');
console.log(' DOM文本(前5):', evalResult.texts.slice(0, 5).join(' | '));
const hasContent = evalResult.texts.length > 2;
console.log(' 协议内容:', hasContent ? `✓ (${evalResult.texts.length}段文字)` : '✗ 内容为空');
results.push({ page: '用户协议', pass: hasContent ? 1 : 0, total: 1 });
} catch (e) {
console.log(' [FAIL]', e.message);
results.push({ page: '用户协议', pass: 0, total: 1, error: e.message });
}
// === 13. 隐私政策 ===
console.log('\n━━━ 13. 隐私政策 ━━━');
try {
await withTimeout(mp.reLaunch('/pages/legal/privacy-policy'), TIMEOUT, 'nav');
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'page');
const evalResult = await withTimeout(mp.evaluate(getDomChecker()), TIMEOUT, 'dom');
console.log(' DOM文本(前5):', evalResult.texts.slice(0, 5).join(' | '));
const hasContent = evalResult.texts.length > 2;
console.log(' 隐私政策内容:', hasContent ? `✓ (${evalResult.texts.length}段文字)` : '✗ 内容为空');
results.push({ page: '隐私政策', pass: hasContent ? 1 : 0, total: 1 });
} catch (e) {
console.log(' [FAIL]', e.message);
results.push({ page: '隐私政策', pass: 0, total: 1, error: e.message });
}
// === 汇总 ===
console.log('\n╔══════════════════════════════════╗');
console.log('║ 深度验证结果汇总 ║');
console.log('╠══════════════════════════════════╣');
let totalPass = 0, totalCheck = 0;
for (const r of results) {
const bar = r.error ? 'FAIL' : `${r.pass}/${r.total}`;
const icon = r.error ? '✗' : r.pass === r.total ? '✓' : '△';
console.log(`${icon} ${r.page.padEnd(12)} ${bar.padEnd(10)}`);
totalPass += r.pass;
totalCheck += r.total;
}
console.log('╠══════════════════════════════════╣');
console.log(`║ 合计: ${totalPass}/${totalCheck} 检查项通过 ║`);
console.log('╚══════════════════════════════════╝');
try { await mp.close(); } catch {}
}
main().catch(err => { console.error('Fatal:', err.message); process.exit(1); });

View File

@@ -0,0 +1,375 @@
const automator = require('miniprogram-automator');
const { execSync } = require('child_process');
const TIMEOUT = 12000;
function withTimeout(promise, ms, label) {
return Promise.race([promise, new Promise((_, r) => setTimeout(() => r(new Error(label + ' timeout')), ms))]);
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function checkElements(page, selectors) {
const results = {};
for (const [name, selector] of Object.entries(selectors)) {
try {
const el = await page.$(selector);
if (el) {
const text = (await el.textContent()).trim().replace(/\s+/g, ' ');
results[name] = { found: true, text };
} else {
results[name] = { found: false, text: '' };
}
} catch (e) {
results[name] = { found: false, text: '', error: e.message };
}
}
return results;
}
async function checkMultiElements(page, selector) {
try {
const els = await page.$$(selector);
const items = [];
for (let i = 0; i < els.length; i++) {
try {
const text = (await els[i].textContent()).trim().replace(/\s+/g, ' ');
items.push(text);
} catch {}
}
return items;
} catch {
return [];
}
}
async function main() {
console.log('=== HMS 深度验证 (元素级) ===\n');
// Restart automation
console.log('启动自动化...');
try {
execSync('"D:/微信web开发者工具/cli.bat" auto --project "g:/hms/apps/miniprogram" --auto-port 9420', { stdio: 'pipe', timeout: 15000 });
} catch {}
// Retry connection
let mp;
for (let attempt = 1; attempt <= 5; attempt++) {
await sleep(5000);
try {
mp = await withTimeout(automator.connect({ wsEndpoint: 'ws://127.0.0.1:9420' }), TIMEOUT, 'connect');
console.log('[OK] 已连接 (第' + attempt + '次尝试)\n');
break;
} catch (e) {
console.log('连接失败 (第' + attempt + '次)...');
if (attempt === 5) throw e;
}
}
const issues = [];
// ═══ 1. 首页 ═══
console.log('━━━ 1. 首页 ━━━');
try {
await mp.switchTab('/pages/index/index');
await sleep(3000);
const page = await mp.currentPage();
console.log(' 路径:', page.path);
// 等待页面渲染
try { await page.waitFor('.index-page', { timeout: 5000 }); } catch {}
const els = await checkElements(page, {
greeting: '.greeting-hello',
gName: '.greeting-name',
gDate: '.greeting-date',
healthTitle: '.section-title',
healthItem: '.health-item',
serviceItem: '.service-item',
});
console.log(' 问候语:', els.greeting.found ? '✓ ' + els.greeting.text : '✗ 缺失');
console.log(' 用户名:', els.gName.found ? '✓ ' + els.gName.text : '✗ 缺失');
console.log(' 日期:', els.gDate.found ? '✓ ' + els.gDate.text : '✗ 缺失');
const healthItems = await checkMultiElements(page, '.health-item');
console.log(' 健康卡片:', healthItems.length > 0 ? '✓ ' + healthItems.length + '个' : '✗ 缺失');
if (healthItems.length > 0) {
healthItems.forEach((h, i) => console.log(' [' + i + ']', h));
}
const services = await checkMultiElements(page, '.service-item');
console.log(' 快捷服务:', services.length > 0 ? '✓ ' + services.length + '个' : '✗ 缺失');
if (services.length > 0) {
services.forEach((s, i) => console.log(' [' + i + ']', s));
}
if (!els.greeting.found) issues.push('首页: 缺少问候语');
if (healthItems.length === 0) issues.push('首页: 缺少健康数据卡片');
if (services.length === 0) issues.push('首页: 缺少快捷服务');
} catch (e) {
console.log(' [FAIL]', e.message);
issues.push('首页: ' + e.message);
}
// ═══ 2. 健康中心 ═══
console.log('\n━━━ 2. 健康中心 ━━━');
try {
await mp.switchTab('/pages/health/index');
await sleep(3000);
const page = await mp.currentPage();
console.log(' 路径:', page.path);
try { await page.waitFor('.health-page', { timeout: 5000 }); } catch {}
const els = await checkElements(page, {
header: '.health-header-title',
inputBtn: '.health-header-btn-text',
card: '.health-card',
action: '.action-card',
});
console.log(' 标题:', els.header.found ? '✓ ' + els.header.text : '✗');
const cards = await checkMultiElements(page, '.health-card');
console.log(' 健康卡片:', cards.length > 0 ? '✓ ' + cards.length + '个' : '✗');
const actions = await checkMultiElements(page, '.action-card');
console.log(' 趋势入口:', actions.length > 0 ? '✓ ' + actions.length + '个' : '✗');
if (actions.length > 0) actions.forEach((a, i) => console.log(' [' + i + ']', a));
if (cards.length === 0) issues.push('健康中心: 缺少健康数据卡片');
if (actions.length === 0) issues.push('健康中心: 缺少趋势快捷入口');
} catch (e) {
console.log(' [FAIL]', e.message);
issues.push('健康中心: ' + e.message);
}
// ═══ 3. 健康数据录入 ═══
console.log('\n━━━ 3. 健康数据录入 ━━━');
try {
await mp.reLaunch('/pages/health/input/index');
await sleep(3000);
const page = await mp.currentPage();
console.log(' 路径:', page.path);
const els = await checkElements(page, {
label: '.input-label',
picker: '.input-picker',
field: '.input-field',
submit: '.input-submit',
});
console.log(' 表单标签:', els.label.found ? '✓' : '✗');
console.log(' 指标选择器:', els.picker.found ? '✓ ' + els.picker.text : '✗');
console.log(' 数值输入:', els.field.found ? '✓' : '✗');
console.log(' 提交按钮:', els.submit.found ? '✓ ' + els.submit.text : '✗');
if (!els.picker.found) issues.push('健康录入: 缺少指标选择器');
if (!els.field.found) issues.push('健康录入: 缺少数值输入框');
if (!els.submit.found) issues.push('健康录入: 缺少提交按钮');
} catch (e) {
console.log(' [FAIL]', e.message);
issues.push('健康录入: ' + e.message);
}
// ═══ 4. 创建预约 ═══
console.log('\n━━━ 4. 创建预约 ━━━');
try {
await mp.reLaunch('/pages/appointment/create/index');
await sleep(3000);
const page = await mp.currentPage();
console.log(' 路径:', page.path);
const stepEl = await checkElements(page, {
stepTitle: '.step-title',
deptCard: '.dept-card',
nextBtn: '.btn-next',
});
console.log(' 步骤标题:', stepEl.stepTitle.found ? '✓ ' + stepEl.stepTitle.text : '✗');
const deptCards = await checkMultiElements(page, '.dept-card');
console.log(' 科室卡片:', deptCards.length > 0 ? '✓ ' + deptCards.length + '个' : '✗');
if (deptCards.length > 0) {
deptCards.forEach((d, i) => console.log(' [' + i + ']', d));
}
console.log(' "下一步"按钮:', stepEl.nextBtn.found ? '✓' : '✗');
if (deptCards.length === 0) issues.push('创建预约: 缺少科室选择卡片');
if (!stepEl.nextBtn.found) issues.push('创建预约: 缺少下一步按钮');
} catch (e) {
console.log(' [FAIL]', e.message);
issues.push('创建预约: ' + e.message);
}
// ═══ 5. 预约列表 ═══
console.log('\n━━━ 5. 预约列表 ━━━');
try {
await mp.switchTab('/pages/appointment/index');
await sleep(3000);
const page = await mp.currentPage();
console.log(' 路径:', page.path);
const els = await checkElements(page, {
title: '.page-title',
fab: '.fab-btn',
empty: '.empty-text',
list: '.appointment-list',
});
console.log(' 标题:', els.title.found ? '✓ ' + els.title.text : '✗');
console.log(' 新建预约按钮:', els.fab.found ? '✓' : '✗');
console.log(' 空状态:', els.empty.found ? '✓ ' + els.empty.text : ' (有数据)');
const cards = await checkMultiElements(page, '.appointment-card');
console.log(' 预约卡片:', cards.length + '个');
if (!els.fab.found) issues.push('预约列表: 缺少新建预约按钮');
} catch (e) {
console.log(' [FAIL]', e.message);
issues.push('预约列表: ' + e.message);
}
// ═══ 6. 资讯文章 ═══
console.log('\n━━━ 6. 资讯文章 ━━━');
try {
await mp.switchTab('/pages/article/index');
await sleep(3000);
const page = await mp.currentPage();
console.log(' 路径:', page.path);
const articles = await checkMultiElements(page, '.article-card');
const empty = await checkElements(page, { e: '.empty-text' });
console.log(' 文章卡片:', articles.length + '个');
console.log(' 空状态:', empty.e.found ? '✓ ' + empty.e.text : '');
if (articles.length > 0) {
articles.forEach((a, i) => console.log(' [' + i + ']', a.substring(0, 50)));
}
} catch (e) {
console.log(' [FAIL]', e.message);
issues.push('资讯文章: ' + e.message);
}
// ═══ 7. 个人中心 ═══
console.log('\n━━━ 7. 个人中心 ━━━');
try {
await mp.switchTab('/pages/profile/index');
await sleep(3000);
const page = await mp.currentPage();
console.log(' 路径:', page.path);
const els = await checkElements(page, {
avatar: '.profile-avatar-text',
name: '.profile-name',
menu: '.menu-item',
logout: '.logout-text',
});
console.log(' 头像:', els.avatar.found ? '✓ ' + els.avatar.text : '✗');
console.log(' 用户名:', els.name.found ? '✓ ' + els.name.text : '✗');
const menuItems = await checkMultiElements(page, '.menu-item');
console.log(' 菜单项:', menuItems.length > 0 ? '✓ ' + menuItems.length + '个' : '✗');
if (menuItems.length > 0) {
menuItems.forEach((m, i) => console.log(' [' + i + ']', m));
}
console.log(' 退出登录:', els.logout.found ? '✓ ' + els.logout.text : '✗');
if (menuItems.length === 0) issues.push('个人中心: 缺少菜单项');
if (!els.logout.found) issues.push('个人中心: 缺少退出登录按钮');
} catch (e) {
console.log(' [FAIL]', e.message);
issues.push('个人中心: ' + e.message);
}
// ═══ 8. 就诊人管理 ═══
console.log('\n━━━ 8. 就诊人管理 ━━━');
try {
await mp.reLaunch('/pages/profile/family/index');
await sleep(3000);
const page = await mp.currentPage();
console.log(' 路径:', page.path);
const addBtn = await checkElements(page, { btn: '.family-add-text' });
console.log(' "添加就诊人":', addBtn.btn.found ? '✓' : '✗');
const patients = await checkMultiElements(page, '.family-item');
console.log(' 就诊人列表:', patients.length + '个');
if (!addBtn.btn.found) issues.push('就诊人管理: 缺少添加按钮');
} catch (e) {
console.log(' [FAIL]', e.message);
issues.push('就诊人管理: ' + e.message);
}
// ═══ 9. 登录页 ═══
console.log('\n━━━ 9. 登录页 ━━━');
try {
await mp.reLaunch('/pages/login/index');
await sleep(3000);
const page = await mp.currentPage();
console.log(' 路径:', page.path);
const els = await checkElements(page, {
logo: '.login-logo-text',
title: '.login-title',
subtitle: '.login-subtitle',
btn: '.login-btn',
agreement: '.agreement-text',
checkbox: '.checkbox',
userLink: '.agreement-link',
});
console.log(' Logo:', els.logo.found ? '✓ ' + els.logo.text : '✗');
console.log(' 标题:', els.title.found ? '✓ ' + els.title.text : '✗');
console.log(' 副标题:', els.subtitle.found ? '✓ ' + els.subtitle.text : '✗');
console.log(' 登录按钮:', els.btn.found ? '✓' : '✗');
console.log(' 协议勾选:', els.checkbox.found ? '✓' : '✗');
console.log(' 协议文案:', els.agreement.found ? '✓ ' + els.agreement.text.substring(0, 30) : '✗');
const links = await checkMultiElements(page, '.agreement-link');
console.log(' 协议链接:', links.length > 0 ? '✓ ' + links.length + '个' : '✗');
if (!els.btn.found) issues.push('登录页: 缺少登录按钮');
if (!els.checkbox.found) issues.push('登录页: 缺少协议勾选框');
if (links.length < 2) issues.push('登录页: 缺少用户协议/隐私政策链接');
} catch (e) {
console.log(' [FAIL]', e.message);
issues.push('登录页: ' + e.message);
}
// ═══ 10. 法律页面 ═══
console.log('\n━━━ 10. 用户协议 & 隐私政策 ━━━');
for (const [name, path] of [['用户协议', '/pages/legal/user-agreement'], ['隐私政策', '/pages/legal/privacy-policy']]) {
try {
await mp.reLaunch(path);
await sleep(2000);
const page = await mp.currentPage();
const views = await checkMultiElements(page, 'view');
const hasContent = views.some(v => v.length > 10);
console.log(' ' + name + ':', hasContent ? '✓ 有内容' : '✗ 内容为空');
if (!hasContent) issues.push(name + ': 内容为空');
} catch (e) {
console.log(' ' + name + ': [FAIL]', e.message);
issues.push(name + ': ' + e.message);
}
}
// ═══ 汇总 ═══
console.log('\n╔══════════════════════════════════════╗');
if (issues.length === 0) {
console.log('║ ✓ 全部页面内容验证通过,无问题发现 ║');
} else {
console.log('║ 发现 ' + issues.length + ' 个问题: ║');
issues.forEach((issue, i) => {
console.log('║ ' + (i + 1) + '. ' + issue.substring(0, 30).padEnd(32) + '║');
});
}
console.log('╚══════════════════════════════════════╝');
// 不用 mp.close() 避免关闭自动化
mp.disconnect();
}
main().catch(err => { console.error('Fatal:', err.message); process.exit(1); });

View File

@@ -0,0 +1,299 @@
const automator = require('miniprogram-automator');
const { execSync } = require('child_process');
const TIMEOUT = 10000;
function wt(p, ms, label) { return Promise.race([p, new Promise((_, r) => setTimeout(() => r(new Error(label + ' timeout')), ms))]); }
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function trySelect(page, sel) {
try {
const el = await wt(page.$(sel), 3000, sel);
if (!el) return null;
const text = (await wt(el.textContent(), 3000, 'text')).trim().replace(/\s+/g, ' ');
return text;
} catch { return null; }
}
async function trySelectAll(page, sel) {
try {
const els = await wt(page.$$(sel), 3000, sel);
const items = [];
for (let i = 0; i < els.length; i++) {
try { items.push((await els[i].textContent()).trim().replace(/\s+/g, ' ')); } catch {}
}
return items;
} catch { return []; }
}
async function main() {
console.log('=== HMS 深度验证 V3 ===\n');
let mp;
for (let i = 1; i <= 8; i++) {
try {
mp = await wt(automator.connect({ wsEndpoint: 'ws://127.0.0.1:9420' }), TIMEOUT, 'connect');
console.log('[OK] 连接成功\n');
break;
} catch { if (i === 8) { console.log('连接失败,请确认已运行: cli.bat auto --project ... --auto-port 9420'); return; } console.log('等待...(' + i + ')'); await sleep(3); }
}
const issues = [];
// === 首页 ===
console.log('━━━ 首页 ━━━');
try {
await wt(mp.switchTab('/pages/index/index'), TIMEOUT, 'nav');
await sleep(2);
const page = await wt(mp.currentPage(), TIMEOUT, 'page');
const greeting = await trySelect(page, '.greeting-hello');
const gName = await trySelect(page, '.greeting-name');
const gDate = await trySelect(page, '.greeting-date');
const healthItems = await trySelectAll(page, '.health-item');
const services = await trySelectAll(page, '.service-item');
const emptyText = await trySelect(page, '.empty-text');
console.log(' 问候语:', greeting || '✗');
console.log(' 用户名:', gName || '✗');
console.log(' 日期:', gDate || '✗');
console.log(' 健康卡片:', healthItems.length > 0 ? healthItems.length + '个' : '✗');
healthItems.forEach(h => console.log(' -', h));
console.log(' 快捷服务:', services.length > 0 ? services.length + '个' : '✗');
services.forEach(s => console.log(' -', s));
console.log(' 空状态:', emptyText || '(无)');
if (!greeting) issues.push('首页缺少问候语');
if (healthItems.length === 0) issues.push('首页缺少健康数据卡片(应有4个)');
if (services.length === 0) issues.push('首页缺少快捷服务(应有4个)');
} catch (e) { console.log(' FAIL:', e.message); issues.push('首页:' + e.message); }
// === 健康中心 ===
console.log('\n━━━ 健康中心 ━━━');
try {
await wt(mp.switchTab('/pages/health/index'), TIMEOUT, 'nav');
await sleep(2);
const page = await wt(mp.currentPage(), TIMEOUT, 'page');
const header = await trySelect(page, '.health-header-title');
const inputBtn = await trySelect(page, '.health-header-btn-text');
const cards = await trySelectAll(page, '.health-card');
const actions = await trySelectAll(page, '.action-card');
console.log(' 标题:', header || '✗');
console.log(' 录入按钮:', inputBtn || '✗');
console.log(' 健康卡片:', cards.length > 0 ? cards.length + '个' : '✗');
cards.forEach(c => console.log(' -', c));
console.log(' 趋势入口:', actions.length > 0 ? actions.length + '个' : '✗');
actions.forEach(a => console.log(' -', a));
if (!header) issues.push('健康中心缺少标题');
if (cards.length === 0) issues.push('健康中心缺少数据卡片');
if (actions.length === 0) issues.push('健康中心缺少趋势入口');
} catch (e) { console.log(' FAIL:', e.message); issues.push('健康中心:' + e.message); }
// === 健康录入 ===
console.log('\n━━━ 健康录入 ━━━');
try {
await wt(mp.reLaunch('/pages/health/input/index'), TIMEOUT, 'nav');
await sleep(2);
const page = await wt(mp.currentPage(), TIMEOUT, 'page');
const picker = await trySelect(page, '.input-picker');
const fields = await trySelectAll(page, '.input-field');
const submit = await trySelect(page, '.input-submit');
const labels = await trySelectAll(page, '.input-label');
console.log(' 指标选择器:', picker || '✗');
console.log(' 表单标签:', labels.length + '个');
labels.forEach(l => console.log(' -', l));
console.log(' 输入框:', fields.length + '个');
console.log(' 提交按钮:', submit || '✗');
if (!picker) issues.push('健康录入缺少指标选择器');
if (fields.length === 0) issues.push('健康录入缺少输入框');
if (!submit) issues.push('健康录入缺少提交按钮');
} catch (e) { console.log(' FAIL:', e.message); issues.push('健康录入:' + e.message); }
// === 健康趋势 ===
console.log('\n━━━ 健康趋势 ━━━');
try {
await wt(mp.reLaunch('/pages/health/trend/index?indicator=heart_rate'), TIMEOUT, 'nav');
await sleep(2);
const page = await wt(mp.currentPage(), TIMEOUT, 'page');
const title = await trySelect(page, '.trend-title');
const tabs = await trySelectAll(page, '.trend-tab-text');
console.log(' 标题:', title || '✗');
console.log(' 时间Tab:', tabs.length > 0 ? tabs.join(', ') : '✗');
if (!title) issues.push('健康趋势缺少标题');
if (tabs.length === 0) issues.push('健康趋势缺少时间范围选项(7天/30天/90天)');
} catch (e) { console.log(' FAIL:', e.message); issues.push('健康趋势:' + e.message); }
// === 预约列表 ===
console.log('\n━━━ 预约列表 ━━━');
try {
await wt(mp.switchTab('/pages/appointment/index'), TIMEOUT, 'nav');
await sleep(2);
const page = await wt(mp.currentPage(), TIMEOUT, 'page');
const pageTitle = await trySelect(page, '.page-title');
const fab = await trySelect(page, '.fab-text');
const empty = await trySelect(page, '.empty-text');
const apptCards = await trySelectAll(page, '.appointment-card');
console.log(' 标题:', pageTitle || '✗');
console.log(' 悬浮按钮:', fab || '✗');
console.log(' 空状态:', empty || '(有数据)');
console.log(' 预约卡片:', apptCards.length + '个');
if (!pageTitle) issues.push('预约列表缺少标题');
if (!fab) issues.push('预约列表缺少新建按钮');
} catch (e) { console.log(' FAIL:', e.message); issues.push('预约列表:' + e.message); }
// === 创建预约 ===
console.log('\n━━━ 创建预约 ━━━');
try {
await wt(mp.reLaunch('/pages/appointment/create/index'), TIMEOUT, 'nav');
await sleep(2);
const page = await wt(mp.currentPage(), TIMEOUT, 'page');
const stepTitle = await trySelect(page, '.step-title');
const depts = await trySelectAll(page, '.dept-card');
const nextBtn = await trySelect(page, '.btn-next');
console.log(' 步骤标题:', stepTitle || '✗');
console.log(' 科室卡片:', depts.length > 0 ? depts.length + '个' : '✗');
depts.forEach(d => console.log(' -', d));
console.log(' 下一步按钮:', nextBtn || '✗');
if (depts.length === 0) issues.push('创建预约缺少科室选择(应有6个)');
if (!nextBtn) issues.push('创建预约缺少下一步按钮');
} catch (e) { console.log(' FAIL:', e.message); issues.push('创建预约:' + e.message); }
// === 资讯文章 ===
console.log('\n━━━ 资讯文章 ━━━');
try {
await wt(mp.switchTab('/pages/article/index'), TIMEOUT, 'nav');
await sleep(2);
const page = await wt(mp.currentPage(), TIMEOUT, 'page');
const articles = await trySelectAll(page, '.article-card');
const empty = await trySelect(page, '.empty-text');
console.log(' 文章卡片:', articles.length + '个');
if (articles.length > 0) articles.forEach((a, i) => console.log(' [' + i + ']', a.substring(0, 50)));
console.log(' 空状态:', empty || '(有数据)');
} catch (e) { console.log(' FAIL:', e.message); issues.push('资讯文章:' + e.message); }
// === 个人中心 ===
console.log('\n━━━ 个人中心 ━━━');
try {
await wt(mp.switchTab('/pages/profile/index'), TIMEOUT, 'nav');
await sleep(2);
const page = await wt(mp.currentPage(), TIMEOUT, 'page');
const avatar = await trySelect(page, '.profile-avatar-text');
const pName = await trySelect(page, '.profile-name');
const menus = await trySelectAll(page, '.menu-item');
const logout = await trySelect(page, '.logout-text');
console.log(' 头像字符:', avatar || '✗');
console.log(' 用户名:', pName || '✗');
console.log(' 菜单项:', menus.length > 0 ? menus.length + '个' : '✗');
menus.forEach(m => console.log(' -', m));
console.log(' 退出按钮:', logout || '✗');
if (menus.length === 0) issues.push('个人中心缺少菜单项(应有5个)');
if (!logout) issues.push('个人中心缺少退出登录');
} catch (e) { console.log(' FAIL:', e.message); issues.push('个人中心:' + e.message); }
// === 就诊人管理 ===
console.log('\n━━━ 就诊人管理 ━━━');
try {
await wt(mp.reLaunch('/pages/profile/family/index'), TIMEOUT, 'nav');
await sleep(2);
const page = await wt(mp.currentPage(), TIMEOUT, 'page');
const addBtn = await trySelect(page, '.family-add-text');
const patients = await trySelectAll(page, '.family-item');
const empty = await trySelect(page, '.empty-text');
console.log(' 添加按钮:', addBtn || '✗');
console.log(' 就诊人:', patients.length + '个');
console.log(' 空状态:', empty || '(有数据)');
if (!addBtn) issues.push('就诊人管理缺少添加按钮');
} catch (e) { console.log(' FAIL:', e.message); issues.push('就诊人管理:' + e.message); }
// === 我的报告 ===
console.log('\n━━━ 我的报告 ━━━');
try {
await wt(mp.reLaunch('/pages/profile/reports/index'), TIMEOUT, 'nav');
await sleep(2);
const page = await wt(mp.currentPage(), TIMEOUT, 'page');
const reports = await trySelectAll(page, '.report-card');
const empty = await trySelect(page, '.empty-text');
console.log(' 报告卡片:', reports.length + '个');
console.log(' 空状态:', empty || '(有数据)');
} catch (e) { console.log(' FAIL:', e.message); issues.push('我的报告:' + e.message); }
// === 登录页 ===
console.log('\n━━━ 登录页 ━━━');
try {
await wt(mp.reLaunch('/pages/login/index'), TIMEOUT, 'nav');
await sleep(2);
const page = await wt(mp.currentPage(), TIMEOUT, 'page');
const logo = await trySelect(page, '.login-logo-text');
const lTitle = await trySelect(page, '.login-title');
const lSub = await trySelect(page, '.login-subtitle');
const lBtn = await trySelect(page, '.login-btn');
const agreement = await trySelect(page, '.agreement-text');
const checkbox = await trySelect(page, '.checkbox');
const links = await trySelectAll(page, '.agreement-link');
console.log(' Logo:', logo || '✗');
console.log(' 标题:', lTitle || '✗');
console.log(' 副标题:', lSub || '✗');
console.log(' 登录按钮:', lBtn ? '✓' : '✗');
console.log(' 协议文案:', agreement ? agreement.substring(0, 40) : '✗');
console.log(' 勾选框:', checkbox ? '✓' : '✗');
console.log(' 协议链接:', links.length + '个');
if (!lBtn) issues.push('登录页缺少登录按钮');
if (!checkbox) issues.push('登录页缺少协议勾选框');
if (links.length < 2) issues.push('登录页缺少用户协议/隐私政策链接(应有2个)');
} catch (e) { console.log(' FAIL:', e.message); issues.push('登录页:' + e.message); }
// === 法律页面 ===
console.log('\n━━━ 法律页面 ━━━');
for (const [name, path] of [['用户协议', '/pages/legal/user-agreement'], ['隐私政策', '/pages/legal/privacy-policy']]) {
try {
await wt(mp.reLaunch(path), TIMEOUT, 'nav');
await sleep(2);
const page = await wt(mp.currentPage(), TIMEOUT, 'page');
const views = await trySelectAll(page, 'view');
const hasContent = views.some(v => v.length > 10);
console.log(' ' + name + ':', hasContent ? '✓ 有内容' : '✗ 内容为空');
if (!hasContent) issues.push(name + '内容为空');
} catch (e) { console.log(' ' + name + ' FAIL:', e.message); }
}
// ═══ 汇总 ═══
console.log('\n═══════════════════════════════════════');
if (issues.length === 0) {
console.log(' 全部页面内容验证通过');
} else {
console.log(' 发现 ' + issues.length + ' 个问题:');
issues.forEach((issue, i) => console.log(' ' + (i + 1) + '. ' + issue));
}
console.log('═══════════════════════════════════════');
mp.disconnect();
}
main().catch(err => { console.error('Fatal:', err.message); process.exit(1); });

View File

@@ -0,0 +1,140 @@
import automator from 'miniprogram-automator';
import fs from 'fs';
import path from 'path';
const WS = 'ws://127.0.0.1:9420';
const TIMEOUT = 15000;
const SCREENSHOTS_DIR = 'g:/hms/apps/miniprogram/screenshots';
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timeout`)), ms))
]);
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function main() {
console.log('=== HMS 小程序完整验证 ===\n');
if (!fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
// Connect
console.log('[1] 连接开发者工具...');
const mp = await withTimeout(automator.connect({ wsEndpoint: WS }), TIMEOUT, 'connect');
console.log(' 已连接\n');
// System info
console.log('[2] 系统信息...');
try {
const info = await withTimeout(mp.systemInfo(), TIMEOUT, 'systemInfo');
console.log(` 设备: ${info.model}`);
console.log(` 系统: ${info.system}`);
console.log(` SDK: ${info.SDKVersion}`);
console.log(` 屏幕: ${info.screenWidth}x${info.screenHeight}\n`);
} catch (e) {
console.log(` [WARN] ${e.message}\n`);
}
// Current page
console.log('[3] 当前页面...');
try {
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'currentPage');
console.log(` 路径: ${page.path}\n`);
} catch (e) {
console.log(` [WARN] ${e.message}\n`);
}
// Navigate and screenshot each page
console.log('[4] 页面导航验证...');
const pages = [
{ path: '/pages/index/index', name: '首页' },
{ path: '/pages/health/index', name: '健康中心' },
{ path: '/pages/health/input/index', name: '健康数据录入' },
{ path: '/pages/health/trend/index', name: '健康趋势' },
{ path: '/pages/appointment/index', name: '预约列表' },
{ path: '/pages/appointment/create/index', name: '创建预约' },
{ path: '/pages/article/index', name: '资讯文章' },
{ path: '/pages/profile/index', name: '个人中心' },
{ path: '/pages/profile/family/index', name: '就诊人管理' },
{ path: '/pages/profile/reports/index', name: '我的报告' },
{ path: '/pages/login/index', name: '登录页' },
];
let passCount = 0;
let failCount = 0;
let ssCount = 0;
for (const p of pages) {
try {
await withTimeout(mp.reLaunch(p.path), TIMEOUT, `reLaunch ${p.path}`);
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'currentPage');
// Screenshot
let ssOk = false;
try {
const ss = await withTimeout(mp.screenshot({ path: `${SCREENSHOTS_DIR}/${p.path.replace(/\//g, '_').replace(/^_/, '')}.png` }), 20000, 'screenshot');
ssOk = true;
ssCount++;
} catch (e) {
// Retry without path option
try {
const ss = await withTimeout(mp.screenshot(), 20000, 'screenshot2');
const safeName = p.path.replace(/\//g, '_').replace(/^_/, '');
const ssPath = `${SCREENSHOTS_DIR}/${safeName}.png`;
if (Buffer.isBuffer(ss)) {
fs.writeFileSync(ssPath, ss);
} else {
fs.writeFileSync(ssPath, Buffer.from(ss, 'base64'));
}
ssOk = true;
ssCount++;
} catch (e2) {
// ignore
}
}
console.log(` [OK] ${p.name} (${p.path})${ssOk ? ' 📸' : ''}`);
passCount++;
} catch (e) {
console.log(` [FAIL] ${p.name}: ${e.message}`);
failCount++;
}
}
// Test page data on key pages
console.log('\n[5] 页面数据验证...');
const dataTests = [
{ path: '/pages/index/index', name: '首页', checkData: ['services'] },
{ path: '/pages/profile/index', name: '个人中心', checkData: ['MENU_ITEMS'] },
{ path: '/pages/login/index', name: '登录页', checkData: [] },
];
for (const test of dataTests) {
try {
await withTimeout(mp.reLaunch(test.path), TIMEOUT, `reLaunch ${test.path}`);
await sleep(2000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'currentPage');
const data = await withTimeout(page.data(), TIMEOUT, 'pageData');
console.log(` [OK] ${test.name} data keys: ${Object.keys(data).join(', ')}`);
} catch (e) {
console.log(` [WARN] ${test.name}: ${e.message}`);
}
}
// Summary
console.log('\n=== 验证结果 ===');
console.log(` 页面加载: ${passCount}/${pages.length} 通过, ${failCount} 失败`);
console.log(` 截图: ${ssCount}`);
console.log(` 截图目录: ${SCREENSHOTS_DIR}`);
await mp.close();
console.log('\n验证完成!');
}
main().catch(err => {
console.error('Fatal:', err.message);
process.exit(1);
});

View File

@@ -0,0 +1,148 @@
import automator from 'miniprogram-automator';
const WS = 'ws://127.0.0.1:9420';
const TIMEOUT = 30000;
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${label} timeout after ${ms}ms`)), ms)
)
]);
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
async function retry(fn, retries = 3, delay = 2000) {
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (e) {
console.log(` [retry ${i + 1}/${retries}] ${e.message}`);
if (i < retries - 1) await sleep(delay);
else throw e;
}
}
}
async function main() {
console.log('=== 小程序验证 V2 ===\n');
// Step 1: Connect
console.log('[1/5] 连接开发者工具...');
let mp;
try {
mp = await withTimeout(
automator.connect({ wsEndpoint: WS }),
TIMEOUT,
'connect'
);
console.log(' [OK] 已连接\n');
} catch (e) {
console.error(' [FAIL] 连接失败:', e.message);
process.exit(1);
}
// Step 2: Get current page
console.log('[2/5] 获取当前页面...');
let currentPage;
try {
currentPage = await retry(async () => {
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'currentPage');
return page;
});
console.log(' [OK] 当前页面:', currentPage.path, '\n');
} catch (e) {
console.log(' [WARN] 无法获取页面:', e.message, '\n');
}
// Step 3: Take screenshot
console.log('[3/5] 截图...');
try {
const screenshot = await withTimeout(mp.screenshot(), TIMEOUT, 'screenshot');
const fs = await import('fs');
const outPath = 'g:/hms/apps/miniprogram/verify-screenshot.png';
if (Buffer.isBuffer(screenshot)) {
fs.writeFileSync(outPath, screenshot);
} else {
fs.writeFileSync(outPath, Buffer.from(screenshot, 'base64'));
}
console.log(' [OK] 截图已保存: verify-screenshot.png\n');
} catch (e) {
console.log(' [WARN] 截图失败:', e.message, '\n');
}
// Step 4: Navigate pages
console.log('[4/5] 导航测试...');
const testPages = [
{ path: '/pages/index/index', name: '首页', method: 'switchTab' },
{ path: '/pages/health/input/index', name: '健康数据录入', method: 'reLaunch' },
{ path: '/pages/health/trend/index', name: '健康趋势', method: 'reLaunch' },
{ path: '/pages/appointment/index', name: '预约列表', method: 'switchTab' },
{ path: '/pages/appointment/create/index', name: '创建预约', method: 'reLaunch' },
{ path: '/pages/article/index', name: '资讯文章', method: 'switchTab' },
{ path: '/pages/profile/index', name: '个人中心', method: 'switchTab' },
{ path: '/pages/profile/family/index', name: '就诊人管理', method: 'reLaunch' },
{ path: '/pages/profile/reports/index', name: '我的报告', method: 'reLaunch' },
{ path: '/pages/login/index', name: '登录页', method: 'reLaunch' },
];
for (const p of testPages) {
try {
await withTimeout(mp.reLaunch(p.path), TIMEOUT, `reLaunch ${p.path}`);
await sleep(3000);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'currentPage');
console.log(` [OK] ${p.name}: ${page.path}`);
// Take screenshot of each page
try {
const ss = await withTimeout(mp.screenshot(), TIMEOUT, 'screenshot');
const fs = await import('fs');
const safeName = p.path.replace(/\//g, '_').replace(/^_/, '');
const ssPath = `g:/hms/apps/miniprogram/screenshots/${safeName}.png`;
const dir = 'g:/hms/apps/miniprogram/screenshots';
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
if (Buffer.isBuffer(ss)) {
fs.writeFileSync(ssPath, ss);
} else {
fs.writeFileSync(ssPath, Buffer.from(ss, 'base64'));
}
console.log(` 截图: screenshots/${safeName}.png`);
} catch (e) {
console.log(` 截图失败: ${e.message}`);
}
} catch (e) {
console.log(` [FAIL] ${p.name}: ${e.message}`);
}
}
// Step 5: System info
console.log('\n[5/5] 系统信息...');
try {
const systemInfo = await withTimeout(mp.evaluate(() => {
try { return wx.getSystemInfoSync(); } catch { return null; }
}), TIMEOUT, 'evaluate');
if (systemInfo) {
console.log(' [OK]', JSON.stringify({
model: systemInfo.model,
system: systemInfo.system,
SDKVersion: systemInfo.SDKVersion,
screenWidth: systemInfo.screenWidth,
screenHeight: systemInfo.screenHeight,
}, null, 2));
}
} catch (e) {
console.log(' [WARN]', e.message);
}
await mp.close();
console.log('\n=== 验证完成 ===');
}
main().catch(err => {
console.error('Fatal:', err.message);
process.exit(1);
});

View File

@@ -0,0 +1,121 @@
import automator from 'miniprogram-automator';
import fs from 'fs';
const WS = 'ws://127.0.0.1:9420';
const TIMEOUT = 15000;
const SS_DIR = 'g:/hms/apps/miniprogram/screenshots';
const TABBAR_PAGES = ['pages/index/index', 'pages/health/index', 'pages/appointment/index', 'pages/article/index', 'pages/profile/index'];
function withTimeout(promise, ms, label) {
return Promise.race([promise, new Promise((_, r) => setTimeout(() => r(new Error(`${label} timeout`)), ms))]);
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function saveScreenshot(mp, name) {
try {
const ss = await withTimeout(mp.screenshot(), 20000, 'screenshot');
const ssPath = `${SS_DIR}/${name}.png`;
if (Buffer.isBuffer(ss)) {
fs.writeFileSync(ssPath, ss);
} else {
fs.writeFileSync(ssPath, Buffer.from(ss, 'base64'));
}
return true;
} catch (e) {
return false;
}
}
async function main() {
console.log('=== HMS 小程序验证 V3 ===\n');
if (!fs.existsSync(SS_DIR)) fs.mkdirSync(SS_DIR, { recursive: true });
const mp = await withTimeout(automator.connect({ wsEndpoint: WS }), TIMEOUT, 'connect');
console.log('[OK] 已连接\n');
// System info
const info = await withTimeout(mp.systemInfo(), TIMEOUT, 'sys');
console.log(`设备: ${info.model} | SDK: ${info.SDKVersion} | 屏幕: ${info.screenWidth}x${info.screenHeight}\n`);
// Test pages - tabBar pages first using switchTab, then non-tabBar using reLaunch
const testPages = [
{ path: '/pages/index/index', name: '首页', tabBar: true },
{ path: '/pages/health/index', name: '健康中心', tabBar: true },
{ path: '/pages/appointment/index', name: '预约列表', tabBar: true },
{ path: '/pages/article/index', name: '资讯文章', tabBar: true },
{ path: '/pages/profile/index', name: '个人中心', tabBar: true },
{ path: '/pages/health/input/index', name: '健康数据录入' },
{ path: '/pages/health/trend/index', name: '健康趋势' },
{ path: '/pages/appointment/create/index', name: '创建预约' },
{ path: '/pages/appointment/detail/index', name: '预约详情' },
{ path: '/pages/article/detail/index', name: '文章详情' },
{ path: '/pages/profile/family/index', name: '就诊人管理' },
{ path: '/pages/profile/reports/index', name: '我的报告' },
{ path: '/pages/profile/followups/index', name: '我的随访' },
{ path: '/pages/profile/medication/index', name: '用药提醒' },
{ path: '/pages/profile/settings/index', name: '设置' },
{ path: '/pages/login/index', name: '登录页' },
{ path: '/pages/legal/user-agreement', name: '用户协议' },
{ path: '/pages/legal/privacy-policy', name: '隐私政策' },
];
let pass = 0, fail = 0, ss = 0;
for (const p of testPages) {
try {
if (p.tabBar) {
await withTimeout(mp.switchTab(p.path), TIMEOUT, `switchTab ${p.path}`);
} else {
await withTimeout(mp.reLaunch(p.path), TIMEOUT, `reLaunch ${p.path}`);
}
await sleep(1500);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'currentPage');
const ssOk = await saveScreenshot(mp, p.path.replace(/\//g, '_').replace(/^_/, ''));
if (ssOk) ss++;
console.log(` [OK] ${p.name}${page.path}${ssOk ? ' 📸' : ''}`);
pass++;
} catch (e) {
console.log(` [FAIL] ${p.name}: ${e.message}`);
fail++;
// Try to recover connection
try {
await sleep(2000);
await withTimeout(mp.currentPage(), 5000, 'recovery');
} catch {
// Connection might be dead, try reconnecting
console.log(' [RECOVERY] 尝试重新连接...');
try {
const mp2 = await withTimeout(automator.connect({ wsEndpoint: WS }), 10000, 'reconnect');
Object.assign(mp, mp2);
console.log(' [RECOVERY] 重新连接成功');
} catch {
console.log(' [RECOVERY] 重新连接失败');
}
}
}
}
// Page data checks
console.log('\n--- 页面数据检查 ---');
const dataPages = ['/pages/index/index', '/pages/login/index'];
for (const dp of dataPages) {
try {
await withTimeout(mp.reLaunch(dp), TIMEOUT, `nav ${dp}`);
await sleep(1500);
const page = await withTimeout(mp.currentPage(), TIMEOUT, 'page');
const data = await withTimeout(page.data(), TIMEOUT, 'data');
console.log(` ${dp}: keys = ${Object.keys(data).join(', ')}`);
} catch (e) {
console.log(` ${dp}: ${e.message}`);
}
}
console.log(`\n=== 结果: ${pass}/${testPages.length} 通过, ${fail} 失败, ${ss} 截图 ===`);
try { await mp.close(); } catch {}
}
main().catch(err => { console.error('Fatal:', err.message); process.exit(1); });

View File

@@ -0,0 +1,89 @@
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const Launcher = require('miniprogram-automator/out/launcher').default;
const WS = 'ws://127.0.0.1:9420';
const TIMEOUT = 8000;
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms))
]);
}
async function main() {
console.log('Connecting to', WS);
const launcher = new Launcher();
const mp = await withTimeout(launcher.connect({ wsEndpoint: WS }), TIMEOUT);
console.log('Connected!');
// 1. Current page
try {
const page = await withTimeout(mp.currentPage(), TIMEOUT);
console.log('[OK] Current page:', page.path);
} catch (e) {
console.log('[WARN] currentPage failed:', e.message);
}
// 2. Screenshot
try {
const screenshot = await withTimeout(mp.screenshot(), TIMEOUT);
const fs = await import('fs');
const outPath = 'g:/hms/apps/miniprogram/verify-screenshot.png';
// screenshot could be buffer or base64
if (Buffer.isBuffer(screenshot)) {
fs.writeFileSync(outPath, screenshot);
} else {
fs.writeFileSync(outPath, Buffer.from(screenshot, 'base64'));
}
console.log('[OK] Screenshot saved to verify-screenshot.png');
} catch (e) {
console.log('[WARN] screenshot failed:', e.message);
}
// 3. Navigate and verify pages
const pages = [
{ path: 'pages/index/index', name: '首页' },
{ path: 'pages/health/index', name: '健康中心' },
{ path: 'pages/appointment/index', name: '预约列表' },
{ path: 'pages/article/index', name: '资讯文章' },
{ path: 'pages/profile/index', name: '个人中心' },
{ path: 'pages/login/index', name: '登录页' },
];
for (const p of pages) {
try {
await withTimeout(mp.reLaunch(`/pages/${p.path.includes('/') ? '' : ''}${p.path}`), TIMEOUT);
await new Promise(r => setTimeout(r, 2000));
const page = await withTimeout(mp.currentPage(), TIMEOUT);
console.log(`[OK] ${p.name} (${p.path}): loaded`);
} catch (e) {
console.log(`[FAIL] ${p.name} (${p.path}): ${e.message}`);
}
}
// 4. System info
try {
const systemInfo = await withTimeout(mp.evaluate(() => {
try { return wx.getSystemInfoSync(); } catch { return null; }
}), TIMEOUT);
if (systemInfo) {
console.log('[OK] System info:', JSON.stringify({
model: systemInfo.model,
system: systemInfo.system,
SDKVersion: systemInfo.SDKVersion,
}));
}
} catch (e) {
console.log('[WARN] systemInfo failed:', e.message);
}
await mp.close();
console.log('\nVerification complete!');
}
main().catch(err => {
console.error('Fatal:', err.message);
process.exit(1);
});