Commit 1a4c1b8b authored by liguangyu06's avatar liguangyu06
Browse files

从 temp 选择性同步框架与用例到 main

合入自动化代码、用例 CSV/MD/spec/PO 及登录态修复;不含 knowledge-base、.cursor、data/scan 等 temp 全量备份内容。
parent f6eec849
#!/usr/bin/env node
/**
* Batch scan: 基础设置 > 手术套包设置 (3 pages) from UI-SPEC SURG-001~003.
* Saves data/scan/*-elements.json, then regenerates locators/spd/*.locator.yaml.
*
* Usage:
* node scripts/scan-surgery-batch.mjs
* node scripts/scan-surgery-batch.mjs surgery-base-pkg surgery-oper-type
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const AUTH_STATE = path.join(ROOT, 'auth/user.json');
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const OUT_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
const URL_MAP_OUT = path.join(OUT_DIR, 'surgery-menu-urls.json');
if (existsSync(path.join(ROOT, '.env'))) {
process.loadEnvFile(path.join(ROOT, '.env'));
}
const MENUS = [
{ pageKey: 'surgery-base-pkg', menu: '基础包维护', pageTitle: '基础设置 > 手术套包设置 > 基础包维护', specId: 'SURG-001' },
{ pageKey: 'surgery-oper-type', menu: '术种维护', pageTitle: '基础设置 > 手术套包设置 > 术种维护', specId: 'SURG-002' },
{ pageKey: 'surgery-std-pkg', menu: '标准套包维护', pageTitle: '基础设置 > 手术套包设置 > 标准套包维护', specId: 'SURG-003' },
];
async function ensureAuthStorage() {
mkdirSync(path.dirname(AUTH_STATE), { recursive: true });
const user = process.env.TEST_USER;
const pass = process.env.TEST_PASS;
if (!user || !pass) {
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号');
}
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined,
});
const page = await context.newPage();
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(2000);
if (!page.url().includes('/login')) {
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Auth state OK:', AUTH_STATE);
return;
}
await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user);
await page.locator('#password, input[type="password"]').first().fill(pass);
await Promise.all([
page.waitForURL((url) => url.pathname.includes('/spd/home'), { timeout: 120_000 }),
page.getByRole('button', { name: /登\s*录/ }).click(),
]).catch(async () => {
for (let i = 0; i < 60; i += 1) {
if (page.url().includes('/spd/home')) return;
await page.waitForTimeout(1000);
}
throw new Error(`登录后未进入首页,当前 URL: ${page.url()}`);
});
await page.waitForTimeout(1500);
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Logged in and saved auth:', AUTH_STATE);
}
/** 展开 基础设置 > 手术套包设置 子菜单 */
async function expandSurgeryMenu(page) {
await page.getByRole('menuitem', { name: '基础设置' }).click({ timeout: 20_000 });
await page.waitForTimeout(500);
const surgTitle = page.locator('.ant-menu-submenu-title').filter({ hasText: /^手术套包设置$/ }).first();
if (await surgTitle.isVisible().catch(() => false)) {
const expanded = await surgTitle
.locator('xpath=..')
.evaluate((el) => el.classList.contains('ant-menu-submenu-open'))
.catch(() => false);
if (!expanded) await surgTitle.click({ timeout: 10_000 });
await page.waitForTimeout(400);
}
}
async function discoverMenuUrls(menus) {
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
storageState: AUTH_STATE,
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
});
const page = await context.newPage();
const urls = {};
for (const item of menus) {
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(1500);
if (page.url().includes('/login')) {
throw new Error('登录态失效,无法打开手术套包设置菜单');
}
await expandSurgeryMenu(page);
await page
.locator('.ant-menu-item')
.filter({ hasText: new RegExp(`^\\s*${item.menu}\\s*$`) })
.first()
.click({ timeout: 20_000 });
await page.waitForTimeout(2000);
const url = page.url().split('?')[0];
if (url.includes('/login') || url.endsWith('/spd/home/index')) {
throw new Error(`未能进入 ${item.menu},当前 URL: ${page.url()}`);
}
urls[item.pageKey] = url;
console.log(`URL ${item.menu}: ${url}`);
}
await browser.close();
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(URL_MAP_OUT, JSON.stringify({ base: BASE, scannedAt: new Date().toISOString(), urls }, null, 2), 'utf8');
console.log('Wrote URL map:', URL_MAP_OUT);
return urls;
}
async function openFirstDropdown(page) {
const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true };
await select.click({ timeout: 10_000 });
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(400);
return { dropdownOpened: true };
}
async function layerOpened(page) {
const modal = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false);
if (modal) return true;
return page.locator('.put-page').first().isVisible().catch(() => false);
}
async function openNamedButton(page, pattern, flag) {
const btn = page.getByRole('button', { name: pattern }).first();
if (!(await btn.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
await btn.click({ timeout: 12_000, force: true });
await page
.locator('.ant-modal, .ant-drawer-open, .put-page')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(500);
const opened = await layerOpened(page);
return { [flag]: opened, skipRescan: !opened };
}
async function openRowAction(page, pattern, flag) {
const row = page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
let action = row.getByRole('button', { name: pattern }).first();
if (!(await action.isVisible().catch(() => false))) {
action = row.locator('a,button,span').filter({ hasText: pattern }).first();
}
if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
const before = page.url();
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(800);
const moved = page.url() !== before && !page.url().includes('/login');
if (moved) return { [flag]: true, navigated: true, returnUrl: before };
const opened = await layerOpened(page);
return { [flag]: Boolean(opened), skipRescan: !opened };
}
async function clickTab(page, name) {
const tab = page.locator('.ant-tabs-tab').filter({ hasText: new RegExp(name) }).first();
if (!(await tab.isVisible().catch(() => false))) return { tabOpened: false, skipRescan: true };
await tab.click({ timeout: 8_000 });
await page.waitForTimeout(600);
return { tabOpened: true };
}
/** 展开左侧分类树首个可展开节点,采集树中元素 */
async function expandFirstTreeNode(page) {
const switcher = page
.locator('.ant-tree-switcher:not(.ant-tree-switcher-noop), .ant-tree-treenode .ant-tree-switcher_open')
.first();
if (!(await switcher.isVisible().catch(() => false))) return { treeExpanded: false, skipRescan: true };
await switcher.click({ timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(600);
return { treeExpanded: true };
}
function pageCfg(targetUrl, item) {
return {
targetUrl,
useIframe: false,
iframeMatch: '',
iframeLabel: 'main',
module: 'spd-basic',
pageKey: item.pageKey,
pageTitle: item.pageTitle,
jsonOut: path.join(OUT_DIR, `${item.pageKey}-elements.json`),
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
jsonSourceName: `${item.pageKey}-elements.json`,
waitSelectors: ['.ant-tree', '.ant-form-item input', '.ant-form', '.ant-table', '.ant-btn', '.ant-tabs'],
waitSelector: '.ant-tree, .ant-form, .ant-table, .ant-btn, .ant-tabs',
waitUntil: 'domcontentloaded',
expandFirstRow: false,
openAdvancedFilter: false,
interactivePhases: [
openFirstDropdown,
expandFirstTreeNode,
(page) => clickTab(page, '停用'),
(page) => clickTab(page, '未建分类'),
(page) => openNamedButton(page, /新增基础包/, 'addBasePkgOpened'),
(page) => openNamedButton(page, /添加基础包/, 'addBasePkgOpened'),
(page) => openNamedButton(page, /添加散件/, 'addLoosePartOpened'),
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /详\s*情/, 'detailOpened'),
(page) => openRowAction(page, /停\s*用/, 'disableDialogOpened'),
],
};
}
async function main() {
const filter = process.argv.slice(2);
const menus = filter.length ? MENUS.filter((p) => filter.includes(p.pageKey)) : MENUS;
if (!menus.length) {
console.error('No pages matched. Available:', MENUS.map((p) => p.pageKey).join(', '));
process.exit(1);
}
await ensureAuthStorage();
const urls = await discoverMenuUrls(menus);
const summary = [];
for (const item of menus) {
const targetUrl = urls[item.pageKey];
console.log(`\n=== Scanning ${item.pageKey} ${targetUrl} ===`);
try {
const result = await runScan(pageCfg(targetUrl, item));
const yaml = writeYamlFromScanJson(result.jsonOut, {
pageKey: item.pageKey,
pageTitle: item.pageTitle,
module: 'spd-basic',
jsonSourceName: `${item.pageKey}-elements.json`,
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
});
summary.push({
page: item.pageKey,
menu: item.menu,
specId: item.specId,
ok: true,
url: targetUrl,
...result,
yamlOut: yaml.yamlOut,
});
} catch (err) {
console.error(`FAILED ${item.pageKey}:`, err);
summary.push({ page: item.pageKey, menu: item.menu, specId: item.specId, ok: false, error: String(err) });
}
}
console.log('\n========== SUMMARY ==========');
for (const row of summary) {
if (row.ok) {
console.log(`✓ ${row.page}: ${row.elementCount} elements ${row.url}`);
console.log(` JSON: ${row.jsonOut}`);
console.log(` YAML: ${row.yamlOut}`);
} else {
console.log(`✗ ${row.page}: ${row.error}`);
}
}
writeFileSync(
path.join(OUT_DIR, 'surgery-scan-summary.json'),
JSON.stringify(summary, null, 2),
'utf8',
);
if (summary.some((r) => !r.ok)) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
#!/usr/bin/env node
/**
* Batch scan: 基础设置 > 用户管理 (3 pages) from UI-SPEC USER-001~003.
* 直接按 UI-SPEC 中已确认的页面路径打开(无需从菜单点进去,避免同名菜单歧义)。
*
* 输出:
* data/scan/basic-user-elements.json 用户管理
* data/scan/basic-user-union-elements.json 医共体用户管理
* data/scan/basic-role-elements.json 角色管理
* locators/spd/{pageKey}.locator.yaml 由 JSON 提炼的 Playwright 定位器
*
* Usage:
* node scripts/scan-user-batch.mjs
* node scripts/scan-user-batch.mjs basic-user basic-role
*
* 只重算 YAML(不重新扫描):
* node scripts/json-to-locator-yaml.mjs data/scan/basic-user-elements.json
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const AUTH_STATE = path.join(ROOT, 'auth/user.json');
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const OUT_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
const SUMMARY_OUT = path.join(OUT_DIR, 'user-scan-summary.json');
if (existsSync(path.join(ROOT, '.env'))) {
process.loadEnvFile(path.join(ROOT, '.env'));
}
const COMMON_WAIT = [
'.ant-form-item input',
'.ant-form-item .ant-select',
'.ant-form',
'.ant-table',
'.ant-btn',
'.ant-pagination',
];
async function closeOverlays(page) {
const footerCancel = page
.locator('.ant-modal .ant-modal-footer button, .ant-drawer .ant-drawer-footer button')
.filter({ hasText: /取\s*消/ })
.last();
if (await footerCancel.isVisible().catch(() => false)) {
await footerCancel.click({ timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(400);
}
for (let i = 0; i < 3; i += 1) {
const popCancel = page
.locator('.ant-popconfirm button, .ant-popover-open button, .ant-modal-confirm button')
.filter({ hasText: /取\s*消/ })
.last();
if (await popCancel.isVisible().catch(() => false)) {
await popCancel.click({ timeout: 5_000 }).catch(() => undefined);
await page.waitForTimeout(300);
continue;
}
const modalClose = page
.locator('.ant-modal-wrap .ant-modal-close, .ant-drawer-close')
.last();
if (await modalClose.isVisible().catch(() => false)) {
await modalClose.click({ timeout: 5_000 }).catch(() => undefined);
await page.waitForTimeout(400);
continue;
}
break;
}
}
/** 打开页面上第一个筛选下拉,采集下拉面板内的选项元素 */
async function openFirstFilterDropdown(page) {
const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true };
await select.click({ timeout: 10_000 });
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(500);
return { dropdownOpened: true };
}
/** 展开「高级筛选」面板(用户管理 / 医共体用户管理) */
async function openAdvancedFilter(page) {
await closeOverlays(page);
const btn = page.getByText(/高级筛选/).first();
if (!(await btn.isVisible().catch(() => false))) return { advancedFilterOpened: false, skipRescan: true };
await btn.click({ timeout: 10_000 }).catch(() => undefined);
await page.waitForTimeout(600);
return { advancedFilterOpened: true };
}
/** 打开「新增」弹窗(按标题断言,避免采到别的弹窗) */
async function openAddModal(content, titleRe) {
await closeOverlays(content);
const btn = content.getByRole('button', { name: /新\s*增/ }).first();
if (!(await btn.isVisible().catch(() => false))) return { dialogOpened: false, skipRescan: true };
await btn.click({ timeout: 15_000, force: true });
await content
.locator('.ant-modal, .ant-drawer-open')
.filter({ hasText: titleRe })
.first()
.waitFor({ state: 'visible', timeout: 15_000 })
.catch(() => undefined);
await content.waitForTimeout(800);
const opened = await content.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false);
return { dialogOpened: opened, skipRescan: !opened };
}
function firstDataRow(page) {
return page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
}
/** 点击首行的某个行操作,记录是弹窗还是跳转 */
async function openRowAction(page, pattern, flag) {
await closeOverlays(page);
const row = firstDataRow(page);
if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
let action = row.getByRole('button', { name: pattern }).first();
if (!(await action.isVisible().catch(() => false))) {
action = row.locator('a,button,span').filter({ hasText: pattern }).first();
}
if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
const before = page.url();
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(900);
const moved = page.url() !== before && !page.url().includes('/login');
if (moved) return { [flag]: true, navigated: true, returnUrl: before };
const opened = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false);
return { [flag]: Boolean(opened), skipRescan: !opened };
}
/** 角色管理:点击左侧角色列表的首个角色,采集右侧权限树 */
async function selectFirstRole(page) {
await closeOverlays(page);
const row = page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
const target = (await row.isVisible().catch(() => false))
? row.locator('td').first()
: page.locator('.ant-list-item, .role-list li').first();
if (!(await target.isVisible().catch(() => false))) return { roleSelected: false, skipRescan: true };
await target.click({ timeout: 10_000 }).catch(() => undefined);
await page.waitForTimeout(900);
return { roleSelected: true };
}
/** 角色管理:展开权限树的一个二级模块 */
async function expandFirstPermissionNode(page) {
const switcher = page
.locator('.ant-tree-switcher:not(.ant-tree-switcher-noop), .ant-tree-switcher_open')
.first();
if (!(await switcher.isVisible().catch(() => false))) return { treeExpanded: false, skipRescan: true };
await switcher.click({ timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(700);
return { treeExpanded: true };
}
function pageCfg(uiPath, rest) {
return {
targetUrl: `${BASE}${uiPath}`,
useIframe: false,
iframeMatch: '',
iframeLabel: 'main',
module: 'spd-basic',
jsonOut: path.join(OUT_DIR, `${rest.pageKey}-elements.json`),
yamlOut: path.join(LOC_DIR, `${rest.pageKey}.locator.yaml`),
jsonSourceName: `${rest.pageKey}-elements.json`,
waitSelectors: COMMON_WAIT,
waitSelector: '.ant-form-item input, .ant-table, .ant-btn, .ant-form',
waitUntil: 'domcontentloaded',
expandFirstRow: false,
openAdvancedFilter: false,
skipQueryClick: true,
...rest,
};
}
const PAGES = [
pageCfg('/spd/basic/user/platform/person', {
pageKey: 'basic-user',
pageTitle: '基础设置 > 用户管理 > 用户管理',
specId: 'USER-001',
interactivePhases: [
openFirstFilterDropdown,
openAdvancedFilter,
(page) => openAddModal(page, /新增用户|新增/),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /授\s*权/, 'authDialogOpened'),
],
}),
pageCfg('/spd/basic/user/platform/personCom', {
pageKey: 'basic-user-union',
pageTitle: '基础设置 > 用户管理 > 医共体用户管理',
specId: 'USER-002',
interactivePhases: [
openFirstFilterDropdown,
openAdvancedFilter,
(page) => openAddModal(page, /新增/),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /授\s*权/, 'authDialogOpened'),
],
}),
pageCfg('/spd/basic/user/platform/role', {
pageKey: 'basic-role',
pageTitle: '基础设置 > 用户管理 > 角色管理',
specId: 'USER-003',
interactivePhases: [
selectFirstRole,
expandFirstPermissionNode,
(page) => openAddModal(page, /新增/),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
],
}),
];
async function ensureAuthStorage() {
mkdirSync(path.dirname(AUTH_STATE), { recursive: true });
const user = process.env.TEST_USER;
const pass = process.env.TEST_PASS;
if (!user || !pass) {
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号');
}
const { chromium } = await import('playwright');
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined,
});
const page = await context.newPage();
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(2000);
if (!page.url().includes('/login')) {
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Auth state OK:', AUTH_STATE);
return;
}
await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user);
await page.locator('#password, input[type="password"]').first().fill(pass);
await page.getByRole('button', { name: /登\s*录/ }).click();
await page.waitForURL(/\/spd\/home/, { timeout: 120_000 });
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Logged in and saved auth:', AUTH_STATE);
}
async function main() {
const filter = process.argv.slice(2);
const pages = filter.length ? PAGES.filter((p) => filter.includes(p.pageKey)) : PAGES;
if (!pages.length) {
console.error('No pages matched. Available:', PAGES.map((p) => p.pageKey).join(', '));
process.exit(1);
}
await ensureAuthStorage();
const summary = [];
for (const item of pages) {
console.log(`\n=== Scanning ${item.pageKey} ${item.targetUrl} ===`);
try {
const result = await runScan(item);
// 扫描阶段会顺带写一份 YAML;这里再按落盘 JSON 提炼一次,确保 YAML 只由 JSON 决定
const yaml = writeYamlFromScanJson(result.jsonOut, {
pageKey: item.pageKey,
pageTitle: item.pageTitle,
module: 'spd-basic',
jsonSourceName: `${item.pageKey}-elements.json`,
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
});
summary.push({
page: item.pageKey,
specId: item.specId,
ok: true,
url: item.targetUrl,
elementCount: result.elementCount,
jsonOut: result.jsonOut,
yamlOut: yaml.yamlOut,
});
} catch (err) {
console.error(`FAILED ${item.pageKey}:`, err);
summary.push({ page: item.pageKey, specId: item.specId, ok: false, error: String(err) });
}
}
console.log('\n========== SUMMARY ==========');
for (const row of summary) {
if (row.ok) {
console.log(`✓ ${row.page}: ${row.elementCount} elements`);
console.log(` JSON: ${row.jsonOut}`);
console.log(` YAML: ${row.yamlOut}`);
} else {
console.log(`✗ ${row.page}: ${row.error}`);
}
}
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(SUMMARY_OUT, JSON.stringify(summary, null, 2), 'utf8');
if (summary.some((r) => !r.ok)) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
#!/usr/bin/env node
/**
* Batch scan: 基础设置 > 厂商管理 (10 pages) from UI-SPEC VENDOR-001~010.
* Saves data/scan/*-elements.json, then regenerates locators/spd/*.locator.yaml.
*
* Usage:
* node scripts/scan-vendor-batch.mjs
* node scripts/scan-vendor-batch.mjs vendor-query vendor-qual
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const AUTH_STATE = path.join(ROOT, 'auth/user.json');
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const OUT_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
const URL_MAP_OUT = path.join(OUT_DIR, 'vendor-menu-urls.json');
if (existsSync(path.join(ROOT, '.env'))) {
process.loadEnvFile(path.join(ROOT, '.env'));
}
const MENUS = [
{ pageKey: 'vendor-query', menu: '厂商查询', pageTitle: '基础设置 > 厂商管理 > 厂商查询', specId: 'VENDOR-001' },
{ pageKey: 'vendor-qual', menu: '供应商资质管理', pageTitle: '基础设置 > 厂商管理 > 供应商资质管理', specId: 'VENDOR-002' },
{ pageKey: 'vendor-auth-first', menu: '授权变更初审', pageTitle: '基础设置 > 厂商管理 > 授权变更初审', specId: 'VENDOR-003' },
{ pageKey: 'vendor-auth-final', menu: '授权变更终审', pageTitle: '基础设置 > 厂商管理 > 授权变更终审', specId: 'VENDOR-004' },
{ pageKey: 'vendor-cert-first', menu: '注册证变更初审', pageTitle: '基础设置 > 厂商管理 > 注册证变更初审', specId: 'VENDOR-005' },
{ pageKey: 'vendor-cert-final', menu: '注册证变更终审', pageTitle: '基础设置 > 厂商管理 > 注册证变更终审', specId: 'VENDOR-006' },
{ pageKey: 'vendor-cert-diff', menu: '注册证差异对比', pageTitle: '基础设置 > 厂商管理 > 注册证差异对比', specId: 'VENDOR-007' },
{ pageKey: 'vendor-eval-scheme', menu: '评价方案维护', pageTitle: '基础设置 > 厂商管理 > 评价方案维护', specId: 'VENDOR-008' },
{ pageKey: 'vendor-eval-indicator', menu: '评价指标维护', pageTitle: '基础设置 > 厂商管理 > 评价指标维护', specId: 'VENDOR-009' },
{ pageKey: 'vendor-holiday', menu: '供应商节假日管理', pageTitle: '基础设置 > 厂商管理 > 供应商节假日管理', specId: 'VENDOR-010' },
];
async function ensureAuthStorage() {
mkdirSync(path.dirname(AUTH_STATE), { recursive: true });
const user = process.env.TEST_USER;
const pass = process.env.TEST_PASS;
if (!user || !pass) {
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号');
}
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined,
});
const page = await context.newPage();
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(2000);
if (!page.url().includes('/login')) {
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Auth state OK:', AUTH_STATE);
return;
}
await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user);
await page.locator('#password, input[type="password"]').first().fill(pass);
await Promise.all([
page.waitForURL((url) => url.pathname.includes('/spd/home'), { timeout: 120_000 }),
page.getByRole('button', { name: /登\s*录/ }).click(),
]).catch(async () => {
// 部分环境导航事件不触发 waitForURL,轮询兜底
for (let i = 0; i < 60; i += 1) {
if (page.url().includes('/spd/home')) return;
await page.waitForTimeout(1000);
}
throw new Error(`登录后未进入首页,当前 URL: ${page.url()}`);
});
await page.waitForTimeout(1500);
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Logged in and saved auth:', AUTH_STATE);
}
async function expandVendorMenu(page) {
await page.getByRole('menuitem', { name: '基础设置' }).click({ timeout: 20_000 });
await page.waitForTimeout(500);
const vendorTitle = page.locator('.ant-menu-submenu-title').filter({ hasText: /^厂商管理$/ }).first();
if (await vendorTitle.isVisible().catch(() => false)) {
const expanded = await vendorTitle
.locator('xpath=..')
.evaluate((el) => el.classList.contains('ant-menu-submenu-open'))
.catch(() => false);
if (!expanded) await vendorTitle.click({ timeout: 10_000 });
}
}
async function discoverMenuUrls(menus) {
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
storageState: AUTH_STATE,
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
});
const page = await context.newPage();
const urls = {};
for (const item of menus) {
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(1500);
if (page.url().includes('/login')) {
throw new Error('登录态失效,无法打开厂商管理菜单');
}
await expandVendorMenu(page);
await page
.locator('.ant-menu-item')
.filter({ hasText: new RegExp(`^\\s*${item.menu}\\s*$`) })
.first()
.click({ timeout: 20_000 });
await page.waitForTimeout(2000);
const url = page.url().split('?')[0];
if (url.includes('/login') || url.endsWith('/spd/home/index')) {
throw new Error(`未能进入 ${item.menu},当前 URL: ${page.url()}`);
}
urls[item.pageKey] = url;
console.log(`URL ${item.menu}: ${url}`);
}
await browser.close();
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(URL_MAP_OUT, JSON.stringify({ base: BASE, scannedAt: new Date().toISOString(), urls }, null, 2), 'utf8');
console.log('Wrote URL map:', URL_MAP_OUT);
return urls;
}
async function openFirstDropdown(page) {
const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true };
await select.click({ timeout: 10_000 });
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(400);
return { dropdownOpened: true };
}
async function layerOpened(page) {
const modal = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false);
if (modal) return true;
return page.locator('.put-page').first().isVisible().catch(() => false);
}
async function openNamedButton(page, pattern, flag) {
const btn = page.getByRole('button', { name: pattern }).first();
if (!(await btn.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
await btn.click({ timeout: 12_000, force: true });
await page
.locator('.ant-modal, .ant-drawer-open, .put-page')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(500);
const opened = await layerOpened(page);
return { [flag]: opened, skipRescan: !opened };
}
async function openRowAction(page, pattern, flag) {
const row = page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
let action = row.getByRole('button', { name: pattern }).first();
if (!(await action.isVisible().catch(() => false))) {
action = row.locator('a,button,span').filter({ hasText: pattern }).first();
}
if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
const before = page.url();
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(800);
const moved = page.url() !== before && !page.url().includes('/login');
if (moved) return { [flag]: true, navigated: true, returnUrl: before };
const opened = await layerOpened(page);
return { [flag]: Boolean(opened), skipRescan: !opened };
}
async function clickTab(page, name) {
const tab = page.locator('.ant-tabs-tab').filter({ hasText: new RegExp(name) }).first();
if (!(await tab.isVisible().catch(() => false))) return { tabOpened: false, skipRescan: true };
await tab.click({ timeout: 8_000 });
await page.waitForTimeout(600);
return { tabOpened: true };
}
function pageCfg(targetUrl, item) {
return {
targetUrl,
useIframe: false,
iframeMatch: '',
iframeLabel: 'main',
module: 'spd-basic',
pageKey: item.pageKey,
pageTitle: item.pageTitle,
jsonOut: path.join(OUT_DIR, `${item.pageKey}-elements.json`),
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
jsonSourceName: `${item.pageKey}-elements.json`,
waitSelectors: ['.ant-form-item input', '.ant-form', '.ant-table', '.ant-btn', '.ant-tabs'],
waitSelector: '.ant-form, .ant-table, .ant-btn, .ant-tabs',
waitUntil: 'domcontentloaded',
expandFirstRow: false,
openAdvancedFilter: false,
interactivePhases: [
openFirstDropdown,
(page) => clickTab(page, '经销商'),
(page) => clickTab(page, '授权链路效期预警'),
(page) => clickTab(page, '不一致'),
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openNamedButton(page, /导\s*出/, 'exportOpened'),
(page) => openRowAction(page, /详\s*情/, 'detailOpened'),
(page) => openRowAction(page, /查\s*看/, 'viewOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /日\s*志/, 'logOpened'),
],
};
}
async function main() {
const filter = process.argv.slice(2);
const menus = filter.length ? MENUS.filter((p) => filter.includes(p.pageKey)) : MENUS;
if (!menus.length) {
console.error('No pages matched. Available:', MENUS.map((p) => p.pageKey).join(', '));
process.exit(1);
}
await ensureAuthStorage();
const urls = await discoverMenuUrls(menus);
const summary = [];
for (const item of menus) {
const targetUrl = urls[item.pageKey];
console.log(`\n=== Scanning ${item.pageKey} ${targetUrl} ===`);
try {
const result = await runScan(pageCfg(targetUrl, item));
const yaml = writeYamlFromScanJson(result.jsonOut, {
pageKey: item.pageKey,
pageTitle: item.pageTitle,
module: 'spd-basic',
jsonSourceName: `${item.pageKey}-elements.json`,
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
});
summary.push({
page: item.pageKey,
menu: item.menu,
specId: item.specId,
ok: true,
url: targetUrl,
...result,
yamlOut: yaml.yamlOut,
});
} catch (err) {
console.error(`FAILED ${item.pageKey}:`, err);
summary.push({ page: item.pageKey, menu: item.menu, specId: item.specId, ok: false, error: String(err) });
}
}
console.log('\n========== SUMMARY ==========');
for (const row of summary) {
if (row.ok) {
console.log(`✓ ${row.page}: ${row.elementCount} elements ${row.url}`);
console.log(` JSON: ${row.jsonOut}`);
console.log(` YAML: ${row.yamlOut}`);
} else {
console.log(`✗ ${row.page}: ${row.error}`);
}
}
writeFileSync(
path.join(OUT_DIR, 'vendor-scan-summary.json'),
JSON.stringify(summary, null, 2),
'utf8',
);
if (summary.some((r) => !r.ok)) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
#!/usr/bin/env node
/**
* Batch scan: 基础设置 > 库房管理 / 流程设置 (UI-SPEC WH-001~002, FLOW-001~002).
* 先按菜单点击解析真实 URL,再扫描交互元素。
*
* 输出:
* data/scan/warehouse-zone-elements.json 库区设置
* data/scan/warehouse-probe-elements.json 探针绑定
* data/scan/flow-def-elements.json 审批流程设置
* data/scan/flow-inst-elements.json 审批流实例
* locators/spd/{pageKey}.locator.yaml
*
* Usage:
* node scripts/scan-wh-flow-batch.mjs
* node scripts/scan-wh-flow-batch.mjs warehouse-zone warehouse-probe
* node scripts/scan-wh-flow-batch.mjs flow-def flow-inst
*
* 只重算 YAML(不重新扫描):
* node scripts/json-to-locator-yaml.mjs --all-warehouse --all-flow
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const AUTH_STATE = path.join(ROOT, 'auth/user.json');
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const OUT_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
const URL_MAP_OUT = path.join(OUT_DIR, 'wh-flow-menu-urls.json');
const SUMMARY_OUT = path.join(OUT_DIR, 'wh-flow-scan-summary.json');
if (existsSync(path.join(ROOT, '.env'))) {
process.loadEnvFile(path.join(ROOT, '.env'));
}
const COMMON_WAIT = [
'.ant-form-item input',
'.ant-form-item .ant-select',
'.ant-form',
'.ant-table',
'.ant-btn',
'.ant-pagination',
'.ant-list-item',
'.ant-menu-item',
];
const MENUS = [
{
pageKey: 'warehouse-zone',
group: '库房管理',
menu: '库区设置',
pageTitle: '基础设置 > 库房管理 > 库区设置',
specId: 'WH-001',
skipQueryClick: true,
interactivePhases: [
selectLeftFirstItem,
openFirstFilterDropdown,
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /人员管理/, 'staffDialogOpened'),
(page) => openRowAction(page, /货位管理/, 'locationDialogOpened'),
],
},
{
pageKey: 'warehouse-probe',
group: '库房管理',
menu: '探针绑定',
pageTitle: '基础设置 > 库房管理 > 探针绑定',
specId: 'WH-002',
skipQueryClick: false,
interactivePhases: [
openFirstFilterDropdown,
(page) => openRowAction(page, /绑定货位/, 'bindDialogOpened'),
openModalFirstDropdown,
],
},
{
pageKey: 'flow-def',
group: '流程设置',
menu: '审批流程设置',
pageTitle: '基础设置 > 流程设置 > 审批流程设置',
specId: 'FLOW-001',
skipQueryClick: true,
interactivePhases: [
selectLeftFirstItem,
openFirstFilterDropdown,
(page) => openNamedButton(page, /新增流程/, 'addFlowClicked'),
(page) => openInlineEdit(page, /编\s*辑/, 'conditionEditorOpened'),
],
},
{
pageKey: 'flow-inst',
group: '流程设置',
menu: '审批流实例',
pageTitle: '基础设置 > 流程设置 > 审批流实例',
specId: 'FLOW-002',
skipQueryClick: false,
interactivePhases: [
openFirstFilterDropdown,
openSecondFilterDropdown,
openFlowDetail,
],
},
];
async function closeOverlays(page) {
await page.keyboard.press('Escape').catch(() => undefined);
await page.waitForTimeout(200);
const footerCancel = page
.locator('.ant-modal .ant-modal-footer button, .ant-drawer .ant-drawer-footer button')
.filter({ hasText: /取\s*消|关\s*闭/ })
.last();
if (await footerCancel.isVisible().catch(() => false)) {
await footerCancel.click({ timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(400);
}
for (let i = 0; i < 3; i += 1) {
const popCancel = page
.locator('.ant-popconfirm button, .ant-popover-open button, .ant-modal-confirm button')
.filter({ hasText: /取\s*消/ })
.last();
if (await popCancel.isVisible().catch(() => false)) {
await popCancel.click({ timeout: 5_000 }).catch(() => undefined);
await page.waitForTimeout(300);
continue;
}
const modalClose = page.locator('.ant-modal-wrap .ant-modal-close, .ant-drawer-close').last();
if (await modalClose.isVisible().catch(() => false)) {
await modalClose.click({ timeout: 5_000 }).catch(() => undefined);
await page.waitForTimeout(400);
continue;
}
break;
}
}
function firstDataRow(page) {
return page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
}
async function layerOpened(page) {
const modal = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false);
if (modal) return true;
return page.locator('.put-page').first().isVisible().catch(() => false);
}
async function openFirstFilterDropdown(page) {
await closeOverlays(page);
const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true };
await select.click({ timeout: 10_000 });
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(500);
return { dropdownOpened: true };
}
async function openSecondFilterDropdown(page) {
await closeOverlays(page);
const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').nth(1);
if (!(await select.isVisible().catch(() => false))) return { secondDropdownOpened: false, skipRescan: true };
await select.click({ timeout: 10_000 });
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(400);
return { secondDropdownOpened: true };
}
async function openModalFirstDropdown(page) {
const modal = page.locator('.ant-modal:visible, .ant-drawer-open').first();
if (!(await modal.isVisible().catch(() => false))) return { modalDropdownOpened: false, skipRescan: true };
const select = modal.locator('.ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { modalDropdownOpened: false, skipRescan: true };
await select.click({ timeout: 8_000 });
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(400);
return { modalDropdownOpened: true };
}
async function selectLeftFirstItem(page) {
await closeOverlays(page);
const candidates = [
page.locator('.ant-tree .ant-tree-title').first(),
page.locator('.ant-menu-inline .ant-menu-item:not(.ant-menu-item-selected)').first(),
page.locator('.ant-list-item').first(),
page.locator('.ant-table-tbody tr.ant-table-row td').first(),
];
for (const loc of candidates) {
if (await loc.isVisible().catch(() => false)) {
await loc.click({ timeout: 10_000 }).catch(() => undefined);
await page.waitForTimeout(800);
return { leftItemSelected: true };
}
}
return { leftItemSelected: false, skipRescan: true };
}
async function openNamedButton(page, pattern, flag) {
await closeOverlays(page);
const btn = page.getByRole('button', { name: pattern }).first();
if (!(await btn.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
await btn.click({ timeout: 12_000, force: true });
await page
.locator('.ant-modal, .ant-drawer-open, .put-page')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(600);
const opened = await layerOpened(page);
return { [flag]: opened, skipRescan: !opened };
}
async function openRowAction(page, pattern, flag) {
await closeOverlays(page);
const row = firstDataRow(page);
if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
let action = row.getByRole('button', { name: pattern }).first();
if (!(await action.isVisible().catch(() => false))) {
action = row.locator('a,button,span').filter({ hasText: pattern }).first();
}
if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
const before = page.url();
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(1600);
const moved = page.url() !== before && !page.url().includes('/login');
if (moved) return { [flag]: true, navigated: true, returnUrl: before };
const opened = await layerOpened(page);
return { [flag]: Boolean(opened), skipRescan: !opened };
}
/** 审批流实例「详情」多为链接/文字按钮,且可能被下拉遮挡 */
async function openFlowDetail(page) {
await closeOverlays(page);
const row = firstDataRow(page);
const candidates = [
row.getByRole('button', { name: /详\s*情/ }).first(),
row.getByRole('link', { name: /详\s*情/ }).first(),
row.locator('a,button,span').filter({ hasText: /详\s*情/ }).first(),
page.locator('.ant-table-tbody a, .ant-table-tbody button, .ant-table-tbody span').filter({ hasText: /详\s*情/ }).first(),
];
let action = null;
for (const loc of candidates) {
if (await loc.isVisible().catch(() => false)) {
action = loc;
break;
}
}
if (!action) return { detailOpened: false, skipRescan: true };
const before = page.url();
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(1600);
const moved = page.url() !== before && !page.url().includes('/login');
if (moved) return { detailOpened: true, navigated: true, returnUrl: before };
const opened = await layerOpened(page);
return { detailOpened: Boolean(opened), skipRescan: !opened };
}
async function openInlineEdit(page, pattern, flag) {
await closeOverlays(page);
const action = page.locator('a,button,span').filter({ hasText: pattern }).first();
if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(700);
const opened = await layerOpened(page);
const picker = await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden), .ant-picker-dropdown, .ant-popover-open')
.first()
.isVisible()
.catch(() => false);
return { [flag]: opened || picker, skipRescan: !(opened || picker) };
}
async function ensureAuthStorage() {
mkdirSync(path.dirname(AUTH_STATE), { recursive: true });
const user = process.env.TEST_USER;
const pass = process.env.TEST_PASS;
if (!user || !pass) {
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号');
}
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined,
});
const page = await context.newPage();
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(2000);
if (!page.url().includes('/login')) {
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Auth state OK:', AUTH_STATE);
return;
}
await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user);
await page.locator('#password, input[type="password"]').first().fill(pass);
await Promise.all([
page.waitForURL((url) => url.pathname.includes('/spd/home'), { timeout: 120_000 }),
page.getByRole('button', { name: /登\s*录/ }).click(),
]).catch(async () => {
for (let i = 0; i < 60; i += 1) {
if (page.url().includes('/spd/home')) return;
await page.waitForTimeout(1000);
}
throw new Error(`登录后未进入首页,当前 URL: ${page.url()}`);
});
await page.waitForTimeout(1500);
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Logged in and saved auth:', AUTH_STATE);
}
async function expandGroupMenu(page, groupName) {
await page.getByRole('menuitem', { name: '基础设置' }).click({ timeout: 20_000 });
await page.waitForTimeout(500);
const title = page.locator('.ant-menu-submenu-title').filter({ hasText: new RegExp(`^${groupName}$`) }).first();
if (await title.isVisible().catch(() => false)) {
const expanded = await title
.locator('xpath=..')
.evaluate((el) => el.classList.contains('ant-menu-submenu-open'))
.catch(() => false);
if (!expanded) await title.click({ timeout: 10_000 });
await page.waitForTimeout(400);
}
}
async function discoverMenuUrls(menus) {
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
storageState: AUTH_STATE,
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
});
const page = await context.newPage();
const urls = {};
for (const item of menus) {
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(1500);
if (page.url().includes('/login')) {
throw new Error(`登录态失效,无法打开${item.group}菜单`);
}
await expandGroupMenu(page, item.group);
await page
.locator('.ant-menu-item')
.filter({ hasText: new RegExp(`^\\s*${item.menu}\\s*$`) })
.first()
.click({ timeout: 20_000 });
await page.waitForTimeout(2000);
const url = page.url().split('?')[0];
if (url.includes('/login') || url.endsWith('/spd/home/index')) {
throw new Error(`未能进入 ${item.menu},当前 URL: ${page.url()}`);
}
urls[item.pageKey] = url;
console.log(`URL ${item.menu}: ${url}`);
}
await browser.close();
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(URL_MAP_OUT, JSON.stringify({ base: BASE, scannedAt: new Date().toISOString(), urls }, null, 2), 'utf8');
console.log('Wrote URL map:', URL_MAP_OUT);
return urls;
}
function pageCfg(targetUrl, item) {
return {
targetUrl,
useIframe: false,
iframeMatch: '',
iframeLabel: 'main',
module: 'spd-basic',
pageKey: item.pageKey,
pageTitle: item.pageTitle,
jsonOut: path.join(OUT_DIR, `${item.pageKey}-elements.json`),
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
jsonSourceName: `${item.pageKey}-elements.json`,
waitSelectors: COMMON_WAIT,
waitSelector: '.ant-form-item input, .ant-table, .ant-btn, .ant-form, .ant-list-item',
waitUntil: 'domcontentloaded',
expandFirstRow: false,
openAdvancedFilter: false,
skipQueryClick: item.skipQueryClick === true,
interactivePhases: item.interactivePhases,
};
}
async function main() {
const filter = process.argv.slice(2);
const menus = filter.length ? MENUS.filter((p) => filter.includes(p.pageKey)) : MENUS;
if (!menus.length) {
console.error('No pages matched. Available:', MENUS.map((p) => p.pageKey).join(', '));
process.exit(1);
}
await ensureAuthStorage();
const urls = await discoverMenuUrls(menus);
const summary = [];
for (const item of menus) {
const targetUrl = urls[item.pageKey];
console.log(`\n=== Scanning ${item.pageKey} ${targetUrl} ===`);
try {
const result = await runScan(pageCfg(targetUrl, item));
const yaml = writeYamlFromScanJson(result.jsonOut, {
pageKey: item.pageKey,
pageTitle: item.pageTitle,
module: 'spd-basic',
jsonSourceName: `${item.pageKey}-elements.json`,
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
});
summary.push({
page: item.pageKey,
menu: item.menu,
specId: item.specId,
ok: true,
url: targetUrl,
elementCount: result.elementCount,
jsonOut: result.jsonOut,
yamlOut: yaml.yamlOut,
});
} catch (err) {
console.error(`FAILED ${item.pageKey}:`, err);
summary.push({
page: item.pageKey,
menu: item.menu,
specId: item.specId,
ok: false,
error: String(err),
});
}
}
console.log('\n========== SUMMARY ==========');
for (const row of summary) {
if (row.ok) {
console.log(`✓ ${row.page}: ${row.elementCount} elements ${row.url}`);
console.log(` JSON: ${row.jsonOut}`);
console.log(` YAML: ${row.yamlOut}`);
} else {
console.log(`✗ ${row.page}: ${row.error}`);
}
}
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(SUMMARY_OUT, JSON.stringify(summary, null, 2), 'utf8');
if (summary.some((r) => !r.ok)) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
#!/usr/bin/env bash
# 脱离终端启动全量 CI;caffeinate 防止锁屏/空闲触发系统休眠中断用例
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
STAMP="$(date +%Y%m%d-%H%M%S)"
LOG="${ROOT}/reports/full-run-${STAMP}.log"
PID_FILE="${ROOT}/reports/full-run.pid"
CAFF_PID_FILE="${ROOT}/reports/caffeinate-full.pid"
mkdir -p "${ROOT}/reports"
export PATH="/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${PATH:-}"
nohup /usr/bin/caffeinate -dims /bin/bash -c "
set -euo pipefail
cd \"${ROOT}\"
export CI=1
export PW_WORKERS=\"${PW_WORKERS:-2}\"
echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] detached 全量 (caffeinate): PW_WORKERS=\$PW_WORKERS npm run test:ci:full\"
npm run test:ci:full
EC=\$?
echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] 结束 exit=\$EC\"
exit \$EC
" >> "$LOG" 2>&1 < /dev/null &
echo $! > "$PID_FILE"
# 外层 bash 会立刻退出;记录 caffeinate 父进程需从 log/ pgrep 查看。另写 wait 包装:
if pgrep -f "caffeinate -dims /bin/bash -c" >/dev/null 2>&1; then
pgrep -f "caffeinate -dims /bin/bash -c" | head -1 > "$CAFF_PID_FILE" || true
fi
echo "LOG=$LOG"
echo "PID=$(cat "$PID_FILE")"
echo "说明: 已用 caffeinate -dims,锁屏不影响;请勿合盖休眠/关机。"
#!/usr/bin/env python3 #!/usr/bin/env python3
"""将 docs/统计报表-* 截图同步至 knowledge-base/08-UI专项规范库。""" """将 docs/ 下页面截图同步至 knowledge-base/08-UI专项规范库(统计报表、准入、厂商、用户、库房、流程等)。"""
from __future__ import annotations from __future__ import annotations
...@@ -13,7 +13,7 @@ DOCS = ROOT / "docs" ...@@ -13,7 +13,7 @@ DOCS = ROOT / "docs"
KB = ROOT / "knowledge-base" / "08-UI专项规范库" KB = ROOT / "knowledge-base" / "08-UI专项规范库"
TODAY = date.today().isoformat() TODAY = date.today().isoformat()
PAGES: list[dict] = [ STAT_PAGES: list[dict] = [
# 对账报表 # 对账报表
{"id": "RECON-001", "group": "对账报表", "name": "科室对账查询", "folder": "统计报表-对账报表-科室对账查询"}, {"id": "RECON-001", "group": "对账报表", "name": "科室对账查询", "folder": "统计报表-对账报表-科室对账查询"},
{"id": "RECON-002", "group": "对账报表", "name": "科室净入汇总表", "folder": "统计报表-对账报表-科室净入汇总表"}, {"id": "RECON-002", "group": "对账报表", "name": "科室净入汇总表", "folder": "统计报表-对账报表-科室净入汇总表"},
...@@ -79,12 +79,716 @@ PAGES: list[dict] = [ ...@@ -79,12 +79,716 @@ PAGES: list[dict] = [
{"id": "REAG-001", "group": "试剂批号统计", "name": "试剂批号统计明细", "folder": "统计报表-试剂批号统计-试剂批号统计明细"}, {"id": "REAG-001", "group": "试剂批号统计", "name": "试剂批号统计明细", "folder": "统计报表-试剂批号统计-试剂批号统计明细"},
] ]
# 基础设置 → 准入管理(docs/基础管理-准入管理-*)
ADMIT_PAGES: list[dict] = [
{
"id": "ADMIT-001",
"name": "物料准入管理",
"folder": "基础管理-准入管理-物料准入管理",
"filters": "新增日期(区间)、进院供应商、耗材类型、产品名称、规格型号、注册证号、器械注册人",
"status_tabs": "全部、待申请、审批中、已通过、已驳回、已作废、生效失败、已生效",
"toolbar": "**关联产品**、**生成申请单**、**一键生成**、**作废**;列设置",
"list_note": "含准入类型(新品入院/产品增规/C码变更等)、产品编号/名称、规格型号、器械注册人、耗材类型、默认进院供应商;行操作 **详情**、**日志**、**编辑**(视状态)",
},
{
"id": "ADMIT-002",
"name": "产品准入申请",
"folder": "基础管理-准入管理-产品准入申请",
"filters": "以截图为准(常见:日期、供应商、产品/规格、单号等)",
"status_tabs": "以截图状态 Tab 为准",
"toolbar": "以截图工具栏为准",
"list_note": "产品准入申请列表与行内操作以截图为准",
},
{
"id": "ADMIT-003",
"name": "物料准入单列表",
"folder": "基础管理-准入管理-物料准入单列表",
"filters": "以截图为准",
"status_tabs": "以截图状态 Tab 为准",
"toolbar": "以截图工具栏为准",
"list_note": "物料准入单列表、详情/审批相关操作以截图为准",
},
{
"id": "ADMIT-004",
"name": "产品准入单列表",
"folder": "基础管理-准入管理-产品准入单列表",
"filters": "以截图为准",
"status_tabs": "以截图状态 Tab 为准",
"toolbar": "以截图工具栏为准",
"list_note": "产品准入单列表与行内操作以截图为准",
},
{
"id": "ADMIT-005",
"name": "供货目录申请",
"folder": "基础管理-准入管理-供货目录申请",
"filters": "制单日期(区间)、进院供应商、二级供应商、变更单号、产品名称、规格型号",
"status_tabs": "全部、待提交、审批中、已通过、已驳回",
"toolbar": "**批量提交**、**新增**;列设置",
"list_note": "申请单号、申请人、申请时间、进院/二级供应商、业务模式、是否默认、备注;行操作 **编辑**、**详情**",
},
{
"id": "ADMIT-006",
"name": "准入任务分配",
"folder": "基础管理-准入管理-准入任务分配",
"filters": "以截图为准",
"status_tabs": "以截图为准(若有)",
"toolbar": "以截图工具栏为准",
"list_note": "准入任务分配列表与分配操作以截图为准",
},
]
# 基础设置 → 厂商管理(docs/基础管理-厂商管理-*)
VENDOR_PAGES: list[dict] = [
{
"id": "VENDOR-001",
"name": "厂商查询",
"folder": "基础管理-厂商管理-厂商查询",
"filters": "厂商名称(搜索框)",
"status_tabs": "器械注册人、经销商",
"toolbar": "搜索、列设置、刷新/导出(以截图图标为准)",
"list_note": "列:序号、厂商编号、厂商名称、产地、详细地址、简称、开户银行、银行账号;底部分页",
"extra_imgs": 1,
},
{
"id": "VENDOR-002",
"name": "供应商资质管理",
"folder": "基础管理-厂商管理-供应商资质管理",
"filters": "随证件 Tab 变化(如进院供应商名称、证件类型;授权链路:授权书名称/授权单位/被授权单位)",
"status_tabs": (
"证件 Tab:证件效期提醒、授权链路效期预警、供应商三证、器械注册人、器械注册证号、"
"授权书、承诺书、法人委托书、二级供应商三证;效期快捷:近三个月到期、近一个月到期、已过期(带计数)"
),
"toolbar": "**导出** / **导出明细**(视 Tab);列设置",
"list_note": "证件效期提醒列含数据来源、进院供应商、有效期至、证件名称/类型、效期提醒、查看;授权链路预警列含授权单位、授权书名称、被授权单位、效期起止、效期提醒、查看",
"extra_imgs": 8,
},
{
"id": "VENDOR-003",
"name": "授权变更初审",
"folder": "基础管理-厂商管理-授权变更初审",
"filters": "制单日期(区间)、单号、进院供应商、预转供应商、状态、变更类型、原供应商、医院ID、医院名称",
"status_tabs": "无独立状态 Tab(状态下拉筛选)",
"toolbar": "查询、重置、收起/展开",
"list_note": "列:进院供应商名称、单号、状态、制单时间、制单人、变更类型、原供应商、预转供应商;行操作 **详情**",
},
{
"id": "VENDOR-004",
"name": "授权变更终审",
"folder": "基础管理-厂商管理-授权变更终审",
"filters": "制单日期(区间)、单号、进院供应商、预转供应商、状态、变更类型、原供应商",
"status_tabs": "无独立状态 Tab(状态下拉筛选)",
"toolbar": "查询、重置、收起/展开",
"list_note": "列与初审类似:进院供应商名称、单号、状态、制单时间、制单人、变更类型、原/预转供应商;行操作 **详情**",
},
{
"id": "VENDOR-005",
"name": "注册证变更初审",
"folder": "基础管理-厂商管理-注册证变更初审",
"filters": "制单日期(区间)、单号、变更类型、注册证号(新)、状态、变更情况具体说明",
"status_tabs": "无独立状态 Tab(状态下拉筛选)",
"toolbar": "查询、重置、收起/展开",
"list_note": "列:进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新);行操作 **详情**",
},
{
"id": "VENDOR-006",
"name": "注册证变更终审",
"folder": "基础管理-厂商管理-注册证变更终审",
"filters": "制单日期(区间)、单号、变更类型、注册证号(新)、状态",
"status_tabs": "无独立状态 Tab(状态下拉筛选)",
"toolbar": "查询、重置、收起/展开",
"list_note": "列与初审类似:进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新);行操作 **详情**",
},
{
"id": "VENDOR-007",
"name": "注册证差异对比",
"folder": "基础管理-厂商管理-注册证差异对比",
"filters": (
"物料名称、规格型号、状态、望海注册证号、是否医疗器械、生产厂家、FLI+注册证号、"
"27位医保编码;可选「包含FLI+长期有效数据」"
),
"status_tabs": "全部、一致、不一致",
"toolbar": "**导出**;数据更新时间 + **立即更新**;列设置",
"list_note": (
"对比望海 vs FLI+:物料编号/名称、规格型号、生产厂家、27位医保编码、"
"两侧注册证号/生效日期/有效期至;宽表横向滚动"
),
},
{
"id": "VENDOR-008",
"name": "评价方案维护",
"folder": "基础管理-厂商管理-评价方案维护",
"filters": "创建日期(区间)、启用日期(区间)、方案编码、方案名称",
"status_tabs": "无",
"toolbar": "**新增**;列设置",
"list_note": "列:方案编码、方案名称、创建人/时间、启用人/时间、停用人/时间、状态、备注、操作",
},
{
"id": "VENDOR-009",
"name": "评价指标维护",
"folder": "基础管理-厂商管理-评价指标维护",
"filters": "左侧指标分类树搜索;右侧:指标编码、指标名称",
"status_tabs": "无(左树 + 右列表)",
"toolbar": "左树 **新增**;右表 **新增**;列设置",
"list_note": "左:指标分类树(可编辑);右列:评价类型、指标编码、指标名称、状态;行操作 **编辑**、**详情**、**日志**",
},
{
"id": "VENDOR-010",
"name": "供应商节假日管理",
"folder": "基础管理-厂商管理-供应商节假日管理",
"filters": "左侧:名称/年度搜索",
"status_tabs": "无(左列表 + 右详情)",
"toolbar": "左:**新增**;右:供应商自定义列表 **新增**、**批量删除**",
"list_note": (
"左:供应商节假日设置(年度、名称、编辑);右上:默认假日详情(名称/年度/起止/创建人时间);"
"右下:供应商自定义节假日(进院供应商编码/名称、起止、备注、创建人时间、操作)"
),
},
]
# 基础设置 → 用户管理(docs/基础管理-用户管理-*)
USER_PAGES: list[dict] = [
{
"id": "USER-001",
"name": "用户管理",
"folder": "基础管理-用户管理-用户管理",
"path": "/spd/basic/user/platform/person",
"tab_title": "用户管理",
"layout": "整页单表:顶部筛选区 + 工具栏 + 用户列表 + 底部分页(无左侧组织树)",
"filters": [
("姓名", "文本", "占位「姓名、登录账号」"),
("科室", "下拉(可搜索)", "请选择"),
("角色", "下拉(可搜索)", "请选择"),
],
"actions": "**重置**、**高级筛选**(`下高级筛选` 收起/展开);本页截图未见独立「查询」按钮",
"toolbar": "页签右侧 **新增用户** 图标按钮(按实际文案定位)",
"columns": [
("序号", "行号(可勾选)"),
("姓名", "用户真实姓名"),
("登录账号", "登录用户名"),
("联系方式", "手机号/电话,可能为非法占位值(如 `1`、`0922`)"),
("所属科室", "科室名称,未分配显示 `--`"),
("医院员工编号", "工号,未填显示 `--`"),
("角色", "已分配角色,多角色截断显示(如 `内网医院初始化模板角色 (…`)"),
("科室任职数", "该用户在各科室的任职条数,无则 `--`"),
("操作", "**编辑**、**授权**、**重置密码**"),
],
"row_ops": "**编辑**、**授权**、**重置密码**",
"paging": "「共 **142** 条」、页码 `1 2 3 >`、**50 条/页**、**跳至 N 页**",
"scan_key": "basic-user",
"po_hint": "pages/spd/basic-user.page.ts",
"notes": [
"菜单导航:`page.getByRole('menuitem', { name: '用户管理', exact: true }).click()` 存在歧义(一级菜单名同为「用户管理」),建议直接 `openPath('/spd/basic/user/platform/person')` 并断言 URL。",
"本页**无「所属医院」筛选/列**;与医共体用户管理页列顺序不同,勿复用同一套列断言。",
"行操作 **授权**、**重置密码** 均为写操作,须先确认当前行。",
],
"points": [
"姓名/科室/角色组合筛选后列表条数与「共 N 条」一致;重置后恢复默认。",
"「角色」多角色截断显示(如 `内网医院初始化模板角色 (…`),编辑页应能查看全部角色。",
"「科室任职数」与用户在各科室任职记录条数一致,无任职显示 `--`。",
"新增/编辑:必填为空拦截;取消不保存;编辑回填与原值一致。",
"授权弹窗保存后重新打开应保留勾选;取消不生效。",
],
},
{
"id": "USER-002",
"name": "医共体用户管理",
"folder": "基础管理-用户管理-医共体用户管理",
"path": "/spd/basic/user/platform/personCom",
"tab_title": "医共体用户管理",
"layout": "整页单表,结构与「用户管理」一致,差异在引入**所属医院**维度(医共体多院区统一查看)",
"filters": [
("姓名", "文本", "占位「姓名、登录账号」"),
("所属医院", "下拉(可搜索)", "默认已带医院,如 `邹城市人民医院(test)`"),
("联系方式", "文本", "占位「联系方式」"),
],
"actions": "**重置**、**高级筛选**;本页截图未见独立「查询」按钮",
"toolbar": "页签右侧 **新增** 图标按钮(按实际文案定位)",
"columns": [
("序号", "行号(可勾选)"),
("登录账号", "列宽较窄,长账号截断显示(如 `zhon.…`、`jiang.…`)"),
("姓名", "用户真实姓名"),
("联系方式", "手机号/电话,可能为非法占位值"),
("所属医院", "医共体下所属医院,如 `邹城市人民医院(test)`"),
("所属科室", "科室名称,未分配显示 `--`"),
("医院员工编号", "工号,未填 `--`"),
("角色", "已分配角色,多角色截断显示"),
("科室任职数", "截断表头(截图显示 `科'`),与用户管理页同义"),
("操作", "**编辑**、**授权**、**重置密码**"),
],
"row_ops": "**编辑**、**授权**、**重置密码**",
"paging": "「共 **142** 条」、页码 `1 2 3 >`、**50 条/页**、**跳至 N 页**(与用户管理页数据量一致,两页为同一用户池)",
"scan_key": "basic-user-union",
"po_hint": "pages/spd/basic-user-union.page.ts",
"notes": [
"菜单导航:`page.getByRole('menuitem', { name: '医共体用户管理', exact: true }).click()`;或 `openPath('/spd/basic/user/platform/personCom')` 断言 URL。",
"**勿与用户管理页混用列断言**:本页为 `登录账号 → 姓名 → 联系方式 → 所属医院 → 所属科室 → 医院员工编号 → 角色 → 科室任职数 → 操作`,用户管理页为 `姓名 → 登录账号 → …`(无「所属医院」)。",
"「登录账号」列窄,`cellText` 取到的是截断文本;按账号搜索/断言应使用筛选框而非列文本。",
"写操作须先定位当前行,医共体下避免误改其他院区用户。",
],
"points": [
"切换「所属医院」后列表只显示该院区用户,「共 N 条」同步变化。",
"两页同一用户的关键字段(姓名、登录账号、角色、所属科室)一致,仅多出「所属医院」维度。",
"姓名 + 联系方式组合筛选精确命中;重置恢复默认。",
"写操作(编辑/授权/重置密码)须先定位当前行,医共体下避免误改其他院区用户。",
],
},
{
"id": "USER-003",
"name": "角色管理",
"folder": "基础管理-用户管理-角色管理",
"path": "/spd/basic/user/platform/role",
"tab_title": "",
"layout": (
"左右分栏:左侧角色列表,右侧「菜单权限」勾选树(应用 `SPD内网系统PC端` → 模块 → 菜单/功能,三级结构)"
),
"filters": [
("角色名称", "文本(左侧列表顶部)", "占位含「角色」,输入即过滤"),
],
"actions": "左侧搜索框实时过滤;右侧权限树按模块展开勾选;底部 **收起导航** 为全局导航按钮,非本页功能",
"toolbar": "左栏 **「角 色」** 按钮(新增);选中后底部/行内 **编辑**、删除图标",
"columns": [
("角色名称", "左列主键;示例:医院管理员、中心库库管、护士、护士长、库管、测试01、test、验收-herp、验收-ict、角色测试123"),
("操作", "行内 **编辑** / 删除类图标按钮"),
],
"row_ops": "**编辑** / 删除类图标按钮;右侧为菜单权限树(非行操作)",
"paging": "左列底部 **收起导航**(全局按钮);未见独立分页控件",
"scan_key": "basic-role",
"po_hint": "pages/spd/basic-role.page.ts",
"notes": [
"菜单导航:`page.getByRole('menuitem', { name: '角色管理', exact: true }).click()`;或 `openPath('/spd/basic/user/platform/role')` 断言 URL。",
"左右分栏:先断言角色列表与权限树均可见;选中角色后再断言右侧勾选状态已刷新(避免读到上一个角色的树)。",
"权限树按模块展开,断言时优先定位「模块名 + 菜单名」层级,避免全局文本匹配到多份同名菜单。",
"新增弹窗:「角 色」按钮;含角色名称、查询配置化复选框;取消/X 关闭不落库。",
"自动化:`tests/spd/basic-role.spec.ts`(12 条 USER-ROL);`npm run test:basic-role:all`。",
],
"points": [
"切换角色后右侧菜单权限勾选与该角色已配置权限一致(无串选/残留)。",
"权限树模块可展开/收起;长列表可纵向滚动(左栏角色列表、右侧子菜单树)。",
"新增/编辑角色弹窗:必填校验、查询配置化勾选、取消与 X 关闭。",
"编辑保存成功提示;权限树批量授权保存与用户页下拉同步见测试点层,当前自动化未全覆盖。",
],
"modules": [
("基础设置", "医院资料、医院信息登记、院区管理、公告管理、财务月份设置"),
(
"运维管理",
"菜单控制规则设置、医院配置、菜单受控列表、院区配置、科室配置、术语配置、资源模板设置、"
"字典管理、打印机配置、编码规则管理、PDA授权、PDA版本管理、缓存监控、操作日志、"
"消息通知模板、消息通知设置、功能注册、任务执行记录查询",
),
(
"厂商管理",
"厂商查询、供应商资质管理、授权变更初审、注册证变更初审、注册证变更终审、授权变更终审、"
"注册证差异对比、评价方案维护、评价指标维护、供应商节假日管理",
),
(
"产品管理",
"产品核对重新发起、产品维护、物料信息管理、产品初审核对、产品供应关系维护、产品物价核对、"
"院区产品设置、院区单元包设置、中心库上下限建议(AI)、请领出库渠道设置、结算点设置、"
"调价管理、产品信息变更、变更单明细查询、外围系统产品新增、外围系统产品变更管理、产品查询 等",
),
],
},
]
# 基础设置 → 库房管理(docs/基础管理-库房管理-*)
# 菜单另有「华清通道」,本次无截图,不建规范。
WH_PAGES: list[dict] = [
{
"id": "WH-001",
"group": "库房管理",
"name": "库区设置",
"folder": "基础管理-库房管理-库区设置",
"path": "/spd/basic/storehouse/platform/storehouse/area",
"siblings": "**库区设置** / **探针绑定**",
"tab_title": "库区设置",
"layout": "左右分栏:左侧「库房信息」树,右侧为当前库房下的库区列表",
"filters": [
("库房名称", "文本(左侧树顶部)", "占位「库房名称」,过滤库房树"),
("库区编号/名称", "文本(右侧列表右上)", "占位「库区编号/名称」,搜索图标触发"),
],
"actions": "左侧 **同步**;右侧搜索图标检索,另有刷新与列设置。截图未见独立「查询」按钮",
"toolbar": "右侧 **新增**",
"columns": [
("库区编号", "如 `A001`、`B001`、`C001`、`E001`、`0807`"),
("库区名称", "如「中心库A区」;默认库区在名称旁带绿色「默认库区」标签(截图为中心库B区)"),
("库区地址", "自由文本,可与编号或名称相同"),
("是否专项库区", "是 / 否"),
("操作", "**编辑**、**人员管理**、**货位管理**、**停用**或**启用**"),
],
"row_ops": "**编辑**、**人员管理**、**货位管理**、**停用** / **启用**",
"paging": "右侧「共 **5** 条」、页码 `1`、**50 条/页**(条数随所选库房变化)",
"scan_key": "warehouse-zone",
"po_hint": "pages/spd/warehouse-zone.page.ts",
"notes": [
"菜单导航:`page.getByRole('menuitem', { name: '库区设置', exact: true }).click()`;或 `openPath('/spd/basic/storehouse/platform/storehouse/area')` 并断言 URL。",
"先点选左侧库房,再断言右侧列表;切换库房后列表须刷新,避免读到上一库房的库区。",
"**停用** / **启用**、**同步**、**新增** 均为写操作。默认库区是名称旁标签,不是独立列。",
"「人员管理」「货位管理」会离开当前列表(弹层或子页),本次无截图,断言前先确认面板标题。",
],
"points": [
"左侧库房关键字过滤只命中名称匹配的节点;清空后恢复全量树。",
"切换库房后,右侧库区编号/名称/地址属于该库房,「共 N 条」同步变化。",
"「库区编号/名称」搜索同时覆盖编号与名称;无命中时列表为空且分页为 0。",
"同一库房仅一个「默认库区」标签;专项库区「是/否」与编辑回填一致。",
"停用后该行操作变为「启用」,再次进入仍保持;启用行显示「停用」。",
],
"extra_md": """
### 左侧库房树
标题 **库房信息**,操作 **同步**。截图节点示例:光谷院区中心库房(当前选中)、洪山院区中心库房、1205院区中心库房、结算院区中心库房、回归测试院区中心库房、千泉山院区中心库房、马王堆院区中心库房、主院区中心库房、岳麓山院区中心库房、数据检查院区中心库房、东土城院区中心库房、智能院区中心库房、自动采购/请领建议专用院区中心库房。
选中库房后,右侧列表只展示该库房的库区。
""",
},
{
"id": "WH-002",
"group": "库房管理",
"name": "探针绑定",
"folder": "基础管理-库房管理-探针绑定",
"path": "/spd/basic/storehouse/storehouse/probe",
"siblings": "**库区设置** / **探针绑定**",
"tab_title": "探针绑定",
"shot_note": "`UI-SPEC-SPD-WH-002/page-main.png`、`page-main-2.png`(货位绑定弹窗)",
"layout": "整页单表:顶部筛选 + 探针列表;行内「绑定货位」打开「货位绑定探针」弹窗",
"filters": [
("探针ID", "文本", "占位「请输入」"),
("主机编码", "文本", "占位「请输入」"),
("是否绑定货位", "下拉", "占位「请选择」(已绑定 / 未绑定)"),
],
"actions": "**查询**、**重置**",
"toolbar": "列设置(截图未见新增按钮)",
"columns": [
("序号", "行号"),
("探针ID", "如 `360001`–`360011`"),
("主机编码", "如 `X233300001`,多条探针可共用同一主机"),
("是否绑定货位", "已绑定 / 未绑定"),
("绑定的货位", "已绑定货位数量(如 `28`、`3`、`2`);未绑定为 `0`"),
("操作", "**绑定货位**"),
],
"row_ops": "**绑定货位**(打开货位选择弹窗)",
"paging": "主列表「共 **11** 条」、**50 条/页**(以当前环境为准)",
"scan_key": "warehouse-probe",
"po_hint": "pages/spd/warehouse-probe.page.ts",
"notes": [
"菜单导航:`page.getByRole('menuitem', { name: '探针绑定', exact: true }).click()`;或 `openPath('/spd/basic/storehouse/storehouse/probe')` 并断言 URL。",
"「绑定的货位」是数量,不是货位号;货位号在弹窗「货位号」列。",
"弹窗有独立筛选与分页(截图「共 2626 条」),不要用弹窗总条数断言主列表。",
"弹窗 **确定** 会改绑定关系,按钮文案带已选数量(如 `确定(0)`);冒烟只打开后 **取消**。",
],
"points": [
"探针ID、主机编码、是否绑定货位组合查询后,列表与「共 N 条」一致;重置恢复默认。",
"「已绑定」行的绑定数量大于 0,「未绑定」行数量为 0。",
"打开「绑定货位」后弹窗标题为「货位绑定探针」,关闭后主列表勾选与筛选条件不丢失。",
"弹窗内按当前库房/库区/货位号筛选,结果列与条件一致;未选货位时确定按钮计数为 0。",
"同一货位行若已有探针ID,再次勾选前需确认是换绑还是追加(以保存后的主列表数量为准)。",
],
"extra_md": """
### 货位绑定探针弹窗
![探针绑定-货位绑定探针](UI-SPEC-SPD-WH-002/page-main-2.png)
行内 **绑定货位** 打开。筛选与主列表相互独立。
| 字段 | 控件 | 占位/默认值 |
|------|------|-------------|
| 当前库房 | 下拉 | 可带默认库房,如「千泉山院区中心…」 |
| 当前库区 | 下拉 | 空 |
| 货位查询 | 文本 | 占位「请输入」 |
| 货位状态 | 下拉 | 空 |
| 默认货位 | 下拉 | 空 |
- 操作:**查询**、**重置**
| 列 | 说明 |
|----|------|
| 勾选 | 多选;底部 **确定(N)** 的 N 为已选货位数 |
| 货位号 | 如 `001-01-01-01`、`001-ZNG001-01-01` |
| 库区默认货位 | 是 / 否 |
| 当前库房 | 如「千泉山院区中心库房」 |
| 当前库区 | 如「千泉山院区中心库库区1」 |
| 状态 | 启用等 |
| 探针ID | 已绑定显示探针号(如 `360003`),未绑定 `--` |
弹窗分页截图为「共 **2626** 条」、**50 条/页**、**跳至 N 页**。底部 **取消**、**确定(0)**。
""",
},
]
# 基础设置 → 流程设置(docs/基础管理-流程设置-*)
FLOW_PAGES: list[dict] = [
{
"id": "FLOW-001",
"group": "流程设置",
"name": "审批流程设置",
"folder": "基础管理-流程设置-审批流程设置",
"path": "/spd/basic/flowSet/workflow/flowSet",
"siblings": "**审批流程设置** / **审批流实例**",
"tab_title": "审批流程设置",
"layout": "左右分栏:左侧业务类型列表,右侧为该类型的默认审批流与自定义审批流",
"filters": [
("流程名称", "文本(左侧列表顶部)", "占位「流程名称」,过滤业务类型"),
],
"actions": "左侧搜索过滤类型;右侧 **新增流程**、底部 **保存** / **重置**",
"toolbar": "自定义审批流区域右上 **新增流程**",
"columns": [
("流程名称", "必填;自定义流名称,截图示例 `1`、`2`"),
("条件", "必填;行内 **编辑**。示例:「是否超限:是」「科室:中心库(洪山)」"),
("审批流程", "必填;节点卡片(节点类型、账号、人员)+ **+** 追加节点"),
("操作", "**删除**"),
],
"row_ops": "条件列 **编辑**;审批节点 **+**;行 **删除**",
"paging": "左侧为业务类型全量列表,未见分页;右侧自定义流按行展示,未见分页",
"scan_key": "flow-def",
"po_hint": "pages/spd/flow-def.page.ts",
"notes": [
"菜单导航:`page.getByRole('menuitem', { name: '审批流程设置', exact: true }).click()`;或 `openPath('/spd/basic/flowSet/workflow/flowSet')` 断言 URL。与「审批流实例」区分,使用 exact。",
"先选中左侧业务类型,再读右侧默认流与自定义流;切换类型后两侧都要刷新,避免串用上一类型的节点。",
"**保存** 会改变线上审批路由,冒烟只断言设计器结构,不提交;改完须 **重置** 或恢复原配置。",
"节点卡片含「节点 / 账号 / 人员」三行,断言时按卡片容器取文本,避免全局匹配到同名人员。",
],
"points": [
"流程名称关键字只保留匹配的业务类型;清空后恢复全量。",
"切换业务类型后,默认审批流节点与自定义审批流行与该类型已保存配置一致。",
"自定义流「流程名称 / 条件 / 审批流程」为空时保存被拦截;删除行后未点保存不落库。",
"重置放弃未保存改动,重新进入与上次保存结果一致。",
"默认流与自定义流互不覆盖:只改自定义行时,默认审批人节点保持不变。",
],
"extra_md": """
### 左侧业务类型
截图可见类型(采购计划为当前选中,列表可继续向下滚动):采购计划、结算、调价、产品信息变更、请领单、领用申请单、跟台手术申请单、试剂性能验证、退库出库单、临采任务、二级库调拨、备货计划、产品准入、供货目录申请、移库申请单、物料准入、退货出库拣货、产品DI变更、科室消耗退回、限量任务、科室领用退库出库单。
### 默认审批流
标题 **默认审批流**。审批人节点卡片示例:节点「运营主管」、账号 `test_1`、人员「蒋华」,卡片右侧 **+** 追加节点。未配置自定义流时,单据走该默认链。
### 自定义审批流
标题 **自定义审批流**。列头「流程名称」「条件」「审批流程」带必填标记。节点示例:
| 行 | 条件 | 节点 |
|----|------|------|
| 1 | 是否超限:是 | 指定用户 / `cgzxtest01` / 采购中心管理员01 |
| 2 | 科室:中心库(洪山) | 指定用户 / `qizhenjun` / 齐-超管 |
底部 **保存**、**重置**。
""",
},
{
"id": "FLOW-002",
"group": "流程设置",
"name": "审批流实例",
"folder": "基础管理-流程设置-审批流实例",
"path": "/spd/basic/flowSet/workflow/flowCase",
"siblings": "**审批流程设置** / **审批流实例**",
"tab_title": "审批流实例",
"layout": "整页单表:顶部筛选 + 审批单据列表 + 底部分页",
"filters": [
("单据编号", "文本", "占位「单据编号」"),
("审批状态", "下拉", "空;列表可见「审批通过」「审批中」"),
("发起时间", "日期区间", "截图示例 `2026-08-21` → `2026-10-20`(约两个月,结束日为当天)"),
("业务类型", "下拉", "空;与审批流程设置左侧类型同源"),
("摘要", "文本", "占位「摘要」"),
],
"actions": "**查询**、**重置**",
"toolbar": "列设置(截图未见新增、导出)",
"columns": [
("序号", "行号"),
("单据编号", "前缀随业务类型变化:`PLAN` 采购计划、`DB` 二级库调拨、`GC` 产品信息变更、`BUY` 请领单"),
("业务类型", "如采购计划、二级库调拨、产品信息变更、请领单"),
("发起人", "姓名,窄列截断"),
("发起部门", "部门名称,窄列截断"),
("发起时间", "日期时间,窄列截断为 `YYYY-MM-DD …`"),
("摘要", "业务摘要;无则 `--`,长文本截断"),
("审批状态", "审批通过 / 审批中(以页面枚举为准)"),
("操作", "**详情**"),
],
"row_ops": "**详情**(只读,打开该单据审批过程)",
"paging": "「共 **2348** 条」、页码、**50 条/页**、**跳至 N 页**(默认日期区间较大,条数随区间变化)",
"scan_key": "flow-inst",
"po_hint": "pages/spd/flow-inst.page.ts",
"notes": [
"菜单导航:`page.getByRole('menuitem', { name: '审批流实例', exact: true }).click()`;或 `openPath('/spd/basic/flowSet/workflow/flowCase')` 断言 URL。不要点到「审批流程设置」。",
"默认发起时间跨度大,全量查询条数多;收窄日期后再断言「共 N 条」,且列表「发起时间」须落在区间内。",
"单据编号前缀与业务类型对应,按编号搜索时用完整单号,不用截断后的发起人/部门列文本。",
"本页行操作只有 **详情**;改审批人、条件应回到审批流程设置,且该页保存会影响后续新实例。",
],
"points": [
"单据编号精确查询至多一条,且编号、业务类型、状态与条件一致。",
"审批状态、业务类型、发起时间、摘要组合筛选后,抽样行同时满足全部条件。",
"重置后日期回到默认区间,其余条件清空,「共 N 条」恢复。",
"摘要为 `--` 的行,按非空摘要关键字查询不应命中。",
"详情打开的单据编号与当前行一致;关闭详情后筛选条件与页码保持。",
],
"extra_md": "",
},
]
PAGES: list[dict] = STAT_PAGES + ADMIT_PAGES + VENDOR_PAGES + USER_PAGES + WH_PAGES + FLOW_PAGES
def png_files(folder: Path) -> list[Path]: def png_files(folder: Path) -> list[Path]:
return sorted(p for p in folder.iterdir() if p.suffix.lower() == ".png") return sorted(p for p in folder.iterdir() if p.suffix.lower() == ".png")
def render_spec(page: dict, spec_id: str) -> str: def render_admit_spec(page: dict, spec_id: str) -> str:
title = f"准入管理-{page['name']}页面说明"
menu = f"**基础设置** → **准入管理** → **{page['name']}**"
docs_path = f"docs/{page['folder']}/"
kb_dir = f"UI-SPEC-SPD-{spec_id}"
extra_img = ""
if spec_id == "ADMIT-005":
extra_img = "\n\n![供货目录申请-补充截图](UI-SPEC-SPD-ADMIT-005/page-main-2.png)\n"
return f"""---
title: {title}
module: 基础设置
status: 生效
related_br: ""
related_api: ""
related_jrn: ""
related_tc: ""
related_def: ""
create_time: "{TODAY}"
update_time: "{TODAY}"
author: ""
---
# 准入管理 - {page['name']}
> 来源:`{docs_path}` 页面截图。用于 UI 自动化定位、功能测试与准入流程验收参考。
> 同模块索引见 [`INDEX.md`](./INDEX.md)。
## 文档与截图
| 项 | 路径 |
|----|------|
| 截图原件 | `{docs_path}` |
| 知识库副本 | `{kb_dir}/page-main.png` |
## 页面路径
{menu}
| 项 | 值 |
|----|-----|
| 主框架 URL | 待元素扫描补充(菜单名:`{page['name']}`) |
| iframe 选择器 | 无(整页渲染,非 iframe 报表) |
| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |
## 主界面
![{page['name']}主界面]({kb_dir}/page-main.png){extra_img}
### 筛选区
- 字段:{page.get('filters', '以截图为准')}
- 操作:**查询**、**重置**;部分页面含 **收起/展开**
### 状态 Tab(若有)
- {page.get('status_tabs', '以截图为准')}
### 列表与工具栏
- {page.get('toolbar', '以截图为准')}
- {page.get('list_note', '主表列与行内操作以截图为准')}
- 底部分页:「共 N 条」、页码与每页条数切换
## 自动化建议
1. 完成专项元素扫描后,补充 `data/scan/`、`locators/spd/` 与 Page Object。
2. 菜单导航:`page.getByRole('menuitem', {{ name: '{page['name']}', exact: true }}).click()`(注意与同组「产品/物料准入单列表」等近似菜单区分)。
3. 状态 Tab 切换后列表与 Tab 计数一致;批量操作需先勾选行。
## 测试关注点
1. 筛选 + 状态 Tab 组合后,列表条数与底部分页「共 N 条」一致。
2. 生成申请单/一键生成/批量提交等写操作与当前勾选行、筛选条件一致。
3. 审批态行操作(编辑/详情/作废)与状态机规则一致。
"""
def render_vendor_spec(page: dict, spec_id: str) -> str:
title = f"厂商管理-{page['name']}页面说明"
menu = f"**基础设置** → **厂商管理** → **{page['name']}**"
docs_path = f"docs/{page['folder']}/"
kb_dir = f"UI-SPEC-SPD-{spec_id}"
extra_n = int(page.get("extra_imgs") or 0)
extra_img = ""
for i in range(2, extra_n + 2):
extra_img += f"\n\n![{page['name']}-补充截图{i - 1}]({kb_dir}/page-main-{i}.png)\n"
return f"""---
title: {title}
module: 基础设置
status: 生效
related_br: ""
related_api: ""
related_jrn: ""
related_tc: ""
related_def: ""
create_time: "{TODAY}"
update_time: "{TODAY}"
author: ""
---
# 厂商管理 - {page['name']}
> 来源:`{docs_path}` 页面截图。用于 UI 自动化定位、功能测试与厂商/资质流程验收参考。
> 同模块索引见 [`INDEX.md`](./INDEX.md)。
## 文档与截图
| 项 | 路径 |
|----|------|
| 截图原件 | `{docs_path}` |
| 知识库副本 | `{kb_dir}/page-main.png` |
## 页面路径
{menu}
| 项 | 值 |
|----|-----|
| 主框架 URL | 待元素扫描补充(菜单名:`{page['name']}`) |
| iframe 选择器 | 无(整页渲染,非 iframe 报表) |
| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |
## 主界面
![{page['name']}主界面]({kb_dir}/page-main.png){extra_img}
### 筛选区
- 字段:{page.get('filters', '以截图为准')}
- 操作:**查询**、**重置**;部分页面含 **收起/展开**
### 状态 / 分类 Tab(若有)
- {page.get('status_tabs', '以截图为准')}
### 列表与工具栏
- {page.get('toolbar', '以截图为准')}
- {page.get('list_note', '主表列与行内操作以截图为准')}
- 底部分页:「共 N 条」、页码与每页条数切换(左右分栏页面对应侧分页)
## 自动化建议
1. 完成专项元素扫描后,补充 `data/scan/`、`locators/spd/` 与 Page Object。
2. 菜单导航:`page.getByRole('menuitem', {{ name: '{page['name']}', exact: true }}).click()`(注意初审/终审、方案/指标等近似菜单区分)。
3. 资质多 Tab、变更审批详情、左右分栏(指标树/节假日)需先断言目标面板可见再操作。
## 测试关注点
1. 筛选 + Tab/状态下拉组合后,列表条数与底部分页「共 N 条」一致。
2. 授权/注册证初审与终审详情、状态流转与业务规则一致;写操作前确认勾选/当前行。
3. 注册证差异对比「一致/不一致」与两侧字段抽样一致;导出与当前查询条件一致。
"""
def render_stat_spec(page: dict, spec_id: str) -> str:
title = f"{page['group']}-{page['name']}页面说明" title = f"{page['group']}-{page['name']}页面说明"
menu = f"**统计报表** → **{page['group']}** → **{page['name']}**" menu = f"**统计报表** → **{page['group']}** → **{page['name']}**"
docs_path = f"docs/{page['folder']}/" docs_path = f"docs/{page['folder']}/"
...@@ -161,6 +865,251 @@ author: "" ...@@ -161,6 +865,251 @@ author: ""
""" """
def render_user_spec(page: dict, spec_id: str) -> str:
title = f"用户管理-{page['name']}页面说明"
menu = f"**基础设置** → **用户管理** → **{page['name']}**"
docs_path = f"docs/{page['folder']}/"
kb_dir = f"UI-SPEC-SPD-{spec_id}"
scan_key = page["scan_key"]
tab_row = f"| 浏览器页签标题 | `{page['tab_title']}` |\n" if page.get("tab_title") else ""
# 第 1 条固定为「先扫描再补 PO」,其余建议顺延编号
notes = "\n".join(f"{i}. {n}" for i, n in enumerate(page["notes"], start=2))
points = "\n".join(f"{i}. {p}" for i, p in enumerate(page["points"], start=1))
filter_rows = "\n".join(f"| {f} | {c} | {d} |" for f, c, d in page["filters"])
col_rows = "\n".join(f"| {name} | {desc} |" for name, desc in page["columns"])
modules = ""
if page.get("modules"):
rows = "\n".join(f"| {m} | {items} |" for m, items in page["modules"])
modules = f"""
### 右侧菜单权限树
二级模块(示例):
| 模块 | 菜单/功能(示例) |
|------|-------------------|
{rows}
- 勾选父节点联动子节点,保存后即为该角色授权范围。
"""
return f"""---
title: {title}
module: 基础设置
status: 生效
related_br: ""
related_api: ""
related_jrn: ""
related_tc: ""
related_def: ""
create_time: "{TODAY}"
update_time: "{TODAY}"
author: ""
---
# 用户管理 - {page['name']}
> 来源:`{docs_path}` 页面截图。用于 UI 自动化定位、功能测试与用户/权限验收参考。
> 同模块索引见 [`INDEX.md`](./INDEX.md)。
## 文档与截图
| 项 | 路径 |
|----|------|
| 截图原件 | `{docs_path}` |
| 知识库副本 | `{kb_dir}/page-main.png` |
## 页面路径
{menu}(同组页面:**用户管理** / **医共体用户管理** / **角色管理**)
| 项 | 值 |
|----|-----|
| 主框架 URL | `http://spdtest.cmic.com.cn:8080{page['path']}` |
{tab_row}| iframe 选择器 | 无(整页渲染,非 iframe 报表) |
| 元素扫描 JSON | 待扫描补充(建议 `data/scan/{scan_key}-elements.json`) |
| 定位器 YAML | 待扫描补充(建议 `locators/spd/{scan_key}.locator.yaml`) |
| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |
## 主界面
![{page['name']}主界面]({kb_dir}/page-main.png)
{page['layout']}
### 筛选/搜索区
| 字段 | 控件 | 占位/默认值 |
|------|------|-------------|
{filter_rows}
- 操作:{page['actions']}
### 列表与工具栏
工具栏:{page.get('toolbar', '**新增**(按实际文案定位)')}。
| 列 | 说明 |
|----|------|
{col_rows}
行操作:{page['row_ops']}。
### 分页
{page['paging']}
{modules}
## 自动化建议
1. 完成元素扫描后,补充 `data/scan/`、`locators/spd/` 与 Page Object(建议 `{page['po_hint']}`)。
{notes}
## 测试关注点
{points}
## 关联
- 同模块:用户管理([`UI-SPEC-SPD-USER-001`](./UI-SPEC-SPD-USER-001.md))、医共体用户管理([`UI-SPEC-SPD-USER-002`](./UI-SPEC-SPD-USER-002.md))、角色管理([`UI-SPEC-SPD-USER-003`](./UI-SPEC-SPD-USER-003.md))。
- 同层菜单:医院资料(`UI-SPEC-SPD-BASIC-*`)、厂商管理(`UI-SPEC-SPD-VENDOR-*`)、手术套包设置(`UI-SPEC-SPD-SURG-*`)。
{page.get('related_extra', '')}"""
def render_group_spec(page: dict, spec_id: str) -> str:
"""库房管理 / 流程设置等左右分栏或单表页面(结构对齐用户管理规范)。"""
group = page["group"]
title = f"{group}-{page['name']}页面说明"
menu = f"**基础设置** → **{group}** → **{page['name']}**"
docs_path = f"docs/{page['folder']}/"
kb_dir = f"UI-SPEC-SPD-{spec_id}"
scan_key = page["scan_key"]
siblings = page.get("siblings", f"**{page['name']}**")
shot = page.get("shot_note") or f"`{kb_dir}/page-main.png`"
tab_row = f"| 浏览器页签标题 | `{page['tab_title']}` |\n" if page.get("tab_title") else ""
notes = "\n".join(f"{i}. {n}" for i, n in enumerate(page["notes"], start=2))
points = "\n".join(f"{i}. {p}" for i, p in enumerate(page["points"], start=1))
filter_rows = "\n".join(f"| {f} | {c} | {d} |" for f, c, d in page["filters"])
col_rows = "\n".join(f"| {name} | {desc} |" for name, desc in page["columns"])
extra = (page.get("extra_md") or "").strip()
extra = f"\n{extra}\n" if extra else "\n"
page_url = (
f"`http://spdtest.cmic.com.cn:8080{page['path']}`"
if page.get("path")
else f"待元素扫描补充(菜单名:`{page['name']}`)"
)
if group == "库房管理":
peer = (
"- 同模块:库区设置([`UI-SPEC-SPD-WH-001`](./UI-SPEC-SPD-WH-001.md))、"
"探针绑定([`UI-SPEC-SPD-WH-002`](./UI-SPEC-SPD-WH-002.md))。"
"菜单另有「华清通道」,本次无截图,未建规范。\n"
"- 同层菜单:医院资料(`UI-SPEC-SPD-BASIC-*`)、用户管理(`UI-SPEC-SPD-USER-*`)、"
"流程设置(`UI-SPEC-SPD-FLOW-*`)。"
)
else:
peer = (
"- 同模块:审批流程设置([`UI-SPEC-SPD-FLOW-001`](./UI-SPEC-SPD-FLOW-001.md))、"
"审批流实例([`UI-SPEC-SPD-FLOW-002`](./UI-SPEC-SPD-FLOW-002.md))。\n"
"- 同层菜单:库房管理(`UI-SPEC-SPD-WH-*`)、准入管理(`UI-SPEC-SPD-ADMIT-*`)、"
"用户管理(`UI-SPEC-SPD-USER-*`)。自定义审批流保存后,新单据出现在审批流实例。"
)
return f"""---
title: {title}
module: 基础设置
status: 生效
related_br: ""
related_api: ""
related_jrn: ""
related_tc: ""
related_def: ""
create_time: "{TODAY}"
update_time: "{TODAY}"
author: ""
---
# {group} - {page['name']}
> 来源:`{docs_path}` 页面截图。用于 UI 自动化定位、功能测试与{group}验收参考。
> 同模块索引见 [`INDEX.md`](./INDEX.md)。
## 文档与截图
| 项 | 路径 |
|----|------|
| 截图原件 | `{docs_path}` |
| 知识库副本 | {shot} |
## 页面路径
{menu}(同组页面:{siblings})
| 项 | 值 |
|----|-----|
| 主框架 URL | {page_url} |
{tab_row}| iframe 选择器 | 无(整页渲染,非 iframe 报表) |
| 元素扫描 JSON | `data/scan/{scan_key}-elements.json` |
| 定位器 YAML | `locators/spd/{scan_key}.locator.yaml` |
| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |
## 主界面
![{page['name']}主界面]({kb_dir}/page-main.png)
{page['layout']}
### 筛选/搜索区
| 字段 | 控件 | 占位/默认值 |
|------|------|-------------|
{filter_rows}
- 操作:{page['actions']}
### 列表与工具栏
工具栏:{page.get('toolbar', '列设置')}。
| 列 | 说明 |
|----|------|
{col_rows}
行操作:{page['row_ops']}。
### 分页
{page['paging']}
{extra}
## 自动化建议
1. 元素扫描 JSON 与定位器 YAML 已生成;下一步补 Page Object(建议 `{page['po_hint']}`)。
{notes}
## 测试关注点
{points}
## 关联
{peer}
"""
def render_spec(page: dict, spec_id: str) -> str:
if spec_id.startswith("ADMIT-"):
return render_admit_spec(page, spec_id)
if spec_id.startswith("VENDOR-"):
return render_vendor_spec(page, spec_id)
if spec_id.startswith("USER-"):
return render_user_spec(page, spec_id)
if spec_id.startswith("WH-") or spec_id.startswith("FLOW-"):
return render_group_spec(page, spec_id)
return render_stat_spec(page, spec_id)
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="同步 docs 截图至 UI 专项规范库") parser = argparse.ArgumentParser(description="同步 docs 截图至 UI 专项规范库")
parser.add_argument( parser.add_argument(
...@@ -184,8 +1133,12 @@ def main() -> None: ...@@ -184,8 +1133,12 @@ def main() -> None:
if not images: if not images:
raise SystemExit(f"no png in {src_dir}") raise SystemExit(f"no png in {src_dir}")
shutil.copy2(images[0], kb_dir / "page-main.png") shutil.copy2(images[0], kb_dir / "page-main.png")
if len(images) > 1: # 复制全部补充截图:page-main-2.png … page-main-N.png
shutil.copy2(images[1], kb_dir / "page-main-2.png") for idx, img in enumerate(images[1:], start=2):
shutil.copy2(img, kb_dir / f"page-main-{idx}.png")
# 若配置了 extra_imgs,确保规范中引用的张数不超过实际文件
if "extra_imgs" in page:
page = {**page, "extra_imgs": max(0, min(int(page["extra_imgs"]), len(images) - 1))}
spec_file.write_text(render_spec(page, spec_id), encoding="utf-8") spec_file.write_text(render_spec(page, spec_id), encoding="utf-8")
print(f"OK UI-SPEC-SPD-{spec_id}\t{page['name']}\t{len(images)} img") print(f"OK UI-SPEC-SPD-{spec_id}\t{page['name']}\t{len(images)} img")
print("done", len(pages)) print("done", len(pages))
......
#!/usr/bin/env node
/**
* Flake 隔离闭环:把 reports/flake 的隔离候选合并写入 config/quarantine.json。
*
* 背景:flake-report.mjs 只产出「隔离候选」清单,若不落库,主门禁会一直跑这些高 flake 用例,
* 导致夜间全量被环境/瞬时问题反复刷红。本脚本负责把它变成真正的隔离项(闭环)。
*
* 用法:
* node scripts/sync-quarantine.mjs # 预览:只打印将要写入的项
* node scripts/sync-quarantine.mjs --write # 实际写入 config/quarantine.json
* node scripts/sync-quarantine.mjs --write --owner=liguangyu --mode=observe
*
* 安全约束:
* - 只隔离 environment / transient / script 类候选;**product(断言/业务缺陷)绝不自动隔离**。
* - 已存在的人工条目(含 ticket/owner 等)一律保留,只补充/更新自动条目。
* - 自动条目带 `auto: true` 标记,便于下次同步识别与回收。
*/
import {
existsSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
/** 允许自动隔离的失败类别;product 属业务缺陷,禁止自动隔离。 */
const AUTO_ISOLATABLE = new Set(['environment', 'transient', 'script', 'data']);
const CLASS_LABEL = {
environment: '环境/基础设施抖动',
transient: '瞬时 Flake',
script: '脚本/定位不稳定',
data: '测试数据不足',
product: '产品缺陷',
};
function parseArgs(argv) {
const out = {
trend: path.join(ROOT, 'reports/flake/latest.json'),
quarantine: path.join(ROOT, 'config/quarantine.json'),
write: false,
mode: 'quarantine',
owner: process.env.QUARANTINE_OWNER || 'ui-auto',
ticket: process.env.QUARANTINE_TICKET || '',
};
for (const arg of argv) {
if (arg === '--write') out.write = true;
else if (arg.startsWith('--trend=')) out.trend = path.resolve(arg.slice(8));
else if (arg.startsWith('--quarantine=')) out.quarantine = path.resolve(arg.slice(13));
else if (arg.startsWith('--mode=')) out.mode = arg.slice(7);
else if (arg.startsWith('--owner=')) out.owner = arg.slice(8);
else if (arg.startsWith('--ticket=')) out.ticket = arg.slice(9);
}
if (!['quarantine', 'observe'].includes(out.mode)) out.mode = 'quarantine';
return out;
}
function readJson(file, fallback) {
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch {
return fallback;
}
}
function extractCaseId(title) {
return String(title || '').match(/\b(STAT-[A-Z]+-\d{3,})\b/)?.[1];
}
/** 取该用例出现最多的失败类别(决定是否可自动隔离)。 */
function dominantClass(classes) {
const entries = Object.entries(classes || {});
if (!entries.length) return { cls: '', n: 0 };
entries.sort((a, b) => b[1] - a[1]);
return { cls: entries[0][0], n: entries[0][1] };
}
function buildAutoItems(trend, opts) {
const items = [];
const skipped = [];
for (const cand of trend.quarantineCandidates || []) {
const { cls } = dominantClass(cand.classes);
const id = extractCaseId(cand.title);
if (!id) {
skipped.push({ title: cand.title, reason: '标题无 STAT- 编号,无法稳定匹配' });
continue;
}
if (!AUTO_ISOLATABLE.has(cls)) {
skipped.push({ title: cand.title, reason: `失败类别 ${cls || '未知'}(疑似产品缺陷)不自动隔离` });
continue;
}
items.push({
id,
mode: opts.mode,
reason: `flake failRate=${Number(cand.failRate).toFixed(2)} (${cand.failCount}/${cand.decided}) 主导类别=${CLASS_LABEL[cls] || cls}`,
owner: opts.owner,
since: new Date().toISOString().slice(0, 10),
category: cls,
auto: true,
...(opts.ticket ? { ticket: opts.ticket } : {}),
});
}
return { items, skipped };
}
function mergeItems(existingItems, autoItems) {
const manual = (existingItems || []).filter((item) => !item.auto);
const manualIds = new Set(manual.map((item) => item.id).filter(Boolean));
const autoById = new Map();
for (const item of autoItems) {
// 人工已声明的用例不再自动覆盖,尊重人工结论。
if (manualIds.has(item.id)) continue;
autoById.set(item.id, item);
}
return [...manual, ...[...autoById.values()]];
}
function main() {
const opts = parseArgs(process.argv.slice(2));
if (!existsSync(opts.trend)) {
console.error(`[quarantine] 未找到 flake 趋势文件:${path.relative(ROOT, opts.trend)}`);
console.error('[quarantine] 先执行 `npm run flake:report` 生成隔离候选。');
process.exit(2);
}
const trend = readJson(opts.trend, { quarantineCandidates: [] });
const { items: autoItems, skipped } = buildAutoItems(trend, opts);
const current = readJson(opts.quarantine, { version: 1, items: [] });
const merged = mergeItems(current.items, autoItems);
const added = merged.filter((item) => item.auto).length;
console.log(
`[quarantine] 候选=${(trend.quarantineCandidates || []).length} 自动可隔离=${autoItems.length} 跳过=${skipped.length} 合并后总计=${merged.length}`,
);
for (const item of autoItems) {
console.log(` + ${item.id} [${item.mode}] ${item.reason}`);
}
for (const s of skipped) {
console.log(` ~ 跳过 ${s.title}:${s.reason}`);
}
if (!opts.write) {
console.log('[quarantine] 预览模式,未写入。加 --write 落库。');
return;
}
const next = { version: current.version ?? 1, items: merged };
writeFileSync(opts.quarantine, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
console.log(
`[quarantine] 已写入 ${path.relative(ROOT, opts.quarantine)}(自动项 ${added},人工项保留 ${
merged.length - added
})。`,
);
}
main();
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"ADMIT-PLS-001","基础设置 · 准入管理","产品准入单列表 · 页面进入","菜单进入产品准入单列表","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 产品准入单列表」进入
2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productOrder/list`,且路径不是 `zrlist`
3. 断言制单日期、查询、状态 Tab、列表表头可见","进入产品准入单列表;不误入物料准入单列表。",""
"ADMIT-PLS-002","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe)","1. 点击「查 询」并等待列表稳定
2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",""
"ADMIT-PLS-003","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","按产品名称筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);默认查询至少 1 条且产品名称非空","1. 从首行读取 `${sampleProductName}`
2. 若筛选区无产品名称,则用注册证号或器械注册人中首行非空字段
3. 制单日期保持默认,点击「查 询」
4. 断言结果包含该关键字","结果与已填关键字匹配。",""
"ADMIT-PLS-004","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已填入筛选关键字","1. 点击「重 置」
2. 断言筛选恢复默认","筛选恢复默认;列表按默认规则刷新。",""
"ADMIT-PLS-005","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe)","1. 注册证号填入 `__NOT_EXIST_CERT__99999`
2. 点击「查 询」
3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。",""
"ADMIT-PLS-006","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","列表核心列与附件计数","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);默认查询至少 1 条数据","1. 断言表头含准入单号、进院供应商、二级供应商、产品名称、注册证号、耗材类型、产品条数、状态、备注、附件、操作
2. 断言首行附件列展示个数(可为 0 个)","核心列可见;附件列有个数;操作为「详情」。本页列表不要求「准入类型」列。",""
"ADMIT-PLS-007","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","切换已通过 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe)","1. 点击「已通过」
2. 断言有数据时状态为已通过
3. 断言 Tab 括号数量等于分页「共 N 条」","已通过 Tab 的状态与计数一致。",""
"ADMIT-PLS-008","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","与物料准入单列表不串 URL","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);当前在产品准入单列表","1. 记录当前 URL
2. 断言 URL 含 `productOrder/list` 且不含 `zrlist`
3. 不点击物料准入单列表菜单","停留在产品准入单列表路径,不串到物料单路径。",""
"ADMIT-PLS-009","基础设置 · 准入管理","产品准入单列表 · 单据详情","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);默认查询至少 1 条数据","1. 记录首行 `${sampleOrderNo}`
2. 点击「详情」
3. 断言可见「审批进程」「单据日志」「返 回」
4. 返回后 URL 仍含 `productOrder/list`","详情可打开;返回后仍是产品准入单列表。",""
"ADMIT-PLS-010","基础设置 · 准入管理","产品准入单列表 · 单据详情","详情产品条数与列表一致","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已打开首行详情","1. 读取详情明细分页总数或当前行数
2. 断言等于列表「产品条数」","明细条数与产品条数一致。",""
"ADMIT-PLS-011","基础设置 · 准入管理","产品准入单列表 · 单据详情","单据日志可打开","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已打开单据详情","1. 点击「单据日志」
2. 断言日志层可见
3. 关闭后仍在该单据","单据日志可查看;详情不被销毁。",""
"ADMIT-PLS-012","基础设置 · 准入管理","产品准入单列表 · 单据详情","详情搜索无匹配(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已打开单据详情","1. 搜索框填入 `__NOT_EXIST_GOODS__99999`
2. 断言明细空态,返回按钮仍在","无匹配只影响明细;单据头仍在。",""
"ADMIT-PLS-013","基础设置 · 准入管理","产品准入单列表 · 单据详情","已通过单据不确认通过","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已通过 Tab 至少 1 条并打开详情","1. 「通过」禁用或不可见时,断言状态保持已通过
2. 若可点且出现确认框,点「取 消」
3. 返回列表,断言状态仍为已通过","不把已通过单据再次审批成功。无已通过数据则 skip。",""
"ADMIT-PLS-014","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","多条件组合查询(制单日期等6项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载
2. 「制单日期」日期区间选择当天
3. 「销售商」填写关键字
4. 「耗材类型」下拉选择任一可选项
5. 「器械注册人」填写关键字
6. 「注册证号」填写关键字
7. 「供应商」填写关键字
8. 点击「查询」
9. 检查列表返回结果","多条件同时生效:制单日期、销售商、耗材类型、器械注册人、注册证号、供应商 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行"
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"ADMIT-PAP-001","基础设置 · 准入管理","产品准入申请 · 页面进入","菜单进入产品准入申请页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 产品准入申请」进入
2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productApply/list`,且不是 `ZRlist`
3. 断言可见「查 询」、「Excel导入」「生成申请单」「一键生成」「新增产品」「作 废」及列表表头","进入产品准入申请页;不误入物料准入管理(ZRlist)或产品准入单列表。",""
"ADMIT-PAP-002","基础设置 · 准入管理","产品准入申请 · 筛选查询","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 点击「查 询」并等待列表稳定
2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",""
"ADMIT-PAP-003","基础设置 · 准入管理","产品准入申请 · 筛选查询","产品名称筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据","1. 从首行读取 `${sampleProductName}`
2. 填入产品名称,新增日期保持默认,点击「查 询」
3. 断言结果行「产品名称」均包含该关键字","产品名称与已填关键字匹配。",""
"ADMIT-PAP-004","基础设置 · 准入管理","产品准入申请 · 筛选查询","名称与耗材类型组合查询","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据","1. 读取首行 `${sampleProductName}`、`${sampleConsumableType}`
2. 同时填入后点击「查 询」(日期保持默认)
3. 断言结果同时满足两个条件","组合查询结果同时满足产品名称与耗材类型。类型为空则只断言名称。",""
"ADMIT-PAP-005","基础设置 · 准入管理","产品准入申请 · 筛选查询","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 填入 `${sampleProductName}`
2. 点击「重 置」
3. 断言筛选恢复默认","筛选字段恢复默认;列表按默认规则刷新。",""
"ADMIT-PAP-006","基础设置 · 准入管理","产品准入申请 · 筛选查询","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 产品名称填入 `__NOT_EXIST_GOODS__99999`
2. 点击「查 询」
3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。",""
"ADMIT-PAP-007","基础设置 · 准入管理","产品准入申请 · 状态与列表","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据","1. 断言表头含准入单号、产品编号、产品名称、规格型号、注册规格、注册型号、器械注册人、单位、操作","核心列可见;操作含「详情」「日志」。",""
"ADMIT-PAP-008","基础设置 · 准入管理","产品准入申请 · 状态与列表","切换待申请 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 点击「待申请」
2. 断言有数据时行不属于已生效/已作废;计数为 0 时为空态","待申请 Tab 与列表状态一致。",""
"ADMIT-PAP-009","基础设置 · 准入管理","产品准入申请 · 状态与列表","翻至第 2 页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);分页总数大于每页条数","1. 记录第 1 页首行准入单号
2. 点击页码「2」或「下一页」
3. 断言页码高亮为 2,首行单号与第 1 页不同","翻页成功且数据更换。不足两页则 skip。",""
"ADMIT-PAP-010","基础设置 · 准入管理","产品准入申请 · 状态与列表","跳至指定页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);总页数不少于 2,分页存在「跳至」","1. 在跳至输入框填入 `2` 并回车
2. 断言当前页为 2","跳转到第 2 页。无跳至或仅 1 页则 skip。",""
"ADMIT-PAP-011","基础设置 · 准入管理","产品准入申请 · 新增与导入","打开新增产品后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 点击「新增产品」
2. 断言可见产品名称、器械注册人、规格型号、注册证号及返回
3. 点击返回,不保存","新增页字段可见;返回后列表不新增记录。",""
"ADMIT-PAP-012","基础设置 · 准入管理","产品准入申请 · 新增与导入","新增取消不落库","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 记录分页总数 `${sampleTotal}`
2. 打开新增,产品名称填 `${uniqProductName}`(建议 `AUTO-PAP-${timestamp}`)后返回不保存
3. 按该名称查询,断言无此行,总数不变","取消后不产生新产品。",""
"ADMIT-PAP-013","基础设置 · 准入管理","产品准入申请 · 新增与导入","Excel 导入打开后取消","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 记录总数 `${sampleTotal}`
2. 点击「Excel导入」
3. 断言导入层打开
4. 关闭或取消,不选择文件
5. 断言总数不变","取消导入不增加产品。",""
"ADMIT-PAP-014","基础设置 · 准入管理","产品准入申请 · 新增与导入","产品名称为空保存拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);已打开新增产品页","1. 产品名称保持 mock 空值,其它能填的非名称字段可留空
2. 点击保存
3. 断言必填提示,页面不关闭
4. 返回列表,断言总数不变","保存被拦截;不落库。",""
"ADMIT-PAP-015","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据","1. 记录首行 `${sampleProductName}`
2. 点击「详情」,断言名称一致
3. 返回列表","详情与该行一致;返回后仍在产品准入申请列表。",""
"ADMIT-PAP-016","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","待申请编辑回填","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);待申请至少 1 行含「编辑」","1. 读取该行 `${sampleProductName}`、`${sampleSpec}`
2. 点击「编辑」
3. 断言名称与规格型号回填一致
4. 返回不保存","编辑回填与列表行一致。无编辑按钮则 skip。",""
"ADMIT-PAP-017","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","编辑取消不保存","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);存在可编辑行","1. 打开编辑,把产品名称改为 `${uniqProductName}` 后返回不保存
2. 再次打开,断言名称仍为原值","取消后数据不变。",""
"ADMIT-PAP-018","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","作废确认后取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);待申请至少 1 条可勾选","1. 勾选 1 行并点击「作 废」
2. 断言确认框出现
3. 点击「取 消」
4. 断言该行仍在,状态未变为已作废","取消作废不改变状态。本条不点击确定。",""
"ADMIT-PAP-019","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","已通过行不提供编辑","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);已通过 Tab 计数大于 0","1. 切换「已通过」
2. 断言首行无「编辑」,或打开后保存被拒绝","已通过行不能改写产品名称。计数为 0 则 skip。",""
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"ADMIT-SUP-001","基础设置 · 准入管理","供货目录申请 · 页面进入","菜单进入供货目录申请页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 供货目录申请」进入
2. 断言 URL 含 `/spd/basic/dataSync/dataSync/supplyApply/list`
3. 断言可见「查 询」、「批量提交」、「新 增」及列表表头","进入供货目录申请页;不误入准入任务分配。",""
"ADMIT-SUP-002","基础设置 · 准入管理","供货目录申请 · 筛选与状态","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 点击「查 询」并等待列表稳定
2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",""
"ADMIT-SUP-003","基础设置 · 准入管理","供货目录申请 · 筛选与状态","按进院供应商筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);默认查询至少 1 条且进院供应商非空","1. 从首行读取 `${sampleHospitalSupplier}`
2. 在进院供应商选择该值,制单日期保持默认,点击「查 询」
3. 断言结果行「进院供应商」与所选值一致","进院供应商列与条件一致。",""
"ADMIT-SUP-004","基础设置 · 准入管理","供货目录申请 · 筛选与状态","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);已选择进院供应商","1. 点击「重 置」
2. 断言进院供应商等筛选恢复默认","筛选恢复默认;列表按默认规则刷新。",""
"ADMIT-SUP-005","基础设置 · 准入管理","供货目录申请 · 筛选与状态","变更单号无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 变更单号填入 `__NOT_EXIST_ORDER__99999`;若筛选区无此框,则在产品名称填同一 mock
2. 点击「查 询」
3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。",""
"ADMIT-SUP-006","基础设置 · 准入管理","供货目录申请 · 筛选与状态","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);默认查询至少 1 条数据","1. 断言表头含申请单号、申请人、申请时间、进院供应商、二级供应商、业务模式、是否默认、备注、操作","核心列可见;操作含「编辑」或「详情」(随状态变化)。",""
"ADMIT-SUP-007","基础设置 · 准入管理","供货目录申请 · 筛选与状态","切换待提交 Tab 且计数一致","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 点击「待提交」
2. 读取括号数量
3. 断言分页「共 N 条」等于该数量;有数据时状态为待提交","待提交 Tab 与分页总数一致。计数为 0 时为空态。",""
"ADMIT-SUP-008","基础设置 · 准入管理","供货目录申请 · 新增与编辑","打开新增供货目录","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 点击「新 增」
2. 断言可见进院供应商、二级供应商、业务模式、是否默认、计划生效时间、添加产品、提交
3. 返回不提交","新增页字段齐全;返回后列表不新增申请单。",""
"ADMIT-SUP-009","基础设置 · 准入管理","供货目录申请 · 新增与编辑","新增放弃不落库","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 记录总数 `${sampleTotal}`
2. 打开新增,备注填 `${uniqRemark}`(建议 `AUTO-SUP-${timestamp}`)后返回不提交
3. 断言总数不变,列表无该备注","未提交不产生申请单。",""
"ADMIT-SUP-010","基础设置 · 准入管理","供货目录申请 · 新增与编辑","业务模式与是否默认只切换不提交","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);已打开新增页","1. 展开业务模式、是否默认,断言存在可选项
2. 各选一项后返回,不提交
3. 断言列表总数不变","选项可见;不提交则无新单。",""
"ADMIT-SUP-011","基础设置 · 准入管理","供货目录申请 · 新增与编辑","待提交编辑回填","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);待提交至少 1 行含「编辑」","1. 读取该行 `${sampleHospitalSupplier}`、`${sampleBizMode}`、`${sampleDefaultFlag}`
2. 点击「编辑」
3. 断言进院供应商、业务模式、是否默认与该行一致
4. 返回不提交","编辑回填与列表行一致。无待提交或无编辑则 skip。",""
"ADMIT-SUP-012","基础设置 · 准入管理","供货目录申请 · 新增与编辑","编辑取消不保存","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);存在可编辑的待提交行","1. 打开编辑,把备注改为 `${uniqRemark}` 后返回不提交
2. 再次打开,断言备注仍为原值","取消后备注不变。",""
"ADMIT-SUP-013","基础设置 · 准入管理","供货目录申请 · 新增与编辑","进院供应商为空不可提交(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);已打开新增页,进院供应商未选","1. 不选进院供应商,点击「提 交」
2. 断言必填提示,页面不关闭
3. 返回列表,断言总数不变","提交被拦截;不产生申请单。",""
"ADMIT-SUP-014","基础设置 · 准入管理","供货目录申请 · 新增与编辑","未添加产品不可提交","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);已打开新增页,产品明细为 0","1. 若进院供应商、二级供应商、业务模式可从样本选择则先选上,但不添加产品
2. 点击「提 交」
3. 断言拦截提示,明细仍为 0,列表总数不变","没有产品明细时不能提交。",""
"ADMIT-SUP-015","基础设置 · 准入管理","供货目录申请 · 批量提交与详情","未勾选时批量提交不可用","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe),无勾选行","1. 断言「批量提交」计数为 0,或按钮不可用
2. 若仍可点击,断言出现请选择提示,且无单据离开待提交","未勾选不提交任何申请单。",""
"ADMIT-SUP-016","基础设置 · 准入管理","供货目录申请 · 批量提交与详情","勾选后批量提交取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);待提交至少 1 条","1. 勾选 1 行,记录申请单号与状态
2. 点击「批量提交」
3. 若出现确认框,点「取 消」
4. 断言该单仍为待提交","取消批量提交不改变状态。本条不点击确定。无待提交则 skip。",""
"ADMIT-SUP-017","基础设置 · 准入管理","供货目录申请 · 批量提交与详情","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);默认查询至少 1 条数据","1. 点击首行「详情」
2. 断言可见「审批进程」「单据日志」「返 回」
3. 返回后 URL 仍含 `supplyApply/list`","详情可打开;返回后仍在供货目录申请列表。",""
"ADMIT-SUP-018","基础设置 · 准入管理","供货目录申请 · 筛选与状态","多条件组合查询(制单日期等6项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载
2. 「制单日期」日期区间选择当天
3. 「销售商」下拉选择任一可选项
4. 「供应商」下拉选择任一可选项
5. 「变更单号」填写关键字
6. 「物料名称」填写关键字
7. 「规格型号」填写关键字
8. 点击「查询」
9. 检查列表返回结果","多条件同时生效:制单日期、销售商、供应商、变更单号、物料名称、规格型号 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行"
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"ADMIT-TSK-001","基础设置 · 准入管理","准入任务分配 · 页面进入","菜单进入准入任务分配页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 准入任务分配」进入
2. 断言 URL 含 `/spd/basic/dataSync/dataSync/accessKind/list`
3. 断言可见「查 询」、「待分类」「已分类」、「批量分配」及列表表头","进入准入任务分配页;不误入供货目录申请。",""
"ADMIT-TSK-002","基础设置 · 准入管理","准入任务分配 · 筛选与分类","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe),当前为待分类","1. 点击「查 询」并等待列表稳定
2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",""
"ADMIT-TSK-003","基础设置 · 准入管理","准入任务分配 · 筛选与分类","产品名称筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类至少 1 条数据","1. 从首行读取 `${sampleProductName}`
2. 填入产品名称,新增日期与操作日期保持默认,点击「查 询」
3. 断言结果行「产品名称」均包含该关键字","产品名称与已填关键字匹配。",""
"ADMIT-TSK-004","基础设置 · 准入管理","准入任务分配 · 筛选与分类","待分类与已分类切换","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe)","1. 记录「待分类」括号数量 `${pendingCount}`
2. 点击「已分类」,断言分页总数等于已分类括号数量;为 0 时为空态
3. 再点「待分类」,断言分页总数等于 `${pendingCount}`","两个 Tab 的列表范围与各自计数一致,不串数据。",""
"ADMIT-TSK-005","基础设置 · 准入管理","准入任务分配 · 筛选与分类","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);已填入 `${sampleProductName}`","1. 点击「重 置」
2. 断言产品名称等筛选恢复默认","筛选恢复默认;列表按默认规则刷新。",""
"ADMIT-TSK-006","基础设置 · 准入管理","准入任务分配 · 筛选与分类","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);当前在待分类","1. 产品名称填入 `__NOT_EXIST_GOODS__99999`
2. 点击「查 询」
3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。",""
"ADMIT-TSK-007","基础设置 · 准入管理","准入任务分配 · 列表分页","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类至少 1 条数据","1. 断言表头含准入类型、产品编号、产品名称、规格型号、规格、型号、器械注册人、操作
2. 断言待分类行操作含「分配类型」","核心列可见;待分类行可发起分配类型。",""
"ADMIT-TSK-008","基础设置 · 准入管理","准入任务分配 · 列表分页","翻至第 2 页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类总数大于每页条数","1. 记录第 1 页首行产品名称或产品编号
2. 点击页码「2」或「下一页」
3. 断言页码为 2,首行与第 1 页不同","翻页成功且数据更换。不足两页则 skip。",""
"ADMIT-TSK-009","基础设置 · 准入管理","准入任务分配 · 列表分页","跳至指定页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);总页数不少于 2,且存在「跳至」","1. 跳至输入 `2` 并回车
2. 断言当前页为 2","跳转到第 2 页。无跳至或仅 1 页则 skip。",""
"ADMIT-TSK-010","基础设置 · 准入管理","准入任务分配 · 列表分页","第 1 页上一页不可用","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);当前在第 1 页","1. 断言「上一页」禁用,或点击后仍停留在第 1 页","第 1 页不能再向前翻。",""
"ADMIT-TSK-011","基础设置 · 准入管理","准入任务分配 · 分配操作","单行分配类型取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类至少 1 行含「分配类型」","1. 记录该行产品名称,以及待分类计数 `${pendingCount}`
2. 点击「分配类型」
3. 断言分配层打开
4. 取消或关闭,不确认
5. 断言该行仍在待分类,计数仍为 `${pendingCount}`","取消分配不改变分类。本条不点击确定。",""
"ADMIT-TSK-012","基础设置 · 准入管理","准入任务分配 · 分配操作","未勾选时批量分配不可用","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类页无勾选行","1. 点击「批量分配」
2. 断言按钮不可用,或提示先选择数据
3. 断言待分类计数不变","未勾选不分配任何行。",""
"ADMIT-TSK-013","基础设置 · 准入管理","准入任务分配 · 分配操作","勾选后批量分配取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类至少 1 条","1. 勾选 1 行,记录待分类计数
2. 点击「批量分配」
3. 若打开分配层或确认框,点取消或关闭
4. 断言该行仍在待分类,计数不变","取消批量分配不改变分类。本条不确认分配。",""
"ADMIT-TSK-014","基础设置 · 准入管理","准入任务分配 · 分配操作","已分类行不再按待分类分配","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);已分类计数大于 0","1. 切换到「已分类」
2. 断言首行无「分配类型」,或点击后被拒绝且该行仍留在已分类
3. 断言待分类计数不因此增加","已分类数据不会被这次操作退回待分类。已分类为 0 则 skip。",""
"ADMIT-TSK-015","基础设置 · 准入管理","准入任务分配 · 筛选与分类","多条件组合查询(新增日期等11项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载
2. 「新增日期」日期区间选择当天
3. 「操作日期」日期区间选择当天
4. 「销售商」填写关键字
5. 「物料名称」填写关键字
6. 「注册证号」填写关键字
7. 「操作人」填写关键字
8. 「器械注册人」填写关键字
9. 「耗材类型」下拉选择任一可选项
10. 「规格型号」填写关键字
11. 「准入类型」下拉选择任一可选项
12. 「准入状态」下拉选择任一可选项
13. 点击「查询」
14. 检查列表返回结果","多条件同时生效:新增日期、操作日期、销售商、物料名称、注册证号、操作人、器械注册人、耗材类型、规格型号、准入类型、准入状态 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行"
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"ADMIT-MLS-001","基础设置 · 准入管理","物料准入单列表 · 页面进入","菜单进入物料准入单列表","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 物料准入单列表」进入
2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productOrder/zrlist`,且不是 `productOrder/list`
3. 断言制单日期、查询、状态 Tab、列表表头可见","进入物料准入单列表;不误入产品准入单列表。",""
"ADMIT-MLS-002","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 点击「查 询」并等待列表稳定
2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",""
"ADMIT-MLS-003","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","按准入单号筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);默认查询至少 1 条数据","1. 从首行读取 `${sampleOrderNo}`
2. 若页面有准入单号条件则填入;否则用注册证号或产品名称中首行非空字段
3. 制单日期保持默认,点击「查 询」
4. 断言结果行包含该单号或该关键字","结果与已填单号或关键字匹配。列表筛选区没有单号框时,改用首行注册证号,并在用例备注中记录实际字段。",""
"ADMIT-MLS-004","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 填入上一条使用的关键字
2. 点击「重 置」
3. 断言筛选恢复默认","筛选恢复默认;列表按默认规则刷新。",""
"ADMIT-MLS-005","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 注册证号填入 `__NOT_EXIST_CERT__99999`
2. 点击「查 询」
3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。",""
"ADMIT-MLS-006","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);默认查询至少 1 条数据","1. 断言表头含准入类型、准入单号、进院供应商、二级供应商、产品名称、注册证号、耗材类型、产品条数、状态、操作","核心列可见;操作列为「详情」。",""
"ADMIT-MLS-007","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","切换已通过 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 点击「已通过」
2. 断言有数据时「状态」列为已通过;计数为 0 时为空态
3. 读取 Tab 括号数量,断言与分页「共 N 条」一致","已通过 Tab 的状态列、计数与分页总数一致。",""
"ADMIT-MLS-008","基础设置 · 准入管理","物料准入单列表 · 单据详情","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);默认查询至少 1 条数据","1. 记录首行 `${sampleOrderNo}`、`${sampleProductCount}`
2. 点击「详情」
3. 断言可见「审批进程」「单据日志」「返 回」
4. 点击「返 回」
5. 断言 URL 仍含 `productOrder/zrlist`","进入单据详情并可返回列表。",""
"ADMIT-MLS-009","基础设置 · 准入管理","物料准入单列表 · 单据详情","详情产品条数与列表一致","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已打开首行详情,且 `${sampleProductCount}` 为正整数","1. 统计详情明细数据行数(跨页则读详情分页总数)
2. 断言等于列表「产品条数」`${sampleProductCount}`","详情明细条数与列表产品条数一致。",""
"ADMIT-MLS-010","基础设置 · 准入管理","物料准入单列表 · 单据详情","审批进程可打开","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已打开单据详情","1. 点击「审批进程」
2. 断言进程层或区域可见
3. 关闭后仍停留在该单据","审批进程可查看;单据不被关掉。",""
"ADMIT-MLS-011","基础设置 · 准入管理","物料准入单列表 · 单据详情","详情导出不白屏","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已打开单据详情","1. 点击「导 出」
2. 断言出现下载,或出现导出任务/成功提示
3. 断言页面仍为该单据详情,不白屏","导出有反馈;详情页仍在。无导出按钮则 skip。",""
"ADMIT-MLS-012","基础设置 · 准入管理","物料准入单列表 · 单据详情","详情搜索无匹配(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已打开单据详情","1. 在「请输入产品编码、名称」填入 `__NOT_EXIST_GOODS__99999`
2. 触发搜索
3. 断言明细空态,单据头(审批进程、返回)仍在","无匹配时只清空明细,不销毁单据头。",""
"ADMIT-MLS-013","基础设置 · 准入管理","物料准入单列表 · 单据详情","已通过单据不确认通过","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已通过 Tab 至少 1 条,并打开其详情","1. 若「通过」不可见或禁用,断言该状态保持已通过后返回
2. 若「通过」可点,点击后出现确认框则点「取 消」
3. 返回列表,断言该单状态仍为已通过","本条不把已通过单据再次审批成功。已通过 Tab 无数据则 skip。",""
"ADMIT-MLS-014","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","多条件组合查询(制单日期等6项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载
2. 「制单日期」日期区间选择当天
3. 「销售商」填写关键字
4. 「耗材类型」下拉选择任一可选项
5. 「器械注册人」填写关键字
6. 「注册证号」填写关键字
7. 「供应商」填写关键字
8. 点击「查询」
9. 检查列表返回结果","多条件同时生效:制单日期、销售商、耗材类型、器械注册人、注册证号、供应商 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行"
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"ADMIT-MAT-001","基础设置 · 准入管理","物料准入管理 · 页面进入","菜单进入物料准入管理页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 物料准入管理」进入
2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productApply/ZRlist`
3. 断言筛选区、「查 询」、状态 Tab、「关联产品」「生成申请单」「一键生成」「作 废」及列表表头可见
4. 断言当前高亮不是「产品准入申请」或「物料准入单列表」","进入物料准入管理页;不误入同组其它准入页;筛选、状态 Tab 与工具栏可见。",""
"ADMIT-MAT-002","基础设置 · 准入管理","物料准入管理 · 筛选查询","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 点击「查 询」并等待列表稳定
2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",""
"ADMIT-MAT-003","基础设置 · 准入管理","物料准入管理 · 筛选查询","产品名称筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);默认查询至少 1 条数据","1. 从首行读取 `${sampleProductName}`
2. 填入「产品名称」,新增日期保持页面默认,点击「查 询」
3. 断言结果行「产品名称」均包含该关键字","查询结果产品名称与已填关键字匹配;无未捕获异常。",""
"ADMIT-MAT-004","基础设置 · 准入管理","物料准入管理 · 筛选查询","耗材类型筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);默认查询至少 1 条且首行耗材类型非空","1. 从首行读取 `${sampleConsumableType}`
2. 在耗材类型下拉选择该值,点击「查 询」
3. 断言结果行「耗材类型」与所选值一致","耗材类型列与所选条件一致。首行类型为空则 skip。",""
"ADMIT-MAT-005","基础设置 · 准入管理","物料准入管理 · 筛选查询","名称与耗材类型组合查询","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);默认查询至少 1 条数据","1. 读取首行 `${sampleProductName}`、`${sampleConsumableType}`
2. 同时填入名称与耗材类型(新增日期保持默认,勿改成单日)
3. 点击「查 询」
4. 断言结果同时满足两个条件","组合查询无报错;结果同时满足产品名称与耗材类型。",""
"ADMIT-MAT-006","基础设置 · 准入管理","物料准入管理 · 筛选查询","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 填入 `${sampleProductName}`
2. 点击「重 置」
3. 断言产品名称等筛选恢复默认","筛选字段恢复默认;列表按默认规则刷新。",""
"ADMIT-MAT-007","基础设置 · 准入管理","物料准入管理 · 筛选查询","收起后再展开保留条件","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 填入 `${sampleProductName}`
2. 点击「收起」,再展开筛选区
3. 断言产品名称仍为已填值","收起不丢失已填条件;展开后值仍在。",""
"ADMIT-MAT-008","基础设置 · 准入管理","物料准入管理 · 筛选查询","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 产品名称填入 mock 值 `__NOT_EXIST_GOODS__99999`
2. 点击「查 询」
3. 断言空态或「暂无数据」,分页总数为 0","无匹配时不报错;空态明确;总数为 0。",""
"ADMIT-MAT-009","基础设置 · 准入管理","物料准入管理 · 状态与列表","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);默认查询至少 1 条数据","1. 点击「查 询」
2. 断言表头含准入类型、产品编号、产品名称、规格型号、器械注册人、单位、耗材类型、默认进院供应商、操作","核心列可见;操作列含「详情」「日志」,待申请行另可见「编辑」。",""
"ADMIT-MAT-010","基础设置 · 准入管理","物料准入管理 · 状态与列表","切换待申请 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 点击状态 Tab「待申请」
2. 断言列表刷新;若有数据,行状态属于待申请(不出现已生效、已作废)
3. 再点「全部」,断言列表不再只限定待申请","待申请 Tab 只展示待申请数据;「全部」不额外收窄。计数为 0 时为空态且不报错。",""
"ADMIT-MAT-011","基础设置 · 准入管理","物料准入管理 · 状态与列表","待申请 Tab 计数与分页总数一致","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 读取「待申请」括号内数量 `${tabCount}`
2. 点击该 Tab 并查询稳定
3. 断言分页「共 N 条」的 N 等于 `${tabCount}`","Tab 计数与该状态下分页总数一致。",""
"ADMIT-MAT-012","基础设置 · 准入管理","物料准入管理 · 状态与列表","未勾选时生成申请单不可用或提示","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe),且当前无勾选行","1. 确认表头复选框与行复选框均未选中
2. 点击「生成申请单」
3. 断言按钮不可用,或出现「请选择」类提示,且列表不新增申请单","未勾选不产生申请单。",""
"ADMIT-MAT-013","基础设置 · 准入管理","物料准入管理 · 详情与编辑","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);默认查询至少 1 条数据","1. 记录首行 `${sampleProductName}`
2. 点击该行「详情」
3. 断言详情中产品名称、规格型号、注册证号可见,且名称包含 `${sampleProductName}`
4. 点击「返回」
5. 断言回到列表,URL 仍含 `productApply/ZRlist`","详情展示该行产品主数据;返回后仍在物料准入管理列表。",""
"ADMIT-MAT-014","基础设置 · 准入管理","物料准入管理 · 详情与编辑","详情关键字段不可直接改写","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);已打开首行详情","1. 断言产品名称等主数据无可用的保存按钮,或输入框为只读
2. 返回列表,断言首行名称仍为 `${sampleProductName}`","详情态不能把产品名称改写入库。",""
"ADMIT-MAT-015","基础设置 · 准入管理","物料准入管理 · 详情与编辑","待申请行编辑回填","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);待申请 Tab 至少 1 行含「编辑」","1. 在待申请列表读取首行 `${sampleProductName}`、`${sampleSpec}`
2. 点击「编辑」
3. 断言产品名称、规格型号与该行一致
4. 返回且不保存","编辑页回填与列表行一致。无「编辑」则 skip。",""
"ADMIT-MAT-016","基础设置 · 准入管理","物料准入管理 · 详情与编辑","编辑取消不保存","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);存在可编辑行","1. 打开编辑,记录原产品名称
2. 将产品名称改为 `${uniqProductName}`(建议 `AUTO-MAT-${timestamp}`)后返回或取消,不保存
3. 再次打开编辑,断言名称仍为原值","取消后列表与回看均为修改前名称。",""
"ADMIT-MAT-017","基础设置 · 准入管理","物料准入管理 · 详情与编辑","编辑清空产品名称拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);存在可编辑行","1. 打开编辑,将产品名称清空为 mock 空值
2. 点击保存
3. 断言必填提示,页面不关闭
4. 取消后再次打开,断言名称仍为原值","保存被拦截;原记录不被写成空。",""
"ADMIT-MAT-018","基础设置 · 准入管理","物料准入管理 · 详情与编辑","已生效行不提供编辑","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);已生效 Tab 计数大于 0","1. 切换到「已生效」
2. 断言首行操作无「编辑」,或点击后保存被拒绝","已生效行不能改写成新的产品名称。计数为 0 则 skip。",""
"ADMIT-MAT-019","基础设置 · 准入管理","物料准入管理 · 关联与作废","关联产品取消不落库","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 记录当前分页总数 `${sampleTotal}` 与首行默认进院供应商
2. 点击「关联产品」
3. 断言关联层打开
4. 取消或关闭,不确认
5. 断言总数不变,首行供应商不变","取消关联不产生申请单,也不改默认进院供应商。",""
"ADMIT-MAT-020","基础设置 · 准入管理","物料准入管理 · 关联与作废","作废确认后取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);待申请至少 1 条可勾选","1. 勾选 1 行,记录其产品名称与当前状态
2. 点击「作 废」
3. 断言出现确认框,含取消与确定
4. 点击「取 消」
5. 断言该行仍在,状态未变为已作废","作废需确认;取消后记录仍在。本条不点击确定,避免作废生产数据。",""
"ADMIT-MAT-021","基础设置 · 准入管理","物料准入管理 · 关联与作废","打开操作日志后关闭","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);默认查询至少 1 条数据","1. 点击首行「日志」
2. 断言出现操作日志层
3. 关闭后断言仍在列表页","日志层可打开可关闭;列表数据不变。",""
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"BASIC-NTC-001","基础设置 · 医院资料","公告管理 · 页面进入","菜单进入公告管理页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备医院资料权限;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 按菜单「基础设置 → 医院资料 → 公告管理」进入
2. 检查当前 URL 是否含 /spd/basic/hospital/paltform/notice
3. 检查筛选区、新增按钮、列表表头是否可见","进入公告管理页;不误入医院信息登记/院区管理/财务月份设置;筛选区与列表可见。",""
"BASIC-NTC-002","基础设置 · 医院资料","公告管理 · 筛选查询","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 点击「查 询」并等待列表稳定
2. 检查是否出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",""
"BASIC-NTC-003","基础设置 · 医院资料","公告管理 · 筛选查询","公告标题模糊查询","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);默认查询至少 1 条数据","1. 从首行读取 ${sampleNoticeTitle}(禁止写死业务标题)
2. 填入筛选「公告标题」,点击「查 询」
3. 检查结果行「公告标题」均包含该关键字(或与产品模糊规则一致)","查询结果标题与已填关键字匹配;无未捕获异常。",""
"BASIC-NTC-004","基础设置 · 医院资料","公告管理 · 筛选查询","发布范围下拉查询","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 打开发布范围下拉,选择与首行 ${samplePublishScope} 相同的选项(勿写死)
2. 点击「查 询」
3. 检查结果行「发布范围」列与所选值一致(选「全部」时不额外收窄)","下拉选项含全部/全院/中心库/进院供应商;查询结果范围列与条件一致。",""
"BASIC-NTC-005","基础设置 · 医院资料","公告管理 · 筛选查询","标题与范围组合查询","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);默认查询至少 1 条数据","1. 读取首行 ${sampleNoticeTitle}、${samplePublishScope}
2. 填入标题与范围(发布日期保持页面默认,勿 fallback 单日)
3. 点击「查 询」
4. 检查结果同时满足两个已填条件","组合查询无报错;结果同时满足标题与发布范围。",""
"BASIC-NTC-006","基础设置 · 医院资料","公告管理 · 筛选查询","快捷区重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 填入 ${sampleNoticeTitle} 等筛选条件
2. 点击「重 置」
3. 检查标题/范围/日期是否恢复默认","筛选字段恢复默认;列表按默认规则刷新。",""
"BASIC-NTC-007","基础设置 · 医院资料","公告管理 · 筛选查询","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 公告标题填入 mock 值 __NOT_EXIST_NOTICE__99999(唯一写死的反例 mock)
2. 点击「查 询」
3. 检查空态或暂无数据,分页总数为 0","无匹配时不报错;空态明确;总数为 0。",""
"BASIC-NTC-008","基础设置 · 医院资料","公告管理 · 列表分页","查询后核心列展示","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 点击「查 询」
2. 检查表头是否含公告编号、公告标题、发布范围、发布科室、发布人、操作","核心列完整;操作列可见「编辑」「删除」。",""
"BASIC-NTC-009","基础设置 · 医院资料","公告管理 · 列表分页","分页翻至第 2 页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);默认查询总条数大于一页","1. 记录第 1 页首行公告编号
2. 点击页码「2」或「下一页」
3. 检查页码高亮为 2,首行编号与第 1 页不同","翻页成功;数据更换;筛选条件保持。总条数不足一页则 skip。",""
"BASIC-NTC-010","基础设置 · 医院资料","公告管理 · 列表分页","跳至指定页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);总页数≥2","1. 在分页「跳至」输入 2 并回车
2. 检查当前页为 2","跳转至指定页;列表刷新为该页数据。无 jumper 或仅 1 页则 skip。",""
"BASIC-NTC-011","基础设置 · 医院资料","公告管理 · 新增公告","打开新增公告弹窗","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 点击「新 增」
2. 检查弹窗标题是否为「新增公告」
3. 检查是否可见标题、发布范围、置顶、截止日期、富文本、取消、发布","新增弹窗可见;必填/选填控件齐全;富文本容器 .ql-editor 可见。",""
"BASIC-NTC-012","基础设置 · 医院资料","公告管理 · 新增公告","新增取消不落库","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 打开新增,标题填 ${uniqNoticeTitle}(建议 AUTO-NTC-${timestamp}),内容填简短正文
2. 点击「取 消」
3. 按标题查询,检查无该记录","弹窗关闭;列表不出现 ${uniqNoticeTitle}。",""
"BASIC-NTC-013","基础设置 · 医院资料","公告管理 · 新增公告","合法必填发布成功","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);允许自动化写公告","1. 打开新增,标题 ${uniqNoticeTitle},发布范围选「中心库」,内容写入 .ql-editor
2. 点击「发 布」
3. 按标题查询,检查出现该行且发布范围为中心库
4. 后置:删除该测试公告","弹窗关闭;列表出现新记录;字段与提交一致。环境禁止写则 skip。",""
"BASIC-NTC-014","基础设置 · 医院资料","公告管理 · 新增公告","标题为空发布拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 打开新增,标题填 mock 空值 """",内容填 mock 正文 __MOCK_NOTICE_BODY__
2. 点击「发 布」
3. 检查必填提示出现,弹窗仍打开","发布被拦截;不落库;弹窗不关闭。",""
"BASIC-NTC-015","基础设置 · 医院资料","公告管理 · 新增公告","内容为空发布拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 打开新增,标题填 mock 值 __MOCK_EMPTY_CONTENT_TITLE__,富文本留空 """"
2. 点击「发 布」
3. 检查内容必填提示,弹窗仍打开","发布被拦截;不落库。",""
"BASIC-NTC-016","基础设置 · 医院资料","公告管理 · 编辑与删除","打开编辑公告并回填","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);列表至少 1 条数据","1. 点击首行「编辑」
2. 检查弹窗标题是否为「编辑公告」
3. 检查标题输入框值与该行 ${sampleNoticeTitle} 一致(允许规范化空白)","编辑弹窗可见;标题/范围/内容回填正确。",""
"BASIC-NTC-017","基础设置 · 医院资料","公告管理 · 编辑与删除","编辑取消不保存","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);列表至少 1 条数据","1. 打开首行编辑,记录原标题
2. 将标题改为 ${uniqNoticeTitle} 后点「取 消」
3. 再次打开编辑,检查标题仍为原值","取消后列表与回看均为修改前标题。",""
"BASIC-NTC-018","基础设置 · 医院资料","公告管理 · 编辑与删除","删除弹出确认框","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);列表至少 1 条数据","1. 点击首行「删除」
2. 检查出现确认框(标题含「提示」),可见「取 消」「确 定」
3. 点击「取 消」
4. 检查该行仍在","删除需二次确认;取消后记录仍在。本条不断言真实删除,避免污染数据。",""
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"BASIC-HOS-001","基础设置 · 医院资料","医院信息登记 · 页面进入与查看","菜单进入医院信息登记页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备医院资料权限;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe)","1. 按菜单进入「医院信息登记」
2. 检查当前 URL 是否含 /spd/basic/hospital/paltform/info
3. 检查可见「编 辑」及医院编码/名称/地址等详情标签","进入详情页;不误入公告/院区/财务月份页;查看态「编辑」可见。",""
"BASIC-HOS-002","基础设置 · 医院资料","医院信息登记 · 页面进入与查看","查看态只读字段不可改","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);未点编辑","1. 检查医院编码、医院名称、地址区域无可用输入框
2. 检查「保 存」「取 消」不可见","查看模式下编码/名称/地址只读;不出现保存按钮。",""
"BASIC-HOS-003","基础设置 · 医院资料","医院信息登记 · 页面进入与查看","已上传图片可预览","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);当前医院已有登录页背景或 logo","1. 点击「预览文件」(背景或 logo 任一可见项)
2. 检查预览打开或新预览层可见","预览可打开;不报错。均无预览入口则 skip。",""
"BASIC-HOS-004","基础设置 · 医院资料","医院信息登记 · 编辑保存","进入编辑模式","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);当前为查看态","1. 点击「编 辑」
2. 检查「保 存」「取 消」可见
3. 检查 #form_item_linkman、#form_item_contactWay、#form_item_anotherName 可编辑","进入编辑态;三个可改文本框可输入;编码/名称/地址仍不可改。",""
"BASIC-HOS-005","基础设置 · 医院资料","医院信息登记 · 编辑保存","编辑态只读字段仍不可改","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);已进入编辑模式","1. 点击「编 辑」
2. 检查医院编码、医院名称、地址仍无可用输入框","编辑模式下系统分配字段保持只读。",""
"BASIC-HOS-006","基础设置 · 医院资料","医院信息登记 · 编辑保存","取消不保存","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);当前为查看态","1. 点击「编 辑」,读取原联系人 ${sampleLinkman}
2. 将联系人改为 ${uniqLinkman} 后点「取 消」
3. 检查回到查看态,联系人展示仍为 ${sampleLinkman}","取消后不落库;查看态为修改前值。",""
"BASIC-HOS-007","基础设置 · 医院资料","医院信息登记 · 编辑保存","保存后字段持久化","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);允许写医院资料","1. 记录原联系人 ${sampleLinkman}
2. 编辑为 ${uniqLinkman} 并「保 存」
3. 刷新或重新打开本页,检查联系人为 ${uniqLinkman}
4. 后置:再编辑回 ${sampleLinkman} 并保存","保存后刷新仍为新值;用例结束恢复原值。环境禁止写则 skip。",""
"BASIC-HOS-008","基础设置 · 医院资料","医院信息登记 · 编辑保存","联系人为空保存拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);当前为查看态","1. 点击「编 辑」,将联系人清空为 mock 空值 """"
2. 点击「保 存」
3. 检查必填提示,仍停留编辑态","保存被拦截;原联系人不被写成空。",""
"BASIC-HOS-009","基础设置 · 医院资料","医院信息登记 · 编辑保存","联系方式为空保存拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);当前为查看态","1. 进入编辑态,将联系方式清空为 mock 空值 """"
2. 点击「保 存」
3. 检查必填提示,编辑态保持","保存被拦截;联系方式不被清空落库。",""
"BASIC-HOS-010","基础设置 · 医院资料","医院信息登记 · 编辑保存","医院简称为空保存拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);当前为查看态","1. 进入编辑态,将医院简称清空为 mock 空值 """"
2. 点击「保 存」
3. 检查必填提示","保存被拦截;简称不被写成空。",""
"BASIC-HOS-011","基础设置 · 医院资料","医院信息登记 · 图片上传","编辑态存在背景与 logo 上传控件","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);当前为查看态","1. 点击「编 辑」
2. 检查存在登录页背景、医院 logo 对应 input[type=file](loginBgUploadInput / logoUploadInput)
3. 点「取 消」,不实际上传","编辑态两个上传控件可定位;本条不强制替换线上图片。",""
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"BASIC-FMN-001","基础设置 · 医院资料","财务月份设置 · 页面进入","菜单进入财务月份设置页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备医院资料权限;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe)","1. 按菜单「基础设置 → 医院资料 → 财务月份设置」进入
2. 检查当前 URL 是否含 /spd/basic/hospital/balance/month
3. 检查筛选区(#form_item_settleMonthDate)、查询/重置、列表列头是否可见","进入财务月份设置页;不误入公告/医院信息/院区页;筛选与列表可见。",""
"BASIC-FMN-002","基础设置 · 医院资料","财务月份设置 · 筛选查询","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe)","1. 点击「查 询」并等待列表稳定
2. 检查是否出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",""
"BASIC-FMN-003","基础设置 · 医院资料","财务月份设置 · 筛选查询","按财务月份筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);默认查询至少 1 条数据","1. 从首行或当前月份行读取 ${sampleFinanceMonth}(禁止写死月份)
2. 在 #form_item_settleMonthDate 选择该月份,点击「查 询」
3. 检查结果行「财务月份」均等于该月份(或与产品筛选规则一致)","结果仅含所选财务月份;无未捕获异常。",""
"BASIC-FMN-004","基础设置 · 医院资料","财务月份设置 · 筛选查询","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe)","1. 选择 ${sampleFinanceMonth} 并查询
2. 点击「重 置」
3. 检查月份筛选恢复默认,列表按默认规则刷新","重置后筛选清空或回到默认;列表可再查询。",""
"BASIC-FMN-005","基础设置 · 医院资料","财务月份设置 · 列表与状态","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);默认查询至少 1 条数据","1. 检查表头是否包含财务月份、开始日期、结束日期、自动结算日期、状态、操作","核心列均可见,不缺「状态」「操作」。",""
"BASIC-FMN-006","基础设置 · 医院资料","财务月份设置 · 列表与状态","仅当前月份行可修改","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);列表同时存在当前月份与历史月份(若仅一行当前月份则只断言该行)","1. 定位状态为「当前月份」的行,检查操作列可见「修 改」(modifyBtn)
2. 对「历史月份」行检查无「修 改」或操作为「--」","可修改入口只出现在当前月份行。无历史月份则只校验当前月份入口。",""
"BASIC-FMN-007","基础设置 · 医院资料","财务月份设置 · 列表与状态","当前月份至多一行","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);默认查询已出列表","1. 统计状态为「当前月份」的行数
2. 检查行数 ≤ 1","同一时点至多一行「当前月份」。",""
"BASIC-FMN-008","基础设置 · 医院资料","财务月份设置 · 列表与状态","历史月份无法进入编辑","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);列表至少 1 条历史月份","1. 对历史月份行尝试点击操作区
2. 检查不出现行内「保 存」「取 消」,自动结算日期仍为文本","历史月份不能改开始/结束/自动结算日期。无历史月份则 skip。",""
"BASIC-FMN-009","基础设置 · 医院资料","财务月份设置 · 当前月份修改","修改进入行内编辑","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);存在状态为「当前月份」的行","1. 点击该行「修 改」
2. 检查出现「保 存」「取 消」
3. 检查自动结算日期变为日期框(autoSettleDateInput)","进入行内编辑;仅自动结算日可改。",""
"BASIC-FMN-010","基础设置 · 医院资料","财务月份设置 · 当前月份修改","编辑态开始结束日期只读","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);已进入当前月份行内编辑","1. 点击「修 改」
2. 检查开始日期、结束日期无可用输入框
3. 检查仅 autoSettleDateInput 可编辑
4. 点击「取 消」退出","开始/结束日期保持只读;账期区间不被改写。",""
"BASIC-FMN-011","基础设置 · 医院资料","财务月份设置 · 当前月份修改","取消不保存自动结算日","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);存在当前月份行","1. 读取 ${sampleAutoSettleDate}
2. 点「修 改」,将自动结算日改为当月区间内另一天 ${uniqAutoSettleDate}
3. 点击「取 消」
4. 检查该行自动结算日仍为 ${sampleAutoSettleDate}","取消后不落库;日期恢复修改前值。",""
"BASIC-FMN-012","基础设置 · 医院资料","财务月份设置 · 当前月份修改","保存自动结算日期持久化","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);存在当前月份行;允许写财务月份","1. 记录 ${sampleAutoSettleDate}、${sampleStartDate}、${sampleEndDate}
2. 将自动结算日改为区间内另一天 ${uniqAutoSettleDate} 并「保 存」
3. 刷新或重新查询,检查该行自动结算日为 ${uniqAutoSettleDate}
4. 后置:再改为 ${sampleAutoSettleDate} 并保存","保存后刷新仍为新日期;用例结束恢复原值。环境禁止写则 skip。",""
"BASIC-FMN-013","基础设置 · 医院资料","财务月份设置 · 当前月份修改","自动结算日为空拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);存在当前月份行","1. 点「修 改」,将 autoSettleDateInput 清空为 mock 空值 """"
2. 点击「保 存」
3. 检查必填或非法提示,行上日期仍为 ${sampleAutoSettleDate}","保存被拦截;原日期不被写成空。",""
"BASIC-FMN-014","基础设置 · 医院资料","财务月份设置 · 当前月份修改","自动结算日超出当月区间拦截(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);存在当前月份行且能读到开始/结束日","1. 点「修 改」,将日期改为 mock 越界值:早于 ${sampleStartDate} 或晚于 ${sampleEndDate}(如 __MOCK_OUT_OF_RANGE_DATE__)
2. 点击「保 存」
3. 检查拒绝保存并提示,原日期不变
4. 点「取 消」退出","超出账期区间时拒绝保存;不脏写。日期控件无法选出区间外日期则 skip 并记录产品限制。",""
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"BASIC-BRN-001","基础设置 · 医院资料","院区管理 · 页面进入","菜单进入院区管理页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备医院资料权限;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe)","1. 按菜单「基础设置 → 医院资料 → 院区管理」进入
2. 检查当前 URL 是否含 /spd/basic/hospital/paltform/area
3. 检查「新 增」、院区名称搜索框(campusNameInput)、列表列头可见
4. 检查页面无独立「查 询」「重 置」按钮","进入院区管理页;不误入公告/医院信息/财务月份页;搜索框与新增可见,无查询/重置。",""
"BASIC-BRN-002","基础设置 · 医院资料","院区管理 · 列表搜索","默认列出院区或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe)","1. 等待列表稳定
2. 检查是否出现数据行,或「暂无数据」且分页总数为 0","加载无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",""
"BASIC-BRN-003","基础设置 · 医院资料","院区管理 · 列表搜索","院区名称搜索过滤","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);默认列表至少 1 条数据","1. 从首行读取 ${sampleCampusName}(禁止写死院区名称)
2. 在 campusNameInput 填入该关键字(即时过滤或回车,以现网为准)
3. 检查结果行「院区名称」均包含该关键字","过滤结果名称与已填关键字匹配;无未捕获异常。",""
"BASIC-BRN-004","基础设置 · 医院资料","院区管理 · 列表搜索","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);默认列表至少 1 条数据","1. 检查表头是否包含序号、院区编号、院区名称、院区地址、状态、启用云仓、操作","核心列均可见,操作列含「编 辑」。",""
"BASIC-BRN-005","基础设置 · 医院资料","院区管理 · 列表搜索","清空搜索恢复全量","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);已用 ${sampleCampusName} 过滤","1. 记录过滤后条数
2. 清空 campusNameInput 并等待列表刷新
3. 检查条数回到未过滤的默认集合(或不少于过滤后条数)","清空后列表恢复默认集合。",""
"BASIC-BRN-006","基础设置 · 医院资料","院区管理 · 列表搜索","搜索无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe)","1. 在院区名称框输入 mock 值 __NOT_EXIST_CAMPUS__99999(唯一写死的反例 mock)
2. 检查「暂无数据」且分页总数为 0","无匹配时空态明确,不白屏。",""
"BASIC-BRN-007","基础设置 · 医院资料","院区管理 · 新增院区","打开新增院区弹窗","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe)","1. 点击「新 增」
2. 检查弹窗标题是否为「新增院区」
3. 检查可见院区名称、所在地区、详细地址、主院区标志、状态、取消/确定
4. 检查无 #form_item_id、无启用云仓
5. 点击「取 消」关闭","新增弹窗字段完整;不含院区编号与启用云仓。",""
"BASIC-BRN-008","基础设置 · 医院资料","院区管理 · 新增院区","新增取消不落库","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe)","1. 记录当前分页总数 ${sampleTotal}
2. 打开新增,填写名称 ${uniqCampusName}(建议 AUTO-BRN-${timestamp})后点「取 消」
3. 检查列表总数不变,且无该名称行","取消后不新增院区。",""
"BASIC-BRN-009","基础设置 · 医院资料","院区管理 · 新增院区","合法必填新增成功","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);允许写院区;名称使用 ${uniqCampusName}","1. 打开新增,填写名称 ${uniqCampusName}、所在地区、详细地址,主院区选「否」,状态选「启用」
2. 点击「确 定」
3. 检查弹窗关闭,列表出现该名称行且字段一致
4. 后置:按环境约定停用或清理该院区,避免污染主数据","新增成功且列表可见;用例结束清理或停用,不留生产脏数据。环境禁止写则 skip。",""
"BASIC-BRN-010","基础设置 · 医院资料","院区管理 · 新增院区","院区名称为空拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);已打开新增弹窗","1. 名称填 mock 空值 """",补齐地区与地址后点「确 定」
2. 检查必填提示,弹窗仍在,列表无新行","确定被拦截;不落库。",""
"BASIC-BRN-011","基础设置 · 医院资料","院区管理 · 新增院区","所在地区为空拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);已打开新增弹窗","1. 名称填 mock 值 __MOCK_CAMPUS_NO_REGION__,地址填 __MOCK_ADDR__,不选所在地区
2. 点击「确 定」
3. 检查必填提示,弹窗仍在","确定被拦截;不落库。",""
"BASIC-BRN-012","基础设置 · 医院资料","院区管理 · 新增院区","详细地址为空拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);已打开新增弹窗","1. 名称填 mock 值 __MOCK_CAMPUS_NO_ADDR__ 并选择地区,地址留空 """"
2. 点击「确 定」
3. 检查必填提示,弹窗仍在","确定被拦截;不落库。",""
"BASIC-BRN-013","基础设置 · 医院资料","院区管理 · 编辑院区","打开编辑弹窗并回填","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);列表至少 1 条数据","1. 从首行读取 ${sampleCampusId}、${sampleCampusName}、${sampleCampusAddress}
2. 点击「编 辑」
3. 检查弹窗标题是否为「编辑院区」
4. 检查 #form_item_id 等于 ${sampleCampusId},名称/地址回填正确
5. 检查额外可见「启用云仓」
6. 点击「取 消」","编辑弹窗回填正确;相对新增多出院区编号与启用云仓。",""
"BASIC-BRN-014","基础设置 · 医院资料","院区管理 · 编辑院区","编辑态院区编号不可换号","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);已打开首行编辑","1. 检查 #form_item_id 可见且值等于列表「院区编号」
2. 若编号只读则直接检查 disabled/readonly;若可输入则改写为 mock 值 __MOCK_CAMPUS_ID__ 后点确定,刷新后编号仍为原值
3. 未改其它字段时点「取 消」","院区编号与列表一致;保存后编号不变。",""
"BASIC-BRN-015","基础设置 · 医院资料","院区管理 · 编辑院区","编辑取消不保存","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);列表至少 1 条数据","1. 打开首行编辑,记录原名称
2. 将名称改为 ${uniqCampusName} 后点「取 消」
3. 再次打开编辑,检查名称仍为原值","取消后列表与回看均为修改前名称。",""
"BASIC-BRN-016","基础设置 · 医院资料","院区管理 · 编辑院区","编辑状态为禁用启用","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);已打开编辑弹窗","1. 检查状态选项文案为「禁用 / 启用」(不是新增弹窗的「停用 / 启用」)
2. 点「取 消」,不改状态","编辑态状态文案与新增不同;本条不真实切换生产院区状态。",""
"BASIC-BRN-017","基础设置 · 医院资料","院区管理 · 编辑院区","编辑可见启用云仓","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);已打开编辑弹窗","1. 检查「启用云仓」单选「是 / 否」可见
2. 点「取 消」,不改云仓标志","编辑可看到启用云仓;本条不真实改生产院区云仓开关。",""
"BASIC-BRN-018","基础设置 · 医院资料","院区管理 · 编辑院区","编辑清空必填拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);列表至少 1 条数据","1. 打开首行编辑,将院区名称清空为 mock 空值 """" 后点「确 定」
2. 检查必填提示,弹窗仍在
3. 点「取 消」后再打开,检查名称仍为原值","确定被拦截;原记录不被写成空。",""
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"VENDOR-HLD-001","基础设置 · 厂商管理","供应商节假日管理 · 页面进入","菜单进入供应商节假日管理页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe)","1. 按菜单进入供应商节假日管理
2. 断言 URL 含 `prov/holiday`
3. 断言可见节假日主区与新增","进入供应商节假日管理页。",""
"VENDOR-HLD-002","基础设置 · 厂商管理","供应商节假日管理 · 左右分栏","选中左侧节假日展示详情","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe);左侧有数据","1. 点击左侧首行
2. 断言默认假日详情或自定义列表区可见","详情联动成功。",""
"VENDOR-HLD-003","基础设置 · 厂商管理","供应商节假日管理 · 左右分栏","左侧搜索无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe)","1. 左侧搜索框填 mock 并回车
2. 断言左侧列表空态","无匹配空态明确。",""
"VENDOR-HLD-004","基础设置 · 厂商管理","供应商节假日管理 · 维护","左侧新增取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe)","1. 点击新增
2. 取消/关闭不保存
3. 断言仍停留本页且无异常","取消不落库。",""
"VENDOR-HLD-005","基础设置 · 厂商管理","供应商节假日管理 · 左右分栏","自定义节假日列表区可见","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe)","1. 选中左侧行
2. 断言「供应商自定义节假日列表」区域可见(可为空态)","自定义区可见。",""
"VENDOR-HLD-006","基础设置 · 厂商管理","供应商节假日管理 · 维护","结束早于开始日期拦截","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe)","1. 打开新增/编辑,结束日期早于开始日期尝试保存
2. 断言拒绝保存并提示","非法日期被拦截。",""
"VENDOR-HLD-007","基础设置 · 厂商管理","供应商节假日管理 · 维护","开始结束为同一天","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe)","1. 打开新增/编辑,开始与结束日期选同一天尝试保存(或仅校验控件)
2. 断言允许保存或明确提示规则(按产品规则),不报错白屏","同日区间规则明确。",""
"VENDOR-HLD-008","基础设置 · 厂商管理","供应商节假日管理 · 左右分栏","进院供应商主数据一致","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe)","1. 在自定义列表抽样进院供应商(若有数据)
2. 断言可在厂商/供应商主数据中找到对应记录","自定义节假日供应商与主数据一致。",""
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"VENDOR-QUL-001","基础设置 · 厂商管理","供应商资质管理 · 页面进入","菜单进入供应商资质管理页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 按菜单「基础设置 → 厂商管理 → 供应商资质管理」进入
2. 断言 URL 含 `/spd/basic/mfrs/basic/qcManage`
3. 断言可见「查 询」「重 置」及证件效期提醒相关入口","进入供应商资质管理;不误入厂商查询。",""
"VENDOR-QUL-002","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 点击「查 询」并等待列表稳定
2. 断言出现数据行或空态明确","查询无报错;有数据时分页 N≥1。",""
"VENDOR-QUL-003","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","切换授权链路效期预警 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 点击「授权链路效期预警」
2. 断言列表有数据或空态明确","Tab 切换成功,列表字段可随 Tab 变化。",""
"VENDOR-QUL-004","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","近三个月到期计数一致","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 切换「近三个月到期」(或等价文案)
2. 若可读到括号/冒号计数,断言等于分页「共 N 条」","计数与分页一致(可读时)。",""
"VENDOR-QUL-005","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","按进院供应商筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe);证件效期提醒至少 1 条且进院供应商非空","1. 从首行读取 `${sampleHospitalSupplier}` 取关键字
2. 在进院供应商名称填入关键字并「查 询」
3. 断言结果行「进院供应商」包含关键字","进院供应商列与条件一致。",""
"VENDOR-QUL-006","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 填写筛选条件后点击「重 置」
2. 断言列表按默认规则刷新","筛选恢复默认。",""
"VENDOR-QUL-007","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 进院供应商名称填入 `__NOT_EXIST_SUPPLIER__99999`
2. 点击「查 询」
3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。",""
"VENDOR-QUL-008","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","证件效期提醒核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 处于证件效期提醒 Tab
2. 断言表头含序号、数据来源、进院供应商、有效期至、证件名称、证件类型、效期提醒、操作","核心列可见。",""
"VENDOR-QUL-009","基础设置 · 厂商管理","供应商资质管理 · 查看与导出","打开查看后关闭","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe);列表至少 1 行含「查看」","1. 点击首行「查 看」
2. 断言详情层可见
3. 关闭/返回,断言回到列表","详情可打开;关闭后回到列表。",""
"VENDOR-QUL-010","基础设置 · 厂商管理","供应商资质管理 · 查看与导出","导出按钮可见","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 断言「导出」或「导出明细」按钮可见(本批不确认下载)","导出入口可见。",""
"VENDOR-QUL-011","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","制单/效期日期起止同一天","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 若存在日期区间筛选,起止选同一天并查询
2. 断言仅返回该日数据或空态明确,不报错","同日区间行为明确。",""
"VENDOR-QUL-012","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","效期提醒与有效期至抽样一致","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 在近三个月到期列表抽样一行
2. 核对「效期提醒」文案与「有效期至」日期逻辑一致","提醒文案与有效期至不矛盾。",""
"VENDOR-QUL-013","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","多条件组合查询(销售商名称等2项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 厂商管理」权限;已进入厂商管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载
2. 「销售商名称」填写关键字
3. 「证件类型」下拉选择任一可选项
4. 点击「查询」
5. 检查列表返回结果","多条件同时生效:销售商名称、证件类型 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行"
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"VENDOR-QRY-001","基础设置 · 厂商管理","厂商查询 · 页面进入","菜单进入厂商查询页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe)","1. 按菜单「基础设置 → 厂商管理 → 厂商查询」进入
2. 断言 URL 含 `/spd/basic/mfrs/paltform/mfrsName/factory`
3. 断言可见「器械注册人」「经销商」Tab、厂商名称搜索框及列表表头","进入厂商查询页;不误入供应商资质管理。",""
"VENDOR-QRY-002","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","默认器械注册人列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe)","1. 切换或停留「器械注册人」
2. 断言出现数据行,或「暂无数据」且分页总数为 0","器械注册人列表稳定;有数据时分页「共 N 条」N≥1。",""
"VENDOR-QRY-003","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","切换经销商 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe)","1. 点击「经销商」
2. 断言 Tab 激活,列表有数据或空态明确","经销商列表加载成功;总数可与器械注册人不同。",""
"VENDOR-QRY-004","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","按厂商名称搜索","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe);器械注册人至少 1 条且厂商名称非空","1. 从首行读取 `${sampleMfrsName}` 取关键字
2. 在厂商名称搜索框填入关键字并回车
3. 断言结果行「厂商名称」包含关键字","厂商名称列与关键字匹配。",""
"VENDOR-QRY-005","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","清空搜索恢复","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe);已按厂商名称过滤","1. 在已过滤状态下清空搜索框并回车
2. 断言分页总数不少于过滤后","恢复更宽结果集。",""
"VENDOR-QRY-006","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe)","1. 厂商名称填入 `__NOT_EXIST_MFRS__99999` 并回车
2. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。",""
"VENDOR-QRY-007","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe)","1. 断言表头含序号、厂商编号、厂商名称、产地、详细地址、简称、开户银行、银行账号","核心列可见。",""
"VENDOR-QRY-008","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","分页翻至第 2 页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe);器械注册人总数足够翻页","1. 总数足够时翻到第 2 页
2. 断言列表可加载;不足两页则 skip","第 2 页可加载。",""
"VENDOR-QRY-009","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","仅空格关键字搜索","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe)","1. 厂商名称仅填空格并回车
2. 断言按空查询处理或提示,不白屏","不报错;行为明确。",""
"VENDOR-QRY-010","基础设置 · 厂商管理","厂商查询 · 页面进入","未登录访问拦截","高","测试环境可访问;使用独立 context,storageState 为空(不加载 test_1 登录态)","1. 新建独立 BrowserContext,不加载 auth/user.json(test.use 空 storageState)
2. 直接打开厂商查询 URL(/spd/basic/mfrs/paltform/mfrsName/factory)
3. 断言跳转登录或不展示业务列表","未登录不可见厂商数据。",""
"VENDOR-QRY-011","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","厂商名称与资质管理进院供应商可对照","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe)","1. 记录厂商查询中某厂商名称(若有)
2. 打开供应商资质管理,用同源关键字检索或目视对照
3. 断言不出现明显主数据串名(允许一方无记录)","厂商主数据跨菜单可对照,不串名。",""
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment