For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 克隆样本接收信息的完整链路(前端页面 + 后端 API + 独立通知表),实现样本入库信息的短信通知管理。
Architecture: 新建独立表 biz_storage_entry_sms_notification,关联 biz_storage_release_review;后端 CRUD 克隆 sample_sms_notification.py 的联查+懒创建+短信生成模式;前端克隆 sample-reception-info/index.vue 页面。
Tech Stack: FastAPI + SQLAlchemy + Pydantic (后端) / Vue 3 + Element Plus + TypeScript (前端)
Design Spec: docs/superpowers/specs/2025-05-23-storage-entry-sms-notification-design.md
| 文件 | 职责 |
|---|---|
docs/sql/biz_storage_entry_sms_notification.sql |
建表 DDL |
backend/app/crud/storage_entry_sms_notification.py |
CRUD 层:联查、懒创建、短信生成 |
backend/app/api/storage_entry_sms_notification.py |
API 路由:3 个端点 |
web-admin/src/api/storage-entry-sms-notification.ts |
前端 API 封装 |
web-admin/src/views/cell-storage/storage-entry-info/index.vue |
前端页面 |
| 文件 | 修改内容 |
|---|---|
backend/app/models/models.py |
追加 BizStorageEntrySmsNotification 模型类 |
backend/app/api/__init__.py |
导入并注册新路由 |
backend/app/main.py |
include_router 挂载新路由 |
web-admin/src/router/modules/sqxp.ts |
CellStorage 下添加路由条目 |
Files:
Create: docs/sql/biz_storage_entry_sms_notification.sql
[ ] Step 1: 创建 SQL 文件
-- 样本入库短信通知记录表
CREATE TABLE biz_storage_entry_sms_notification (
id VARCHAR(64) NOT NULL COMMENT 'UUID',
storage_review_id VARCHAR(64) NOT NULL COMMENT '关联 biz_storage_release_review.id',
protocol_id VARCHAR(64) DEFAULT NULL COMMENT '关联采集协议单ID(冗余)',
sms_content VARCHAR(500) DEFAULT NULL COMMENT '生成的短信内容',
sms_notified TINYINT(1) DEFAULT 0 COMMENT '0=未通知 1=已通知',
notified_by VARCHAR(64) DEFAULT NULL COMMENT '通知操作人ID',
notified_time DATETIME DEFAULT NULL COMMENT '通知时间',
-- 公共字段
tenant_no VARCHAR(32) DEFAULT NULL,
order_num INT DEFAULT 0,
remark VARCHAR(500) DEFAULT NULL,
create_by VARCHAR(64) DEFAULT NULL,
create_dept_id VARCHAR(64) DEFAULT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64) DEFAULT NULL,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
del_flag CHAR(1) DEFAULT '0',
PRIMARY KEY (id),
KEY idx_storage_review_id (storage_review_id),
KEY idx_protocol_id (protocol_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='样本入库短信通知记录';
Run: source venv/bin/activate && python -c "from app.core.database import engine; from sqlalchemy import text; import os; sql=open('docs/sql/biz_storage_entry_sms_notification.sql').read(); conn=engine.connect(); conn.execute(text(sql)); conn.commit(); print('OK')"
或直接在 MySQL 客户端执行该 SQL 文件。
Files:
Modify: backend/app/models/models.py — 在 BizSampleSmsNotification 类之后追加
[ ] Step 1: 追加模型类
在 BizSampleSmsNotification 类(约第 1117 行 del_flag 之后)追加:
class BizStorageEntrySmsNotification(Base):
"""样本入库短信通知 biz_storage_entry_sms_notification"""
__tablename__ = "biz_storage_entry_sms_notification"
id = Column(String(64), primary_key=True)
storage_review_id = Column(String(64), nullable=False, comment="关联 biz_storage_release_review.id")
protocol_id = Column(String(64), comment="关联采集协议单ID")
sms_content = Column(String(500), comment="生成的短信内容")
sms_notified = Column(SmallInteger, default=0, comment="0=未通知 1=已通知")
notified_by = Column(String(64), comment="通知操作人ID")
notified_time = Column(DateTime, comment="通知时间")
tenant_no = Column(String(32))
order_num = Column(Integer, default=0)
remark = Column(String(500))
create_by = Column(String(64))
create_dept_id = Column(String(64))
create_time = Column(DateTime, default=datetime.now)
update_by = Column(String(64))
update_time = Column(DateTime, default=datetime.now, onupdate=datetime.now)
del_flag = Column(String(1), default="0")
Run: cd backend && source venv/bin/activate && python -c "from app.models.models import BizStorageEntrySmsNotification; print('Model OK:', BizStorageEntrySmsNotification.__tablename__)"
Expected: Model OK: biz_storage_entry_sms_notification
Files:
backend/app/crud/storage_entry_sms_notification.py参考文件: backend/app/crud/sample_sms_notification.py(完整克隆并调整)
关键差异点(相对 sample_sms_notification.py):
BizSampleReception → BizStorageReleaseReviewBizSampleSmsNotification → BizStorageEntrySmsNotificationsample_reception_id → storage_review_idreception_source='cell_storage',(storage_status='yrk' OR review_conclusion='btg')BizStorageReleaseReview.create_time.desc()_row_to_dict 字段调整:storageReviewId、storageStatus、storageStatusLabel、storageTime"""样本入库短信通知 CRUD"""
from __future__ import annotations
import math
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import (
BizCellSampleType,
BizCollectionProtocol,
BizProtocolPartyCustomer,
BizProtocolSample,
BizStorageEntrySmsNotification,
BizStorageReleaseReview,
)
_UNICODE_CI = "utf8mb4_unicode_ci"
def _unicode_ci(col):
return col.collate(_UNICODE_CI)
def total_pages(total: int, page_size: int) -> int:
if total <= 0:
return 0
return int(math.ceil(total / float(page_size)))
def _fmt_dt(t: Optional[datetime]) -> str:
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
def _storage_status_label(row: BizStorageReleaseReview) -> str:
"""入库状态标签:已入库 or 拒绝入库"""
if row.storage_status == "yrk":
return "已入库"
if row.review_conclusion == "btg":
return "拒绝入库"
return "待入库"
def _row_to_dict(
*,
notification_id: str = "",
storage_review_id: str = "",
protocol_id: str = "",
sms_notified: int = 0,
sms_content: str = "",
protocol_no: str = "",
mother_name: str = "",
newborn_name: str = "",
sample_code: str = "",
sample_type_name: str = "",
storage_time: str = "",
storage_status_label: str = "",
collection_protocol_id: str = "",
) -> Dict[str, Any]:
return {
"id": notification_id,
"storageReviewId": storage_review_id,
"protocolId": protocol_id,
"protocolNo": protocol_no,
"motherName": mother_name,
"newbornName": newborn_name,
"sampleCode": sample_code,
"sampleTypeName": sample_type_name,
"storageTime": storage_time,
"storageStatusLabel": storage_status_label,
"smsNotified": sms_notified,
"smsContent": sms_content,
"collectionProtocolId": collection_protocol_id,
}
def _base_query(db: Session, tenant_no: str):
return (
db.query(
BizStorageReleaseReview,
BizStorageEntrySmsNotification,
BizCollectionProtocol,
BizProtocolPartyCustomer,
BizProtocolSample,
)
.outerjoin(
BizStorageEntrySmsNotification,
and_(
_unicode_ci(BizStorageEntrySmsNotification.storage_review_id)
== _unicode_ci(BizStorageReleaseReview.id),
_unicode_ci(BizStorageEntrySmsNotification.tenant_no) == tenant_no,
BizStorageEntrySmsNotification.del_flag == "0",
),
)
.outerjoin(
BizCollectionProtocol,
and_(
BizCollectionProtocol.id == BizStorageReleaseReview.protocol_id,
BizCollectionProtocol.del_flag == 0,
),
)
.outerjoin(
BizProtocolPartyCustomer,
and_(
BizProtocolPartyCustomer.collection_protocol_id == BizCollectionProtocol.id,
BizProtocolPartyCustomer.del_flag == 0,
),
)
.outerjoin(
BizProtocolSample,
and_(
_unicode_ci(BizProtocolSample.sample_code)
== _unicode_ci(BizStorageReleaseReview.sample_code),
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
),
)
.filter(
BizStorageReleaseReview.tenant_no == tenant_no,
BizStorageReleaseReview.del_flag == 0,
BizStorageReleaseReview.reception_source == "cell_storage",
or_(
BizStorageReleaseReview.storage_status == "yrk",
BizStorageReleaseReview.review_conclusion == "btg",
),
)
)
def _ensure_notification(
db: Session,
review: BizStorageReleaseReview,
tenant_no: str,
user_id: Optional[str] = None,
) -> BizStorageEntrySmsNotification:
existing = (
db.query(BizStorageEntrySmsNotification)
.filter(
BizStorageEntrySmsNotification.storage_review_id == review.id,
BizStorageEntrySmsNotification.tenant_no == tenant_no,
BizStorageEntrySmsNotification.del_flag == "0",
)
.first()
)
if existing:
return existing
obj = BizStorageEntrySmsNotification(
id=generate_uuid(),
storage_review_id=review.id,
protocol_id=review.protocol_id or "",
sms_notified=0,
tenant_no=tenant_no,
create_by=user_id,
update_by=user_id,
)
db.add(obj)
db.flush()
db.commit()
db.refresh(obj)
return obj
def resolve_notification(
db: Session,
*,
notification_id: str,
tenant_no: str,
storage_review_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> Optional[BizStorageEntrySmsNotification]:
"""Find notification by id; fallback to storage_review_id and ensure row exists."""
notif = (
db.query(BizStorageEntrySmsNotification)
.filter(
BizStorageEntrySmsNotification.id == notification_id,
BizStorageEntrySmsNotification.tenant_no == tenant_no,
BizStorageEntrySmsNotification.del_flag == "0",
)
.first()
)
if notif:
return notif
if not storage_review_id:
return None
review = (
db.query(BizStorageReleaseReview)
.filter(
BizStorageReleaseReview.id == storage_review_id,
BizStorageReleaseReview.tenant_no == tenant_no,
BizStorageReleaseReview.del_flag == 0,
)
.first()
)
if not review:
return None
return _ensure_notification(db, review, tenant_no=tenant_no, user_id=user_id)
def _fetch_sample_type_name(db: Session, sample_type_id: Optional[str], tenant_no: str) -> str:
if not sample_type_id:
return ""
t = (
db.query(BizCellSampleType)
.filter(
BizCellSampleType.id == sample_type_id,
BizCellSampleType.del_flag == 0,
)
.first()
)
return (t.type_name or "") if t else ""
def list_notifications(
db: Session,
*,
page: int = 1,
page_size: int = 10,
tenant_no: str,
keyword: Optional[str] = None,
storage_status: Optional[str] = None,
sms_notified: Optional[int] = None,
user_info=None,
) -> Tuple[List[Dict[str, Any]], int]:
if not tenant_no:
return [], 0
q = _base_query(db, tenant_no)
if keyword and keyword.strip():
kw = f"%{keyword.strip()}%"
q = q.filter(
or_(
BizCollectionProtocol.protocol_no.like(kw),
BizProtocolPartyCustomer.mother_name.like(kw),
BizStorageReleaseReview.sample_code.like(kw),
)
)
if storage_status:
if storage_status == "yrk":
q = q.filter(BizStorageReleaseReview.storage_status == "yrk")
elif storage_status == "btg":
q = q.filter(BizStorageReleaseReview.review_conclusion == "btg")
if sms_notified is not None:
if sms_notified == 0:
q = q.filter(
or_(
BizStorageEntrySmsNotification.sms_notified == 0,
BizStorageEntrySmsNotification.id.is_(None),
)
)
else:
q = q.filter(BizStorageEntrySmsNotification.sms_notified == sms_notified)
if user_info:
from app.core.data_scope import apply_data_scope
q = apply_data_scope(q, BizStorageReleaseReview, user_info, db, tenant_no)
total = q.count()
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
q.order_by(BizStorageReleaseReview.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
sample_type_ids = list({
ps.sample_type_id
for _, _, _, _, ps in rows
if ps and ps.sample_type_id
})
type_map: Dict[str, str] = {}
if sample_type_ids:
for t in (
db.query(BizCellSampleType)
.filter(BizCellSampleType.id.in_(sample_type_ids), BizCellSampleType.del_flag == 0)
.all()
):
type_map[t.id] = t.type_name or ""
items: List[Dict[str, Any]] = []
for review, notif, proto, party, psample in rows:
if notif is None:
notif = _ensure_notification(db, review, tenant_no=tenant_no)
sample_type_name = ""
if psample and psample.sample_type_id:
sample_type_name = type_map.get(psample.sample_type_id, "")
# 入库时间:已入库取 storage_time,拒绝入库取 review_time
display_time = ""
if review.storage_status == "yrk" and review.storage_time:
display_time = _fmt_dt(review.storage_time)
elif review.review_time:
display_time = _fmt_dt(review.review_time)
items.append(
_row_to_dict(
notification_id=notif.id,
storage_review_id=review.id,
protocol_id=notif.protocol_id or "",
sms_notified=notif.sms_notified or 0,
sms_content=notif.sms_content or "",
protocol_no=(proto.protocol_no or "") if proto else (review.protocol_no or ""),
mother_name=(party.mother_name or "") if party else "",
newborn_name=(party.newborn_name or "") if party else "",
sample_code=review.sample_code or "",
sample_type_name=sample_type_name,
storage_time=display_time,
storage_status_label=_storage_status_label(review),
collection_protocol_id=(proto.id or "") if proto else (review.protocol_id or ""),
)
)
return items, int(total)
def toggle_notification_status(
db: Session,
*,
notification_id: str,
tenant_no: str,
sms_notified: int,
user_id: Optional[str] = None,
storage_review_id: Optional[str] = None,
) -> Optional[BizStorageEntrySmsNotification]:
notif = resolve_notification(
db,
notification_id=notification_id,
tenant_no=tenant_no,
storage_review_id=storage_review_id,
user_id=user_id,
)
if not notif:
return None
notif.sms_notified = sms_notified
notif.notified_by = user_id if sms_notified == 1 else None
notif.notified_time = datetime.now() if sms_notified == 1 else None
notif.update_by = user_id
notif.update_time = datetime.now()
db.commit()
db.refresh(notif)
return notif
def generate_sms_content(
db: Session,
*,
notification_id: str,
tenant_no: str,
storage_review_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> Optional[str]:
notif = resolve_notification(
db,
notification_id=notification_id,
tenant_no=tenant_no,
storage_review_id=storage_review_id,
user_id=user_id,
)
if not notif:
return None
review = (
db.query(BizStorageReleaseReview)
.filter(
BizStorageReleaseReview.id == notif.storage_review_id,
BizStorageReleaseReview.tenant_no == tenant_no,
BizStorageReleaseReview.del_flag == 0,
)
.first()
)
if not review:
return None
mother_name = ""
proto = (
db.query(BizCollectionProtocol)
.filter(
BizCollectionProtocol.id == review.protocol_id,
BizCollectionProtocol.del_flag == 0,
)
.first()
)
if proto:
party = (
db.query(BizProtocolPartyCustomer)
.filter(
BizProtocolPartyCustomer.collection_protocol_id == proto.id,
BizProtocolPartyCustomer.del_flag == 0,
)
.first()
)
if party:
mother_name = party.mother_name or ""
sample_code = review.sample_code or ""
# 短信模板:区分已入库和拒绝入库
if review.storage_status == "yrk":
storage_date = _fmt_dt(review.storage_time)[:10] if review.storage_time else ""
content = (
f"尊敬的{mother_name}女士,您的样本(编码:{sample_code})"
f"已于{storage_date}完成入库处理。如有疑问请联系客服。"
)
else:
review_date = _fmt_dt(review.review_time)[:10] if review.review_time else ""
content = (
f"尊敬的{mother_name}女士,您的样本(编码:{sample_code})"
f"入库审核未通过。如有疑问请联系客服。"
)
notif.sms_content = content
notif.update_time = datetime.now()
db.commit()
db.refresh(notif)
return content
Run: cd backend && source venv/bin/activate && python -c "from app.crud.storage_entry_sms_notification import list_notifications; print('CRUD OK')"
Expected: CRUD OK
Files:
backend/app/api/storage_entry_sms_notification.py参考文件: backend/app/api/sample_sms_notification.py
关键差异点(相对 sample_sms_notification.py):
cell-storage:storage-entry-info:liststorage_review_id、storage_status/storage-entry-sms-notifications"""样本入库短信通知 API"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.menu_guard import require_menu_code
from app.core.response import error_response, success_response
from app.crud import storage_entry_sms_notification as svc
from app.crud.collection_protocol import total_pages
from app.models.models import SysUser
router = APIRouter(prefix="/web/admin", tags=["样本入库短信通知"])
def _get_tenant_no(db: Session, user: dict) -> str:
user_id = user.get("user_id")
if not user_id:
raise PermissionError("用户身份信息缺失,无法确定租户")
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
if not sys_user or not sys_user.tenant_no:
raise PermissionError("用户租户信息缺失")
return sys_user.tenant_no
def _get_current_user_id(user: dict) -> str:
user_id = user.get("user_id")
if not user_id:
raise PermissionError("用户身份信息缺失")
return str(user_id)
def _page_payload(items: list, total: int, page: int, page_size: int) -> dict:
return {
"list": items,
"total": total,
"page": page,
"pageSize": page_size,
"totalPages": total_pages(total, page_size),
}
class ToggleStatusBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
sms_notified: int = Field(..., alias="smsNotified")
storage_review_id: Optional[str] = Field(None, alias="storageReviewId")
@router.get("/storage-entry-sms-notifications/list")
async def list_notifications(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100),
keyword: Optional[str] = None,
storageStatus: Optional[str] = None,
smsNotified: Optional[int] = None,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("cell-storage:storage-entry-info:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
items, total = svc.list_notifications(
db,
page=page,
page_size=pageSize,
tenant_no=tenant_no,
keyword=keyword,
storage_status=storageStatus,
sms_notified=smsNotified,
user_info=user,
)
return success_response(_page_payload(items, total, page, pageSize))
except PermissionError as e:
return error_response(403, str(e))
except Exception as e:
return error_response(400, str(e))
@router.put("/storage-entry-sms-notifications/{notification_id}/toggle-status")
async def toggle_status(
notification_id: str,
body: ToggleStatusBody,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("cell-storage:storage-entry-info:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
result = svc.toggle_notification_status(
db,
notification_id=notification_id,
tenant_no=tenant_no,
sms_notified=body.sms_notified,
user_id=user_id,
storage_review_id=body.storage_review_id,
)
if not result:
return error_response(404, "通知记录不存在")
return success_response(None)
except PermissionError as e:
return error_response(403, str(e))
except Exception as e:
return error_response(400, str(e))
@router.post("/storage-entry-sms-notifications/{notification_id}/generate-sms")
async def generate_sms(
notification_id: str,
storageReviewId: Optional[str] = Query(None),
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("cell-storage:storage-entry-info:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
content = svc.generate_sms_content(
db,
notification_id=notification_id,
tenant_no=tenant_no,
storage_review_id=storageReviewId,
user_id=user_id,
)
if content is None:
return error_response(404, "通知记录不存在")
return success_response({"smsContent": content})
except PermissionError as e:
return error_response(403, str(e))
except Exception as e:
return error_response(400, str(e))
Run: cd backend && source venv/bin/activate && python -c "from app.api.storage_entry_sms_notification import router; print('API OK, routes:', len(router.routes))"
Expected: API OK, routes: 3
Files:
backend/app/api/__init__.pyModify: backend/app/main.py
[ ] Step 1: 在 __init__.py 追加 import 和 all 条目
在 sample_sms_notification_router 之后追加:
from app.api.storage_entry_sms_notification import router as storage_entry_sms_notification_router
在 __all__ 列表的 "sample_sms_notification_router" 之后追加:
"storage_entry_sms_notification_router",
main.py 追加 include_router在 app.include_router(sample_sms_notification_router, prefix=settings.API_V1_PREFIX) 之后追加:
app.include_router(storage_entry_sms_notification_router, prefix=settings.API_V1_PREFIX)
Run: cd backend && source venv/bin/activate && python -c "from app.main import app; routes = [r.path for r in app.routes if hasattr(r, 'path') and 'storage-entry-sms' in r.path]; print('Found', len(routes), 'routes:', routes)"
Expected: Found 3 routes: ['/api/storage-entry-sms-notifications/list', ...]
Files:
Create: web-admin/src/api/storage-entry-sms-notification.ts
[ ] Step 1: 创建 API 文件
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
/** 查询列表(联查 storage_release_review + sms_notification) */
export function fetchStorageEntrySmsList(params?: {
page?: number
pageSize?: number
keyword?: string
storageStatus?: string
smsNotified?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/storage-entry-sms-notifications/list',
params
})
}
/** 切换通知状态 */
export function fetchToggleStorageEntrySmsNotified(
id: string,
data: { smsNotified: number; storageReviewId?: string }
) {
return request.put({
url: `/web/admin/storage-entry-sms-notifications/${id}/toggle-status`,
data
})
}
/** 生成短信内容 */
export function fetchGenerateStorageEntrySms(id: string, storageReviewId?: string) {
return request.post<{ smsContent: string }>({
url: `/web/admin/storage-entry-sms-notifications/${id}/generate-sms`,
params: storageReviewId ? { storageReviewId } : undefined
})
}
Files:
web-admin/src/views/cell-storage/storage-entry-info/index.vue参考文件: web-admin/src/views/cell-storage/sample-reception-info/index.vue
关键差异点(相对 sample-reception-info/index.vue):
acceptConclusion → storageStatus(已入库/拒绝入库)storage-entry-sms-notification 的 3 个函数success,拒绝入库=dangerCellStorageStorageEntryInfo<!-- 样本入库信息 -->
<template>
<div class="page-container">
<header class="page-head">
<h1>样本入库信息</h1>
<p class="page-desc">查看样本入库信息,提取短信通知内容并管理通知状态。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">样本入库列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="协议号/姓名/样本编码" clearable />
</el-form-item>
<el-form-item label="入库状态">
<el-select v-model="searchForm.storageStatus" placeholder="全部" clearable style="width: 100%">
<el-option label="已入库" value="yrk" />
<el-option label="拒绝入库" value="btg" />
</el-select>
</el-form-item>
<el-form-item label="通知状态">
<el-select v-model="searchForm.smsNotified" placeholder="全部" clearable style="width: 100%">
<el-option label="已通知" value="1" />
<el-option label="未通知" value="0" />
</el-select>
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@row-dblclick="handleView"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1"
/>
<el-table-column prop="protocolNo" label="协议号" min-width="140" align="center" header-align="center" />
<el-table-column prop="motherName" label="母亲姓名" min-width="100" align="center" header-align="center" />
<el-table-column prop="newbornName" label="新生儿姓名" min-width="110" align="center" header-align="center" show-overflow-tooltip />
<el-table-column prop="sampleCode" label="样本编码" min-width="130" align="center" header-align="center" />
<el-table-column prop="sampleTypeName" label="样本类型" min-width="110" align="center" header-align="center" />
<el-table-column prop="storageTime" label="入库时间" min-width="170" align="center" header-align="center" />
<el-table-column prop="storageStatusLabel" label="入库状态" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="row.storageStatusLabel === '已入库' ? 'success' : 'danger'" size="small">
{{ row.storageStatusLabel }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="smsNotified" label="通知状态" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="row.smsNotified == 1 ? 'success' : 'info'" size="small">
{{ row.smsNotified == 1 ? '已通知' : '未通知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="260" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="handleExtractSms(row)">
<el-icon><ChatDotRound /></el-icon>提取短信
</el-button>
<el-button class="btn-op-item" @click="handleToggleNotified(row)">
<el-icon><Bell /></el-icon>{{ row.smsNotified == 1 ? '标记未通知' : '标记已通知' }}
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<ProtocolViewDialog
v-model="viewVisible"
:protocol-id="viewProtocolId"
:list-row="viewRow"
show-audit-history
show-storage-cert
show-fullmoon-insurance
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, RefreshLeft, View, ChatDotRound, Bell } from '@element-plus/icons-vue'
import { useClipboard } from '@vueuse/core'
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
import {
fetchStorageEntrySmsList,
fetchToggleStorageEntrySmsNotified,
fetchGenerateStorageEntrySms
} from '@/api/storage-entry-sms-notification'
import { type CollectionProtocolRow } from '@/api/collection-protocol'
defineOptions({ name: 'CellStorageStorageEntryInfo' })
const searchForm = reactive({ keyword: '', storageStatus: '', smsNotified: '' })
const tableData = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const viewVisible = ref(false)
const viewProtocolId = ref<string | null>(null)
const viewRow = ref<CollectionProtocolRow | null>(null)
const { copy } = useClipboard()
async function loadData() {
loading.value = true
try {
const result = await fetchStorageEntrySmsList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
storageStatus: searchForm.storageStatus || undefined,
smsNotified: searchForm.smsNotified || undefined
})
const res = (result as any) || {}
tableData.value = res.list || []
pagination.total = res.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
Object.assign(searchForm, { keyword: '', storageStatus: '', smsNotified: '' })
handleSearch()
}
function handleView(row: Record<string, unknown>) {
const pid = String(row.collectionProtocolId || row.protocolId || '')
if (!pid) {
ElMessage.warning('缺少关联协议单,无法查看')
return
}
viewProtocolId.value = pid
viewRow.value = {
id: pid,
protocolNo: row.protocolNo,
motherName: row.motherName,
newbornName: row.newbornName
}
viewVisible.value = true
}
async function handleExtractSms(row: any) {
try {
const result = await fetchGenerateStorageEntrySms(row.id, row.storageReviewId)
const content = (result as any)?.smsContent || ''
if (!content) {
ElMessage.warning('短信内容为空')
return
}
await copy(content)
ElMessage.success('短信内容已复制到剪贴板')
} catch {
ElMessage.error('提取短信失败')
}
}
async function handleToggleNotified(row: any) {
const newVal = row.smsNotified == 1 ? 0 : 1
try {
await fetchToggleStorageEntrySmsNotified(row.id, {
smsNotified: newVal,
storageReviewId: row.storageReviewId
})
ElMessage.success(newVal === 1 ? '已标记为已通知' : '已标记为未通知')
loadData()
} catch {
ElMessage.error('操作失败')
}
}
onMounted(() => {
loadData()
})
</script>
Files:
Modify: web-admin/src/router/modules/sqxp.ts
[ ] Step 1: 在 CellStorage 下的 sample-reception-info 条目之后追加
在 sample-reception-info 路由块(约第 260 行)之后,storage-cert 路由块之前,插入:
{
id: '50000000000000000000000000000012',
name: 'CellStorageStorageEntryInfo',
path: 'storage-entry-info',
component: '/cell-storage/storage-entry-info/index',
meta: { title: '样本入库信息', keepAlive: true, menuCode: 'cell-storage:storage-entry-info:list' }
},
Run: cd web-admin && pnpm build 2>&1 | tail -5
Expected: 构建成功,无 TypeScript 错误。
Run: cd backend && source venv/bin/activate && ./start-local.sh
访问 http://localhost:8000/docs,检查:
GET /api/storage-entry-sms-notifications/list 存在PUT /api/storage-entry-sms-notifications/{id}/toggle-status 存在POST /api/storage-entry-sms-notifications/{id}/generate-sms 存在
[ ] Step 2: 启动前端,访问页面
Run: cd web-admin && pnpm dev
访问 http://localhost:3006/#/cell-storage/storage-entry-info,验证:
浏览器窗口缩放时,各列等比伸缩(min-width 生效)
[ ] Step 3: 提交
git add docs/sql/biz_storage_entry_sms_notification.sql
git add backend/app/models/models.py
git add backend/app/crud/storage_entry_sms_notification.py
git add backend/app/api/storage_entry_sms_notification.py
git add backend/app/api/__init__.py
git add backend/app/main.py
git add web-admin/src/api/storage-entry-sms-notification.ts
git add web-admin/src/views/cell-storage/storage-entry-info/index.vue
git add web-admin/src/router/modules/sqxp.ts
git commit -m "feat: 新增样本入库信息页面,支持短信通知管理(克隆样本接收信息模式)"