feat:新增病历列表页面

This commit is contained in:
Jafeng 2026-01-20 16:24:43 +08:00
parent e838f8af15
commit 594913404b
9 changed files with 2132 additions and 4 deletions

View File

@ -12,6 +12,48 @@
"navigationBarTitleText": "病例"
}
},
{
"path": "pages/case/search",
"style": {
"navigationBarTitleText": "搜索患者"
}
},
{
"path": "pages/case/group-manage",
"style": {
"navigationBarTitleText": "分组管理"
}
},
{
"path": "pages/case/batch-transfer",
"style": {
"navigationBarTitleText": "转移客户给其他团队"
}
},
{
"path": "pages/case/batch-share",
"style": {
"navigationBarTitleText": "共享客户"
}
},
{
"path": "pages/case/patient-invite",
"style": {
"navigationBarTitleText": "邀请患者"
}
},
{
"path": "pages/case/patient-create",
"style": {
"navigationBarTitleText": "新增患者"
}
},
{
"path": "pages/case/patient-inner-info",
"style": {
"navigationBarTitleText": "内部信息"
}
},
{
"path": "pages/work/work",
"style": {

173
pages/case/batch-share.vue Normal file
View File

@ -0,0 +1,173 @@
<template>
<view class="share-container">
<view class="content">
<view class="section-title">选择共享团队</view>
<view class="selector-item" @click="selectTeam">
<text :class="team ? '' : 'placeholder'">{{ team ? team.name : "请选择团队" }}</text>
<uni-icons type="arrowdown" size="16" color="#999"></uni-icons>
</view>
<view class="tips">共享客户表示客户档案共享多个团队可见多个团队可同时为该客户服务</view>
<template v-if="team">
<view class="section-title">选择责任人</view>
<view class="selector-item" @click="selectUser">
<text :class="user ? '' : 'placeholder'">{{ user ? user.name : "请选择责任人" }}</text>
<uni-icons type="arrowdown" size="16" color="#999"></uni-icons>
</view>
</template>
</view>
<view class="footer">
<button class="btn plain" @click="cancel">取消</button>
<button class="btn primary" @click="save">保存</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
const team = ref(null);
const user = ref(null);
// Mock Data
const teams = [
{ id: 1, name: '李医生团队' },
{ id: 2, name: '王医生团队' }
];
const users = [
{ id: 101, name: '张医生' },
{ id: 102, name: '李医生' },
{ id: 103, name: '王医生' }
];
const selectTeam = () => {
uni.showActionSheet({
itemList: teams.map(t => t.name),
success: (res) => {
team.value = teams[res.tapIndex];
user.value = null;
}
});
};
const selectUser = () => {
uni.showActionSheet({
itemList: users.map(u => u.name),
success: (res) => {
user.value = users[res.tapIndex];
}
});
};
const cancel = () => {
uni.navigateBack();
};
const save = () => {
if (!team.value) {
uni.showToast({ title: '请选择团队', icon: 'none' });
return;
}
if (!user.value) {
uni.showToast({ title: '请选择负责人', icon: 'none' });
return;
}
uni.showLoading({ title: '保存中' });
setTimeout(() => {
uni.hideLoading();
uni.showToast({ title: '操作成功' });
setTimeout(() => {
uni.navigateBack();
}, 1500);
}, 1000);
};
</script>
<style lang="scss" scoped>
.share-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f7f8fa;
}
.content {
flex: 1;
padding: 20px;
.section-title {
font-size: 16px;
font-weight: bold;
color: #333;
margin-top: 20px;
margin-bottom: 10px;
&:first-child {
margin-top: 0;
}
}
.selector-item {
background-color: #fff;
height: 44px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
text {
font-size: 14px;
color: #333;
&.placeholder {
color: #999;
}
}
}
.tips {
margin-top: 15px;
font-size: 14px;
color: #999;
line-height: 1.5;
}
}
.footer {
background-color: #fff;
padding: 15px 20px 30px;
display: flex;
gap: 15px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
.btn {
flex: 1;
height: 44px;
line-height: 44px;
font-size: 16px;
border-radius: 4px;
margin: 0;
&.plain {
background-color: #fff;
color: #666;
border: 1px solid #ddd;
}
&.primary {
background-color: #5d8aff;
color: #fff;
border: none;
}
&::after {
border: none;
}
}
}
</style>

View File

@ -0,0 +1,174 @@
<template>
<view class="transfer-container">
<view class="content">
<view class="section-title">选择新负责团队</view>
<view class="selector-item" @click="selectTeam">
<text :class="team ? '' : 'placeholder'">{{ team ? team.name : "请选择选择团队" }}</text>
<uni-icons type="arrowdown" size="16" color="#999"></uni-icons>
</view>
<template v-if="team">
<view class="section-title">选择责任人</view>
<view class="selector-item" @click="selectUser">
<text :class="user ? '' : 'placeholder'">{{ user ? user.name : "请选择责任人" }}</text>
<uni-icons type="arrowdown" size="16" color="#999"></uni-icons>
</view>
</template>
<view class="tips">客户将与本团队解除服务关系本团队成员将没有权限查询到客户档案</view>
</view>
<view class="footer">
<button class="btn plain" @click="cancel">取消</button>
<button class="btn primary" @click="save">保存</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
const team = ref(null);
const user = ref(null);
// Mock Data
const teams = [
{ id: 1, name: '张敏西服务团队' },
{ id: 2, name: '李医生团队' },
{ id: 3, name: '王医生团队' }
];
const users = [
{ id: 101, name: '张医生' },
{ id: 102, name: '李医生' },
{ id: 103, name: '王医生' }
];
const selectTeam = () => {
uni.showActionSheet({
itemList: teams.map(t => t.name),
success: (res) => {
team.value = teams[res.tapIndex];
user.value = null; // Reset user when team changes
}
});
};
const selectUser = () => {
uni.showActionSheet({
itemList: users.map(u => u.name),
success: (res) => {
user.value = users[res.tapIndex];
}
});
};
const cancel = () => {
uni.navigateBack();
};
const save = () => {
if (!team.value) {
uni.showToast({ title: '请选择团队', icon: 'none' });
return;
}
if (!user.value) {
uni.showToast({ title: '请选择责任人', icon: 'none' });
return;
}
uni.showLoading({ title: '保存中' });
setTimeout(() => {
uni.hideLoading();
uni.showToast({ title: '操作成功' });
setTimeout(() => {
uni.navigateBack();
}, 1500);
}, 1000);
};
</script>
<style lang="scss" scoped>
.transfer-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f7f8fa;
}
.content {
flex: 1;
padding: 20px;
.section-title {
font-size: 16px;
font-weight: bold;
color: #333;
margin-top: 20px;
margin-bottom: 10px;
&:first-child {
margin-top: 0;
}
}
.selector-item {
background-color: #fff;
height: 44px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
text {
font-size: 14px;
color: #333;
&.placeholder {
color: #999;
}
}
}
.tips {
margin-top: 15px;
font-size: 14px;
color: #999;
line-height: 1.5;
}
}
.footer {
background-color: #fff;
padding: 15px 20px 30px;
display: flex;
gap: 15px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
.btn {
flex: 1;
height: 44px;
line-height: 44px;
font-size: 16px;
border-radius: 4px;
margin: 0;
&.plain {
background-color: #fff;
color: #666;
border: 1px solid #ddd;
}
&.primary {
background-color: #5d8aff;
color: #fff;
border: none;
}
&::after {
border: none;
}
}
}
</style>

View File

@ -1,8 +1,724 @@
<template>
<div>case</div>
<view class="container">
<!-- Header -->
<view class="header">
<view class="team-selector" @click="toggleTeamPopup">
<text class="team-name">{{ teamDisplay }}</text>
<uni-icons type="loop" size="18" color="#333" class="team-icon"></uni-icons>
</view>
<view class="header-actions">
<view class="action-item" @click="goToSearch">
<uni-icons type="search" size="22" color="#333"></uni-icons>
<text class="action-text">搜索</text>
</view>
<view class="action-item" @click="toggleBatchMode">
<uni-icons type="checkbox" size="22" color="#333"></uni-icons>
<text class="action-text">批量</text>
</view>
<view class="action-item" @click="goToGroupManage">
<uni-icons type="staff" size="22" color="#333"></uni-icons>
<text class="action-text">分组</text>
</view>
<view class="action-item" @click="handleCreate">
<uni-icons type="plusempty" size="22" color="#333"></uni-icons>
<text class="action-text">新增</text>
</view>
</view>
</view>
<!-- Tabs and Count -->
<view class="tabs-area">
<scroll-view scroll-x class="tabs-scroll" :show-scrollbar="false">
<view class="tabs-container">
<view
v-for="(tab, index) in tabs"
:key="index"
class="tab-item"
:class="{ active: currentTab === index }"
@click="currentTab = index"
>
{{ tab }}
</view>
</view>
</scroll-view>
<view class="total-count-inline">{{ totalPatients }}</view>
</view>
<!-- Main Content -->
<view class="content-body">
<!-- Patient List -->
<scroll-view
scroll-y
class="patient-list"
:scroll-into-view="scrollIntoId"
:scroll-with-animation="true"
>
<view v-for="group in patientList" :key="group.letter" :id="'letter-' + group.letter">
<view class="group-title">{{ group.letter }}</view>
<view v-for="(patient, pIndex) in group.data" :key="pIndex" class="patient-card" @click="isBatchMode ? toggleSelect(patient) : null">
<!-- Checkbox for Batch Mode -->
<view v-if="isBatchMode" class="checkbox-area">
<uni-icons
:type="selectedItems.includes(patient.phone) ? 'checkbox-filled' : 'checkbox'"
size="24"
:color="selectedItems.includes(patient.phone) ? '#007aff' : '#ccc'"
></uni-icons>
</view>
<view class="card-content">
<!-- Row 1 -->
<view class="card-row-top">
<view class="patient-info">
<text class="patient-name">{{ patient.name }}</text>
<text class="patient-meta">{{ patient.gender }}/{{ patient.age }}</text>
</view>
<view class="patient-tags">
<view v-for="(tag, tIndex) in patient.tags" :key="tIndex" class="tag">
{{ tag }}
</view>
</view>
</view>
<!-- Row 2 / 3 -->
<view class="card-row-bottom">
<template v-if="currentTab === 1"> <!-- New Patient Tab -->
<text class="record-text">
{{ patient.createTime || '-' }} / {{ patient.creator || '-' }}
</text>
</template>
<template v-else>
<text v-if="patient.record" class="record-text">
{{ patient.record.type }} / {{ patient.record.date }} / {{ patient.record.diagnosis }}
</text>
<text v-else class="no-record">暂无病历记录</text>
</template>
</view>
</view>
</view>
</view>
<!-- Bottom padding for tabbar -->
<view style="height: 100px;"></view> <!-- Increased padding -->
</scroll-view>
<!-- Sidebar Index -->
<view v-if="!isBatchMode" class="sidebar-index">
<view
v-for="letter in indexList"
:key="letter"
class="index-item"
@click="scrollToLetter(letter)"
>
{{ letter }}
</view>
</view>
</view>
<!-- Batch Actions Footer -->
<view v-if="isBatchMode" class="batch-footer">
<view class="left-action" @click="handleSelectAll">
<uni-icons
:type="selectedItems.length > 0 && selectedItems.length === patientList.flatMap(g => g.data).length ? 'checkbox-filled' : 'checkbox'"
size="24"
color="#666"
></uni-icons>
<text class="footer-text">全选 ({{ selectedItems.length }})</text>
</view>
<view class="right-actions">
<button class="footer-btn plain" @click="cancelBatch">取消</button>
<button class="footer-btn primary" @click="handleTransfer">转移</button>
<button class="footer-btn primary" @click="handleShare">共享</button>
</view>
</view>
</view>
</template>
<script>
<script setup>
import { ref, computed } from 'vue';
// State
const currentTeam = ref('张敏西服务团队');
const currentTab = ref(0);
const scrollIntoId = ref('');
const tabs = ['全部', '新患者', '糖尿病', '高血压', '冠心病', '慢阻肺'];
const isBatchMode = ref(false);
const selectedItems = ref([]); // Stores patient phone or unique ID
//
const managedArchiveCountAllTeams = ref(10); //
const isVerified = ref(true); //
const hasVerifyFailedHistory = ref(false); //
const verifyFailedReason = ref('资料不完整,请补充营业执照/资质证明后重新提交。');
const teamDisplay = computed(() => `${currentTeam.value}(${managedArchiveCountAllTeams.value})`);
// Mock Data
const allPatients = [
{
letter: 'A',
data: [
{
name: '安乐', gender: '男', age: 45, tags: ['糖尿病'],
record: { type: '门诊', date: '2026.1.10', diagnosis: '2型糖尿病' },
createTime: '2026.1.19 14:30', creator: '李医生', phone: '13888888888', hospitalId: '1001'
},
{
name: '奥利奥', gender: '女', age: 22, tags: [], record: null,
createTime: '2026.1.15 09:00', creator: '王医生', phone: '13999999999', hospitalId: '1002'
}
]
},
{
letter: 'L',
data: [
{
name: '李珊珊', gender: '女', age: 37, tags: ['糖尿病', '高血压'],
record: { type: '门诊', date: '2026.1.10', diagnosis: '急性上呼吸道感染' },
createTime: '2026.1.10 10:20', creator: '张医生', phone: '13666666666', hospitalId: '1003'
},
{
name: '李珊珊', gender: '女', age: 37, tags: [],
record: { type: '住院', date: '2026.1.10', diagnosis: '急性上呼吸道感染' },
createTime: '2025.12.30 11:00', creator: '张医生', phone: '13666666667', hospitalId: '10031'
},
{
name: '李某某', gender: '女', age: 37, tags: [], record: null,
createTime: '2025.12.01 08:30', creator: '系统导入', phone: '13555555555', hospitalId: '1004'
},
{
name: '李四', gender: '男', age: 50, tags: ['高血压'], record: null,
createTime: '2026.1.18 16:45', creator: '管理员', phone: '13444444444', hospitalId: '1005'
}
]
},
{
letter: 'Z',
data: [
{
name: '张三', gender: '男', age: 28, tags: [], record: null,
createTime: '2026.1.19 10:00', creator: '赵医生', phone: '13333333333', hospitalId: '1006'
},
{
name: '张敏', gender: '女', age: 32, tags: ['高血压'],
record: { type: '门诊', date: '2025.12.15', diagnosis: '高血压' },
createTime: '2025.11.20 15:15', creator: '孙医生', phone: '13222222222', hospitalId: '1007'
}
]
}
];
// Computed
const patientList = computed(() => {
let list = allPatients;
// New Patient Filter (Last 7 days)
if (currentTab.value === 1) {
// Current date logic: 2026-01-20
const now = new Date('2026-01-20').getTime();
const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
let flatList = [];
list.forEach(group => {
group.data.forEach(p => {
if (p.createTime) {
// Parse format 2026.1.19 14:30
const timeStr = p.createTime.replace(/\./g, '/'); // safari compatibility often needs /, but for calculation standardizing
const pTime = new Date(timeStr).getTime();
if (pTime >= sevenDaysAgo) {
flatList.push({ ...p, _ts: pTime });
}
}
});
});
// Sort by create time desc
flatList.sort((a, b) => b._ts - a._ts);
// Re-group or just show one group?
// Usually grouped list maintains grouping or creates a "Search Result" group.
// Requirement says "New Patient show...". Usually lists are still grouped by alphabet or just a flat list.
// The requirement "Sorted from late to early" for creation time implies order is important, creating letter groups breaks time order.
// So for "New Patient" tab, we should probably output a single group or regoup by date?
// Requirement 1.2 "New Patient card... order late to early". This strongly implies creating a single list sorted by time.
// But the template expects `group.letter` and `group.data`.
// I will return a single group named 'Recent' or similar, or just empty letter to hide header.
// The template has `<view class="group-title">{{ group.letter }}</view>`. If letter is empty, it shows empty line.
// I will return [{ letter: '', data: flatList }]
return [{ letter: '最近新增', data: flatList }];
}
// Tab Filtering (Mock logic for other tabs)
if (currentTab.value > 1) {
const tabName = tabs[currentTab.value];
list = list.map(group => ({
...group,
data: group.data.filter(p => p.tags.includes(tabName))
})).filter(group => group.data.length > 0);
}
return list;
});
const indexList = computed(() => {
if (currentTab.value === 1) return []; // No index bar for new patient
return 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').filter(l => patientList.value.some(g => g.letter === l));
});
const totalPatients = computed(() => {
let count = 0;
patientList.value.forEach(g => count += g.data.length);
return count;
});
// Methods
const checkBatchMode = () => {
if (isBatchMode.value) {
uni.showToast({ title: '请先完成当前批量设置或点击底部“取消”按钮退出', icon: 'none' });
return true;
}
return false;
};
const scrollToLetter = (letter) => {
if (currentTab.value === 1) return;
scrollIntoId.value = 'letter-' + letter;
};
const toggleTeamPopup = () => {
if (checkBatchMode()) return;
uni.showActionSheet({
itemList: ['张敏西服务团队', '李医生团队', '王医生团队'],
success: function (res) {
const teams = ['张敏西服务团队', '李医生团队', '王医生团队'];
currentTeam.value = teams[res.tapIndex];
}
});
};
const goToSearch = () => {
if (checkBatchMode()) return;
uni.navigateTo({
url: '/pages/case/search'
});
};
const goToGroupManage = () => {
if (checkBatchMode()) return;
uni.navigateTo({
url: '/pages/case/group-manage'
});
};
const toggleBatchMode = () => {
if (isBatchMode.value) {
// Already in batch mode, do nothing or prompt?
// Prompt says "click Other operations... prompt please finish".
// Clicking "Batch" itself while in batch mode: user usually expects toggle off or nothing.
// Based on "click Cancel button to exit", I'll assume clicking existing batch button doesn't exit.
return;
}
isBatchMode.value = true;
selectedItems.value = [];
};
const handleCreate = () => {
if (checkBatchMode()) return;
// 100 ->
if (managedArchiveCountAllTeams.value >= 100) {
uni.showModal({
title: '提示',
content: '当前管理档案数已达 100 个,无法继续新增。如需提升档案管理数,请联系客服处理。',
cancelText: '知道了',
confirmText: '添加客服',
success: (res) => {
if (res.confirm) {
openAddCustomerServiceEntry();
}
}
});
return;
}
// + 10
if (!isVerified.value && managedArchiveCountAllTeams.value >= 10) {
uni.showModal({
title: '提示',
content: '当前管理档案数已达上限 10 个,完成认证即可升级至 100 个。',
cancelText: '暂不认证',
confirmText: '去认证',
success: (res) => {
if (res.confirm) {
startVerifyFlow();
}
}
});
return;
}
//
uni.showActionSheet({
itemList: ['邀请患者建档', '我帮患者建档'],
success: (res) => {
if (res.tapIndex === 0) {
openInvitePatientEntry();
} else if (res.tapIndex === 1) {
openCreatePatientEntry();
}
}
});
};
//
const startVerifyFlow = () => {
// -> &
if (hasVerifyFailedHistory.value) {
uni.showModal({
title: '提示',
content: `您有历史认证未通过记录。失败原因为:\n\n${verifyFailedReason.value}`,
cancelText: '取消',
confirmText: '重新认证',
success: (res) => {
if (res.confirm) {
openVerifyEntry();
}
}
});
return;
}
//
openVerifyEntry();
};
// ===== / =====
const openVerifyEntry = () => {
uni.showToast({ title: '认证功能待接入', icon: 'none' });
};
const openAddCustomerServiceEntry = () => {
uni.showToast({ title: '添加客服功能待接入', icon: 'none' });
};
const openInvitePatientEntry = () => {
uni.navigateTo({ url: '/pages/case/patient-invite' });
};
const openCreatePatientEntry = () => {
uni.navigateTo({ url: '/pages/case/patient-create' });
};
// Batch Operations
const toggleSelect = (patient) => {
if (!isBatchMode.value) return; // Should not happen if click handler is correct
const id = patient.phone; // Using phone as unique ID for mock
const index = selectedItems.value.indexOf(id);
if (index > -1) {
selectedItems.value.splice(index, 1);
} else {
selectedItems.value.push(id);
}
};
const handleSelectAll = () => {
// Flatten current list
const currentList = patientList.value.flatMap(group => group.data);
if (selectedItems.value.length === currentList.length) {
selectedItems.value = []; // Unselect All
} else {
selectedItems.value = currentList.map(p => p.phone);
}
};
const cancelBatch = () => {
isBatchMode.value = false;
selectedItems.value = [];
};
const handleTransfer = () => {
if (selectedItems.value.length === 0) {
uni.showToast({ title: '请选择患者', icon: 'none' });
return;
}
// Navigate to Transfer Page
uni.navigateTo({ url: '/pages/case/batch-transfer' });
};
const handleShare = () => {
if (selectedItems.value.length === 0) {
uni.showToast({ title: '请选择患者', icon: 'none' });
return;
}
// Navigate to Share Page
uni.navigateTo({ url: '/pages/case/batch-share' });
};
</script>
<style>
<style lang="scss" scoped>
.container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #fff;
padding-bottom: 0; // Default
// Padding for batch footer
/* &.is-batch {
padding-bottom: 50px;
} */
// We can't use &.is-batch because scoped style and root element is tricky depending on uni-app version/style
// Instead we handle it in content-body or separate view
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
background-color: #fff;
border-bottom: 1px solid #f0f0f0;
.team-selector {
display: flex;
align-items: center;
font-size: 18px;
font-weight: bold;
color: #333;
.team-name {
margin-right: 5px;
}
}
.header-actions {
display: flex;
gap: 15px;
.action-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.action-text {
font-size: 10px;
color: #333;
margin-top: 2px;
}
}
}
}
.tabs-area {
display: flex;
align-items: center;
background-color: #f5f7fa;
border-bottom: 1px solid #eee;
padding-right: 15px; // Padding for the count
.tabs-scroll {
flex: 1;
white-space: nowrap;
overflow: hidden;
.tabs-container {
display: flex;
padding: 10px 15px;
.tab-item {
padding: 5px 15px;
margin-right: 10px;
font-size: 14px;
color: #666;
background-color: #fff;
border-radius: 4px;
flex-shrink: 0;
&.active {
color: #5d8aff;
background-color: #e6f0ff;
font-weight: bold;
}
}
}
}
.total-count-inline {
font-size: 12px;
color: #666;
white-space: nowrap;
flex-shrink: 0;
min-width: 50px;
text-align: right;
}
}
.content-body {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
.patient-list {
flex: 1;
height: 100%;
background-color: #f7f8fa;
}
.group-title {
padding: 5px 15px;
font-size: 14px;
color: #333;
}
.patient-card {
display: flex;
background-color: #fff;
padding: 15px;
margin-bottom: 1px; // Separator line
border-bottom: 1px solid #f0f0f0;
.checkbox-area {
display: flex;
align-items: center;
margin-right: 10px;
}
.card-content {
flex: 1;
}
.card-row-top {
display: flex;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
.patient-info {
display: flex;
align-items: flex-end;
margin-right: 10px;
.patient-name {
font-size: 18px;
font-weight: bold;
color: #333;
margin-right: 8px;
}
.patient-meta {
font-size: 12px;
color: #999;
margin-bottom: 2px;
}
}
.patient-tags {
display: flex;
gap: 5px;
.tag {
font-size: 10px;
color: #5d8aff;
border: 1px solid #5d8aff;
padding: 0 4px;
border-radius: 8px;
height: 16px;
line-height: 14px;
}
}
}
.card-row-bottom {
font-size: 14px;
.record-text {
color: #666;
}
.no-record {
color: #bdc3c7;
}
}
}
.batch-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 50px;
background-color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
z-index: 99;
.left-action {
display: flex;
align-items: center;
.footer-text {
margin-left: 5px;
font-size: 14px;
color: #333;
}
}
.right-actions {
display: flex;
gap: 10px;
.footer-btn {
font-size: 14px;
padding: 0 15px;
height: 32px;
line-height: 32px;
margin: 0;
border-radius: 4px;
&.plain {
border: 1px solid #ddd;
background-color: #fff;
color: #666;
}
&.primary {
background-color: #5d8aff;
color: #fff;
border: none;
}
&::after {
border: none;
}
}
}
}
.sidebar-index {
width: 20px;
position: absolute;
right: 0;
top: 20px;
bottom: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: transparent;
z-index: 10;
.index-item {
font-size: 10px;
color: #555;
padding: 2px 0;
width: 20px;
text-align: center;
font-weight: 500;
}
}
</style>

239
pages/case/group-manage.vue Normal file
View File

@ -0,0 +1,239 @@
<template>
<view class="manage-container">
<view class="group-list">
<view v-for="(item, index) in groups" :key="index" class="group-item">
<view class="left-action" @click="handleDelete(index)">
<uni-icons type="minus-filled" size="24" color="#ff4d4f"></uni-icons>
</view>
<view class="group-name">{{ item.name }}</view>
<view class="right-actions">
<uni-icons type="compose" size="24" color="#5d8aff" class="icon-edit" @click="handleEdit(item, index)"></uni-icons>
<uni-icons type="bars" size="24" color="#5d8aff" class="icon-drag"></uni-icons>
</view>
</view>
</view>
<!-- Bottom Button -->
<view class="footer">
<button class="add-btn" @click="handleAdd">添加新分组</button>
</view>
<!-- Dialog -->
<view v-if="showDialog" class="dialog-mask">
<view class="dialog-content">
<view class="dialog-header">{{ dialogTitle }}</view>
<view class="dialog-body">
<input class="dialog-input" type="text" v-model="inputValue" placeholder="请输入分组名称" />
</view>
<view class="dialog-footer">
<button class="dialog-btn cancel" @click="closeDialog">取消</button>
<button class="dialog-btn confirm" @click="handleSave">保存</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
// State
const groups = ref([
{ id: 1, name: '糖尿病' },
{ id: 2, name: '高血压' },
{ id: 3, name: '高血脂' }
]);
const showDialog = ref(false);
const dialogMode = ref('add'); // 'add' or 'edit'
const inputValue = ref('');
const editingIndex = ref(-1);
const dialogTitle = ref('添加新分组');
// Methods
const handleAdd = () => {
dialogMode.value = 'add';
dialogTitle.value = '添加新分组';
inputValue.value = '';
showDialog.value = true;
};
const handleEdit = (item, index) => {
dialogMode.value = 'edit';
dialogTitle.value = '编辑分组名称';
inputValue.value = item.name;
editingIndex.value = index;
showDialog.value = true;
};
const handleDelete = (index) => {
uni.showModal({
title: '提示',
content: '确定要删除该分组吗?',
success: (res) => {
if (res.confirm) {
groups.value.splice(index, 1);
}
}
});
};
const closeDialog = () => {
showDialog.value = false;
};
const handleSave = () => {
if (!inputValue.value.trim()) {
uni.showToast({ title: '请输入分组名称', icon: 'none' });
return;
}
if (dialogMode.value === 'add') {
groups.value.push({
id: Date.now(),
name: inputValue.value
});
} else {
groups.value[editingIndex.value].name = inputValue.value;
}
closeDialog();
};
</script>
<style lang="scss" scoped>
.manage-container {
min-height: 100vh;
background-color: #fff;
padding-bottom: 80px; // Space for footer
}
.group-list {
padding: 0 15px;
}
.group-item {
display: flex;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #eee;
.left-action {
margin-right: 15px;
}
.group-name {
flex: 1;
font-size: 16px;
color: #333;
}
.right-actions {
display: flex;
align-items: center;
gap: 15px;
.icon-edit, .icon-drag {
padding: 5px; // Increase tap area
}
}
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
padding: 15px 20px 30px; // Safe area padding
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
.add-btn {
background-color: #5d8aff;
color: #fff;
border-radius: 4px;
font-size: 16px;
height: 44px;
line-height: 44px;
border: none;
&::after {
border: none;
}
}
}
// Custom Dialog Styles
.dialog-mask {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.dialog-content {
width: 280px;
background-color: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
.dialog-header {
padding: 20px 0 10px;
text-align: center;
font-size: 18px;
font-weight: 500;
color: #333;
}
.dialog-body {
padding: 10px 20px 20px;
.dialog-input {
border: 1px solid #ddd;
height: 40px;
padding: 0 10px;
border-radius: 4px;
font-size: 14px;
}
}
.dialog-footer {
display: flex;
padding: 0 20px 20px;
justify-content: space-between;
gap: 15px;
.dialog-btn {
flex: 1;
height: 36px;
line-height: 36px;
font-size: 14px;
margin: 0;
&.cancel {
background-color: #fff;
color: #666;
border: 1px solid #ddd;
}
&.confirm {
background-color: #5d8aff;
color: #fff;
border: none; // Remove border for confirm button usually
}
&::after {
border: none;
}
}
}
}
</style>

View File

@ -0,0 +1,132 @@
<template>
<view class="page">
<view class="body">
<scroll-view scroll-y class="scroll">
<view class="form-wrap">
<form-template ref="formRef" :items="baseItems" :form="form" :rule="rules" @change="onChange" />
</view>
</scroll-view>
<view class="footer">
<button class="primary" @click="next">下一步</button>
</view>
</view>
</view>
</template>
<script setup>
import { reactive, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import FormTemplate from '@/components/form-template/index.vue';
import validate from '@/utils/validate';
const STORAGE_KEY = 'patient-create-base';
const formRef = ref(null);
const form = reactive({
name: '',
gender: '',
age: '',
mobile: '',
birthday: '',
idType: '',
idNo: ''
});
const baseItems = [
{ title: 'name', name: '姓名', type: 'input', operateType: 'formCell', required: true, wordLimit: 20, inputType: 'text' },
{ title: 'gender', name: '性别', type: 'select', operateType: 'formCell', required: false, range: ['男', '女'] },
{ title: 'age', name: '年龄', type: 'input', operateType: 'formCell', required: false, wordLimit: 3, inputType: 'number' },
{ title: 'mobile', name: '手机号', type: 'input', operateType: 'formCell', required: false, wordLimit: 11, inputType: 'number' },
{ title: 'birthday', name: '出生日期', type: 'date', operateType: 'formCell', required: false },
{ title: 'idType', name: '证件类型', type: 'select', operateType: 'formCell', required: false, range: ['身份证', '护照', '港澳台通行证', '其他'] },
{ title: 'idNo', name: '证件号', type: 'input', operateType: 'formCell', required: false, wordLimit: 30, inputType: 'text' }
];
const rules = {
idNo(value) {
if (!value) return true;
if (form.idType === '身份证') {
const [ok, msg] = validate.isChinaId(value);
if (!ok) return msg || '证件号格式不正确';
}
return true;
}
};
onLoad(() => {
const cached = uni.getStorageSync(STORAGE_KEY);
if (cached && typeof cached === 'object') {
Object.assign(form, cached);
}
});
function onChange({ title, value }) {
form[title] = value;
}
function next() {
if (!formRef.value?.verify?.()) return;
uni.setStorageSync(STORAGE_KEY, { ...form });
uni.navigateTo({ url: '/pages/case/patient-inner-info' });
}
</script>
<style lang="scss" scoped>
.page {
height: 100vh;
background: #f6f6f6;
}
.body {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll {
flex: 1;
}
.form-wrap {
background: #fff;
}
.step-note {
position: fixed;
left: 50%;
bottom: 92px;
transform: translateX(-50%);
background: #f2df52;
color: #000;
font-size: 14px;
padding: 10px 16px;
border-radius: 4px;
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.18);
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
}
.primary {
width: 100%;
height: 48px;
line-height: 48px;
background: #5d8aff;
color: #fff;
border-radius: 6px;
font-size: 16px;
}
.primary::after {
border: none;
}
</style>

View File

@ -0,0 +1,144 @@
<template>
<view class="page">
<view class="body">
<scroll-view scroll-y class="scroll">
<view class="form-wrap">
<form-template v-if="items.length" ref="formRef" :items="items" :form="form" :rule="rules" @change="onChange" />
</view>
</scroll-view>
<view class="footer">
<button class="btn plain" @click="cancel">取消</button>
<button class="btn primary" @click="save">完成</button>
</view>
</view>
</view>
</template>
<script setup>
import { reactive, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import FormTemplate from '@/components/form-template/index.vue';
const BASE_KEY = 'patient-create-base';
const INNER_KEY = 'patient-create-inner';
const formRef = ref(null);
const form = reactive({
customerSource: '',
firstVisitDate: '',
diseaseTag: '',
remark: ''
});
const items = [
{ title: 'customerSource', name: '患者来源', type: 'select', operateType: 'formCell', required: false, range: ['线上咨询', '同事推荐', '客户推荐', '其他'] },
{ title: 'firstVisitDate', name: '首次就诊日期', type: 'date', operateType: 'formCell', required: false },
{ title: 'diseaseTag', name: '慢病标签', type: 'select', operateType: 'formCell', required: false, range: ['糖尿病', '高血压', '冠心病', '慢阻肺'] },
{ title: 'remark', name: '备注', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 200 }
];
const rules = {};
onLoad(() => {
const base = uni.getStorageSync(BASE_KEY);
if (!base || typeof base !== 'object') {
uni.showToast({ title: '请先填写基础信息', icon: 'none' });
uni.navigateBack();
return;
}
const cached = uni.getStorageSync(INNER_KEY);
if (cached && typeof cached === 'object') {
Object.assign(form, cached);
}
});
function onChange({ title, value }) {
form[title] = value;
}
function cancel() {
uni.navigateBack();
}
function save() {
if (formRef.value?.verify && !formRef.value.verify()) return;
const base = uni.getStorageSync(BASE_KEY) || {};
const payload = { ...base, ...form };
uni.setStorageSync(INNER_KEY, { ...form });
// payload
uni.showModal({
title: '提示',
content: '已完成新增mock。后续将对接真实建档接口。',
showCancel: false,
success: () => {
uni.removeStorageSync(BASE_KEY);
uni.removeStorageSync(INNER_KEY);
//
uni.navigateBack({ delta: 2 });
}
});
}
</script>
<style lang="scss" scoped>
.page {
height: 100vh;
background: #f6f6f6;
}
.body {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll {
flex: 1;
}
.form-wrap {
background: #fff;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
display: flex;
gap: 12px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
}
.btn {
flex: 1;
height: 44px;
line-height: 44px;
border-radius: 6px;
font-size: 15px;
}
.btn::after {
border: none;
}
.btn.plain {
background: #fff;
color: #666;
border: 1px solid #ddd;
}
.btn.primary {
background: #5d8aff;
color: #fff;
}
</style>

View File

@ -0,0 +1,225 @@
<template>
<view class="page">
<view class="content">
<view class="code-row">
<text class="code-text">{{ displayTeamCode }}</text>
<view class="help" @click="showHelp">
<uni-icons type="help-filled" size="18" color="#5d8aff"></uni-icons>
</view>
</view>
<view class="qr-card">
<image class="qr-image" :src="qrImageUrl" mode="aspectFit" @click="previewQr" />
</view>
<view class="tips">
<text class="tips-title">微信扫一扫上面的二维码</text>
<text class="tips-sub">进入团队首页即可发起线上咨询建档授权等服务</text>
</view>
</view>
<view class="footer">
<button class="footer-btn outline" @click="saveQr">保存图片</button>
<!-- #ifdef MP-WEIXIN -->
<button class="footer-btn primary" open-type="share">分享到微信</button>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<button class="footer-btn primary" @click="shareFallback">分享到微信</button>
<!-- #endif -->
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
import { onLoad, onShareAppMessage } from '@dcloudio/uni-app';
const teamCode = ref('133****3356');
const qrImageUrl = ref('');
const displayTeamCode = computed(() => `${teamCode.value}服务团队码`);
const buildQrUrl = (text) => {
// UI
const encoded = encodeURIComponent(text);
return `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encoded}`;
};
onLoad((query) => {
if (query?.teamCode) teamCode.value = String(query.teamCode);
if (query?.qrUrl) {
qrImageUrl.value = String(query.qrUrl);
} else {
qrImageUrl.value = buildQrUrl(`TEAM_CODE:${teamCode.value}`);
}
});
const showHelp = () => {
uni.showModal({
title: '服务团队码',
content: '患者微信扫一扫二维码进入团队首页,可发起线上咨询、建档授权等服务。',
showCancel: false
});
};
const previewQr = () => {
if (!qrImageUrl.value) return;
uni.previewImage({ urls: [qrImageUrl.value] });
};
const saveQr = () => {
if (!qrImageUrl.value) {
uni.showToast({ title: '二维码生成中,请稍后', icon: 'none' });
return;
}
uni.showLoading({ title: '保存中...' });
uni.downloadFile({
url: qrImageUrl.value,
success: (res) => {
const filePath = res.tempFilePath;
uni.saveImageToPhotosAlbum({
filePath,
success: () => {
uni.hideLoading();
uni.showToast({ title: '已保存到相册', icon: 'success' });
},
fail: () => {
uni.hideLoading();
uni.showModal({
title: '保存失败',
content: '请在系统设置中允许保存到相册后重试。',
confirmText: '去设置',
cancelText: '取消',
success: (r) => {
if (r.confirm) {
uni.openSetting?.({});
}
}
});
}
});
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: '下载二维码失败', icon: 'none' });
}
});
};
const shareFallback = () => {
uni.showToast({ title: '请使用右上角菜单分享', icon: 'none' });
};
onShareAppMessage(() => {
return {
title: '邀请您加入服务团队',
path: `/pages/case/patient-invite?teamCode=${encodeURIComponent(teamCode.value)}`
};
});
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #ffffff;
padding-bottom: calc(76px + env(safe-area-inset-bottom));
}
.content {
padding: 22px 16px 0;
}
.code-row {
display: flex;
align-items: center;
justify-content: center;
margin-top: 8px;
}
.code-text {
font-size: 16px;
color: #333;
font-weight: 600;
}
.help {
margin-left: 8px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.qr-card {
margin: 18px auto 0;
width: 280px;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
}
.qr-image {
width: 260px;
height: 260px;
}
.tips {
margin-top: 16px;
text-align: center;
padding: 0 16px;
}
.tips-title {
display: block;
font-size: 14px;
color: #3a3a3a;
margin-bottom: 6px;
}
.tips-sub {
display: block;
font-size: 12px;
color: #999;
line-height: 18px;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
display: flex;
gap: 12px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
}
.footer-btn {
flex: 1;
height: 44px;
line-height: 44px;
border-radius: 6px;
font-size: 15px;
}
.footer-btn::after {
border: none;
}
.footer-btn.outline {
background: #fff;
color: #5d8aff;
border: 1px solid #5d8aff;
}
.footer-btn.primary {
background: #5d8aff;
color: #fff;
}
</style>

283
pages/case/search.vue Normal file
View File

@ -0,0 +1,283 @@
<template>
<view class="search-container">
<!-- Search Header -->
<view class="search-header">
<view class="search-bar">
<uni-icons type="search" size="18" color="#999" class="search-icon"></uni-icons>
<input
class="search-input"
placeholder="搜索患者名称/手机号/院内ID号"
v-model="searchQuery"
confirm-type="search"
focus
@input="handleSearch"
/>
<uni-icons v-if="searchQuery" type="clear" size="18" color="#ccc" @click="clearSearch" class="clear-icon"></uni-icons>
</view>
<text class="cancel-btn" @click="goBack">取消</text>
</view>
<!-- Search Results -->
<scroll-view v-if="searchQuery" scroll-y class="search-results">
<view v-if="searchResults.length === 0" class="empty-state">
<text class="empty-text">暂无搜索结果</text>
</view>
<view v-else>
<view v-for="(patient, index) in searchResults" :key="index" class="patient-card">
<!-- Row 1 -->
<view class="card-row-top">
<view class="patient-info">
<text class="patient-name">{{ patient.name }}</text>
<text class="patient-meta">{{ patient.gender }}/{{ patient.age }}</text>
</view>
<view class="patient-tags">
<view v-for="(tag, tIndex) in patient.tags" :key="tIndex" class="tag">
{{ tag }}
</view>
</view>
</view>
<!-- Row 2 -->
<view class="card-row-bottom">
<text v-if="patient.record" class="record-text">
{{ patient.record.type }} / {{ patient.record.date }} / {{ patient.record.diagnosis }}
</text>
<text v-else class="no-record">暂无病历记录</text>
</view>
</view>
</view>
</scroll-view>
<!-- History or Suggestions (when no search) -->
<view v-else class="search-tips">
<text class="tips-text">输入患者名称手机号或院内ID号进行搜索</text>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
// State
const searchQuery = ref('');
// Mock all patients data (same as case.vue)
const allPatients = [
{
letter: 'A',
data: [
{
name: '安乐', gender: '男', age: 45, tags: ['糖尿病'],
record: { type: '门诊', date: '2026.1.10', diagnosis: '2型糖尿病' },
createTime: '2026.1.19 14:30', creator: '李医生', phone: '13888888888', hospitalId: '1001'
},
{
name: '奥利奥', gender: '女', age: 22, tags: [], record: null,
createTime: '2026.1.15 09:00', creator: '王医生', phone: '13999999999', hospitalId: '1002'
}
]
},
{
letter: 'L',
data: [
{
name: '李珊珊', gender: '女', age: 37, tags: ['糖尿病', '高血压'],
record: { type: '门诊', date: '2026.1.10', diagnosis: '急性上呼吸道感染' },
createTime: '2026.1.10 10:20', creator: '张医生', phone: '13666666666', hospitalId: '1003'
},
{
name: '李珊珊', gender: '女', age: 37, tags: [],
record: { type: '住院', date: '2026.1.10', diagnosis: '急性上呼吸道感染' },
createTime: '2025.12.30 11:00', creator: '张医生', phone: '13666666666', hospitalId: '1003'
},
{
name: '李某某', gender: '女', age: 37, tags: [], record: null,
createTime: '2025.12.01 08:30', creator: '系统导入', phone: '13555555555', hospitalId: '1004'
},
{
name: '李四', gender: '男', age: 50, tags: ['高血压'], record: null,
createTime: '2026.1.18 16:45', creator: '管理员', phone: '13444444444', hospitalId: '1005'
}
]
},
{
letter: 'Z',
data: [
{
name: '张三', gender: '男', age: 28, tags: [], record: null,
createTime: '2026.1.19 10:00', creator: '赵医生', phone: '13333333333', hospitalId: '1006'
},
{
name: '张敏', gender: '女', age: 32, tags: ['高血压'],
record: { type: '门诊', date: '2025.12.15', diagnosis: '高血压' },
createTime: '2025.11.20 15:15', creator: '孙医生', phone: '13222222222', hospitalId: '1007'
}
]
}
];
// Computed
const searchResults = computed(() => {
if (!searchQuery.value) return [];
const q = searchQuery.value.toLowerCase();
let results = [];
allPatients.forEach(group => {
group.data.forEach(p => {
if (p.name.includes(q) ||
(p.phone && p.phone.includes(q)) ||
(p.hospitalId && p.hospitalId.includes(q))) {
results.push(p);
}
});
});
return results;
});
// Methods
const handleSearch = () => {
// Search logic handled by computed property
};
const clearSearch = () => {
searchQuery.value = '';
};
const goBack = () => {
uni.navigateBack();
};
</script>
<style lang="scss" scoped>
.search-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f7f8fa;
}
.search-header {
display: flex;
align-items: center;
padding: 10px 15px;
background-color: #fff;
border-bottom: 1px solid #f0f0f0;
.search-bar {
flex: 1;
height: 36px;
background-color: #f5f5f5;
border-radius: 18px;
display: flex;
align-items: center;
padding: 0 12px;
margin-right: 10px;
.search-icon {
margin-right: 8px;
}
.search-input {
flex: 1;
font-size: 14px;
color: #333;
}
.clear-icon {
margin-left: 8px;
}
}
.cancel-btn {
font-size: 14px;
color: #5d8aff;
}
}
.search-results {
flex: 1;
.empty-state {
padding: 80px 20px;
text-align: center;
.empty-text {
font-size: 14px;
color: #999;
}
}
}
.search-tips {
flex: 1;
padding: 20px;
.tips-text {
font-size: 14px;
color: #999;
}
}
.patient-card {
background-color: #fff;
padding: 15px;
margin-bottom: 1px;
border-bottom: 1px solid #f0f0f0;
.card-row-top {
display: flex;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
.patient-info {
display: flex;
align-items: flex-end;
margin-right: 10px;
.patient-name {
font-size: 18px;
font-weight: bold;
color: #333;
margin-right: 8px;
}
.patient-meta {
font-size: 12px;
color: #999;
margin-bottom: 2px;
}
}
.patient-tags {
display: flex;
gap: 5px;
.tag {
font-size: 10px;
color: #5d8aff;
border: 1px solid #5d8aff;
padding: 0 4px;
border-radius: 8px;
height: 16px;
line-height: 14px;
}
}
}
.card-row-bottom {
font-size: 14px;
.record-text {
color: #666;
}
.no-record {
color: #bdc3c7;
}
}
}
</style>