release(main): 清理示例数据并统一空态体验
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
- 运行拓扑:`nginx`、`backend`、`db`
|
- 运行拓扑:`nginx`、`backend`、`db`
|
||||||
- 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API
|
- 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API
|
||||||
- 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql`
|
- 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql`
|
||||||
- 默认无任何 demo 数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在
|
- 默认无任何业务预置数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在
|
||||||
- 验证方式:
|
- 验证方式:
|
||||||
- `docker compose config`
|
- `docker compose config`
|
||||||
- `curl -i http://127.0.0.1:8888/`
|
- `curl -i http://127.0.0.1:8888/`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""seed overview demo data
|
"""disable overview demo data seed
|
||||||
|
|
||||||
Revision ID: 20260116_03
|
Revision ID: 20260116_03
|
||||||
Revises: 20260116_02
|
Revises: 20260116_02
|
||||||
@@ -6,14 +6,7 @@ Create Date: 2026-01-16 16:30:00.000000
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
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'
|
revision: str = '20260116_03'
|
||||||
down_revision: Union[str, None] = '20260116_02'
|
down_revision: Union[str, None] = '20260116_02'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
@@ -21,179 +14,11 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# 获取connection
|
# This historical revision is intentionally kept as a no-op so fresh
|
||||||
conn = op.get_bind()
|
# databases no longer receive overview demo data during migration.
|
||||||
|
|
||||||
# 首先获取第一个项目ID
|
|
||||||
result = conn.execute(sa.text("SELECT id FROM studies LIMIT 1"))
|
|
||||||
study_row = result.first()
|
|
||||||
|
|
||||||
if not study_row:
|
|
||||||
print("没有项目,跳过示例数据注入")
|
|
||||||
return
|
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("✅ 示例数据注入完成")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# 删除示例数据 (可选 - 也可以留空)
|
return
|
||||||
pass
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
|
||||||
@@ -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())
|
|
||||||
@@ -43,9 +43,8 @@ class _DummyEngine:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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()
|
ensure_admin_exists = AsyncMock()
|
||||||
seed_demo_fees = AsyncMock()
|
|
||||||
create_all = AsyncMock()
|
create_all = AsyncMock()
|
||||||
legacy_pk = AsyncMock()
|
legacy_pk = AsyncMock()
|
||||||
scheduler_task = _DummyTask()
|
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.settings, "ENV", "development")
|
||||||
monkeypatch.setattr(main, "ensure_admin_exists", ensure_admin_exists)
|
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, "run_daily_lost_visit_job", fake_scheduler)
|
||||||
monkeypatch.setattr(main.asyncio, "create_task", fake_create_task)
|
monkeypatch.setattr(main.asyncio, "create_task", fake_create_task)
|
||||||
monkeypatch.setattr(main, "engine", _DummyEngine())
|
monkeypatch.setattr(main, "engine", _DummyEngine())
|
||||||
@@ -75,4 +73,3 @@ async def test_development_startup_does_not_seed_demo_fees(monkeypatch):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
ensure_admin_exists.assert_awaited_once()
|
ensure_admin_exists.assert_awaited_once()
|
||||||
seed_demo_fees.assert_not_awaited()
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Reviewer: `Codex`
|
|||||||
- [x] Finance pages visual consistency
|
- [x] Finance pages visual consistency
|
||||||
- [x] File/admin detail visual consistency
|
- [x] File/admin detail visual consistency
|
||||||
- [x] Desktop and iPad width checks
|
- [x] Desktop and iPad width checks
|
||||||
- [x] Management demo path continuity
|
- [x] Management path continuity
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Verified unified contract classes and shared shell usage on all target pages.
|
- Verified unified contract classes and shared shell usage on all target pages.
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
},
|
},
|
||||||
"published_data": null,
|
"published_data": null,
|
||||||
"published_project_snapshot": {
|
"published_project_snapshot": {
|
||||||
"code": "DEMO-CTMS",
|
"code": "CTMS-001",
|
||||||
"name": "演示项目",
|
"name": "演示项目",
|
||||||
"project_full_name": "演示项目全称",
|
"project_full_name": "演示项目全称",
|
||||||
"sponsor": "",
|
"sponsor": "",
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ Create acceptance checklist with explicit pass/fail items. Mark all as pending i
|
|||||||
- [ ] Finance pages visual consistency
|
- [ ] Finance pages visual consistency
|
||||||
- [ ] File/admin detail visual consistency
|
- [ ] File/admin detail visual consistency
|
||||||
- [ ] Desktop and iPad width checks
|
- [ ] Desktop and iPad width checks
|
||||||
- [ ] Management demo path continuity
|
- [ ] Management path continuity
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 2: Run test to verify it fails**
|
**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
|
## 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).
|
2. **M2 (Day 4-7):** Tasks 4-5 (first 6 core pages).
|
||||||
3. **M3 (Day 8-10):** Tasks 6-8 (remaining 4 pages + states).
|
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
|
## Definition of Done
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
- Production schema source of truth becomes Alembic only.
|
- Production schema source of truth becomes Alembic only.
|
||||||
- New production environments must start from an empty database.
|
- New production environments must start from an empty database.
|
||||||
- Production initialization explicitly runs schema migrations first, then ensures a fixed administrator account exists.
|
- 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 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 cannot be deleted, disabled, downgraded from `ADMIN`, or have its email changed.
|
||||||
- The fixed administrator may change its password after first login.
|
- 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.
|
4. Log in with `admin@huapont.cn / admin123`, then change the password.
|
||||||
|
|
||||||
**Non-Goals**
|
**Non-Goals**
|
||||||
- No automatic demo data import.
|
- No automatic business seed import.
|
||||||
- No runtime auto-migration inside the long-lived backend container.
|
- No runtime auto-migration inside the long-lived backend container.
|
||||||
- No second schema definition maintained in `database/init.sql`.
|
- No second schema definition maintained in `database/init.sql`.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
> **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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
> **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
|
**Tech Stack:** FastAPI, pytest, unittest.mock
|
||||||
|
|
||||||
@@ -19,18 +19,18 @@
|
|||||||
**Step 1: Write the failing test**
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
```python
|
```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**
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
Run: `pytest backend/tests/test_app_startup.py -q`
|
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**
|
**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**
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,3 @@
|
|||||||
VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3
|
VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3
|
||||||
VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3
|
VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3
|
||||||
VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6
|
VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6
|
||||||
VITE_USE_OVERVIEW_API=false
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { apiGet, apiPost } from "./axios";
|
|||||||
import type { AdminUserListResponse, UserInfo, UserStatus } from "../types/api";
|
import type { AdminUserListResponse, UserInfo, UserStatus } from "../types/api";
|
||||||
|
|
||||||
export const listPendingUsers = (status: UserStatus = "PENDING"): Promise<AxiosResponse<AdminUserListResponse>> =>
|
export const listPendingUsers = (status: UserStatus = "PENDING"): Promise<AxiosResponse<AdminUserListResponse>> =>
|
||||||
apiGet("/api/v1/admin/users", { params: { status } });
|
apiGet("/api/v1/admin/users/", { params: { status } });
|
||||||
|
|
||||||
export const approveUser = (userId: string): Promise<AxiosResponse<UserInfo>> =>
|
export const approveUser = (userId: string): Promise<AxiosResponse<UserInfo>> =>
|
||||||
apiPost(`/api/v1/admin/users/${userId}/approve`, { action: "approve" });
|
apiPost(`/api/v1/admin/users/${userId}/approve`, { action: "approve" });
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ export interface FaqReply {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const fetchFaqCategories = (params?: Record<string, any>): Promise<AxiosResponse<ApiListResponse<FaqCategory>>> =>
|
export const fetchFaqCategories = (params?: Record<string, any>): Promise<AxiosResponse<ApiListResponse<FaqCategory>>> =>
|
||||||
apiGet("/api/v1/faqs/categories", { params });
|
apiGet("/api/v1/faqs/categories/", { params });
|
||||||
|
|
||||||
export const createFaqCategory = (payload: Record<string, any>) =>
|
export const createFaqCategory = (payload: Record<string, any>) =>
|
||||||
apiPost<FaqCategory>("/api/v1/faqs/categories", payload);
|
apiPost<FaqCategory>("/api/v1/faqs/categories/", payload);
|
||||||
|
|
||||||
export const updateFaqCategory = (categoryId: string, payload: Record<string, any>) =>
|
export const updateFaqCategory = (categoryId: string, payload: Record<string, any>) =>
|
||||||
apiPatch<FaqCategory>(`/api/v1/faqs/categories/${categoryId}`, payload);
|
apiPatch<FaqCategory>(`/api/v1/faqs/categories/${categoryId}`, payload);
|
||||||
@@ -61,11 +61,11 @@ export const deleteFaqCategory = (categoryId: string) =>
|
|||||||
apiDelete<void>(`/api/v1/faqs/categories/${categoryId}`);
|
apiDelete<void>(`/api/v1/faqs/categories/${categoryId}`);
|
||||||
|
|
||||||
export const fetchFaqItems = (params?: Record<string, any>): Promise<AxiosResponse<ApiListResponse<FaqItem>>> =>
|
export const fetchFaqItems = (params?: Record<string, any>): Promise<AxiosResponse<ApiListResponse<FaqItem>>> =>
|
||||||
apiGet("/api/v1/faqs/items", { params });
|
apiGet("/api/v1/faqs/items/", { params });
|
||||||
|
|
||||||
export const fetchFaqItem = (itemId: string) => apiGet<FaqItem>(`/api/v1/faqs/items/${itemId}`);
|
export const fetchFaqItem = (itemId: string) => apiGet<FaqItem>(`/api/v1/faqs/items/${itemId}`);
|
||||||
|
|
||||||
export const createFaqItem = (payload: Record<string, any>) => apiPost<FaqItem>("/api/v1/faqs/items", payload);
|
export const createFaqItem = (payload: Record<string, any>) => apiPost<FaqItem>("/api/v1/faqs/items/", payload);
|
||||||
|
|
||||||
export const updateFaqItem = (itemId: string, payload: Record<string, any>) =>
|
export const updateFaqItem = (itemId: string, payload: Record<string, any>) =>
|
||||||
apiPatch<FaqItem>(`/api/v1/faqs/items/${itemId}`, payload);
|
apiPatch<FaqItem>(`/api/v1/faqs/items/${itemId}`, payload);
|
||||||
|
|||||||
@@ -10,11 +10,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="module-content unified-section section--flush">
|
<div class="module-content unified-section section--flush">
|
||||||
<div class="module-list">
|
<div class="module-list">
|
||||||
<div class="module-list-header">
|
<div class="module-placeholder-surface">
|
||||||
<span class="list-title">{{ listTitle }}</span>
|
<div class="module-placeholder-main">{{ emptyDescription || emptyTitle }}</div>
|
||||||
<span class="list-note">{{ TEXT.common.empty.listPlaceholder }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<StateEmpty :title="emptyTitle" :description="emptyDescription" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,7 +20,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import StateEmpty from "./StateEmpty.vue";
|
|
||||||
import { TEXT } from "../locales";
|
import { TEXT } from "../locales";
|
||||||
|
|
||||||
withDefaults(
|
withDefaults(
|
||||||
@@ -69,21 +66,24 @@ withDefaults(
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-list-header {
|
.module-placeholder-surface {
|
||||||
|
min-height: 168px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 12px;
|
justify-content: center;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 24px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(246, 249, 253, 0.96), rgba(251, 253, 255, 0.98));
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-title {
|
.module-placeholder-main {
|
||||||
font-size: 14px;
|
max-width: 560px;
|
||||||
font-weight: 600;
|
text-align: center;
|
||||||
color: var(--ctms-text-main);
|
font-size: 18px;
|
||||||
}
|
font-weight: 700;
|
||||||
|
line-height: 1.6;
|
||||||
.list-note {
|
letter-spacing: 0.01em;
|
||||||
font-size: 12px;
|
color: #243a5a;
|
||||||
color: var(--ctms-text-disabled);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="ctms-state state state-empty">
|
<section class="ctms-state state state-empty">
|
||||||
<el-icon class="ctms-state-icon state-icon"><Document /></el-icon>
|
<el-icon v-if="false" class="ctms-state-icon state-icon"><Document /></el-icon>
|
||||||
<h3 class="ctms-state-title state-title">{{ title }}</h3>
|
<h3 v-if="!description" class="ctms-state-title state-title">{{ title }}</h3>
|
||||||
<p v-if="description" class="ctms-state-desc state-desc">{{ description }}</p>
|
<p class="ctms-state-desc state-desc">{{ description || title }}</p>
|
||||||
<div v-if="$slots.action" class="ctms-state-action state-action">
|
<div v-if="$slots.action" class="ctms-state-action state-action">
|
||||||
<slot name="action" />
|
<slot name="action" />
|
||||||
</div>
|
</div>
|
||||||
@@ -26,15 +26,33 @@ withDefaults(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.state-empty {
|
||||||
|
padding: 28px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.state-icon {
|
.state-icon {
|
||||||
color: var(--ctms-text-disabled);
|
color: var(--ctms-text-disabled);
|
||||||
|
font-size: 18px;
|
||||||
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-title {
|
.state-title {
|
||||||
color: var(--ctms-text-regular);
|
color: var(--ctms-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-desc {
|
.state-desc {
|
||||||
color: var(--ctms-text-secondary);
|
color: #8a97ab;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-action {
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("auth store logout", () => {
|
|||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
const auth = useAuthStore();
|
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();
|
auth.logout();
|
||||||
|
|
||||||
expect(session.locked).toBe(false);
|
expect(session.locked).toBe(false);
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ describe("study store startup rehydration", () => {
|
|||||||
"ctms_current_study",
|
"ctms_current_study",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
id: "deleted-study",
|
id: "deleted-study",
|
||||||
code: "DEMO-CTMS",
|
code: "ARCHIVED-CTMS",
|
||||||
name: "示例临床试验项目",
|
name: "已删除项目",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
fetchStudies.mockResolvedValue({
|
fetchStudies.mockResolvedValue({
|
||||||
|
|||||||
@@ -84,8 +84,10 @@
|
|||||||
<el-table-column :label="TEXT.common.fields.remark" min-width="200" show-overflow-tooltip>
|
<el-table-column :label="TEXT.common.fields.remark" min-width="200" show-overflow-tooltip>
|
||||||
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
|
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div class="table-empty">{{ TEXT.modules.feeContracts.paymentEmpty }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="detail.payments.length === 0" :description="TEXT.modules.feeContracts.paymentEmpty" />
|
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card class="section-card unified-shell" shadow="never">
|
<el-card class="section-card unified-shell" shadow="never">
|
||||||
@@ -116,7 +118,6 @@ import { fetchSites } from "../../api/sites";
|
|||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { usePermission } from "../../utils/permission";
|
import { usePermission } from "../../utils/permission";
|
||||||
import { displayDate } from "../../utils/display";
|
import { displayDate } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import StateError from "../../components/StateError.vue";
|
import StateError from "../../components/StateError.vue";
|
||||||
import StateLoading from "../../components/StateLoading.vue";
|
import StateLoading from "../../components/StateLoading.vue";
|
||||||
import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue";
|
import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue";
|
||||||
@@ -307,4 +308,14 @@ onMounted(async () => {
|
|||||||
color: var(--ctms-text-placeholder);
|
color: var(--ctms-text-placeholder);
|
||||||
font-size: 13px;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -164,8 +164,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div class="table-empty">{{ TEXT.modules.feeContracts.empty }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="sortedContracts.length === 0" :description="TEXT.modules.feeContracts.empty" />
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +183,6 @@ import { fetchSites } from "../../api/sites";
|
|||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { usePermission } from "../../utils/permission";
|
import { usePermission } from "../../utils/permission";
|
||||||
import { displayDate } from "../../utils/display";
|
import { displayDate } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import StateLoading from "../../components/StateLoading.vue";
|
import StateLoading from "../../components/StateLoading.vue";
|
||||||
import StateError from "../../components/StateError.vue";
|
import StateError from "../../components/StateError.vue";
|
||||||
import KpiCard from "../../components/KpiCard.vue";
|
import KpiCard from "../../components/KpiCard.vue";
|
||||||
@@ -444,6 +445,16 @@ onMounted(async () => {
|
|||||||
font-family: var(--el-font-family);
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -163,8 +163,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div class="table-empty">{{ TEXT.modules.feeSpecials.empty }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="sortedExpenses.length === 0" :description="TEXT.modules.feeSpecials.empty" />
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +182,6 @@ import { fetchSites } from "../../api/sites";
|
|||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { usePermission } from "../../utils/permission";
|
import { usePermission } from "../../utils/permission";
|
||||||
import { displayDate, displayEnum } from "../../utils/display";
|
import { displayDate, displayEnum } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import StateLoading from "../../components/StateLoading.vue";
|
import StateLoading from "../../components/StateLoading.vue";
|
||||||
import StateError from "../../components/StateError.vue";
|
import StateError from "../../components/StateError.vue";
|
||||||
import KpiCard from "../../components/KpiCard.vue";
|
import KpiCard from "../../components/KpiCard.vue";
|
||||||
@@ -424,6 +425,16 @@ onMounted(async () => {
|
|||||||
color: var(--ctms-text-placeholder);
|
color: var(--ctms-text-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -102,8 +102,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loading" class="table-empty">{{ TEXT.modules.drugShipments.empty }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.drugShipments.empty" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +120,6 @@ import { useStudyStore } from "../../store/study";
|
|||||||
import { listDrugShipments, deleteDrugShipment } from "../../api/drugShipments";
|
import { listDrugShipments, deleteDrugShipment } from "../../api/drugShipments";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDate, displayEnum } from "../../utils/display";
|
import { displayDate, displayEnum } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -312,6 +313,16 @@ onMounted(async () => {
|
|||||||
.text-muted {
|
.text-muted {
|
||||||
color: var(--ctms-text-placeholder);
|
color: var(--ctms-text-placeholder);
|
||||||
}
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -67,8 +67,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loading" class="table-empty">{{ TEXT.modules.financeContracts.empty }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeContracts.empty" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,7 +84,6 @@ import { useStudyStore } from "../../store/study";
|
|||||||
import { listFinanceContracts, deleteFinanceContract } from "../../api/financeContracts";
|
import { listFinanceContracts, deleteFinanceContract } from "../../api/financeContracts";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDate, displayDateTime } from "../../utils/display";
|
import { displayDate, displayDateTime } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -223,6 +224,16 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -77,8 +77,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loading" class="table-empty">{{ TEXT.modules.financeSpecials.empty }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeSpecials.empty" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +94,6 @@ import { useStudyStore } from "../../store/study";
|
|||||||
import { listFinanceSpecials, deleteFinanceSpecial } from "../../api/financeSpecials";
|
import { listFinanceSpecials, deleteFinanceSpecial } from "../../api/financeSpecials";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDate, displayDateTime, displayEnum } from "../../utils/display";
|
import { displayDate, displayDateTime, displayEnum } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -235,6 +236,16 @@ onMounted(async () => {
|
|||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -57,8 +57,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loading" class="table-empty">{{ TEXT.modules.knowledgeNotes.empty }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.knowledgeNotes.empty" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +74,6 @@ import { useStudyStore } from "../../store/study";
|
|||||||
import { listKnowledgeNotes, deleteKnowledgeNote } from "../../api/knowledgeNotes";
|
import { listKnowledgeNotes, deleteKnowledgeNote } from "../../api/knowledgeNotes";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDateTime } from "../../utils/display";
|
import { displayDateTime } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -199,6 +200,16 @@ onMounted(async () => {
|
|||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -31,9 +31,10 @@
|
|||||||
<el-button link type="danger" @click="removeRow(row)">删除</el-button>
|
<el-button link type="danger" @click="removeRow(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loading" class="table-empty">暂无设备数据</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<StateEmpty v-if="!loading && rows.length === 0" description="暂无设备数据" />
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
|
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
|
||||||
@@ -598,4 +599,14 @@ watch(
|
|||||||
.equipment-table :deep(td.el-table__cell .cell) {
|
.equipment-table :deep(td.el-table__cell .cell) {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -102,17 +102,15 @@
|
|||||||
<el-table-column prop="remark" :label="TEXT.modules.projectMilestones.columns.remark" min-width="100">
|
<el-table-column prop="remark" :label="TEXT.modules.projectMilestones.columns.remark" min-width="100">
|
||||||
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
|
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.modules.projectMilestones.columns.operations" width="84" fixed="right">
|
<el-table-column :label="TEXT.modules.projectMilestones.columns.operations" width="84">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="primary" @click="openEditor(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
<el-button link type="primary" @click="openEditor(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loading" class="table-empty">{{ TEXT.modules.projectMilestones.emptyDescription }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<StateEmpty
|
|
||||||
v-if="!loading && rows.length === 0"
|
|
||||||
:description="TEXT.modules.projectMilestones.emptyDescription"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
|
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
|
||||||
@@ -665,4 +663,15 @@ watch(
|
|||||||
.ctms-table :deep(.el-table__inner-wrapper::before) {
|
.ctms-table :deep(.el-table__inner-wrapper::before) {
|
||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
<div class="unified-shell ctms-table-card">
|
<div class="unified-shell ctms-table-card">
|
||||||
<section class="unified-section">
|
<section class="unified-section">
|
||||||
<div class="overview-meta-inline">
|
<div class="overview-meta-inline">
|
||||||
<el-tag v-if="usingDemo" effect="plain" type="info" class="demo-tag">示例数据</el-tag>
|
|
||||||
<div class="updated-at">更新:{{ updatedAtLabel }}</div>
|
<div class="updated-at">更新:{{ updatedAtLabel }}</div>
|
||||||
<el-button size="small" @click="loadOverview">刷新</el-button>
|
<el-button size="small" @click="loadOverview">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -20,11 +19,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StateLoading v-if="loading" :rows="6" />
|
<StateLoading v-if="loading" :rows="6" />
|
||||||
<StateEmpty
|
<div v-else-if="centers.length === 0" class="overview-empty-panel">
|
||||||
v-else-if="centers.length === 0"
|
<div class="overview-empty-content">
|
||||||
title="暂无中心进度"
|
<div class="overview-empty-title">中心整体进度暂未生成</div>
|
||||||
description="当前项目未配置中心或暂无进度数据"
|
<div class="overview-empty-desc">当前项目未配置中心,或尚未形成可展示的中心进度数据。</div>
|
||||||
/>
|
</div>
|
||||||
|
</div>
|
||||||
<div v-else class="progress-list">
|
<div v-else class="progress-list">
|
||||||
<CenterProgressRow
|
<CenterProgressRow
|
||||||
v-for="(center, index) in centers"
|
v-for="(center, index) in centers"
|
||||||
@@ -72,17 +72,13 @@ import StateLoading from "../../components/StateLoading.vue";
|
|||||||
import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
|
import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
|
||||||
import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue";
|
import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue";
|
||||||
import { adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter";
|
import { adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter";
|
||||||
import { overviewMock } from "./project-overview/overview.mock";
|
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const usingDemo = ref(false);
|
|
||||||
const overview = ref<ProjectOverviewViewModel | null>(null);
|
const overview = ref<ProjectOverviewViewModel | null>(null);
|
||||||
const chartMode = ref<"center" | "month">("center");
|
const chartMode = ref<"center" | "month">("center");
|
||||||
const updatedAtLabel = ref(displayDateTime(new Date()));
|
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 centers = computed(() => overview.value?.centers || []);
|
||||||
|
|
||||||
const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] =>
|
const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] =>
|
||||||
@@ -128,29 +124,35 @@ const chartEmptyText = computed(() =>
|
|||||||
chartMode.value === "center" ? "暂无中心入组数据" : "暂无月度入组数据"
|
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 loadOverview = async () => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
let siteList: any[] = [];
|
||||||
try {
|
try {
|
||||||
const sitesResp = await fetchSites(studyId, { limit: 500 });
|
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<string, boolean>(siteList.map((s: any) => [s.id, !!s.is_active]));
|
const siteActiveMap = new Map<string, boolean>(siteList.map((s: any) => [s.id, !!s.is_active]));
|
||||||
|
|
||||||
if (preferApi) {
|
|
||||||
const { data } = await fetchProjectOverview(studyId);
|
const { data } = await fetchProjectOverview(studyId);
|
||||||
overview.value = adaptProjectOverview(data);
|
overview.value = adaptProjectOverview(data);
|
||||||
usingDemo.value = false;
|
|
||||||
updatedAtLabel.value = overview.value.updated_at
|
updatedAtLabel.value = overview.value.updated_at
|
||||||
? displayDateTime(overview.value.updated_at)
|
? displayDateTime(overview.value.updated_at)
|
||||||
: displayDateTime(new Date());
|
: 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overview.value && overview.value.centers.length === 0 && siteList.length > 0) {
|
if (overview.value && overview.value.centers.length === 0 && siteList.length > 0) {
|
||||||
overview.value.centers = buildFallbackCentersFromSites(siteList);
|
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));
|
overview.value.centers.sort((a, b) => Number(b.is_active) - Number(a.is_active));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
overview.value = adaptProjectOverview(overviewMock);
|
overview.value = buildOverviewFromSites(studyId, siteList);
|
||||||
usingDemo.value = true;
|
updatedAtLabel.value = displayDateTime(overview.value.updated_at);
|
||||||
updatedAtLabel.value = overview.value.updated_at
|
|
||||||
? displayDateTime(overview.value.updated_at)
|
|
||||||
: displayDateTime(new Date());
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -178,7 +177,6 @@ const loadOverview = async () => {
|
|||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
overview.value = null;
|
overview.value = null;
|
||||||
usingDemo.value = false;
|
|
||||||
updatedAtLabel.value = displayDateTime(new Date());
|
updatedAtLabel.value = displayDateTime(new Date());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -208,11 +206,6 @@ watch(
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.demo-tag {
|
|
||||||
border-color: var(--ctms-border-color);
|
|
||||||
color: var(--ctms-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.updated-at {
|
.updated-at {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -292,6 +285,37 @@ watch(
|
|||||||
gap: 2px;
|
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) {
|
.mode-switch :deep(.el-radio-button__inner) {
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<StateEmpty v-if="!loading" description="暂无监查访视问题" />
|
<div v-if="!loading" class="issue-table-empty">暂无监查访视问题</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
@@ -824,6 +824,17 @@ watch(
|
|||||||
justify-content: flex-end;
|
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) {
|
.issue-table :deep(.el-table__inner-wrapper::before) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loading" class="risk-table-empty">{{ TEXT.modules.riskIssuePd.emptyDescription }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loading && filteredItems.length === 0" :description="TEXT.modules.riskIssuePd.emptyDescription" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +69,6 @@ import { useStudyStore } from "../../store/study";
|
|||||||
import { listStudySubjectPds } from "../../api/subjectPds";
|
import { listStudySubjectPds } from "../../api/subjectPds";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayEnum } from "../../utils/display";
|
import { displayEnum } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -190,4 +191,15 @@ onMounted(load);
|
|||||||
.risk-table :deep(.el-table__inner-wrapper::before) {
|
.risk-table :deep(.el-table__inner-wrapper::before) {
|
||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -79,8 +79,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loading" class="risk-table-empty">{{ TEXT.modules.riskIssueSae.emptyDescription }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loading && filteredItems.length === 0" :description="TEXT.modules.riskIssueSae.emptyDescription" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +98,6 @@ import { fetchAes } from "../../api/aes";
|
|||||||
import { fetchSubjects } from "../../api/subjects";
|
import { fetchSubjects } from "../../api/subjects";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDate, displayEnum } from "../../utils/display";
|
import { displayDate, displayEnum } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -247,4 +248,15 @@ onMounted(load);
|
|||||||
.risk-table :deep(.el-table__inner-wrapper::before) {
|
.risk-table :deep(.el-table__inner-wrapper::before) {
|
||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -58,8 +58,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loadingFeasibility" class="table-empty">{{ TEXT.modules.startupFeasibilityEthics.emptyFeasibility }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loadingFeasibility && sortedFeasibilityItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyFeasibility" />
|
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics">
|
<el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics">
|
||||||
@@ -121,8 +123,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loadingEthics" class="table-empty">{{ TEXT.modules.startupFeasibilityEthics.emptyEthics }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loadingEthics && sortedEthicsItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyEthics" />
|
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
@@ -140,7 +144,6 @@ import { listFeasibility, listEthics, deleteFeasibility, deleteEthics } from "..
|
|||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
import type { Site } from "../../types/api";
|
import type { Site } from "../../types/api";
|
||||||
import { displayDate } from "../../utils/display";
|
import { displayDate } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import type { StageStatus } from "./project-overview/overview.adapter";
|
import type { StageStatus } from "./project-overview/overview.adapter";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
@@ -323,6 +326,16 @@ onMounted(() => {
|
|||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -24,8 +24,10 @@
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loading" class="table-empty">{{ TEXT.modules.startupMeetingAuth.emptyKickoff }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loading && kickoffRows.length === 0" :description="TEXT.modules.startupMeetingAuth.emptyKickoff" />
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +44,6 @@ import { fetchSites } from "../../api/sites";
|
|||||||
import { listMembers } from "../../api/members";
|
import { listMembers } from "../../api/members";
|
||||||
import { fetchUsers } from "../../api/users";
|
import { fetchUsers } from "../../api/users";
|
||||||
import { displayDate } from "../../utils/display";
|
import { displayDate } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -170,6 +171,16 @@ onMounted(() => {
|
|||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -105,6 +105,9 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loading" class="table-empty">{{ TEXT.modules.subjectManagement.empty }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<div class="pagination-wrap" v-if="filteredItems.length > 0">
|
<div class="pagination-wrap" v-if="filteredItems.length > 0">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
@@ -116,7 +119,6 @@
|
|||||||
small
|
small
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.subjectManagement.empty" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +133,6 @@ import { useStudyStore } from "../../store/study";
|
|||||||
import { deleteSubject, fetchSubjects } from "../../api/subjects";
|
import { deleteSubject, fetchSubjects } from "../../api/subjects";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
import { displayDate, displayEnum } from "../../utils/display";
|
import { displayDate, displayEnum } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -364,6 +365,16 @@ onMounted(async () => {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="enrollment-chart">
|
<div class="enrollment-chart">
|
||||||
<StateLoading v-if="loading" :rows="5" />
|
<StateLoading v-if="loading" :rows="5" />
|
||||||
<StateEmpty v-else-if="items.length === 0" :description="emptyText" />
|
<div v-else-if="items.length === 0" class="chart-empty-shell">
|
||||||
|
<div class="chart-empty-body">
|
||||||
|
<div class="chart-empty-title">暂无入组进度数据</div>
|
||||||
|
<div class="chart-empty-desc">{{ emptyText }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-else class="chart-body">
|
<div v-else class="chart-body">
|
||||||
<div class="chart-scroll">
|
<div class="chart-scroll">
|
||||||
<div class="chart-plot">
|
<div class="chart-plot">
|
||||||
@@ -94,7 +99,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import StateLoading from "../../../components/StateLoading.vue";
|
import StateLoading from "../../../components/StateLoading.vue";
|
||||||
import StateEmpty from "../../../components/StateEmpty.vue";
|
|
||||||
|
|
||||||
export type ChartMode = "center" | "month";
|
export type ChartMode = "center" | "month";
|
||||||
|
|
||||||
@@ -206,6 +210,37 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-empty-shell {
|
||||||
|
min-height: 176px;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 22px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(246, 249, 253, 0.96), rgba(251, 253, 255, 0.98));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-empty-body {
|
||||||
|
min-height: 118px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-empty-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #243a5a;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-empty-desc {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #8a97ab;
|
||||||
|
}
|
||||||
|
|
||||||
.chart-body {
|
.chart-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import type { ProjectOverviewResponse } from "./overview.adapter";
|
|
||||||
|
|
||||||
// demo / overview only
|
|
||||||
|
|
||||||
export const overviewMock: ProjectOverviewResponse = {
|
|
||||||
study_id: "demo-overview",
|
|
||||||
updated_at: "2025-06-18",
|
|
||||||
centers: [
|
|
||||||
{
|
|
||||||
center_id: "center-001",
|
|
||||||
center_name: "中心01",
|
|
||||||
institution_initiation_status: "COMPLETED",
|
|
||||||
institution_initiation_completed_at: "2024-12-20",
|
|
||||||
ethics_status: "COMPLETED",
|
|
||||||
ethics_completed_at: "2025-01-12",
|
|
||||||
contract_sign_status: "COMPLETED",
|
|
||||||
contract_sign_completed_at: "2025-01-20",
|
|
||||||
startup_status: "COMPLETED",
|
|
||||||
startup_completed_at: "2025-02-03",
|
|
||||||
enrollment_status: "IN_PROGRESS",
|
|
||||||
inspection_status: "NOT_STARTED",
|
|
||||||
closeout_status: "NOT_STARTED",
|
|
||||||
enrollment_target: 80,
|
|
||||||
enrollment_actual: 52,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
center_id: "center-002",
|
|
||||||
center_name: "中心02",
|
|
||||||
institution_initiation_status: "COMPLETED",
|
|
||||||
institution_initiation_completed_at: "2025-01-05",
|
|
||||||
ethics_status: "COMPLETED",
|
|
||||||
ethics_completed_at: "2025-02-08",
|
|
||||||
contract_sign_status: "IN_PROGRESS",
|
|
||||||
startup_status: "IN_PROGRESS",
|
|
||||||
enrollment_status: "NOT_STARTED",
|
|
||||||
inspection_status: "NOT_STARTED",
|
|
||||||
closeout_status: "NOT_STARTED",
|
|
||||||
enrollment_target: 60,
|
|
||||||
enrollment_actual: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
center_id: "center-003",
|
|
||||||
center_name: "中心03",
|
|
||||||
institution_initiation_status: "COMPLETED",
|
|
||||||
institution_initiation_completed_at: "2024-12-10",
|
|
||||||
ethics_status: "COMPLETED",
|
|
||||||
ethics_completed_at: "2024-12-28",
|
|
||||||
contract_sign_status: "COMPLETED",
|
|
||||||
contract_sign_completed_at: "2025-01-05",
|
|
||||||
startup_status: "COMPLETED",
|
|
||||||
startup_completed_at: "2025-01-20",
|
|
||||||
enrollment_status: "COMPLETED",
|
|
||||||
enrollment_completed_at: "2025-04-05",
|
|
||||||
inspection_status: "IN_PROGRESS",
|
|
||||||
closeout_status: "NOT_STARTED",
|
|
||||||
enrollment_target: 70,
|
|
||||||
enrollment_actual: 70,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
center_id: "center-004",
|
|
||||||
center_name: "中心04",
|
|
||||||
institution_initiation_status: "COMPLETED",
|
|
||||||
institution_initiation_completed_at: "2024-12-22",
|
|
||||||
ethics_status: "COMPLETED",
|
|
||||||
ethics_completed_at: "2025-01-15",
|
|
||||||
contract_sign_status: "COMPLETED",
|
|
||||||
contract_sign_completed_at: "2025-01-25",
|
|
||||||
startup_status: "BLOCKED",
|
|
||||||
enrollment_status: "NOT_STARTED",
|
|
||||||
inspection_status: "NOT_STARTED",
|
|
||||||
closeout_status: "NOT_STARTED",
|
|
||||||
enrollment_target: 50,
|
|
||||||
enrollment_actual: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
center_id: "center-005",
|
|
||||||
center_name: "中心05",
|
|
||||||
institution_initiation_status: "IN_PROGRESS",
|
|
||||||
ethics_status: "IN_PROGRESS",
|
|
||||||
contract_sign_status: "NOT_STARTED",
|
|
||||||
startup_status: "NOT_STARTED",
|
|
||||||
enrollment_status: "NOT_STARTED",
|
|
||||||
inspection_status: "NOT_STARTED",
|
|
||||||
closeout_status: "NOT_STARTED",
|
|
||||||
enrollment_target: 40,
|
|
||||||
enrollment_actual: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
enrollment_by_month: [
|
|
||||||
{ month: "2025-01", count: 12 },
|
|
||||||
{ month: "2025-02", count: 18 },
|
|
||||||
{ month: "2025-03", count: 24 },
|
|
||||||
{ month: "2025-04", count: 28 },
|
|
||||||
{ month: "2025-05", count: 32 },
|
|
||||||
{ month: "2025-06", count: 30 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -111,8 +111,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loadingHistory" class="table-empty">{{ TEXT.modules.subjectDetail.emptyHistory }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loadingHistory && historyItems.length === 0" :description="TEXT.modules.subjectDetail.emptyHistory" />
|
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
|
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
|
||||||
@@ -192,8 +194,10 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loadingVisits" class="table-empty">{{ TEXT.modules.subjectDetail.emptyVisits }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loadingVisits && visitItems.length === 0" :description="TEXT.modules.subjectDetail.emptyVisits" />
|
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
|
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
|
||||||
@@ -248,8 +252,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loadingAes" class="table-empty">{{ TEXT.modules.subjectDetail.emptyAe }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loadingAes && aeItems.length === 0" :description="TEXT.modules.subjectDetail.emptyAe" />
|
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.pd" name="pd">
|
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.pd" name="pd">
|
||||||
@@ -292,8 +298,10 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loadingPds" class="table-empty">{{ TEXT.modules.subjectDetail.emptyPd }}</div>
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<StateEmpty v-if="!loadingPds && pdItems.length === 0" :description="TEXT.modules.subjectDetail.emptyPd" />
|
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -427,7 +435,6 @@ import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/vi
|
|||||||
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
|
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
|
||||||
import { listSubjectPds, createSubjectPd, updateSubjectPd, deleteSubjectPd } from "../../api/subjectPds";
|
import { listSubjectPds, createSubjectPd, updateSubjectPd, deleteSubjectPd } from "../../api/subjectPds";
|
||||||
import { displayDate, displayEnum, displayText } from "../../utils/display";
|
import { displayDate, displayEnum, displayText } from "../../utils/display";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -1161,4 +1168,14 @@ onMounted(async () => {
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background-color: #8b5cf6;
|
background-color: #8b5cf6;
|
||||||
}
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user