- Create README.md for user guides covering roles: independent doctor, clinic owner, clinic manager, secretary, and staff. - Add detailed step-by-step guides for setting up and managing clinics for clinic managers and clinic owners. - Include instructions for independent doctors on setting up their practice. - Provide a comprehensive guide for secretaries on daily operations and patient management. - Introduce staff guide for nurses and technicians on session management.
284 lines
12 KiB
JavaScript
284 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* guide-shots.mjs — تصویربردار راهنمای کاربری.
|
|
*
|
|
* برای هر نقش لاگین میکند، فهرست صفحاتش را میپیماید و از هرکدام یک PNG میگیرد.
|
|
* خروجی: <out>/<role>/<NN-slug>.png بههمراه shots.json که ترتیب و عنوانها را
|
|
* برای متن راهنما نگه میدارد.
|
|
*
|
|
* node guide-shots.mjs --role clinic-owner --out /tmp/guide
|
|
* node guide-shots.mjs --all --out /tmp/guide
|
|
*
|
|
* وابستگی ندارد: Node 22 با WebSocket سراسری مستقیم با CDP حرف میزند.
|
|
*/
|
|
import { spawn } from 'node:child_process';
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
|
|
const CHROME = process.env.CHROME_BIN
|
|
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
const PASSWORD = process.env.GUIDE_PASSWORD ?? 'QaTest@1234';
|
|
const PORT = Number(process.env.CDP_PORT ?? 9500);
|
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
|
|
/**
|
|
* پنج شخصیتِ راهنما. هر صفحه یک گام از داستانِ همان نقش است، پس ترتیب مهم است و
|
|
* الفبایی نیست: کاربر تازهوارد از داشبورد شروع میکند، نه از تنظیمات.
|
|
*/
|
|
const ROLES = {
|
|
'independent-doctor': {
|
|
title: 'پزشک مستقل',
|
|
mobile: '0912000101',
|
|
pages: [
|
|
['dashboard', 'داشبورد'],
|
|
['appointments', 'نوبتهای امروز'],
|
|
['appointments/new', 'ثبت نوبت جدید'],
|
|
['patients', 'پروندهها'],
|
|
['my-patients', 'بیماران من'],
|
|
['clinic-services', 'سرویسها'],
|
|
['service-categories', 'دستهبندی سرویسها'],
|
|
['insurance-pricing', 'بیمه و تعرفه'],
|
|
['my-payments', 'پرداختها'],
|
|
['claims', 'مطالبات بیمه'],
|
|
['treatment-cases', 'دورههای درمان'],
|
|
['resources', 'منابع'],
|
|
['inventory', 'انبارداری'],
|
|
['my-secretaries', 'منشیها'],
|
|
['staff', 'پرسنل'],
|
|
['settings-menu', 'تنظیمات'],
|
|
['appointment-settings', 'تنظیمات نوبتدهی'],
|
|
['holidays', 'تعطیلات'],
|
|
['record-number-settings', 'شمارهٔ پرونده'],
|
|
['tags-settings', 'برچسبها'],
|
|
['sms-wallet', 'کیف پول پیامک'],
|
|
['subscription', 'اشتراک'],
|
|
['discounts', 'تخفیفها'],
|
|
['profile', 'پروفایل پزشک'],
|
|
['account-settings', 'حساب کاربری'],
|
|
],
|
|
},
|
|
'clinic-owner-doctor': {
|
|
title: 'پزشکی که کلینیک دارد',
|
|
mobile: '0912000201',
|
|
// این حساب هم مطب شخصی دارد هم کلینیک، پس اپ اول «انتخاب محیط» میپرسد.
|
|
context: 'کلینیک',
|
|
pages: [
|
|
['dashboard', 'داشبورد'],
|
|
['appointments', 'نوبتهای کلینیک'],
|
|
['my-clinic', 'کلینیک من'],
|
|
['settings/clinic-doctors', 'پزشکان کلینیک'],
|
|
['settings/appointment-settings', 'نوبتدهی کلینیک'],
|
|
['settings/practice-domain', 'حوزهٔ فعالیت'],
|
|
['clinic-services', 'سرویسهای کلینیک'],
|
|
['resources', 'منابع'],
|
|
['resources/types', 'نوع منابع'],
|
|
['resources/pools', 'استخر منابع'],
|
|
['resources/skills', 'مهارتها'],
|
|
['staff', 'پرسنل'],
|
|
['my-secretaries', 'منشیها'],
|
|
['patients', 'پروندهها'],
|
|
['treatment-cases', 'دورههای درمان'],
|
|
['my-payments', 'پرداختها'],
|
|
['my-financial', 'مدیریت پرداخت'],
|
|
['claims', 'مطالبات بیمه'],
|
|
['inventory', 'انبارداری'],
|
|
['settings-menu', 'تنظیمات'],
|
|
['subscription', 'اشتراک'],
|
|
],
|
|
},
|
|
'clinic-manager': {
|
|
title: 'مدیر کلینیک',
|
|
mobile: '0912000301',
|
|
pages: [
|
|
['dashboard', 'داشبورد'],
|
|
['appointments', 'نوبتهای درمانگاه'],
|
|
['my-clinic', 'کلینیک من'],
|
|
['settings/clinic-doctors', 'پزشکان'],
|
|
['settings/appointment-settings', 'نوبتدهی'],
|
|
['clinic-services', 'سرویسها'],
|
|
['service-categories', 'دستهبندیها'],
|
|
['resources', 'منابع'],
|
|
['staff', 'پرسنل'],
|
|
['my-secretaries', 'منشیها'],
|
|
['patients', 'پروندهها'],
|
|
['my-payments', 'پرداختها'],
|
|
['claims', 'مطالبات بیمه'],
|
|
['insurance-pricing', 'بیمه و تعرفه'],
|
|
['inventory', 'انبارداری'],
|
|
['treatment-cases', 'دورههای درمان'],
|
|
['settings-menu', 'تنظیمات'],
|
|
['sms-wallet', 'کیف پول پیامک'],
|
|
['subscription', 'اشتراک'],
|
|
],
|
|
},
|
|
secretary: {
|
|
title: 'منشی',
|
|
mobile: '0912000209',
|
|
pages: [
|
|
['appointments', 'نوبتها'],
|
|
['appointments/new', 'ثبت نوبت'],
|
|
['appointments/reserve', 'نوبت رزرو'],
|
|
['patients', 'پروندهها'],
|
|
['patients/new', 'تشکیل پرونده'],
|
|
['my-payments', 'پرداختها'],
|
|
['claims', 'مطالبات بیمه'],
|
|
['treatment-cases', 'دورههای درمان'],
|
|
['secretary-earnings', 'درآمد من'],
|
|
['secretary-settlement', 'تسویه حساب'],
|
|
['account-settings', 'حساب کاربری'],
|
|
],
|
|
},
|
|
staff: {
|
|
title: 'پرسنل',
|
|
mobile: '09120002105',
|
|
pages: [
|
|
['my-sessions', 'جلسات امروز من'],
|
|
['account-settings', 'حساب کاربری'],
|
|
],
|
|
},
|
|
};
|
|
|
|
const args = process.argv.slice(2);
|
|
const argOf = (name, fallback = null) => {
|
|
const i = args.indexOf(`--${name}`);
|
|
return i === -1 ? fallback : args[i + 1];
|
|
};
|
|
const OUT = argOf('out', '/tmp/guide');
|
|
const WIDTH = Number(argOf('w', 1440));
|
|
const HEIGHT = Number(argOf('h', 900));
|
|
const WAIT = Number(argOf('wait', 3500));
|
|
const wanted = args.includes('--all') ? Object.keys(ROLES) : [argOf('role')].filter(Boolean);
|
|
|
|
if (wanted.length === 0) {
|
|
console.error('استفاده: --role <name> یا --all');
|
|
console.error('نقشها: ' + Object.keys(ROLES).join(', '));
|
|
process.exit(1);
|
|
}
|
|
|
|
async function login(mobile) {
|
|
const res = await fetch(`${BASE}/api/v1/user/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mobile_number: mobile, password: PASSWORD }),
|
|
});
|
|
const body = await res.json();
|
|
if (!body.access_token) {
|
|
throw new Error(`ورود ${mobile} ناموفق: ${JSON.stringify(body).slice(0, 160)}`);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
async function cdpUrl() {
|
|
for (let i = 0; i < 50; i++) {
|
|
try {
|
|
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
|
return (await r.json()).webSocketDebuggerUrl;
|
|
} catch { await new Promise((r) => setTimeout(r, 300)); }
|
|
}
|
|
throw new Error('Chrome بالا نیامد');
|
|
}
|
|
|
|
const chrome = spawn(CHROME, [
|
|
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
|
|
'--ignore-certificate-errors', '--force-device-scale-factor=2',
|
|
`--remote-debugging-port=${PORT}`, `--user-data-dir=/tmp/guide-shots-${process.pid}`,
|
|
`--window-size=${WIDTH},${HEIGHT}`, 'about:blank',
|
|
], { stdio: 'ignore' });
|
|
|
|
const ws = new WebSocket(await cdpUrl());
|
|
await new Promise((r) => ws.addEventListener('open', r, { once: true }));
|
|
|
|
let msgId = 0;
|
|
const pending = new Map();
|
|
ws.addEventListener('message', (e) => {
|
|
const m = JSON.parse(e.data);
|
|
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); }
|
|
});
|
|
const rpc = (method, params, sessionId) => new Promise((resolve) => {
|
|
const id = ++msgId;
|
|
pending.set(id, resolve);
|
|
ws.send(JSON.stringify({ id, method, params, sessionId }));
|
|
});
|
|
|
|
const { targetId } = await rpc('Target.createTarget', { url: 'about:blank' });
|
|
const { sessionId } = await rpc('Target.attachToTarget', { targetId, flatten: true });
|
|
const S = (m, p) => rpc(m, p, sessionId);
|
|
await S('Page.enable');
|
|
await S('Runtime.enable');
|
|
const evalJs = async (expression) => {
|
|
const { result } = await S('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
|
|
return result?.value;
|
|
};
|
|
|
|
// اجرای تکنقشی نباید فهرست نقشهای قبلی را پاک کند.
|
|
const manifestPath = join(OUT, 'shots.json');
|
|
const manifest = existsSync(manifestPath)
|
|
? JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
: {};
|
|
|
|
for (const roleKey of wanted) {
|
|
const role = ROLES[roleKey];
|
|
const dir = join(OUT, roleKey);
|
|
mkdirSync(dir, { recursive: true });
|
|
|
|
const { access_token, refresh_token } = await login(role.mobile);
|
|
|
|
// localStorage به origin وابسته است؛ اول باید روی همان دامنه باشیم.
|
|
await S('Page.navigate', { url: `${BASE}/admin/login` });
|
|
await new Promise((r) => setTimeout(r, 1500));
|
|
await evalJs(`localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify({
|
|
state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true }, version: 0,
|
|
}))});
|
|
localStorage.setItem('pwa-dismissed','1');
|
|
// تور راهنما روی تصویرها میافتد و متنِ خودِ راهنما را میپوشاند.
|
|
localStorage.setItem('clinicpro-tours-suppress','1');`);
|
|
|
|
// حسابِ چندمحیطی تا محیطش را انتخاب نکند، هر مسیری به «انتخاب محیط» میرود.
|
|
if (role.context) {
|
|
await S('Page.navigate', { url: `${BASE}/admin/select-context` });
|
|
await new Promise((r) => setTimeout(r, 2500));
|
|
const picked = await evalJs(`(() => {
|
|
const btn = [...document.querySelectorAll('button')]
|
|
.find(b => b.textContent.includes(${JSON.stringify(role.context)}));
|
|
if (!btn) return false;
|
|
btn.click();
|
|
return btn.textContent.trim().slice(0, 60);
|
|
})()`);
|
|
await new Promise((r) => setTimeout(r, 2500));
|
|
console.log(` محیط انتخاب شد: ${picked || 'پیدا نشد ⚠'}`);
|
|
}
|
|
|
|
manifest[roleKey] = { title: role.title, mobile: role.mobile, shots: [] };
|
|
console.log(`\n### ${role.title} (${role.mobile})`);
|
|
|
|
for (const [index, [path, label]] of role.pages.entries()) {
|
|
const file = `${String(index + 1).padStart(2, '0')}-${path.replace(/\//g, '-')}.png`;
|
|
await S('Page.navigate', { url: `${BASE}/admin/${path}` });
|
|
await new Promise((r) => setTimeout(r, WAIT));
|
|
|
|
// تور خودکار روی صفحه مینشیند؛ بسته میشود تا تصویر خودِ صفحه را نشان دهد.
|
|
await evalJs(`(() => {
|
|
const close = document.querySelector('.driver-popover-close-btn');
|
|
if (close) close.click();
|
|
})()`);
|
|
await new Promise((r) => setTimeout(r, 400));
|
|
|
|
const landed = await evalJs('location.pathname');
|
|
const empty = await evalJs('(document.body.innerText || "").trim().length < 40');
|
|
const shot = await S('Page.captureScreenshot', { format: 'png' });
|
|
writeFileSync(join(dir, file), Buffer.from(shot.data, 'base64'));
|
|
|
|
const note = landed.endsWith(path) ? '' : ` (منتقل شد به ${landed})`;
|
|
console.log(` ${empty ? '⚠' : '✓'} ${label} → ${file}${note}`);
|
|
manifest[roleKey].shots.push({ file, path, label, landed, empty });
|
|
}
|
|
}
|
|
|
|
mkdirSync(OUT, { recursive: true });
|
|
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
console.log(`\nفهرست: ${manifestPath}`);
|
|
|
|
ws.close();
|
|
chrome.kill();
|