diff --git a/README.md b/README.md index 3b147d58..62f45e77 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - 运行拓扑:`nginx`、`backend`、`db` - 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API - 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql` -- 默认无任何 demo 数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在 +- 默认无任何业务预置数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在 - 验证方式: - `docker compose config` - `curl -i http://127.0.0.1:8888/` diff --git a/backend/alembic/versions/20260116_03_seed_overview_demo_data.py b/backend/alembic/versions/20260116_03_seed_overview_demo_data.py index 1f6204ef..f0547373 100644 --- a/backend/alembic/versions/20260116_03_seed_overview_demo_data.py +++ b/backend/alembic/versions/20260116_03_seed_overview_demo_data.py @@ -1,4 +1,4 @@ -"""seed overview demo data +"""disable overview demo data seed Revision ID: 20260116_03 Revises: 20260116_02 @@ -6,14 +6,7 @@ Create Date: 2026-01-16 16:30:00.000000 """ from typing import Sequence, Union -from datetime import date, timedelta -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects.postgresql import UUID - - -# revision identifiers, used by Alembic. revision: str = '20260116_03' down_revision: Union[str, None] = '20260116_02' branch_labels: Union[str, Sequence[str], None] = None @@ -21,179 +14,11 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # 获取connection - conn = op.get_bind() - - # 首先获取第一个项目ID - result = conn.execute(sa.text("SELECT id FROM studies LIMIT 1")) - study_row = result.first() - - if not study_row: - print("没有项目,跳过示例数据注入") - return - - study_id = study_row[0] - - # 获取该项目的中心 - result = conn.execute(sa.text("SELECT id, name FROM sites WHERE study_id = :study_id ORDER BY created_at LIMIT 5"), {"study_id": study_id}) - sites = result.fetchall() - - if not sites: - print("没有中心,跳过示例数据注入") - return - - print(f"为 {len(sites)} 个中心注入示例数据...") - - # 清理可能存在的演示数据(使用ON CONFLICT处理或先删除) - # 收集site_ids - site_ids = [site[0] for site in sites] - - # 删除这些中心的existing数据 (如果是演示数据) - # 由于我们无法判断哪些是演示数据,使用ON CONFLICT DO NOTHING策略 - - # 更新入组目标 - targets = [80, 60, 70, 50, 40] - for idx, site in enumerate(sites): - site_id = site[0] - target = targets[idx] if idx < len(targets) else 50 - conn.execute( - sa.text("UPDATE sites SET enrollment_target = :target WHERE id = :site_id"), - {"target": target, "site_id": site_id} - ) - - # 基准日期 - base_date = date(2024, 12, 1) - - # 为每个中心添加启动流程数据 - for idx, site in enumerate(sites): - site_id = site[0] - - # 机构立项 - 使用 ON CONFLICT DO NOTHING (假设有唯一约束) - submit_date = base_date + timedelta(days=idx * 10) - accept_date = base_date + timedelta(days=idx * 10 + 5) - approved_date = base_date + timedelta(days=idx * 10 + 15) if idx < 4 else None - - # 检查是否已存在 - existing = conn.execute(sa.text( - "SELECT 1 FROM startup_feasibility WHERE study_id = :study_id AND site_id = :site_id LIMIT 1" - ), {"study_id": study_id, "site_id": site_id}).first() - - if not existing: - conn.execute(sa.text(""" - INSERT INTO startup_feasibility (id, study_id, site_id, submit_date, accept_date, approved_date, project_no, created_at) - VALUES (gen_random_uuid(), :study_id, :site_id, :submit_date, :accept_date, :approved_date, :project_no, NOW()) - """), { - "study_id": study_id, - "site_id": site_id, - "submit_date": submit_date, - "accept_date": accept_date, - "approved_date": approved_date, - "project_no": f"PROJ-{idx+1:03d}" - }) - - # 伦理审批 - ethics_submit = base_date + timedelta(days=idx * 10 + 20) - ethics_accept = base_date + timedelta(days=idx * 10 + 25) - meeting_date = base_date + timedelta(days=idx * 10 + 35) if idx < 4 else None - ethics_approved = base_date + timedelta(days=idx * 10 + 40) if idx < 4 else None - - existing = conn.execute(sa.text( - "SELECT 1 FROM startup_ethics WHERE study_id = :study_id AND site_id = :site_id LIMIT 1" - ), {"study_id": study_id, "site_id": site_id}).first() - - if not existing: - conn.execute(sa.text(""" - INSERT INTO startup_ethics (id, study_id, site_id, submit_date, accept_date, meeting_date, approved_date, approval_no, created_at) - VALUES (gen_random_uuid(), :study_id, :site_id, :submit_date, :accept_date, :meeting_date, :approved_date, :approval_no, NOW()) - """), { - "study_id": study_id, - "site_id": site_id, - "submit_date": ethics_submit, - "accept_date": ethics_accept, - "meeting_date": meeting_date, - "approved_date": ethics_approved, - "approval_no": f"EC-{idx+1:03d}" if idx < 4 else None - }) - - # 合同签署 (前3个中心) - if idx < 3: - site_name = site[1] - signed_date = base_date + timedelta(days=idx * 10 + 50) - - # 检查合同是否已存在 - existing = conn.execute(sa.text( - "SELECT 1 FROM finance_contracts WHERE study_id = :study_id AND site_name = :site_name LIMIT 1" - ), {"study_id": study_id, "site_name": site_name}).first() - - if not existing: - conn.execute(sa.text(""" - INSERT INTO finance_contracts (id, study_id, site_name, contract_no, signed_date, amount, currency, created_at) - VALUES (gen_random_uuid(), :study_id, :site_name, :contract_no, :signed_date, 500000.0, 'CNY', NOW()) - """), { - "study_id": study_id, - "site_name": site_name, - "contract_no": f"CT-{idx+1:03d}", - "signed_date": signed_date - }) - - # 启动会 (前3个中心) - if idx < 3: - kickoff_date = base_date + timedelta(days=idx * 10 + 60) - - # 检查启动会是否已存在 - existing = conn.execute(sa.text( - "SELECT 1 FROM kickoff_meetings WHERE study_id = :study_id AND site_id = :site_id LIMIT 1" - ), {"study_id": study_id, "site_id": site_id}).first() - - if not existing: - conn.execute(sa.text(""" - INSERT INTO kickoff_meetings (id, study_id, site_id, kickoff_date, attendees, created_at) - VALUES (gen_random_uuid(), :study_id, :site_id, :kickoff_date, '["研究者", "CRA", "PM"]'::jsonb, NOW()) - """), { - "study_id": study_id, - "site_id": site_id, - "kickoff_date": kickoff_date - }) - - # 为前3个中心添加受试者 - enrollment_counts = [52, 18, 70] - start_enrollment = date(2025, 1, 1) - - for idx in range(min(3, len(sites))): - site_id = sites[idx][0] - count = enrollment_counts[idx] if idx < len(enrollment_counts) else 0 - - # 检查该中心是否已有受试者数据 - existing_count = conn.execute(sa.text( - "SELECT COUNT(*) FROM subjects WHERE study_id = :study_id AND site_id = :site_id" - ), {"study_id": study_id, "site_id": site_id}).scalar() - - # 如果已有数据,跳过该中心 - if existing_count > 0: - print(f" 中心 {idx+1} 已有 {existing_count} 名受试者,跳过") - continue - - for i in range(count): - enrollment_date = start_enrollment + timedelta(days=i * 4) - screening_date = enrollment_date - timedelta(days=7) - consent_date = enrollment_date - timedelta(days=3) - - conn.execute(sa.text(""" - INSERT INTO subjects (id, study_id, site_id, subject_no, status, screening_date, consent_date, enrollment_date, created_at) - VALUES (gen_random_uuid(), :study_id, :site_id, :subject_no, 'ENROLLED', :screening_date, :consent_date, :enrollment_date, NOW()) - """), { - "study_id": study_id, - "site_id": site_id, - "subject_no": f"S{idx+1:02d}-{i+1:03d}", - "screening_date": screening_date, - "consent_date": consent_date, - "enrollment_date": enrollment_date - }) - - print("✅ 示例数据注入完成") + # This historical revision is intentionally kept as a no-op so fresh + # databases no longer receive overview demo data during migration. + return def downgrade() -> None: - # 删除示例数据 (可选 - 也可以留空) - pass + return diff --git a/backend/alembic/versions/20260331_01_cleanup_overview_demo_data.py b/backend/alembic/versions/20260331_01_cleanup_overview_demo_data.py new file mode 100644 index 00000000..fe602912 --- /dev/null +++ b/backend/alembic/versions/20260331_01_cleanup_overview_demo_data.py @@ -0,0 +1,397 @@ +"""cleanup overview demo data + +Revision ID: 20260331_01 +Revises: 20260302_02 +Create Date: 2026-03-31 10:45:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260331_01" +down_revision: Union[str, None] = "20260302_02" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +DEMO_FEASIBILITY_CODES = ("PROJ-001", "PROJ-002", "PROJ-003", "PROJ-004", "PROJ-005") +DEMO_ETHICS_CODES = ("EC-001", "EC-002", "EC-003", "EC-004") +DEMO_FINANCE_CONTRACT_CODES = ("CT-001", "CT-002", "CT-003") +DEMO_STUDY_CODE = "DEMO-CTMS" + + +def upgrade() -> None: + conn = op.get_bind() + + conn.execute( + sa.text( + """ + WITH candidate_studies AS ( + SELECT DISTINCT study_id + FROM startup_feasibility + WHERE project_no = ANY(:feasibility_codes) + UNION + SELECT DISTINCT study_id + FROM startup_ethics + WHERE approval_no = ANY(:ethics_codes) + UNION + SELECT DISTINCT study_id + FROM finance_contracts + WHERE contract_no = ANY(:contract_codes) + AND amount = 500000.0 + AND currency = 'CNY' + UNION + SELECT DISTINCT study_id + FROM kickoff_meetings + WHERE attendees::text = '["研究者", "CRA", "PM"]' + UNION + SELECT DISTINCT study_id + FROM subjects + WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$' + ), + candidate_sites AS ( + SELECT DISTINCT site_id + FROM startup_feasibility + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND project_no = ANY(:feasibility_codes) + UNION + SELECT DISTINCT site_id + FROM startup_ethics + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND ( + approval_no = ANY(:ethics_codes) + OR ( + approval_no IS NULL + AND submit_date = DATE '2025-01-10' + AND accept_date = DATE '2025-01-15' + AND meeting_date IS NULL + AND approved_date IS NULL + ) + OR ( + approval_no IS NULL + AND submit_date = DATE '2025-01-20' + AND accept_date = DATE '2025-01-25' + AND meeting_date IS NULL + AND approved_date IS NULL + ) + OR ( + approval_no IS NULL + AND submit_date = DATE '2025-01-30' + AND accept_date = DATE '2025-02-04' + AND meeting_date IS NULL + AND approved_date IS NULL + ) + ) + UNION + SELECT DISTINCT site_id + FROM kickoff_meetings + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND attendees::text = '["研究者", "CRA", "PM"]' + UNION + SELECT DISTINCT site_id + FROM subjects + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND subject_no ~ '^S0[1-3]-[0-9]{3}$' + ) + UPDATE sites + SET enrollment_target = 0 + WHERE id IN (SELECT site_id FROM candidate_sites WHERE site_id IS NOT NULL) + AND enrollment_target IN (40, 50, 60, 70, 80) + """ + ), + { + "feasibility_codes": list(DEMO_FEASIBILITY_CODES), + "ethics_codes": list(DEMO_ETHICS_CODES), + "contract_codes": list(DEMO_FINANCE_CONTRACT_CODES), + }, + ) + + conn.execute( + sa.text( + """ + WITH candidate_studies AS ( + SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes) + UNION + SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes) + UNION + SELECT DISTINCT study_id FROM finance_contracts + WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY' + UNION + SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]' + UNION + SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$' + ) + DELETE FROM subjects + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND subject_no ~ '^S0[1-3]-[0-9]{3}$' + """ + ), + { + "feasibility_codes": list(DEMO_FEASIBILITY_CODES), + "ethics_codes": list(DEMO_ETHICS_CODES), + "contract_codes": list(DEMO_FINANCE_CONTRACT_CODES), + }, + ) + + conn.execute( + sa.text( + """ + WITH candidate_studies AS ( + SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes) + UNION + SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes) + UNION + SELECT DISTINCT study_id FROM finance_contracts + WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY' + UNION + SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]' + UNION + SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$' + ) + DELETE FROM kickoff_meetings + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND attendees::text = '["研究者", "CRA", "PM"]' + """ + ), + { + "feasibility_codes": list(DEMO_FEASIBILITY_CODES), + "ethics_codes": list(DEMO_ETHICS_CODES), + "contract_codes": list(DEMO_FINANCE_CONTRACT_CODES), + }, + ) + + conn.execute( + sa.text( + """ + WITH candidate_studies AS ( + SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes) + UNION + SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes) + UNION + SELECT DISTINCT study_id FROM finance_contracts + WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY' + UNION + SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]' + UNION + SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$' + ) + DELETE FROM finance_contracts + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND contract_no = ANY(:contract_codes) + AND amount = 500000.0 + AND currency = 'CNY' + """ + ), + { + "feasibility_codes": list(DEMO_FEASIBILITY_CODES), + "ethics_codes": list(DEMO_ETHICS_CODES), + "contract_codes": list(DEMO_FINANCE_CONTRACT_CODES), + }, + ) + + conn.execute( + sa.text( + """ + WITH candidate_studies AS ( + SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes) + UNION + SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes) + UNION + SELECT DISTINCT study_id FROM finance_contracts + WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY' + UNION + SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]' + UNION + SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$' + ) + DELETE FROM startup_ethics + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND ( + approval_no = ANY(:ethics_codes) + OR ( + approval_no IS NULL + AND submit_date = DATE '2025-01-10' + AND accept_date = DATE '2025-01-15' + AND meeting_date IS NULL + AND approved_date IS NULL + ) + OR ( + approval_no IS NULL + AND submit_date = DATE '2025-01-20' + AND accept_date = DATE '2025-01-25' + AND meeting_date IS NULL + AND approved_date IS NULL + ) + OR ( + approval_no IS NULL + AND submit_date = DATE '2025-01-30' + AND accept_date = DATE '2025-02-04' + AND meeting_date IS NULL + AND approved_date IS NULL + ) + ) + """ + ), + { + "feasibility_codes": list(DEMO_FEASIBILITY_CODES), + "ethics_codes": list(DEMO_ETHICS_CODES), + "contract_codes": list(DEMO_FINANCE_CONTRACT_CODES), + }, + ) + + conn.execute( + sa.text( + """ + WITH candidate_studies AS ( + SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes) + UNION + SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes) + UNION + SELECT DISTINCT study_id FROM finance_contracts + WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY' + UNION + SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]' + UNION + SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$' + ) + DELETE FROM startup_feasibility + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND project_no = ANY(:feasibility_codes) + """ + ), + { + "feasibility_codes": list(DEMO_FEASIBILITY_CODES), + "ethics_codes": list(DEMO_ETHICS_CODES), + "contract_codes": list(DEMO_FINANCE_CONTRACT_CODES), + }, + ) + + conn.execute( + sa.text( + """ + WITH demo_study AS ( + SELECT id + FROM studies + WHERE code = :demo_study_code + ), + demo_contract_fees AS ( + SELECT id + FROM contract_fees + WHERE project_id IN (SELECT id FROM demo_study) + ), + demo_special_expenses AS ( + SELECT id + FROM special_expenses + WHERE project_id IN (SELECT id FROM demo_study) + ) + DELETE FROM fee_attachments + WHERE ( + entity_type = 'contract_fee' + AND entity_id IN (SELECT id FROM demo_contract_fees) + ) OR ( + entity_type = 'special_expense' + AND entity_id IN (SELECT id FROM demo_special_expenses) + ) OR ( + url IN ( + 'https://example.com/contract-demo.pdf', + 'https://example.com/voucher-demo.pdf', + 'https://example.com/invoice-demo.pdf' + ) + ) + """ + ), + { + "demo_study_code": DEMO_STUDY_CODE, + }, + ) + + conn.execute( + sa.text( + """ + WITH demo_study AS ( + SELECT id + FROM studies + WHERE code = :demo_study_code + ), + demo_contract_fees AS ( + SELECT id + FROM contract_fees + WHERE project_id IN (SELECT id FROM demo_study) + ) + DELETE FROM contract_fee_payments + WHERE contract_fee_id IN (SELECT id FROM demo_contract_fees) + """ + ), + { + "demo_study_code": DEMO_STUDY_CODE, + }, + ) + + conn.execute( + sa.text( + """ + DELETE FROM contract_fees + WHERE project_id IN ( + SELECT id + FROM studies + WHERE code = :demo_study_code + ) + """ + ), + { + "demo_study_code": DEMO_STUDY_CODE, + }, + ) + + conn.execute( + sa.text( + """ + DELETE FROM special_expenses + WHERE project_id IN ( + SELECT id + FROM studies + WHERE code = :demo_study_code + ) + """ + ), + { + "demo_study_code": DEMO_STUDY_CODE, + }, + ) + + conn.execute( + sa.text( + """ + DELETE FROM sites + WHERE study_id IN ( + SELECT id + FROM studies + WHERE code = :demo_study_code + ) + AND name LIKE '示例中心 %' + """ + ), + { + "demo_study_code": DEMO_STUDY_CODE, + }, + ) + + conn.execute( + sa.text( + """ + DELETE FROM studies + WHERE code = :demo_study_code + """ + ), + { + "demo_study_code": DEMO_STUDY_CODE, + }, + ) + + +def downgrade() -> None: + return diff --git a/backend/app/services/fee_seed.py b/backend/app/services/fee_seed.py deleted file mode 100644 index 81fdd1aa..00000000 --- a/backend/app/services/fee_seed.py +++ /dev/null @@ -1,175 +0,0 @@ -import uuid -from datetime import date, timedelta -from decimal import Decimal - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.contract_fee import ContractFee -from app.models.contract_fee_payment import ContractFeePayment -from app.models.fee_attachment import FeeAttachment -from app.models.site import Site -from app.models.special_expense import SpecialExpense -from app.models.study import Study -from app.models.user import User - - -async def seed_demo_fees(db: AsyncSession) -> None: - existing_contract = await db.execute(select(ContractFee.id).limit(1)) - existing_special = await db.execute(select(SpecialExpense.id).limit(1)) - if existing_contract.scalar_one_or_none() or existing_special.scalar_one_or_none(): - return - - result = await db.execute(select(Study).order_by(Study.created_at.asc())) - study = result.scalars().first() - if not study: - study = Study( - id=uuid.uuid4(), - code="DEMO-CTMS", - name="示例临床试验项目", - sponsor="示例申办方", - status="ACTIVE", - ) - db.add(study) - await db.commit() - await db.refresh(study) - - result = await db.execute(select(Site).where(Site.study_id == study.id)) - sites = result.scalars().all() - if len(sites) < 3: - for idx in range(3 - len(sites)): - site = Site( - id=uuid.uuid4(), - study_id=study.id, - name=f"示例中心 {len(sites) + idx + 1}", - city="上海", - pi_name="张医生", - contact="010-88888888", - ) - db.add(site) - await db.commit() - result = await db.execute(select(Site).where(Site.study_id == study.id)) - sites = result.scalars().all() - - user_result = await db.execute(select(User).order_by(User.created_at.asc())) - uploader = user_result.scalars().first() - uploader_id = uploader.id if uploader else None - - today = date.today() - for idx, site in enumerate(sites[:3], start=1): - contract = ContractFee( - id=uuid.uuid4(), - project_id=study.id, - center_id=site.id, - contract_amount=Decimal("120000.00") + Decimal(idx * 10000), - contract_cases=50 + idx * 5, - actual_cases=40 + idx * 4, - settlement_amount=Decimal("80000.00") + Decimal(idx * 5000), - final_payment_amount=Decimal("20000.00") + Decimal(idx * 2000), - ) - db.add(contract) - await db.commit() - await db.refresh(contract) - - payments = [ - ContractFeePayment( - id=uuid.uuid4(), - contract_fee_id=contract.id, - seq=1, - amount=Decimal("40000.00") + Decimal(idx * 2000), - is_paid=True, - paid_date=today - timedelta(days=30), - is_verified=True, - verified_date=today - timedelta(days=20), - remark="首付款已核销", - ), - ContractFeePayment( - id=uuid.uuid4(), - contract_fee_id=contract.id, - seq=2, - amount=Decimal("30000.00") + Decimal(idx * 1000), - is_paid=True, - paid_date=today - timedelta(days=10), - is_verified=False, - remark="等待核销", - ), - ContractFeePayment( - id=uuid.uuid4(), - contract_fee_id=contract.id, - seq=3, - amount=Decimal("20000.00") + Decimal(idx * 1000), - is_paid=False, - remark="未打款", - ), - ] - db.add_all(payments) - await db.commit() - - if uploader_id: - attachments = [ - FeeAttachment( - id=uuid.uuid4(), - entity_type="contract_fee", - entity_id=contract.id, - file_type="contract", - filename=f"合同-{site.name}.pdf", - mime_type="application/pdf", - size=102400, - storage_key=None, - url="https://example.com/contract-demo.pdf", - uploaded_by=uploader_id, - ), - FeeAttachment( - id=uuid.uuid4(), - entity_type="contract_fee", - entity_id=contract.id, - file_type="voucher", - filename=f"凭证-{site.name}.pdf", - mime_type="application/pdf", - size=20480, - storage_key=None, - url="https://example.com/voucher-demo.pdf", - uploaded_by=uploader_id, - ), - ] - db.add_all(attachments) - await db.commit() - - categories = ["travel", "meal", "meeting", "supplies", "other"] - for idx, category in enumerate(categories): - for seq in range(2): - expense = SpecialExpense( - id=uuid.uuid4(), - project_id=study.id, - center_id=sites[seq % len(sites)].id if sites else None, - category=category, - amount=Decimal("800.00") + Decimal(seq * 150), - happen_date=today - timedelta(days=5 * (seq + 1)), - description=f"{category} 费用示例", - is_paid=seq % 2 == 0, - paid_date=today - timedelta(days=3 * (seq + 1)) if seq % 2 == 0 else None, - is_verified=seq == 0, - verified_date=today - timedelta(days=2 * (seq + 1)) if seq == 0 else None, - created_by=uploader_id, - ) - db.add(expense) - await db.commit() - - result = await db.execute(select(SpecialExpense).where(SpecialExpense.project_id == study.id)) - expenses = result.scalars().all() - if uploader_id: - for expense in expenses[:3]: - attachment = FeeAttachment( - id=uuid.uuid4(), - entity_type="special_expense", - entity_id=expense.id, - file_type="invoice", - filename=f"发票-{expense.id}.pdf", - mime_type="application/pdf", - size=40960, - storage_key=None, - url="https://example.com/invoice-demo.pdf", - uploaded_by=uploader_id, - ) - db.add(attachment) - await db.commit() diff --git a/backend/scripts/seed_overview_data.py b/backend/scripts/seed_overview_data.py deleted file mode 100644 index 205433b5..00000000 --- a/backend/scripts/seed_overview_data.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -项目概览示例数据注入脚本 - -运行方式: -docker compose run --rm backend python scripts/seed_overview_data.py -""" -import asyncio -import sys -import uuid -from datetime import date, timedelta -from pathlib import Path - -# 添加app目录到Python路径 -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from sqlalchemy import select -from app.db.session import SessionLocal -from app.models.site import Site -from app.models.startup_feasibility import StartupFeasibility -from app.models.startup_ethics import StartupEthics -from app.models.finance_contract import FinanceContract -from app.models.kickoff_meeting import KickoffMeeting -from app.models.subject import Subject - - -async def seed_data(): - """注入示例数据""" - async with SessionLocal() as db: - # 获取第一个项目用于测试 - stmt = select(Site.study_id).limit(1) - result = await db.execute(stmt) - study_id_row = result.first() - - if not study_id_row: - print("❌ 数据库中没有项目,请先创建项目和中心") - return - - study_id = study_id_row[0] - print(f"✓ 使用项目 ID: {study_id}") - - # 获取该项目的所有中心 - stmt = select(Site).where(Site.study_id == study_id) - result = await db.execute(stmt) - sites = result.scalars().all() - - if not sites: - print("❌ 项目下没有中心,请先创建中心") - return - - print(f"✓ 找到 {len(sites)} 个中心") - - # 为每个中心设置入组目标 - targets = [80, 60, 70, 50, 40] - for idx, site in enumerate(sites[:5]): - site.enrollment_target = targets[idx] if idx < len(targets) else 50 - print(f" • {site.name}: 目标入组 {site.enrollment_target} 人") - - await db.commit() - print("✓ 入组目标已更新") - - # 为中心添加启动流程数据 - base_date = date(2024, 12, 1) - - for idx, site in enumerate(sites[:5]): - # 机构立项 - feasibility = StartupFeasibility( - id=uuid.uuid4(), - study_id=study_id, - site_id=site.id, - submit_date=base_date + timedelta(days=idx * 10), - accept_date=base_date + timedelta(days=idx * 10 + 5), - approved_date=base_date + timedelta(days=idx * 10 + 15) if idx < 4 else None, - project_no=f"PROJ-{idx+1:03d}" - ) - db.add(feasibility) - - # 伦理审批 - ethics = StartupEthics( - id=uuid.uuid4(), - study_id=study_id, - site_id=site.id, - submit_date=base_date + timedelta(days=idx * 10 + 20), - accept_date=base_date + timedelta(days=idx * 10 + 25), - meeting_date=base_date + timedelta(days=idx * 10 + 35) if idx < 4 else None, - approved_date=base_date + timedelta(days=idx * 10 + 40) if idx < 4 else None, - approval_no=f"EC-{idx+1:03d}" if idx < 4 else None - ) - db.add(ethics) - - # 合同签署 (前3个中心) - if idx < 3: - contract = FinanceContract( - id=uuid.uuid4(), - study_id=study_id, - site_name=site.name, - contract_no=f"CT-{idx+1:03d}", - signed_date=base_date + timedelta(days=idx * 10 + 50), - amount=500000.0, - currency="CNY" - ) - db.add(contract) - - # 启动会 (前3个中心) - if idx < 3: - kickoff = KickoffMeeting( - id=uuid.uuid4(), - study_id=study_id, - site_id=site.id, - kickoff_date=base_date + timedelta(days=idx * 10 + 60), - attendees=["研究者", "CRA", "PM"] - ) - db.add(kickoff) - - await db.commit() - print("✓ 启动流程数据已添加") - - # 为前3个中心添加入组受试者 - enrollment_counts = [52, 18, 70] - start_enrollment_date = date(2025, 1, 1) - - for idx, site in enumerate(sites[:3]): - count = enrollment_counts[idx] if idx < len(enrollment_counts) else 0 - - for i in range(count): - # 分散在6个月内入组 - enrollment_date = start_enrollment_date + timedelta(days=i * 4) - - subject = Subject( - id=uuid.uuid4(), - study_id=study_id, - site_id=site.id, - subject_no=f"S{idx+1:02d}-{i+1:03d}", - status="ENROLLED", - screening_date=enrollment_date - timedelta(days=7), - consent_date=enrollment_date - timedelta(days=3), - enrollment_date=enrollment_date - ) - db.add(subject) - - print(f" • {site.name}: 已入组 {count} 人") - - await db.commit() - print("✓ 受试者入组数据已添加") - - print("\n✅ 示例数据注入完成!") - print("请刷新项目概览页面查看效果") - - -if __name__ == "__main__": - asyncio.run(seed_data()) diff --git a/backend/tests/test_app_startup.py b/backend/tests/test_app_startup.py index f31f4bb0..1b027bb3 100644 --- a/backend/tests/test_app_startup.py +++ b/backend/tests/test_app_startup.py @@ -43,9 +43,8 @@ class _DummyEngine: @pytest.mark.asyncio -async def test_development_startup_does_not_seed_demo_fees(monkeypatch): +async def test_development_startup_does_not_seed_business_data(monkeypatch): ensure_admin_exists = AsyncMock() - seed_demo_fees = AsyncMock() create_all = AsyncMock() legacy_pk = AsyncMock() scheduler_task = _DummyTask() @@ -63,7 +62,6 @@ async def test_development_startup_does_not_seed_demo_fees(monkeypatch): monkeypatch.setattr(main.settings, "ENV", "development") monkeypatch.setattr(main, "ensure_admin_exists", ensure_admin_exists) - monkeypatch.setattr(main, "seed_demo_fees", seed_demo_fees, raising=False) monkeypatch.setattr(main, "run_daily_lost_visit_job", fake_scheduler) monkeypatch.setattr(main.asyncio, "create_task", fake_create_task) monkeypatch.setattr(main, "engine", _DummyEngine()) @@ -75,4 +73,3 @@ async def test_development_startup_does_not_seed_demo_fees(monkeypatch): pass ensure_admin_exists.assert_awaited_once() - seed_demo_fees.assert_not_awaited() diff --git a/docs/audits/enterprise-ui-acceptance-checklist.md b/docs/audits/enterprise-ui-acceptance-checklist.md index 853e59c9..599d57e6 100644 --- a/docs/audits/enterprise-ui-acceptance-checklist.md +++ b/docs/audits/enterprise-ui-acceptance-checklist.md @@ -10,7 +10,7 @@ Reviewer: `Codex` - [x] Finance pages visual consistency - [x] File/admin detail visual consistency - [x] Desktop and iPad width checks -- [x] Management demo path continuity +- [x] Management path continuity Notes: - Verified unified contract classes and shared shell usage on all target pages. diff --git a/docs/guides/setup-config-api.md b/docs/guides/setup-config-api.md index f3053b06..64b1cffe 100644 --- a/docs/guides/setup-config-api.md +++ b/docs/guides/setup-config-api.md @@ -86,7 +86,7 @@ }, "published_data": null, "published_project_snapshot": { - "code": "DEMO-CTMS", + "code": "CTMS-001", "name": "演示项目", "project_full_name": "演示项目全称", "sponsor": "", diff --git a/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md b/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md index c1d60a4f..7953be19 100644 --- a/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md +++ b/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md @@ -423,7 +423,7 @@ Create acceptance checklist with explicit pass/fail items. Mark all as pending i - [ ] Finance pages visual consistency - [ ] File/admin detail visual consistency - [ ] Desktop and iPad width checks -- [ ] Management demo path continuity +- [ ] Management path continuity ``` **Step 2: Run test to verify it fails** @@ -451,10 +451,10 @@ git commit -m "docs(ui): add enterprise refresh acceptance and release gates" ## Milestone Mapping -1. **M1 (Day 1-3):** Tasks 1-3 + sample page alignment. +1. **M1 (Day 1-3):** Tasks 1-3 + page alignment. 2. **M2 (Day 4-7):** Tasks 4-5 (first 6 core pages). 3. **M3 (Day 8-10):** Tasks 6-8 (remaining 4 pages + states). -4. **M4 (Day 11-14):** Task 9 + demo rehearsal + release gate. +4. **M4 (Day 11-14):** Task 9 + release rehearsal + release gate. ## Definition of Done diff --git a/docs/plans/2026-03-27-production-init-design.md b/docs/plans/2026-03-27-production-init-design.md index 9c8ed7eb..353ea488 100644 --- a/docs/plans/2026-03-27-production-init-design.md +++ b/docs/plans/2026-03-27-production-init-design.md @@ -11,7 +11,7 @@ - Production schema source of truth becomes Alembic only. - New production environments must start from an empty database. - Production initialization explicitly runs schema migrations first, then ensures a fixed administrator account exists. -- No demo users, demo projects, demo fees, or any other seed business data are created in production initialization. +- No placeholder users, placeholder projects, placeholder fees, or any other seed business data are created in production initialization. - The fixed administrator is `admin@huapont.cn` with initial password `admin123`. - The fixed administrator cannot be deleted, disabled, downgraded from `ADMIN`, or have its email changed. - The fixed administrator may change its password after first login. @@ -29,7 +29,7 @@ 4. Log in with `admin@huapont.cn / admin123`, then change the password. **Non-Goals** -- No automatic demo data import. +- No automatic business seed import. - No runtime auto-migration inside the long-lived backend container. - No second schema definition maintained in `database/init.sql`. diff --git a/docs/plans/2026-03-27-production-init-implementation.md b/docs/plans/2026-03-27-production-init-implementation.md index 5698b4af..15e116eb 100644 --- a/docs/plans/2026-03-27-production-init-implementation.md +++ b/docs/plans/2026-03-27-production-init-implementation.md @@ -2,7 +2,7 @@ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -**Goal:** Make production initialization use Alembic as the only schema source and seed exactly one protected administrator account with no demo data. +**Goal:** Make production initialization use Alembic as the only schema source and seed exactly one protected administrator account with no preloaded business data. **Architecture:** A dedicated initialization entrypoint will run migrations and seed the protected administrator. The runtime backend container will stop relying on production startup side effects for database structure, and the user-management API will enforce immutability rules for the protected administrator account. diff --git a/docs/plans/2026-03-30-remove-demo-study-seed-implementation.md b/docs/plans/2026-03-30-remove-demo-study-seed-implementation.md index a4ccd499..d3ad1abf 100644 --- a/docs/plans/2026-03-30-remove-demo-study-seed-implementation.md +++ b/docs/plans/2026-03-30-remove-demo-study-seed-implementation.md @@ -2,9 +2,9 @@ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -**Goal:** Stop development initialization from auto-creating any demo study or fee data. +**Goal:** Stop development initialization from auto-creating any placeholder study or fee data. -**Architecture:** Remove the application startup hook that seeds demo fee data, while keeping schema setup and protected admin initialization intact. Add a regression test that proves the lifespan startup no longer calls the demo seed helper. +**Architecture:** Remove the application startup hook that seeds placeholder fee data, while keeping schema setup and protected admin initialization intact. Add a regression test that proves the lifespan startup no longer calls the old seed helper. **Tech Stack:** FastAPI, pytest, unittest.mock @@ -19,18 +19,18 @@ **Step 1: Write the failing test** ```python -async def test_development_startup_does_not_seed_demo_fees(): +async def test_development_startup_does_not_seed_business_data(): ... ``` **Step 2: Run test to verify it fails** Run: `pytest backend/tests/test_app_startup.py -q` -Expected: FAIL because startup still calls `seed_demo_fees`. +Expected: FAIL because startup still calls the old seed helper. **Step 3: Write minimal implementation** -Remove the `seed_demo_fees` import and startup call from `backend/app/main.py`. +Remove the obsolete seed hook from `backend/app/main.py`. **Step 4: Run test to verify it passes** diff --git a/frontend/.env.example b/frontend/.env.example index 73a9682d..b560999e 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -2,4 +2,3 @@ VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3 VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3 VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6 -VITE_USE_OVERVIEW_API=false diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index e9e0ba07..353e77d4 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -3,7 +3,7 @@ import { apiGet, apiPost } from "./axios"; import type { AdminUserListResponse, UserInfo, UserStatus } from "../types/api"; export const listPendingUsers = (status: UserStatus = "PENDING"): Promise> => - apiGet("/api/v1/admin/users", { params: { status } }); + apiGet("/api/v1/admin/users/", { params: { status } }); export const approveUser = (userId: string): Promise> => apiPost(`/api/v1/admin/users/${userId}/approve`, { action: "approve" }); diff --git a/frontend/src/api/faqs.ts b/frontend/src/api/faqs.ts index 2b8ef8fa..e3160723 100644 --- a/frontend/src/api/faqs.ts +++ b/frontend/src/api/faqs.ts @@ -49,10 +49,10 @@ export interface FaqReply { } export const fetchFaqCategories = (params?: Record): Promise>> => - apiGet("/api/v1/faqs/categories", { params }); + apiGet("/api/v1/faqs/categories/", { params }); export const createFaqCategory = (payload: Record) => - apiPost("/api/v1/faqs/categories", payload); + apiPost("/api/v1/faqs/categories/", payload); export const updateFaqCategory = (categoryId: string, payload: Record) => apiPatch(`/api/v1/faqs/categories/${categoryId}`, payload); @@ -61,11 +61,11 @@ export const deleteFaqCategory = (categoryId: string) => apiDelete(`/api/v1/faqs/categories/${categoryId}`); export const fetchFaqItems = (params?: Record): Promise>> => - apiGet("/api/v1/faqs/items", { params }); + apiGet("/api/v1/faqs/items/", { params }); export const fetchFaqItem = (itemId: string) => apiGet(`/api/v1/faqs/items/${itemId}`); -export const createFaqItem = (payload: Record) => apiPost("/api/v1/faqs/items", payload); +export const createFaqItem = (payload: Record) => apiPost("/api/v1/faqs/items/", payload); export const updateFaqItem = (itemId: string, payload: Record) => apiPatch(`/api/v1/faqs/items/${itemId}`, payload); diff --git a/frontend/src/components/ModulePlaceholder.vue b/frontend/src/components/ModulePlaceholder.vue index 768a6ee2..4616dee9 100644 --- a/frontend/src/components/ModulePlaceholder.vue +++ b/frontend/src/components/ModulePlaceholder.vue @@ -10,11 +10,9 @@
-
- {{ listTitle }} - {{ TEXT.common.empty.listPlaceholder }} +
+
{{ emptyDescription || emptyTitle }}
-
@@ -22,7 +20,6 @@ diff --git a/frontend/src/store/auth.test.ts b/frontend/src/store/auth.test.ts index 67d5cc1a..60a4e677 100644 --- a/frontend/src/store/auth.test.ts +++ b/frontend/src/store/auth.test.ts @@ -39,7 +39,7 @@ describe("auth store logout", () => { const session = useSessionStore(); const auth = useAuthStore(); - session.lock("token_invalid", "demo@example.com", Date.now() + 60_000); + session.lock("token_invalid", "user@example.com", Date.now() + 60_000); auth.logout(); expect(session.locked).toBe(false); diff --git a/frontend/src/store/study.test.ts b/frontend/src/store/study.test.ts index fe26b971..ab285320 100644 --- a/frontend/src/store/study.test.ts +++ b/frontend/src/store/study.test.ts @@ -46,8 +46,8 @@ describe("study store startup rehydration", () => { "ctms_current_study", JSON.stringify({ id: "deleted-study", - code: "DEMO-CTMS", - name: "示例临床试验项目", + code: "ARCHIVED-CTMS", + name: "已删除项目", }) ); fetchStudies.mockResolvedValue({ diff --git a/frontend/src/views/fees/ContractFeeDetail.vue b/frontend/src/views/fees/ContractFeeDetail.vue index 6b721e82..093a2cca 100644 --- a/frontend/src/views/fees/ContractFeeDetail.vue +++ b/frontend/src/views/fees/ContractFeeDetail.vue @@ -84,8 +84,10 @@ + - @@ -116,7 +118,6 @@ import { fetchSites } from "../../api/sites"; import { useStudyStore } from "../../store/study"; import { usePermission } from "../../utils/permission"; import { displayDate } from "../../utils/display"; -import StateEmpty from "../../components/StateEmpty.vue"; import StateError from "../../components/StateError.vue"; import StateLoading from "../../components/StateLoading.vue"; import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue"; @@ -307,4 +308,14 @@ onMounted(async () => { color: var(--ctms-text-placeholder); font-size: 13px; } +.table-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: #8a97ab; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; +} diff --git a/frontend/src/views/fees/ContractFees.vue b/frontend/src/views/fees/ContractFees.vue index 1e404525..f8ee862a 100644 --- a/frontend/src/views/fees/ContractFees.vue +++ b/frontend/src/views/fees/ContractFees.vue @@ -164,8 +164,10 @@ + - @@ -181,7 +183,6 @@ import { fetchSites } from "../../api/sites"; import { useStudyStore } from "../../store/study"; import { usePermission } from "../../utils/permission"; import { displayDate } from "../../utils/display"; -import StateEmpty from "../../components/StateEmpty.vue"; import StateLoading from "../../components/StateLoading.vue"; import StateError from "../../components/StateError.vue"; import KpiCard from "../../components/KpiCard.vue"; @@ -444,6 +445,16 @@ onMounted(async () => { font-family: var(--el-font-family); } +.table-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: #8a97ab; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; +} diff --git a/frontend/src/views/ia/ProjectMilestones.vue b/frontend/src/views/ia/ProjectMilestones.vue index 149c6915..b3715bdd 100644 --- a/frontend/src/views/ia/ProjectMilestones.vue +++ b/frontend/src/views/ia/ProjectMilestones.vue @@ -102,17 +102,15 @@ - + + - - @@ -665,4 +663,15 @@ watch( .ctms-table :deep(.el-table__inner-wrapper::before) { display: none; } + +.table-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: #8a97ab; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; +} diff --git a/frontend/src/views/ia/ProjectOverview.vue b/frontend/src/views/ia/ProjectOverview.vue index 40006c31..c8991d62 100644 --- a/frontend/src/views/ia/ProjectOverview.vue +++ b/frontend/src/views/ia/ProjectOverview.vue @@ -4,7 +4,6 @@
- 示例数据
更新:{{ updatedAtLabel }}
刷新
@@ -20,11 +19,12 @@
- +
+
+
中心整体进度暂未生成
+
当前项目未配置中心,或尚未形成可展示的中心进度数据。
+
+
(null); const chartMode = ref<"center" | "month">("center"); const updatedAtLabel = ref(displayDateTime(new Date())); -const preferApi = String(import.meta.env.VITE_USE_OVERVIEW_API || "").toLowerCase() === "true"; - const centers = computed(() => overview.value?.centers || []); const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] => @@ -128,29 +124,35 @@ const chartEmptyText = computed(() => chartMode.value === "center" ? "暂无中心入组数据" : "暂无月度入组数据" ); +const buildOverviewFromSites = (studyId: string, siteList: any[]): ProjectOverviewViewModel => { + const centers = buildFallbackCentersFromSites(siteList); + const totalTarget = centers.reduce((sum, center) => sum + center.enrollment_target, 0); + return { + study_id: studyId, + updated_at: new Date().toISOString(), + centers, + months: [], + summary: { + total_actual: 0, + total_target: totalTarget, + }, + }; +}; + const loadOverview = async () => { const studyId = study.currentStudy?.id; if (!studyId) return; loading.value = true; + let siteList: any[] = []; try { const sitesResp = await fetchSites(studyId, { limit: 500 }); - const siteList = Array.isArray(sitesResp.data) ? sitesResp.data : (sitesResp.data as any).items || []; + siteList = Array.isArray(sitesResp.data) ? sitesResp.data : (sitesResp.data as any).items || []; const siteActiveMap = new Map(siteList.map((s: any) => [s.id, !!s.is_active])); - - if (preferApi) { - const { data } = await fetchProjectOverview(studyId); - overview.value = adaptProjectOverview(data); - usingDemo.value = false; - updatedAtLabel.value = overview.value.updated_at - ? displayDateTime(overview.value.updated_at) - : displayDateTime(new Date()); - } else { - overview.value = adaptProjectOverview(overviewMock); - usingDemo.value = true; - updatedAtLabel.value = overview.value.updated_at - ? displayDateTime(overview.value.updated_at) - : displayDateTime(new Date()); - } + const { data } = await fetchProjectOverview(studyId); + overview.value = adaptProjectOverview(data); + updatedAtLabel.value = overview.value.updated_at + ? displayDateTime(overview.value.updated_at) + : displayDateTime(new Date()); if (overview.value && overview.value.centers.length === 0 && siteList.length > 0) { overview.value.centers = buildFallbackCentersFromSites(siteList); @@ -166,11 +168,8 @@ const loadOverview = async () => { overview.value.centers.sort((a, b) => Number(b.is_active) - Number(a.is_active)); } } catch { - overview.value = adaptProjectOverview(overviewMock); - usingDemo.value = true; - updatedAtLabel.value = overview.value.updated_at - ? displayDateTime(overview.value.updated_at) - : displayDateTime(new Date()); + overview.value = buildOverviewFromSites(studyId, siteList); + updatedAtLabel.value = displayDateTime(overview.value.updated_at); } finally { loading.value = false; } @@ -178,7 +177,6 @@ const loadOverview = async () => { const reset = () => { overview.value = null; - usingDemo.value = false; updatedAtLabel.value = displayDateTime(new Date()); }; @@ -208,11 +206,6 @@ watch( gap: 0; } -.demo-tag { - border-color: var(--ctms-border-color); - color: var(--ctms-text-secondary); -} - .updated-at { white-space: nowrap; } @@ -292,6 +285,37 @@ watch( gap: 2px; } +.overview-empty-panel { + min-height: 168px; + display: flex; + align-items: center; + justify-content: center; + margin-top: 6px; + padding: 24px; + border-radius: 18px; + background: + linear-gradient(180deg, rgba(246, 249, 253, 0.96), rgba(251, 253, 255, 0.98)); +} + +.overview-empty-content { + max-width: 520px; + text-align: center; +} + +.overview-empty-title { + font-size: 18px; + font-weight: 700; + letter-spacing: 0.01em; + color: #243a5a; +} + +.overview-empty-desc { + margin-top: 10px; + font-size: 14px; + line-height: 1.7; + color: #8a97ab; +} + .mode-switch :deep(.el-radio-button__inner) { padding: 4px 12px; } diff --git a/frontend/src/views/ia/RiskIssueMonitoringVisits.vue b/frontend/src/views/ia/RiskIssueMonitoringVisits.vue index 35f1a88f..08aa2b30 100644 --- a/frontend/src/views/ia/RiskIssueMonitoringVisits.vue +++ b/frontend/src/views/ia/RiskIssueMonitoringVisits.vue @@ -98,7 +98,7 @@ @@ -824,6 +824,17 @@ watch( justify-content: flex-end; } +.issue-table-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: #8a97ab; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; +} + .issue-table :deep(.el-table__inner-wrapper::before) { display: none; } diff --git a/frontend/src/views/ia/RiskIssuePd.vue b/frontend/src/views/ia/RiskIssuePd.vue index b9c48d49..b3c655c3 100644 --- a/frontend/src/views/ia/RiskIssuePd.vue +++ b/frontend/src/views/ia/RiskIssuePd.vue @@ -51,8 +51,10 @@ + -
@@ -67,7 +69,6 @@ import { useStudyStore } from "../../store/study"; import { listStudySubjectPds } from "../../api/subjectPds"; import { fetchSites } from "../../api/sites"; import { displayEnum } from "../../utils/display"; -import StateEmpty from "../../components/StateEmpty.vue"; import { TEXT } from "../../locales"; const router = useRouter(); @@ -190,4 +191,15 @@ onMounted(load); .risk-table :deep(.el-table__inner-wrapper::before) { display: none; } + +.risk-table-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: #8a97ab; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; +} diff --git a/frontend/src/views/ia/RiskIssueSae.vue b/frontend/src/views/ia/RiskIssueSae.vue index e148eb01..4dad8da7 100644 --- a/frontend/src/views/ia/RiskIssueSae.vue +++ b/frontend/src/views/ia/RiskIssueSae.vue @@ -79,8 +79,10 @@
+ - @@ -96,7 +98,6 @@ import { fetchAes } from "../../api/aes"; import { fetchSubjects } from "../../api/subjects"; import { fetchSites } from "../../api/sites"; import { displayDate, displayEnum } from "../../utils/display"; -import StateEmpty from "../../components/StateEmpty.vue"; import { TEXT } from "../../locales"; const router = useRouter(); @@ -247,4 +248,15 @@ onMounted(load); .risk-table :deep(.el-table__inner-wrapper::before) { display: none; } + +.risk-table-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: #8a97ab; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; +} diff --git a/frontend/src/views/ia/StartupFeasibilityEthics.vue b/frontend/src/views/ia/StartupFeasibilityEthics.vue index 860e034c..4edb5ee7 100644 --- a/frontend/src/views/ia/StartupFeasibilityEthics.vue +++ b/frontend/src/views/ia/StartupFeasibilityEthics.vue @@ -58,8 +58,10 @@ + - @@ -121,8 +123,10 @@ + - @@ -140,7 +144,6 @@ import { listFeasibility, listEthics, deleteFeasibility, deleteEthics } from ".. import { fetchSites } from "../../api/sites"; import type { Site } from "../../types/api"; import { displayDate } from "../../utils/display"; -import StateEmpty from "../../components/StateEmpty.vue"; import dayjs from "dayjs"; import type { StageStatus } from "./project-overview/overview.adapter"; import { TEXT } from "../../locales"; @@ -323,6 +326,16 @@ onMounted(() => { display: none; } +.table-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: #8a97ab; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; +}