Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
阮
阮涛作业2
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Labels
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Analytics
Analytics
CI / CD
Repository
Value Stream
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Jobs
Commits
Open sidebar
阮涛
阮涛作业2
Commits
c2578ff3
Commit
c2578ff3
authored
Mar 05, 2026
by
阮涛
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add new file
parents
Changes
1
Show whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
1604 additions
and
0 deletions
+1604
-0
智能随访管理系统
智能随访管理系统
+1604
-0
No files found.
智能随访管理系统
0 → 100644
View file @
c2578ff3
<!DOCTYPE html>
<html
lang=
"zh-CN"
>
<head>
<meta
charset=
"UTF-8"
>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
>
<title>
智能随访管理系统 · MedFollow
</title>
<script
src=
"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"
></script>
<link
href=
"https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+JP:wght@300;400;500;600&family=DM+Serif+Display&display=swap"
rel=
"stylesheet"
>
<style>
:root
{
--navy
:
#0a1628
;
--navy-mid
:
#122040
;
--navy-soft
:
#1e3358
;
--teal
:
#0d9488
;
--teal-light
:
#14b8a6
;
--teal-glow
:
rgba
(
13
,
148
,
136
,
0.18
);
--sky
:
#38bdf8
;
--amber
:
#f59e0b
;
--rose
:
#f43f5e
;
--green
:
#22c55e
;
--surface
:
#111c30
;
--surface2
:
#162236
;
--border
:
rgba
(
56
,
189
,
248
,
0.12
);
--border2
:
rgba
(
56
,
189
,
248
,
0.06
);
--text
:
#e2eaf5
;
--text-dim
:
#7a9bbf
;
--text-faint
:
#3d5a7a
;
--sidebar-w
:
240px
;
--header-h
:
60px
;
}
*
{
box-sizing
:
border-box
;
margin
:
0
;
padding
:
0
;}
html
,
body
{
height
:
100%
;
font-family
:
'IBM Plex Sans JP'
,
sans-serif
;
background
:
var
(
--navy
);
color
:
var
(
--text
);
font-size
:
14px
;
overflow
:
hidden
;}
/* ── LAYOUT ── */
.app
{
display
:
flex
;
height
:
100vh
;
overflow
:
hidden
;}
/* ── SIDEBAR ── */
.sidebar
{
width
:
var
(
--sidebar-w
);
min-width
:
var
(
--sidebar-w
);
background
:
var
(
--navy-mid
);
border-right
:
1px
solid
var
(
--border
);
display
:
flex
;
flex-direction
:
column
;
overflow
:
hidden
;
}
.brand
{
padding
:
20px
20px
16px
;
border-bottom
:
1px
solid
var
(
--border2
);
}
.brand-logo
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
}
.brand-icon
{
width
:
34px
;
height
:
34px
;
background
:
linear-gradient
(
135deg
,
var
(
--teal
),
var
(
--sky
));
border-radius
:
8px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
font-size
:
16px
;
box-shadow
:
0
0
16px
var
(
--teal-glow
);
}
.brand-name
{
font-family
:
'DM Serif Display'
,
serif
;
font-size
:
17px
;
color
:
#fff
;
letter-spacing
:
.5px
;}
.brand-sub
{
font-size
:
10px
;
color
:
var
(
--text-dim
);
letter-spacing
:
1.5px
;
text-transform
:
uppercase
;
margin-top
:
2px
;}
.nav
{
flex
:
1
;
padding
:
12px
10px
;
overflow-y
:
auto
;}
.nav-section
{
margin-bottom
:
20px
;}
.nav-label
{
font-size
:
10px
;
color
:
var
(
--text-faint
);
letter-spacing
:
1.5px
;
text-transform
:
uppercase
;
padding
:
0
10px
;
margin-bottom
:
8px
;}
.nav-item
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
padding
:
9px
10px
;
border-radius
:
8px
;
cursor
:
pointer
;
transition
:
all
.15s
;
color
:
var
(
--text-dim
);
font-size
:
13px
;
font-weight
:
400
;
margin-bottom
:
2px
;
}
.nav-item
:hover
{
background
:
var
(
--border2
);
color
:
var
(
--text
);}
.nav-item.active
{
background
:
var
(
--teal-glow
);
color
:
var
(
--teal-light
);
font-weight
:
500
;}
.nav-item
.nav-icon
{
font-size
:
15px
;
width
:
20px
;
text-align
:
center
;}
.nav-badge
{
margin-left
:
auto
;
background
:
var
(
--rose
);
color
:
#fff
;
font-size
:
10px
;
font-weight
:
600
;
padding
:
2px
6px
;
border-radius
:
20px
;
}
.sidebar-footer
{
padding
:
14px
16px
;
border-top
:
1px
solid
var
(
--border2
);
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
}
.avatar
{
width
:
32px
;
height
:
32px
;
border-radius
:
50%
;
background
:
linear-gradient
(
135deg
,
#3b82f6
,
var
(
--teal
));
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
font-size
:
13px
;
font-weight
:
600
;
color
:
#fff
;
}
.user-name
{
font-size
:
13px
;
font-weight
:
500
;}
.user-role
{
font-size
:
11px
;
color
:
var
(
--text-dim
);}
/* ── MAIN ── */
.main
{
flex
:
1
;
display
:
flex
;
flex-direction
:
column
;
overflow
:
hidden
;}
.topbar
{
height
:
var
(
--header-h
);
min-height
:
var
(
--header-h
);
background
:
var
(
--navy-mid
);
border-bottom
:
1px
solid
var
(
--border
);
display
:
flex
;
align-items
:
center
;
padding
:
0
28px
;
gap
:
16px
;
}
.page-title
{
font-family
:
'DM Serif Display'
,
serif
;
font-size
:
20px
;
color
:
#fff
;}
.page-sub
{
font-size
:
12px
;
color
:
var
(
--text-dim
);
margin-top
:
2px
;}
.topbar-actions
{
margin-left
:
auto
;
display
:
flex
;
gap
:
8px
;
align-items
:
center
;}
.status-dot
{
width
:
8px
;
height
:
8px
;
border-radius
:
50%
;
background
:
var
(
--green
);
box-shadow
:
0
0
6px
var
(
--green
);
animation
:
blink
2.5s
infinite
;}
@keyframes
blink
{
0
%,
100
%
{
opacity
:
1
;}
50
%
{
opacity
:
.4
;}}
.status-text
{
font-size
:
11px
;
color
:
var
(
--text-dim
);}
.content
{
flex
:
1
;
overflow-y
:
auto
;
padding
:
24px
28px
;}
.content
::-webkit-scrollbar
{
width
:
4px
;}
.content
::-webkit-scrollbar-track
{
background
:
transparent
;}
.content
::-webkit-scrollbar-thumb
{
background
:
var
(
--border
);
border-radius
:
4px
;}
/* PAGE visibility */
.page
{
display
:
none
;}
.page.active
{
display
:
block
;
animation
:
fadeSlide
.25s
ease
;}
@keyframes
fadeSlide
{
from
{
opacity
:
0
;
transform
:
translateY
(
6px
);}
to
{
opacity
:
1
;
transform
:
none
;}}
/* ── COMPONENTS ── */
.btn
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
6px
;
padding
:
8px
16px
;
border
:
none
;
border-radius
:
8px
;
font-family
:
'IBM Plex Sans JP'
,
sans-serif
;
font-size
:
13px
;
font-weight
:
500
;
cursor
:
pointer
;
transition
:
all
.15s
;
letter-spacing
:
.3px
;
}
.btn-primary
{
background
:
var
(
--teal
);
color
:
#fff
;}
.btn-primary
:hover
{
background
:
var
(
--teal-light
);
box-shadow
:
0
0
12px
var
(
--teal-glow
);}
.btn-outline
{
background
:
transparent
;
color
:
var
(
--teal-light
);
border
:
1px
solid
var
(
--teal
);}
.btn-outline
:hover
{
background
:
var
(
--teal-glow
);}
.btn-ghost
{
background
:
var
(
--surface2
);
color
:
var
(
--text
);
border
:
1px
solid
var
(
--border
);}
.btn-ghost
:hover
{
border-color
:
var
(
--teal
);
color
:
var
(
--teal-light
);}
.btn-danger
{
background
:
rgba
(
244
,
63
,
94
,
.15
);
color
:
var
(
--rose
);
border
:
1px
solid
rgba
(
244
,
63
,
94
,
.3
);}
.btn-danger
:hover
{
background
:
rgba
(
244
,
63
,
94
,
.25
);}
.btn-sm
{
padding
:
5px
12px
;
font-size
:
12px
;}
.btn-amber
{
background
:
rgba
(
245
,
158
,
11
,
.15
);
color
:
var
(
--amber
);
border
:
1px
solid
rgba
(
245
,
158
,
11
,
.3
);}
.btn-amber
:hover
{
background
:
rgba
(
245
,
158
,
11
,
.25
);}
.card
{
background
:
var
(
--surface
);
border
:
1px
solid
var
(
--border
);
border-radius
:
12px
;
padding
:
20px
24px
;
margin-bottom
:
20px
;
}
.card-header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
margin-bottom
:
18px
;
padding-bottom
:
14px
;
border-bottom
:
1px
solid
var
(
--border2
);
}
.card-title
{
font-size
:
15px
;
font-weight
:
600
;
color
:
#fff
;
display
:
flex
;
align-items
:
center
;
gap
:
8px
;}
.card-title
.ct-icon
{
font-size
:
16px
;}
/* KPI GRID */
.kpi-grid
{
display
:
grid
;
grid-template-columns
:
repeat
(
4
,
1
fr
);
gap
:
14px
;
margin-bottom
:
20px
;}
.kpi
{
background
:
var
(
--surface
);
border
:
1px
solid
var
(
--border
);
border-radius
:
12px
;
padding
:
18px
20px
;
position
:
relative
;
overflow
:
hidden
;
transition
:
transform
.2s
;
}
.kpi
:hover
{
transform
:
translateY
(
-2px
);}
.kpi
::before
{
content
:
''
;
position
:
absolute
;
top
:
0
;
left
:
0
;
right
:
0
;
height
:
2px
;
}
.kpi.teal
::before
{
background
:
linear-gradient
(
90deg
,
var
(
--teal
),
var
(
--sky
));}
.kpi.amber
::before
{
background
:
linear-gradient
(
90deg
,
var
(
--amber
),
#fbbf24
);}
.kpi.green
::before
{
background
:
linear-gradient
(
90deg
,
var
(
--green
),
#4ade80
);}
.kpi.rose
::before
{
background
:
linear-gradient
(
90deg
,
var
(
--rose
),
#fb7185
);}
.kpi-label
{
font-size
:
11px
;
color
:
var
(
--text-dim
);
text-transform
:
uppercase
;
letter-spacing
:
1px
;
margin-bottom
:
8px
;}
.kpi-value
{
font-family
:
'DM Serif Display'
,
serif
;
font-size
:
32px
;
color
:
#fff
;
line-height
:
1
;}
.kpi-value
span
{
font-size
:
14px
;
color
:
var
(
--text-dim
);
font-family
:
'IBM Plex Sans JP'
,
sans-serif
;
margin-left
:
4px
;}
.kpi-change
{
font-size
:
11px
;
margin-top
:
6px
;}
.kpi-change.up
{
color
:
var
(
--green
);}
.kpi-change.down
{
color
:
var
(
--rose
);}
.kpi-glyph
{
position
:
absolute
;
right
:
16px
;
top
:
50%
;
transform
:
translateY
(
-50%
);
font-size
:
36px
;
opacity
:
.08
;}
/* TABLE */
.tbl-wrap
{
overflow-x
:
auto
;}
table
{
width
:
100%
;
border-collapse
:
collapse
;}
thead
th
{
padding
:
10px
14px
;
text-align
:
left
;
font-size
:
11px
;
color
:
var
(
--text-faint
);
text-transform
:
uppercase
;
letter-spacing
:
1px
;
font-weight
:
500
;
border-bottom
:
1px
solid
var
(
--border
);
white-space
:
nowrap
;
}
tbody
td
{
padding
:
12px
14px
;
border-bottom
:
1px
solid
var
(
--border2
);
color
:
var
(
--text
);
font-size
:
13px
;
vertical-align
:
middle
;
}
tbody
tr
:last-child
td
{
border-bottom
:
none
;}
tbody
tr
:hover
td
{
background
:
var
(
--border2
);}
/* STATUS PILLS */
.pill
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
5px
;
padding
:
3px
10px
;
border-radius
:
20px
;
font-size
:
11px
;
font-weight
:
500
;}
.pill-green
{
background
:
rgba
(
34
,
197
,
94
,
.15
);
color
:
#4ade80
;}
.pill-amber
{
background
:
rgba
(
245
,
158
,
11
,
.15
);
color
:
#fbbf24
;}
.pill-rose
{
background
:
rgba
(
244
,
63
,
94
,
.15
);
color
:
#fb7185
;}
.pill-blue
{
background
:
rgba
(
56
,
189
,
248
,
.15
);
color
:
#7dd3fc
;}
.pill-gray
{
background
:
rgba
(
100
,
116
,
139
,
.15
);
color
:
#94a3b8
;}
.pill-dot
{
width
:
5px
;
height
:
5px
;
border-radius
:
50%
;
background
:
currentColor
;}
/* FORMS */
.form-grid
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
14px
;}
.form-grid.cols3
{
grid-template-columns
:
1
fr
1
fr
1
fr
;}
.form-group
{
display
:
flex
;
flex-direction
:
column
;
gap
:
5px
;}
.form-group.full
{
grid-column
:
1
/
-1
;}
.form-label
{
font-size
:
11px
;
color
:
var
(
--text-dim
);
text-transform
:
uppercase
;
letter-spacing
:
.8px
;}
.form-input
,
.form-select
,
.form-textarea
{
padding
:
9px
12px
;
border
:
1px
solid
var
(
--border
);
border-radius
:
8px
;
background
:
var
(
--surface2
);
color
:
var
(
--text
);
font-family
:
'IBM Plex Sans JP'
,
sans-serif
;
font-size
:
13px
;
outline
:
none
;
transition
:
border-color
.15s
;
}
.form-input
:focus
,
.form-select
:focus
,
.form-textarea
:focus
{
border-color
:
var
(
--teal
);}
.form-select
option
{
background
:
var
(
--navy-mid
);}
.form-textarea
{
resize
:
vertical
;
min-height
:
80px
;}
.form-actions
{
display
:
flex
;
justify-content
:
flex-end
;
gap
:
10px
;
margin-top
:
16px
;}
/* MODAL */
.overlay
{
display
:
none
;
position
:
fixed
;
inset
:
0
;
background
:
rgba
(
10
,
22
,
40
,
.8
);
backdrop-filter
:
blur
(
4px
);
z-index
:
500
;
align-items
:
center
;
justify-content
:
center
;
}
.overlay.show
{
display
:
flex
;
animation
:
fadeSlide
.2s
;}
.modal
{
background
:
var
(
--surface
);
border
:
1px
solid
var
(
--border
);
border-radius
:
16px
;
padding
:
28px
32px
;
width
:
90%
;
max-width
:
560px
;
max-height
:
85vh
;
overflow-y
:
auto
;
}
.modal-lg
{
max-width
:
780px
;}
.modal-header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
margin-bottom
:
20px
;}
.modal-title
{
font-size
:
17px
;
font-weight
:
600
;
color
:
#fff
;}
.modal-close
{
background
:
none
;
border
:
none
;
color
:
var
(
--text-dim
);
font-size
:
20px
;
cursor
:
pointer
;
line-height
:
1
;}
.modal-close
:hover
{
color
:
var
(
--text
);}
/* SECTION HEADER */
.section-header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
margin-bottom
:
16px
;}
.section-title
{
font-size
:
13px
;
font-weight
:
600
;
color
:
var
(
--text-dim
);
text-transform
:
uppercase
;
letter-spacing
:
1px
;}
/* SEARCH BAR */
.search-bar
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
background
:
var
(
--surface2
);
border
:
1px
solid
var
(
--border
);
border-radius
:
8px
;
padding
:
8px
14px
;
flex
:
1
;
max-width
:
320px
;
}
.search-bar
input
{
background
:
none
;
border
:
none
;
outline
:
none
;
color
:
var
(
--text
);
font-family
:
'IBM Plex Sans JP'
,
sans-serif
;
font-size
:
13px
;
flex
:
1
;
}
.search-bar
input
::placeholder
{
color
:
var
(
--text-faint
);}
.search-icon
{
color
:
var
(
--text-faint
);
font-size
:
14px
;}
/* TABS */
.tab-row
{
display
:
flex
;
gap
:
4px
;
margin-bottom
:
18px
;
border-bottom
:
1px
solid
var
(
--border2
);
padding-bottom
:
0
;}
.tab
{
padding
:
8px
16px
;
cursor
:
pointer
;
border-bottom
:
2px
solid
transparent
;
font-size
:
13px
;
color
:
var
(
--text-dim
);
transition
:
all
.15s
;
margin-bottom
:
-1px
;
}
.tab
:hover
{
color
:
var
(
--text
);}
.tab.active
{
color
:
var
(
--teal-light
);
border-bottom-color
:
var
(
--teal
);}
/* PROGRESS BAR */
.prog-wrap
{
height
:
6px
;
background
:
var
(
--border
);
border-radius
:
3px
;
overflow
:
hidden
;}
.prog-bar
{
height
:
100%
;
border-radius
:
3px
;
transition
:
width
.4s
;}
.prog-bar.teal
{
background
:
linear-gradient
(
90deg
,
var
(
--teal
),
var
(
--sky
));}
.prog-bar.amber
{
background
:
var
(
--amber
);}
.prog-bar.rose
{
background
:
var
(
--rose
);}
.prog-bar.green
{
background
:
var
(
--green
);}
/* TIMELINE */
.timeline
{
padding-left
:
20px
;
border-left
:
2px
solid
var
(
--border
);}
.tl-item
{
position
:
relative
;
padding
:
0
0
16px
20px
;}
.tl-item
::before
{
content
:
''
;
position
:
absolute
;
left
:
-25px
;
top
:
4px
;
width
:
10px
;
height
:
10px
;
border-radius
:
50%
;
background
:
var
(
--teal
);
border
:
2px
solid
var
(
--navy
);
box-shadow
:
0
0
8px
var
(
--teal-glow
);
}
.tl-time
{
font-size
:
11px
;
color
:
var
(
--text-faint
);
margin-bottom
:
3px
;}
.tl-title
{
font-size
:
13px
;
font-weight
:
500
;
color
:
var
(
--text
);
margin-bottom
:
2px
;}
.tl-desc
{
font-size
:
12px
;
color
:
var
(
--text-dim
);}
/* QUESTIONNAIRE */
.q-item
{
background
:
var
(
--surface2
);
border
:
1px
solid
var
(
--border
);
border-radius
:
10px
;
padding
:
16px
;
margin-bottom
:
12px
;}
.q-num
{
font-size
:
10px
;
color
:
var
(
--teal
);
text-transform
:
uppercase
;
letter-spacing
:
1px
;
margin-bottom
:
4px
;}
.q-text
{
font-size
:
14px
;
color
:
#fff
;
margin-bottom
:
12px
;
font-weight
:
500
;}
.q-options
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
8px
;}
.q-opt
{
padding
:
6px
14px
;
border
:
1px
solid
var
(
--border
);
border-radius
:
20px
;
font-size
:
12px
;
color
:
var
(
--text-dim
);
cursor
:
pointer
;
transition
:
all
.15s
;
}
.q-opt
:hover
,
.q-opt.selected
{
border-color
:
var
(
--teal
);
color
:
var
(
--teal-light
);
background
:
var
(
--teal-glow
);}
.q-scale
{
display
:
flex
;
gap
:
6px
;}
.q-scale-btn
{
flex
:
1
;
text-align
:
center
;
padding
:
8px
4px
;
border
:
1px
solid
var
(
--border
);
border-radius
:
8px
;
font-size
:
13px
;
cursor
:
pointer
;
transition
:
all
.15s
;
color
:
var
(
--text-dim
);
}
.q-scale-btn
:hover
,
.q-scale-btn.selected
{
border-color
:
var
(
--teal
);
background
:
var
(
--teal-glow
);
color
:
#fff
;}
/* TOAST */
.toast-wrap
{
position
:
fixed
;
bottom
:
24px
;
right
:
24px
;
z-index
:
9999
;
display
:
flex
;
flex-direction
:
column
;
gap
:
8px
;}
.toast
{
background
:
var
(
--surface
);
border
:
1px
solid
var
(
--border
);
border-radius
:
10px
;
padding
:
12px
18px
;
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
box-shadow
:
0
8px
24px
rgba
(
0
,
0
,
0
,
.4
);
animation
:
slideUp
.3s
ease
;
font-size
:
13px
;
min-width
:
240px
;
max-width
:
340px
;
}
@keyframes
slideUp
{
from
{
opacity
:
0
;
transform
:
translateY
(
12px
);}
to
{
opacity
:
1
;
transform
:
none
;}}
.toast-icon
{
font-size
:
16px
;}
.toast.success
{
border-color
:
rgba
(
34
,
197
,
94
,
.3
);}
.toast.error
{
border-color
:
rgba
(
244
,
63
,
94
,
.3
);}
.toast.info
{
border-color
:
rgba
(
56
,
189
,
248
,
.3
);}
/* CHART containers */
.chart-box
{
position
:
relative
;
height
:
220px
;}
.chart-box-tall
{
position
:
relative
;
height
:
280px
;}
/* EMPTY STATE */
.empty
{
text-align
:
center
;
padding
:
40px
20px
;
color
:
var
(
--text-faint
);}
.empty-icon
{
font-size
:
40px
;
margin-bottom
:
10px
;
opacity
:
.4
;}
.empty-text
{
font-size
:
13px
;}
/* DETAIL DRAWER */
.drawer
{
position
:
fixed
;
right
:
-480px
;
top
:
0
;
bottom
:
0
;
width
:
480px
;
background
:
var
(
--navy-mid
);
border-left
:
1px
solid
var
(
--border
);
z-index
:
400
;
transition
:
right
.3s
ease
;
display
:
flex
;
flex-direction
:
column
;
overflow
:
hidden
;
}
.drawer.open
{
right
:
0
;}
.drawer-header
{
padding
:
20px
24px
;
border-bottom
:
1px
solid
var
(
--border
);
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
}
.drawer-body
{
flex
:
1
;
overflow-y
:
auto
;
padding
:
20px
24px
;}
.drawer-body
::-webkit-scrollbar
{
width
:
3px
;}
.drawer-body
::-webkit-scrollbar-thumb
{
background
:
var
(
--border
);}
.drawer-footer
{
padding
:
16px
24px
;
border-top
:
1px
solid
var
(
--border
);
display
:
flex
;
gap
:
8px
;}
/* SCORE RING */
.score-ring
{
text-align
:
center
;
padding
:
10px
;}
.score-num
{
font-family
:
'DM Serif Display'
,
serif
;
font-size
:
48px
;
color
:
var
(
--teal-light
);}
.score-label
{
font-size
:
12px
;
color
:
var
(
--text-dim
);}
/* EXPORT */
.export-options
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
12px
;
margin-top
:
16px
;}
.export-card
{
background
:
var
(
--surface2
);
border
:
1px
solid
var
(
--border
);
border-radius
:
10px
;
padding
:
16px
;
cursor
:
pointer
;
transition
:
all
.2s
;
text-align
:
center
;
}
.export-card
:hover
{
border-color
:
var
(
--teal
);
transform
:
translateY
(
-2px
);}
.export-card
.ec-icon
{
font-size
:
28px
;
margin-bottom
:
8px
;}
.export-card
.ec-name
{
font-size
:
13px
;
font-weight
:
500
;
color
:
#fff
;}
.export-card
.ec-desc
{
font-size
:
11px
;
color
:
var
(
--text-dim
);
margin-top
:
3px
;}
/* NOTIFICATION BELL */
.notif-btn
{
position
:
relative
;
background
:
var
(
--surface2
);
border
:
1px
solid
var
(
--border
);
border-radius
:
8px
;
padding
:
6px
10px
;
cursor
:
pointer
;
font-size
:
16px
;
transition
:
border-color
.15s
;
}
.notif-btn
:hover
{
border-color
:
var
(
--teal
);}
.notif-count
{
position
:
absolute
;
top
:
-4px
;
right
:
-4px
;
background
:
var
(
--rose
);
color
:
#fff
;
font-size
:
9px
;
font-weight
:
700
;
width
:
16px
;
height
:
16px
;
border-radius
:
50%
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
border
:
2px
solid
var
(
--navy-mid
);
}
/* RESPONSIVE NOTE */
@media
(
max-width
:
900px
){
.kpi-grid
{
grid-template-columns
:
repeat
(
2
,
1
fr
);}
.form-grid
{
grid-template-columns
:
1
fr
;}
.form-grid.cols3
{
grid-template-columns
:
1
fr
;}
}
</style>
</head>
<body>
<div
class=
"app"
>
<!-- SIDEBAR -->
<aside
class=
"sidebar"
>
<div
class=
"brand"
>
<div
class=
"brand-logo"
>
<div
class=
"brand-icon"
>
🏥
</div>
<div>
<div
class=
"brand-name"
>
MedFollow
</div>
<div
class=
"brand-sub"
>
智能随访系统
</div>
</div>
</div>
</div>
<nav
class=
"nav"
>
<div
class=
"nav-section"
>
<div
class=
"nav-label"
>
主要功能
</div>
<div
class=
"nav-item active"
onclick=
"nav(this,'dashboard')"
>
<span
class=
"nav-icon"
>
📊
</span>
数据总览
</div>
<div
class=
"nav-item"
onclick=
"nav(this,'patients')"
>
<span
class=
"nav-icon"
>
👥
</span>
患者管理
</div>
<div
class=
"nav-item"
onclick=
"nav(this,'followup')"
>
<span
class=
"nav-icon"
>
📅
</span>
随访计划
<span
class=
"nav-badge"
id=
"badge-followup"
>
3
</span>
</div>
<div
class=
"nav-item"
onclick=
"nav(this,'questionnaire')"
>
<span
class=
"nav-icon"
>
📋
</span>
问卷管理
</div>
<div
class=
"nav-item"
onclick=
"nav(this,'calls')"
>
<span
class=
"nav-icon"
>
📞
</span>
外呼中心
<span
class=
"nav-badge"
id=
"badge-calls"
>
7
</span>
</div>
</div>
<div
class=
"nav-section"
>
<div
class=
"nav-label"
>
分析报告
</div>
<div
class=
"nav-item"
onclick=
"nav(this,'analytics')"
>
<span
class=
"nav-icon"
>
📈
</span>
统计分析
</div>
<div
class=
"nav-item"
onclick=
"nav(this,'satisfaction')"
>
<span
class=
"nav-icon"
>
⭐
</span>
满意度调查
</div>
<div
class=
"nav-item"
onclick=
"nav(this,'export')"
>
<span
class=
"nav-icon"
>
📤
</span>
数据导出
</div>
</div>
<div
class=
"nav-section"
>
<div
class=
"nav-label"
>
系统
</div>
<div
class=
"nav-item"
onclick=
"nav(this,'settings')"
>
<span
class=
"nav-icon"
>
⚙️
</span>
系统设置
</div>
</div>
</nav>
<div
class=
"sidebar-footer"
>
<div
class=
"avatar"
>
王
</div>
<div>
<div
class=
"user-name"
>
王医生
</div>
<div
class=
"user-role"
>
随访管理员
</div>
</div>
</div>
</aside>
<!-- MAIN -->
<div
class=
"main"
>
<div
class=
"topbar"
>
<div>
<div
class=
"page-title"
id=
"topbar-title"
>
数据总览
</div>
<div
class=
"page-sub"
id=
"topbar-sub"
>
欢迎回来,今日共有 12 项随访任务待处理
</div>
</div>
<div
class=
"topbar-actions"
>
<div
style=
"display:flex;align-items:center;gap:6px;"
>
<div
class=
"status-dot"
></div>
<span
class=
"status-text"
>
系统运行正常
</span>
</div>
<div
class=
"notif-btn"
onclick=
"showNotif()"
>
🔔
<div
class=
"notif-count"
>
5
</div></div>
<button
class=
"btn btn-primary btn-sm"
onclick=
"openAddPatient()"
>
+ 添加患者
</button>
</div>
</div>
<div
class=
"content"
>
<!-- ═══════ PAGE: DASHBOARD ═══════ -->
<div
class=
"page active"
id=
"page-dashboard"
>
<div
class=
"kpi-grid"
>
<div
class=
"kpi teal"
>
<div
class=
"kpi-label"
>
患者总数
</div>
<div
class=
"kpi-value"
>
1,284
<span>
人
</span></div>
<div
class=
"kpi-change up"
>
↑ 23 本月新增
</div>
<div
class=
"kpi-glyph"
>
👥
</div>
</div>
<div
class=
"kpi amber"
>
<div
class=
"kpi-label"
>
本月随访
</div>
<div
class=
"kpi-value"
>
348
<span>
次
</span></div>
<div
class=
"kpi-change up"
>
↑ 完成率 87.4%
</div>
<div
class=
"kpi-glyph"
>
📅
</div>
</div>
<div
class=
"kpi green"
>
<div
class=
"kpi-label"
>
问卷回收
</div>
<div
class=
"kpi-value"
>
1,096
<span>
份
</span></div>
<div
class=
"kpi-change up"
>
↑ 回收率 91.2%
</div>
<div
class=
"kpi-glyph"
>
📋
</div>
</div>
<div
class=
"kpi rose"
>
<div
class=
"kpi-label"
>
待处理任务
</div>
<div
class=
"kpi-value"
>
12
<span>
项
</span></div>
<div
class=
"kpi-change down"
>
↓ 包含 3 项紧急
</div>
<div
class=
"kpi-glyph"
>
⚡
</div>
</div>
</div>
<div
style=
"display:grid;grid-template-columns:2fr 1fr;gap:20px;"
>
<div
class=
"card"
>
<div
class=
"card-header"
>
<div
class=
"card-title"
><span
class=
"ct-icon"
>
📈
</span>
随访完成趋势(近6月)
</div>
<button
class=
"btn btn-ghost btn-sm"
onclick=
"nav(document.querySelector('[onclick*=analytics]'),'analytics')"
>
查看详情
</button>
</div>
<div
class=
"chart-box"
><canvas
id=
"trendChart"
></canvas></div>
</div>
<div
class=
"card"
>
<div
class=
"card-header"
>
<div
class=
"card-title"
><span
class=
"ct-icon"
>
🥧
</span>
疾病分类
</div>
</div>
<div
class=
"chart-box"
><canvas
id=
"diseaseChart"
></canvas></div>
</div>
</div>
<div
style=
"display:grid;grid-template-columns:1fr 1fr;gap:20px;"
>
<div
class=
"card"
>
<div
class=
"card-header"
>
<div
class=
"card-title"
><span
class=
"ct-icon"
>
⚡
</span>
今日待办任务
</div>
<span
class=
"pill pill-rose"
><span
class=
"pill-dot"
></span>
12 项未完成
</span>
</div>
<div
id=
"todo-list"
>
<!-- rendered by JS -->
</div>
</div>
<div
class=
"card"
>
<div
class=
"card-header"
>
<div
class=
"card-title"
><span
class=
"ct-icon"
>
🕐
</span>
最近随访记录
</div>
</div>
<div
class=
"timeline"
id=
"recent-timeline"
>
<!-- JS -->
</div>
</div>
</div>
</div>
<!-- ═══════ PAGE: PATIENTS ═══════ -->
<div
class=
"page"
id=
"page-patients"
>
<div
class=
"card"
style=
"padding:16px 20px;margin-bottom:16px;"
>
<div
style=
"display:flex;align-items:center;gap:12px;flex-wrap:wrap;"
>
<div
class=
"search-bar"
>
<span
class=
"search-icon"
>
🔍
</span>
<input
type=
"text"
id=
"pt-search"
placeholder=
"搜索患者姓名、ID、诊断…"
oninput=
"filterPatients()"
>
</div>
<select
class=
"form-select"
style=
"width:140px;"
id=
"pt-filter-status"
onchange=
"filterPatients()"
>
<option
value=
""
>
全部状态
</option>
<option
value=
"随访中"
>
随访中
</option>
<option
value=
"待随访"
>
待随访
</option>
<option
value=
"已完成"
>
已完成
</option>
<option
value=
"失访"
>
失访
</option>
</select>
<select
class=
"form-select"
style=
"width:140px;"
id=
"pt-filter-disease"
onchange=
"filterPatients()"
>
<option
value=
""
>
全部疾病
</option>
<option
value=
"心血管"
>
心血管
</option>
<option
value=
"糖尿病"
>
糖尿病
</option>
<option
value=
"骨科术后"
>
骨科术后
</option>
<option
value=
"肿瘤"
>
肿瘤
</option>
<option
value=
"呼吸系统"
>
呼吸系统
</option>
</select>
<button
class=
"btn btn-primary btn-sm"
onclick=
"openAddPatient()"
>
+ 添加患者
</button>
<button
class=
"btn btn-ghost btn-sm"
onclick=
"exportData('patients')"
>
📤 导出
</button>
</div>
</div>
<div
class=
"card"
>
<div
class=
"tbl-wrap"
>
<table>
<thead>
<tr>
<th>
患者ID
</th><th>
姓名
</th><th>
年龄/性别
</th>
<th>
主要诊断
</th><th>
责任医生
</th>
<th>
随访状态
</th><th>
下次随访
</th><th>
操作
</th>
</tr>
</thead>
<tbody
id=
"patient-tbody"
></tbody>
</table>
<div
class=
"empty"
id=
"pt-empty"
style=
"display:none;"
>
<div
class=
"empty-icon"
>
👥
</div>
<div
class=
"empty-text"
>
未找到匹配患者
</div>
</div>
</div>
<div
style=
"display:flex;align-items:center;justify-content:space-between;margin-top:14px;padding-top:14px;border-top:1px solid var(--border2);"
>
<span
style=
"font-size:12px;color:var(--text-dim);"
id=
"pt-count"
>
共 0 名患者
</span>
<div
style=
"display:flex;gap:6px;"
>
<button
class=
"btn btn-ghost btn-sm"
>
« 上一页
</button>
<button
class=
"btn btn-ghost btn-sm"
style=
"border-color:var(--teal);color:var(--teal-light);"
>
1
</button>
<button
class=
"btn btn-ghost btn-sm"
>
2
</button>
<button
class=
"btn btn-ghost btn-sm"
>
3
</button>
<button
class=
"btn btn-ghost btn-sm"
>
下一页 »
</button>
</div>
</div>
</div>
</div>
<!-- ═══════ PAGE: FOLLOWUP ═══════ -->
<div
class=
"page"
id=
"page-followup"
>
<div
style=
"display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;"
>
<button
class=
"btn btn-primary"
onclick=
"openScheduleModal()"
>
+ 安排随访
</button>
<button
class=
"btn btn-ghost"
onclick=
"openBatchSMS()"
>
📱 批量短信
</button>
<button
class=
"btn btn-ghost"
onclick=
"openBatchCall()"
>
📞 批量外呼
</button>
<button
class=
"btn btn-ghost btn-sm"
onclick=
"exportData('followup')"
>
📤 导出计划
</button>
</div>
<div
class=
"tab-row"
>
<div
class=
"tab active"
onclick=
"switchFollowupTab(this,'all')"
>
全部
</div>
<div
class=
"tab"
onclick=
"switchFollowupTab(this,'today')"
>
今日(5)
</div>
<div
class=
"tab"
onclick=
"switchFollowupTab(this,'week')"
>
本周(18)
</div>
<div
class=
"tab"
onclick=
"switchFollowupTab(this,'overdue')"
>
逾期(3)
</div>
</div>
<div
class=
"card"
>
<div
class=
"tbl-wrap"
>
<table>
<thead>
<tr>
<th>
随访ID
</th><th>
患者
</th><th>
随访类型
</th>
<th>
计划时间
</th><th>
联系方式
</th>
<th>
状态
</th><th>
执行人
</th><th>
操作
</th>
</tr>
</thead>
<tbody
id=
"followup-tbody"
></tbody>
</table>
</div>
</div>
</div>
<!-- ═══════ PAGE: QUESTIONNAIRE ═══════ -->
<div
class=
"page"
id=
"page-questionnaire"
>
<div
style=
"display:flex;gap:12px;margin-bottom:16px;"
>
<button
class=
"btn btn-primary"
onclick=
"openQuestModal()"
>
+ 创建问卷
</button>
<button
class=
"btn btn-ghost"
onclick=
"openFillQuest()"
>
✏️ 填写示例问卷
</button>
</div>
<div
style=
"display:grid;grid-template-columns:repeat(3,1fr);gap:16px;"
id=
"quest-cards"
></div>
</div>
<!-- ═══════ PAGE: CALLS ═══════ -->
<div
class=
"page"
id=
"page-calls"
>
<div
style=
"display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-bottom:20px;"
>
<div
class=
"kpi amber"
>
<div
class=
"kpi-label"
>
今日外呼
</div>
<div
class=
"kpi-value"
>
47
<span>
次
</span></div>
<div
class=
"kpi-change up"
>
↑ 接通率 78.7%
</div>
</div>
<div
class=
"kpi teal"
>
<div
class=
"kpi-label"
>
短信发送
</div>
<div
class=
"kpi-value"
>
132
<span>
条
</span></div>
<div
class=
"kpi-change up"
>
↑ 送达率 99.2%
</div>
</div>
<div
class=
"kpi rose"
>
<div
class=
"kpi-label"
>
未接通
</div>
<div
class=
"kpi-value"
>
10
<span>
次
</span></div>
<div
class=
"kpi-change down"
>
↓ 需人工跟进
</div>
</div>
</div>
<div
class=
"card"
>
<div
class=
"card-header"
>
<div
class=
"card-title"
><span
class=
"ct-icon"
>
📞
</span>
外呼记录
</div>
<div
style=
"display:flex;gap:8px;"
>
<button
class=
"btn btn-primary btn-sm"
onclick=
"simulateCall()"
>
▶ 模拟外呼
</button>
<button
class=
"btn btn-ghost btn-sm"
onclick=
"openBatchSMS()"
>
📱 群发短信
</button>
</div>
</div>
<div
class=
"tbl-wrap"
>
<table>
<thead><tr><th>
时间
</th><th>
患者
</th><th>
电话
</th><th>
类型
</th><th>
时长
</th><th>
结果
</th><th>
备注
</th></tr></thead>
<tbody
id=
"call-tbody"
></tbody>
</table>
</div>
</div>
</div>
<!-- ═══════ PAGE: ANALYTICS ═══════ -->
<div
class=
"page"
id=
"page-analytics"
>
<div
style=
"display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px;"
>
<div
class=
"card"
>
<div
class=
"card-header"
>
<div
class=
"card-title"
><span
class=
"ct-icon"
>
📊
</span>
月度随访完成率
</div>
</div>
<div
class=
"chart-box-tall"
><canvas
id=
"monthChart"
></canvas></div>
</div>
<div
class=
"card"
>
<div
class=
"card-header"
>
<div
class=
"card-title"
><span
class=
"ct-icon"
>
🌡️
</span>
患者健康评分分布
</div>
</div>
<div
class=
"chart-box-tall"
><canvas
id=
"healthChart"
></canvas></div>
</div>
</div>
<div
style=
"display:grid;grid-template-columns:1fr 1fr;gap:20px;"
>
<div
class=
"card"
>
<div
class=
"card-header"
>
<div
class=
"card-title"
><span
class=
"ct-icon"
>
🔁
</span>
随访方式分布
</div>
</div>
<div
class=
"chart-box"
><canvas
id=
"methodChart"
></canvas></div>
</div>
<div
class=
"card"
>
<div
class=
"card-header"
>
<div
class=
"card-title"
><span
class=
"ct-icon"
>
⏱️
</span>
随访响应时间分析
</div>
</div>
<div
class=
"chart-box"
><canvas
id=
"responseChart"
></canvas></div>
</div>
</div>
</div>
<!-- ═══════ PAGE: SATISFACTION ═══════ -->
<div
class=
"page"
id=
"page-satisfaction"
>
<div
style=
"display:grid;grid-template-columns:1fr 2fr;gap:20px;"
>
<div
class=
"card"
>
<div
class=
"card-title"
style=
"margin-bottom:16px;"
><span
class=
"ct-icon"
>
⭐
</span>
综合满意度
</div>
<div
class=
"score-ring"
>
<div
class=
"score-num"
>
4.7
</div>
<div
class=
"score-label"
>
满分 5.0
</div>
</div>
<div
style=
"margin-top:16px;"
>
<div
id=
"sat-bars"
></div>
</div>
</div>
<div
class=
"card"
>
<div
class=
"card-header"
>
<div
class=
"card-title"
><span
class=
"ct-icon"
>
💬
</span>
患者反馈列表
</div>
</div>
<div
id=
"feedback-list"
></div>
</div>
</div>
</div>
<!-- ═══════ PAGE: EXPORT ═══════ -->
<div
class=
"page"
id=
"page-export"
>
<div
class=
"card"
>
<div
class=
"card-title"
style=
"margin-bottom:6px;"
><span
class=
"ct-icon"
>
📤
</span>
数据导出中心
</div>
<p
style=
"font-size:13px;color:var(--text-dim);margin-bottom:0;"
>
选择导出格式和数据范围,生成报表文件
</p>
<div
class=
"export-options"
>
<div
class=
"export-card"
onclick=
"doExport('excel')"
>
<div
class=
"ec-icon"
>
📊
</div>
<div
class=
"ec-name"
>
Excel 报表
</div>
<div
class=
"ec-desc"
>
患者随访数据明细
</div>
</div>
<div
class=
"export-card"
onclick=
"doExport('csv')"
>
<div
class=
"ec-icon"
>
📄
</div>
<div
class=
"ec-name"
>
CSV 格式
</div>
<div
class=
"ec-desc"
>
便于二次分析处理
</div>
</div>
<div
class=
"export-card"
onclick=
"doExport('pdf')"
>
<div
class=
"ec-icon"
>
📑
</div>
<div
class=
"ec-name"
>
PDF 报告
</div>
<div
class=
"ec-desc"
>
可打印的统计报告
</div>
</div>
<div
class=
"export-card"
onclick=
"doExport('json')"
>
<div
class=
"ec-icon"
>
🔗
</div>
<div
class=
"ec-name"
>
JSON 接口
</div>
<div
class=
"ec-desc"
>
用于系统集成对接
</div>
</div>
</div>
</div>
<div
class=
"card"
>
<div
class=
"card-header"
>
<div
class=
"card-title"
><span
class=
"ct-icon"
>
⚙️
</span>
导出配置
</div>
</div>
<div
class=
"form-grid cols3"
>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
数据类型
</label>
<select
class=
"form-select"
id=
"exp-type"
>
<option>
全部数据
</option>
<option>
随访记录
</option>
<option>
问卷结果
</option>
<option>
满意度数据
</option>
<option>
外呼记录
</option>
</select>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
开始日期
</label>
<input
type=
"date"
class=
"form-input"
id=
"exp-start"
>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
结束日期
</label>
<input
type=
"date"
class=
"form-input"
id=
"exp-end"
>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
科室筛选
</label>
<select
class=
"form-select"
>
<option>
全部科室
</option>
<option>
心内科
</option>
<option>
骨科
</option>
<option>
内分泌科
</option>
<option>
肿瘤科
</option>
</select>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
随访状态
</label>
<select
class=
"form-select"
>
<option>
全部状态
</option>
<option>
已完成
</option>
<option>
逾期
</option>
<option>
失访
</option>
</select>
</div>
<div
class=
"form-group"
style=
"align-self:flex-end;"
>
<button
class=
"btn btn-primary"
onclick=
"doExport('excel')"
style=
"width:100%;"
>
🚀 生成并下载
</button>
</div>
</div>
</div>
</div>
<!-- ═══════ PAGE: SETTINGS ═══════ -->
<div
class=
"page"
id=
"page-settings"
>
<div
class=
"card"
>
<div
class=
"card-title"
style=
"margin-bottom:18px;"
><span
class=
"ct-icon"
>
⚙️
</span>
随访规则配置
</div>
<div
class=
"form-grid"
>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
术后随访频率(默认)
</label>
<select
class=
"form-select"
>
<option>
每周1次
</option><option>
每2周1次
</option><option>
每月1次
</option>
</select>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
提前提醒时间
</label>
<select
class=
"form-select"
>
<option>
提前1天
</option><option>
提前2天
</option><option>
提前3天
</option><option>
提前1周
</option>
</select>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
自动外呼开始时间
</label>
<input
type=
"time"
class=
"form-input"
value=
"09:00"
>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
自动外呼结束时间
</label>
<input
type=
"time"
class=
"form-input"
value=
"18:00"
>
</div>
</div>
</div>
<div
class=
"card"
>
<div
class=
"card-title"
style=
"margin-bottom:18px;"
><span
class=
"ct-icon"
>
📱
</span>
短信模板配置
</div>
<div
class=
"form-grid"
>
<div
class=
"form-group full"
>
<label
class=
"form-label"
>
随访提醒短信模板
</label>
<textarea
class=
"form-textarea"
>
尊敬的{患者姓名},您好!您在{医院名称}的随访时间为{随访日期},请按时配合随访。如有问题请拨打{联系电话}。
</textarea>
</div>
<div
class=
"form-group full"
>
<label
class=
"form-label"
>
问卷填写提醒模板
</label>
<textarea
class=
"form-textarea"
>
您好{患者姓名},请点击以下链接填写本次随访问卷:{问卷链接},感谢您的配合!
</textarea>
</div>
</div>
<div
class=
"form-actions"
>
<button
class=
"btn btn-ghost"
>
恢复默认
</button>
<button
class=
"btn btn-primary"
onclick=
"toast('设置已保存','success')"
>
保存设置
</button>
</div>
</div>
</div>
</div>
<!-- /content -->
</div>
<!-- /main -->
</div>
<!-- /app -->
<!-- ═══ MODALS ═══ -->
<!-- Add Patient -->
<div
class=
"overlay"
id=
"modal-addpatient"
>
<div
class=
"modal"
>
<div
class=
"modal-header"
>
<div
class=
"modal-title"
>
➕ 新增患者
</div>
<button
class=
"modal-close"
onclick=
"closeModal('modal-addpatient')"
>
✕
</button>
</div>
<div
class=
"form-grid"
>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
姓名 *
</label>
<input
type=
"text"
class=
"form-input"
id=
"np-name"
placeholder=
"患者姓名"
>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
身份证号
</label>
<input
type=
"text"
class=
"form-input"
id=
"np-id"
placeholder=
"18位身份证"
>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
年龄
</label>
<input
type=
"number"
class=
"form-input"
id=
"np-age"
placeholder=
"岁"
>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
性别
</label>
<select
class=
"form-select"
id=
"np-sex"
><option>
男
</option><option>
女
</option></select>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
联系电话 *
</label>
<input
type=
"tel"
class=
"form-input"
id=
"np-phone"
placeholder=
"手机号"
>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
主要诊断 *
</label>
<select
class=
"form-select"
id=
"np-disease"
>
<option>
心血管
</option><option>
糖尿病
</option><option>
骨科术后
</option><option>
肿瘤
</option><option>
呼吸系统
</option>
</select>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
责任医生
</label>
<input
type=
"text"
class=
"form-input"
id=
"np-doctor"
placeholder=
"医生姓名"
>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
科室
</label>
<select
class=
"form-select"
id=
"np-dept"
>
<option>
心内科
</option><option>
骨科
</option><option>
内分泌科
</option><option>
肿瘤科
</option><option>
呼吸科
</option>
</select>
</div>
<div
class=
"form-group full"
>
<label
class=
"form-label"
>
备注
</label>
<textarea
class=
"form-textarea"
id=
"np-note"
placeholder=
"过敏史、特殊情况等"
></textarea>
</div>
</div>
<div
class=
"form-actions"
>
<button
class=
"btn btn-ghost"
onclick=
"closeModal('modal-addpatient')"
>
取消
</button>
<button
class=
"btn btn-primary"
onclick=
"submitPatient()"
>
保存患者
</button>
</div>
</div>
</div>
<!-- Schedule Followup -->
<div
class=
"overlay"
id=
"modal-schedule"
>
<div
class=
"modal"
>
<div
class=
"modal-header"
>
<div
class=
"modal-title"
>
📅 安排随访
</div>
<button
class=
"modal-close"
onclick=
"closeModal('modal-schedule')"
>
✕
</button>
</div>
<div
class=
"form-grid"
>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
选择患者 *
</label>
<select
class=
"form-select"
id=
"sch-patient"
></select>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
随访类型
</label>
<select
class=
"form-select"
id=
"sch-type"
>
<option>
电话随访
</option><option>
门诊随访
</option><option>
短信随访
</option><option>
上门随访
</option>
</select>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
计划时间 *
</label>
<input
type=
"datetime-local"
class=
"form-input"
id=
"sch-time"
>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
执行人
</label>
<input
type=
"text"
class=
"form-input"
id=
"sch-executor"
placeholder=
"责任护士/医生"
>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
关联问卷
</label>
<select
class=
"form-select"
id=
"sch-quest"
>
<option
value=
""
>
不关联
</option>
<option>
术后恢复评估问卷
</option>
<option>
慢病管理随访问卷
</option>
<option>
满意度调查问卷
</option>
</select>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
提醒方式
</label>
<select
class=
"form-select"
>
<option>
短信+电话
</option><option>
仅短信
</option><option>
仅电话
</option><option>
不提醒
</option>
</select>
</div>
<div
class=
"form-group full"
>
<label
class=
"form-label"
>
备注
</label>
<textarea
class=
"form-textarea"
id=
"sch-note"
placeholder=
"随访注意事项、特殊说明…"
></textarea>
</div>
</div>
<div
class=
"form-actions"
>
<button
class=
"btn btn-ghost"
onclick=
"closeModal('modal-schedule')"
>
取消
</button>
<button
class=
"btn btn-primary"
onclick=
"submitSchedule()"
>
确认安排
</button>
</div>
</div>
</div>
<!-- Fill Questionnaire -->
<div
class=
"overlay"
id=
"modal-fillquest"
>
<div
class=
"modal modal-lg"
>
<div
class=
"modal-header"
>
<div
class=
"modal-title"
>
📋 术后恢复评估问卷
</div>
<button
class=
"modal-close"
onclick=
"closeModal('modal-fillquest')"
>
✕
</button>
</div>
<p
style=
"font-size:12px;color:var(--text-dim);margin-bottom:16px;"
>
患者:张伟民 · 随访日期:2025-01-15 · 预计用时 3 分钟
</p>
<div
id=
"quest-form-body"
></div>
<div
class=
"form-actions"
>
<button
class=
"btn btn-ghost"
onclick=
"closeModal('modal-fillquest')"
>
取消
</button>
<button
class=
"btn btn-primary"
onclick=
"submitQuest()"
>
提交问卷
</button>
</div>
</div>
</div>
<!-- Batch SMS -->
<div
class=
"overlay"
id=
"modal-sms"
>
<div
class=
"modal"
>
<div
class=
"modal-header"
>
<div
class=
"modal-title"
>
📱 群发短信
</div>
<button
class=
"modal-close"
onclick=
"closeModal('modal-sms')"
>
✕
</button>
</div>
<div
class=
"form-group"
style=
"margin-bottom:12px;"
>
<label
class=
"form-label"
>
发送对象
</label>
<select
class=
"form-select"
>
<option>
本周随访患者(18人)
</option>
<option>
逾期未随访患者(3人)
</option>
<option>
全部随访中患者
</option>
<option>
自定义选择
</option>
</select>
</div>
<div
class=
"form-group"
style=
"margin-bottom:12px;"
>
<label
class=
"form-label"
>
短信模板
</label>
<select
class=
"form-select"
onchange=
"updateSMSPreview(this)"
>
<option>
随访提醒模板
</option>
<option>
问卷填写提醒
</option>
<option>
复诊通知
</option>
<option>
自定义内容
</option>
</select>
</div>
<div
class=
"form-group"
style=
"margin-bottom:16px;"
>
<label
class=
"form-label"
>
短信内容预览
</label>
<textarea
class=
"form-textarea"
id=
"sms-content"
>
尊敬的患者,您好!您在仁济医院的随访时间即将到来,请于本周内配合随访。如有问题请拨打:021-12345678。
</textarea>
</div>
<div
style=
"display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;"
>
<span
style=
"font-size:12px;color:var(--text-dim);"
>
预计发送:
<strong
style=
"color:var(--teal-light);"
>
18 条
</strong>
短信
</span>
<span
style=
"font-size:12px;color:var(--text-dim);"
>
预计费用:
<strong
style=
"color:var(--amber);"
>
¥ 1.80
</strong></span>
</div>
<div
class=
"form-actions"
>
<button
class=
"btn btn-ghost"
onclick=
"closeModal('modal-sms')"
>
取消
</button>
<button
class=
"btn btn-primary"
onclick=
"sendSMS()"
>
📤 确认发送
</button>
</div>
</div>
</div>
<!-- Patient Detail Drawer -->
<div
class=
"drawer"
id=
"patient-drawer"
>
<div
class=
"drawer-header"
>
<div
class=
"modal-title"
id=
"drawer-name"
>
患者详情
</div>
<button
class=
"modal-close"
onclick=
"closeDrawer()"
>
✕
</button>
</div>
<div
class=
"drawer-body"
id=
"drawer-body"
></div>
<div
class=
"drawer-footer"
>
<button
class=
"btn btn-primary btn-sm"
onclick=
"openScheduleModal()"
>
安排随访
</button>
<button
class=
"btn btn-ghost btn-sm"
onclick=
"openFillQuest()"
>
填写问卷
</button>
<button
class=
"btn btn-amber btn-sm"
onclick=
"simulateCall()"
>
立即外呼
</button>
</div>
</div>
<!-- Toast Container -->
<div
class=
"toast-wrap"
id=
"toasts"
></div>
<script>
// ════════════════════════════════════
// DATA STORE
// ════════════════════════════════════
const
DISEASES
=
[
'
心血管
'
,
'
糖尿病
'
,
'
骨科术后
'
,
'
肿瘤
'
,
'
呼吸系统
'
];
const
DOCTORS
=
[
'
李明医生
'
,
'
张华医生
'
,
'
刘伟医生
'
,
'
陈静医生
'
,
'
王芳医生
'
];
const
STATUS
=
[
'
随访中
'
,
'
待随访
'
,
'
已完成
'
,
'
失访
'
];
const
FUSTATUS
=
[
'
待执行
'
,
'
已完成
'
,
'
逾期
'
,
'
已取消
'
];
const
CALL_RESULTS
=
[
'
接通-完成
'
,
'
接通-拒绝
'
,
'
未接通
'
,
'
占线
'
,
'
停机
'
];
function
rnd
(
a
,
b
){
return
Math
.
floor
(
Math
.
random
()
*
(
b
-
a
+
1
))
+
a
;}
function
pick
(
arr
){
return
arr
[
rnd
(
0
,
arr
.
length
-
1
)];}
function
fmtDate
(
d
){
return
d
.
toISOString
().
slice
(
0
,
10
);}
function
rndDate
(
daysOffset
){
const
d
=
new
Date
();
d
.
setDate
(
d
.
getDate
()
+
daysOffset
);
return
fmtDate
(
d
);
}
// Generate patients
const
surnames
=
[
'
张
'
,
'
李
'
,
'
王
'
,
'
刘
'
,
'
陈
'
,
'
杨
'
,
'
赵
'
,
'
黄
'
,
'
周
'
,
'
吴
'
,
'
徐
'
,
'
孙
'
,
'
胡
'
,
'
朱
'
,
'
高
'
,
'
林
'
,
'
何
'
,
'
郑
'
,
'
马
'
,
'
罗
'
];
const
names
=
[
'
明
'
,
'
伟
'
,
'
芳
'
,
'
丽
'
,
'
强
'
,
'
磊
'
,
'
军
'
,
'
洋
'
,
'
勇
'
,
'
艳
'
,
'
杰
'
,
'
娟
'
,
'
涛
'
,
'
静
'
,
'
敏
'
,
'
超
'
,
'
霞
'
,
'
平
'
,
'
龙
'
,
'
飞
'
];
let
patients
=
Array
.
from
({
length
:
24
},(
_
,
i
)
=>
{
const
sn
=
pick
(
surnames
);
return
{
id
:
`P
${
String
(
i
+
1001
).
padStart
(
4
,
'
0
'
)}
`
,
name
:
sn
+
pick
(
names
)
+
(
Math
.
random
()
>
.
6
?
pick
(
names
):
''
),
age
:
rnd
(
35
,
82
),
sex
:
Math
.
random
()
>
.
5
?
'
男
'
:
'
女
'
,
phone
:
`1
${
rnd
(
30
,
99
)}${
String
(
rnd
(
1
e7
,
9.9e7
)).
slice
(
0
,
8
)}
`
,
disease
:
pick
(
DISEASES
),
doctor
:
pick
(
DOCTORS
),
dept
:
pick
([
'
心内科
'
,
'
骨科
'
,
'
内分泌科
'
,
'
肿瘤科
'
,
'
呼吸科
'
]),
status
:
pick
(
STATUS
),
nextVisit
:
rndDate
(
rnd
(
-
5
,
30
)),
score
:
rnd
(
60
,
98
),
note
:
''
,
history
:[
{
date
:
rndDate
(
-
60
),
type
:
'
电话随访
'
,
result
:
'
正常
'
,
note
:
'
患者恢复良好
'
},
{
date
:
rndDate
(
-
30
),
type
:
'
门诊随访
'
,
result
:
'
需复查
'
,
note
:
'
建议1个月后复查
'
},
]
};
});
let
followups
=
Array
.
from
({
length
:
18
},(
_
,
i
)
=>
({
id
:
`FU
${
String
(
i
+
2001
).
padStart
(
4
,
'
0
'
)}
`
,
patientId
:
patients
[
rnd
(
0
,
patients
.
length
-
1
)].
id
,
patientName
:
patients
[
rnd
(
0
,
patients
.
length
-
1
)].
name
,
type
:
pick
([
'
电话随访
'
,
'
门诊随访
'
,
'
短信随访
'
,
'
上门随访
'
]),
time
:
rndDate
(
rnd
(
-
3
,
10
))
+
'
'
+
`
${
String
(
rnd
(
8
,
17
)).
padStart
(
2
,
'
0
'
)}
:
${
rnd
(
0
,
1
)?
'
00
'
:
'
30
'
}
`
,
phone
:
`139
${
String
(
rnd
(
1
e7
,
9.9e7
)).
slice
(
0
,
8
)}
`
,
status
:
pick
(
FUSTATUS
),
executor
:
pick
(
DOCTORS
).
replace
(
'
医生
'
,
'
护士
'
),
note
:
''
}));
let
calls
=
Array
.
from
({
length
:
20
},(
_
,
i
)
=>
{
const
p
=
pick
(
patients
);
const
dur
=
rnd
(
0
,
480
);
return
{
time
:
new
Date
(
Date
.
now
()
-
rnd
(
0
,
86400000
)
*
3
).
toLocaleString
(
'
zh
'
),
patient
:
p
.
name
,
phone
:
p
.
phone
,
type
:
pick
([
'
随访外呼
'
,
'
提醒外呼
'
,
'
回访外呼
'
]),
duration
:
dur
?
`
${
Math
.
floor
(
dur
/
60
)}
m
${
dur
%
60
}
s`
:
'
—
'
,
result
:
pick
(
CALL_RESULTS
),
note
:
Math
.
random
()
>
.
5
?
pick
([
'
患者反映良好
'
,
'
需复诊
'
,
'
已记录症状
'
,
'
患者外出
'
]):
'
—
'
};
});
// ════════════════════════════════════
// NAVIGATION
// ════════════════════════════════════
const
PAGE_META
=
{
dashboard
:
[
'
数据总览
'
,
'
欢迎回来,今日共有 12 项随访任务待处理
'
],
patients
:
[
'
患者管理
'
,
'
管理所有在册随访患者信息
'
],
followup
:
[
'
随访计划
'
,
'
安排与跟踪患者随访日程
'
],
questionnaire
:[
'
问卷管理
'
,
'
创建与管理患者健康问卷
'
],
calls
:
[
'
外呼中心
'
,
'
自动外呼与短信提醒记录
'
],
analytics
:
[
'
统计分析
'
,
'
随访数据可视化统计报告
'
],
satisfaction
:[
'
满意度调查
'
,
'
患者满意度数据汇总
'
],
export
:
[
'
数据导出
'
,
'
生成并下载随访报表文件
'
],
settings
:
[
'
系统设置
'
,
'
随访规则与模板配置
'
],
};
function
nav
(
el
,
name
)
{
document
.
querySelectorAll
(
'
.nav-item
'
).
forEach
(
n
=>
n
.
classList
.
remove
(
'
active
'
));
el
.
classList
.
add
(
'
active
'
);
document
.
querySelectorAll
(
'
.page
'
).
forEach
(
p
=>
p
.
classList
.
remove
(
'
active
'
));
document
.
getElementById
(
'
page-
'
+
name
).
classList
.
add
(
'
active
'
);
const
[
t
,
s
]
=
PAGE_META
[
name
]
||
[
name
,
''
];
document
.
getElementById
(
'
topbar-title
'
).
textContent
=
t
;
document
.
getElementById
(
'
topbar-sub
'
).
textContent
=
s
;
if
(
name
===
'
patients
'
)
renderPatients
();
if
(
name
===
'
followup
'
)
renderFollowups
();
if
(
name
===
'
calls
'
)
renderCalls
();
if
(
name
===
'
analytics
'
)
renderAnalytics
();
if
(
name
===
'
satisfaction
'
)
renderSatisfaction
();
if
(
name
===
'
questionnaire
'
)
renderQuestCards
();
}
// ════════════════════════════════════
// DASHBOARD
// ════════════════════════════════════
function
renderDashboard
()
{
// Todo list
const
todos
=
[
{
urgency
:
'
rose
'
,
text
:
'
张伟民 – 术后30天随访逾期
'
,
sub
:
'
应于 2025-01-13 完成
'
},
{
urgency
:
'
rose
'
,
text
:
'
李秀英 – 未接通,需人工跟进
'
,
sub
:
'
已尝试 3 次外呼
'
},
{
urgency
:
'
rose
'
,
text
:
'
王建国 – 血糖异常需复查提醒
'
,
sub
:
'
问卷评分 42/100
'
},
{
urgency
:
'
amber
'
,
text
:
'
明日随访提醒 – 5位患者
'
,
sub
:
'
已发送短信提醒
'
},
{
urgency
:
'
amber
'
,
text
:
'
刘静 – 问卷未填写(逾期2天)
'
,
sub
:
'
问卷链接已发送
'
},
{
urgency
:
'
green
'
,
text
:
'
赵磊 – 门诊随访已完成
'
,
sub
:
'
今日 10:30 完成
'
},
];
document
.
getElementById
(
'
todo-list
'
).
innerHTML
=
todos
.
map
(
t
=>
`
<div style="display:flex;align-items:flex-start;gap:10px;padding:10px 0;border-bottom:1px solid var(--border2);">
<div style="width:8px;height:8px;border-radius:50%;background:var(--
${
t
.
urgency
}
);margin-top:4px;flex-shrink:0;"></div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:500;">
${
t
.
text
}
</div>
<div style="font-size:11px;color:var(--text-dim);">
${
t
.
sub
}
</div>
</div>
</div>
`
).
join
(
''
);
const
tl
=
[
{
time
:
'
今日 14:22
'
,
title
:
'
王芳完成随访 – 陈建华
'
,
desc
:
'
电话随访,状态正常,已记录
'
},
{
time
:
'
今日 11:05
'
,
title
:
'
问卷回收 – 刘志强
'
,
desc
:
'
慢病管理问卷,评分 78/100
'
},
{
time
:
'
今日 09:30
'
,
title
:
'
自动外呼失败 – 赵丽
'
,
desc
:
'
未接通,已标记待人工跟进
'
},
{
time
:
'
昨日 16:40
'
,
title
:
'
门诊随访完成 – 张伟
'
,
desc
:
'
骨科术后复查,状态良好
'
},
{
time
:
'
昨日 14:10
'
,
title
:
'
短信提醒发送 – 12位患者
'
,
desc
:
'
本周随访批量提醒已送达
'
},
];
document
.
getElementById
(
'
recent-timeline
'
).
innerHTML
=
tl
.
map
(
t
=>
`
<div class="tl-item">
<div class="tl-time">
${
t
.
time
}
</div>
<div class="tl-title">
${
t
.
title
}
</div>
<div class="tl-desc">
${
t
.
desc
}
</div>
</div>
`
).
join
(
''
);
// Trend Chart
const
ctx1
=
document
.
getElementById
(
'
trendChart
'
).
getContext
(
'
2d
'
);
new
Chart
(
ctx1
,{
type
:
'
line
'
,
data
:{
labels
:[
'
8月
'
,
'
9月
'
,
'
10月
'
,
'
11月
'
,
'
12月
'
,
'
1月
'
],
datasets
:[
{
label
:
'
计划随访
'
,
data
:[
280
,
310
,
295
,
340
,
360
,
348
],
borderColor
:
'
#38bdf8
'
,
backgroundColor
:
'
rgba(56,189,248,.08)
'
,
fill
:
true
,
tension
:.
4
,
pointRadius
:
4
},
{
label
:
'
完成随访
'
,
data
:[
241
,
278
,
265
,
301
,
327
,
304
],
borderColor
:
'
#0d9488
'
,
backgroundColor
:
'
rgba(13,148,136,.08)
'
,
fill
:
true
,
tension
:.
4
,
pointRadius
:
4
}
]
},
options
:{
responsive
:
true
,
maintainAspectRatio
:
false
,
plugins
:{
legend
:{
labels
:{
color
:
'
#7a9bbf
'
,
font
:{
size
:
11
}}}},
scales
:{
x
:{
ticks
:{
color
:
'
#7a9bbf
'
}},
y
:{
ticks
:{
color
:
'
#7a9bbf
'
},
grid
:{
color
:
'
rgba(255,255,255,.04)
'
}}}}});
const
ctx2
=
document
.
getElementById
(
'
diseaseChart
'
).
getContext
(
'
2d
'
);
new
Chart
(
ctx2
,{
type
:
'
doughnut
'
,
data
:{
labels
:
DISEASES
,
datasets
:[{
data
:[
28
,
22
,
18
,
15
,
17
],
backgroundColor
:[
'
#0d9488
'
,
'
#38bdf8
'
,
'
#f59e0b
'
,
'
#f43f5e
'
,
'
#22c55e
'
],
borderWidth
:
0
}]
},
options
:{
responsive
:
true
,
maintainAspectRatio
:
false
,
plugins
:{
legend
:{
position
:
'
bottom
'
,
labels
:{
color
:
'
#7a9bbf
'
,
font
:{
size
:
11
},
padding
:
8
}}}}});
}
// ════════════════════════════════════
// PATIENTS
// ════════════════════════════════════
function
statusClass
(
s
){
return
{
随访中
:
'
pill-green
'
,
待随访
:
'
pill-amber
'
,
已完成
:
'
pill-blue
'
,
失访
:
'
pill-rose
'
}[
s
]
||
'
pill-gray
'
;}
function
renderPatients
(
list
)
{
const
data
=
list
||
patients
;
const
tbody
=
document
.
getElementById
(
'
patient-tbody
'
);
document
.
getElementById
(
'
pt-count
'
).
textContent
=
`共
${
data
.
length
}
名患者`
;
if
(
!
data
.
length
){
tbody
.
innerHTML
=
''
;
document
.
getElementById
(
'
pt-empty
'
).
style
.
display
=
'
block
'
;
return
;}
document
.
getElementById
(
'
pt-empty
'
).
style
.
display
=
'
none
'
;
tbody
.
innerHTML
=
data
.
map
(
p
=>
`
<tr>
<td style="font-family:monospace;color:var(--teal-light);">
${
p
.
id
}
</td>
<td style="font-weight:500;">
${
p
.
name
}
</td>
<td>
${
p
.
age
}
岁 /
${
p
.
sex
}
</td>
<td><span class="pill pill-blue">
${
p
.
disease
}
</span></td>
<td style="color:var(--text-dim);">
${
p
.
doctor
}
</td>
<td><span class="pill
${
statusClass
(
p
.
status
)}
"><span class="pill-dot"></span>
${
p
.
status
}
</span></td>
<td style="color:
${
p
.
nextVisit
<
fmtDate
(
new
Date
())?
'
var(--rose)
'
:
'
var(--text)
'
}
;">
${
p
.
nextVisit
}
</td>
<td>
<button class="btn btn-ghost btn-sm" onclick="openDrawer('
${
p
.
id
}
')">详情</button>
<button class="btn btn-outline btn-sm" onclick="openScheduleModal('
${
p
.
id
}
')">随访</button>
</td>
</tr>
`
).
join
(
''
);
}
function
filterPatients
()
{
const
q
=
document
.
getElementById
(
'
pt-search
'
).
value
.
toLowerCase
();
const
st
=
document
.
getElementById
(
'
pt-filter-status
'
).
value
;
const
ds
=
document
.
getElementById
(
'
pt-filter-disease
'
).
value
;
const
filtered
=
patients
.
filter
(
p
=>
(
!
q
||
(
p
.
name
+
p
.
id
+
p
.
disease
+
p
.
doctor
).
toLowerCase
().
includes
(
q
))
&&
(
!
st
||
p
.
status
===
st
)
&&
(
!
ds
||
p
.
disease
===
ds
)
);
renderPatients
(
filtered
);
}
// ════════════════════════════════════
// FOLLOWUP
// ════════════════════════════════════
function
fuClass
(
s
){
return
{
待执行
:
'
pill-amber
'
,
已完成
:
'
pill-green
'
,
逾期
:
'
pill-rose
'
,
已取消
:
'
pill-gray
'
}[
s
]
||
'
pill-gray
'
;}
function
renderFollowups
(
list
){
const
data
=
list
||
followups
;
document
.
getElementById
(
'
followup-tbody
'
).
innerHTML
=
data
.
map
(
f
=>
`
<tr>
<td style="font-family:monospace;color:var(--teal-light);">
${
f
.
id
}
</td>
<td style="font-weight:500;">
${
f
.
patientName
}
</td>
<td>
${
f
.
type
}
</td>
<td>
${
f
.
time
}
</td>
<td style="font-family:monospace;font-size:12px;">
${
f
.
phone
}
</td>
<td><span class="pill
${
fuClass
(
f
.
status
)}
"><span class="pill-dot"></span>
${
f
.
status
}
</span></td>
<td style="color:var(--text-dim);">
${
f
.
executor
}
</td>
<td style="display:flex;gap:6px;flex-wrap:wrap;">
<button class="btn btn-ghost btn-sm" onclick="completeFollowup('
${
f
.
id
}
')">完成</button>
<button class="btn btn-amber btn-sm" onclick="simulateCall()">外呼</button>
</td>
</tr>
`
).
join
(
''
);
}
function
switchFollowupTab
(
el
,
filter
){
document
.
querySelectorAll
(
'
.tab
'
).
forEach
(
t
=>
t
.
classList
.
remove
(
'
active
'
));
el
.
classList
.
add
(
'
active
'
);
const
today
=
fmtDate
(
new
Date
());
const
map
=
{
all
:
followups
,
today
:
followups
.
filter
(
f
=>
f
.
time
.
startsWith
(
today
)),
week
:
followups
.
slice
(
0
,
10
),
overdue
:
followups
.
filter
(
f
=>
f
.
status
===
'
逾期
'
)
};
renderFollowups
(
map
[
filter
]
||
followups
);
}
function
completeFollowup
(
id
){
const
f
=
followups
.
find
(
x
=>
x
.
id
===
id
);
if
(
f
){
f
.
status
=
'
已完成
'
;
renderFollowups
();
toast
(
'
随访已标记完成
'
,
'
success
'
);}
}
// ════════════════════════════════════
// CALLS
// ════════════════════════════════════
function
callClass
(
r
){
if
(
r
.
includes
(
'
完成
'
))
return
'
pill-green
'
;
if
(
r
.
includes
(
'
未接
'
))
return
'
pill-rose
'
;
return
'
pill-amber
'
;
}
function
renderCalls
(){
document
.
getElementById
(
'
call-tbody
'
).
innerHTML
=
calls
.
map
(
c
=>
`
<tr>
<td style="font-size:12px;color:var(--text-dim);">
${
c
.
time
}
</td>
<td style="font-weight:500;">
${
c
.
patient
}
</td>
<td style="font-family:monospace;font-size:12px;">
${
c
.
phone
}
</td>
<td>
${
c
.
type
}
</td>
<td style="color:var(--text-dim);">
${
c
.
duration
}
</td>
<td><span class="pill
${
callClass
(
c
.
result
)}
"><span class="pill-dot"></span>
${
c
.
result
}
</span></td>
<td style="font-size:12px;color:var(--text-dim);">
${
c
.
note
}
</td>
</tr>
`
).
join
(
''
);
}
function
simulateCall
(){
closeDrawer
();
toast
(
'
📞 正在呼叫患者…
'
,
'
info
'
);
setTimeout
(()
=>
{
const
r
=
Math
.
random
()
>
.
3
?
'
接通-完成
'
:
'
未接通
'
;
const
p
=
pick
(
patients
);
calls
.
unshift
({
time
:
new
Date
().
toLocaleString
(
'
zh
'
),
patient
:
p
.
name
,
phone
:
p
.
phone
,
type
:
'
随访外呼
'
,
duration
:
r
.
includes
(
'
接通
'
)?
`
${
rnd
(
1
,
8
)}
m
${
rnd
(
0
,
59
)}
s`
:
'
—
'
,
result
:
r
,
note
:
r
.
includes
(
'
接通
'
)?
'
通话记录已保存
'
:
'
待人工跟进
'
});
renderCalls
();
toast
(
r
.
includes
(
'
接通
'
)?
'
✅ 通话完成,已记录
'
:
'
⚠️ 未接通,已标记待跟进
'
,
r
.
includes
(
'
接通
'
)?
'
success
'
:
'
error
'
);
},
2500
);
}
// ════════════════════════════════════
// QUESTIONNAIRE
// ════════════════════════════════════
const
QUESTS
=
[
{
id
:
'
q1
'
,
name
:
'
术后恢复评估问卷
'
,
desc
:
'
评估患者术后恢复情况及症状变化
'
,
count
:
128
,
rate
:
91
,
updated
:
'
2025-01-10
'
,
color
:
'
teal
'
},
{
id
:
'
q2
'
,
name
:
'
慢病管理随访问卷
'
,
desc
:
'
长期慢病患者月度健康状态评估
'
,
count
:
342
,
rate
:
88
,
updated
:
'
2025-01-08
'
,
color
:
'
amber
'
},
{
id
:
'
q3
'
,
name
:
'
满意度调查问卷
'
,
desc
:
'
患者对随访服务满意度的综合评价
'
,
count
:
215
,
rate
:
95
,
updated
:
'
2025-01-12
'
,
color
:
'
green
'
},
{
id
:
'
q4
'
,
name
:
'
治疗依从性评估
'
,
desc
:
'
患者用药与治疗依从情况周期评估
'
,
count
:
97
,
rate
:
82
,
updated
:
'
2025-01-05
'
,
color
:
'
rose
'
},
];
function
renderQuestCards
(){
document
.
getElementById
(
'
quest-cards
'
).
innerHTML
=
QUESTS
.
map
(
q
=>
`
<div class="card" style="margin-bottom:0;cursor:pointer;" onclick="openFillQuest()">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:12px;">
<div style="font-size:15px;font-weight:600;color:#fff;">
${
q
.
name
}
</div>
<span class="pill pill-
${
q
.
color
===
'
teal
'
?
'
green
'
:
'
amber
'
}
">
${
q
.
count
}
份</span>
</div>
<div style="font-size:12px;color:var(--text-dim);margin-bottom:14px;">
${
q
.
desc
}
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:11px;color:var(--text-dim);">回收率</span>
<span style="font-size:12px;font-weight:600;color:#fff;">
${
q
.
rate
}
%</span>
</div>
<div class="prog-wrap"><div class="prog-bar
${
q
.
color
}
" style="width:
${
q
.
rate
}
%"></div></div>
<div style="font-size:11px;color:var(--text-faint);margin-top:10px;">更新于
${
q
.
updated
}
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="btn btn-primary btn-sm" onclick="event.stopPropagation();openFillQuest()">✏️ 填写</button>
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();toast('数据分析功能完整版支持','info')">📊 分析</button>
</div>
</div>
`
).
join
(
''
);
}
const
SAMPLE_QUESTIONS
=
[
{
type
:
'
scale
'
,
text
:
'
目前您的疼痛程度如何?(0=无痛,10=剧痛)
'
,
options
:[
'
0
'
,
'
1
'
,
'
2
'
,
'
3
'
,
'
4
'
,
'
5
'
,
'
6
'
,
'
7
'
,
'
8
'
,
'
9
'
,
'
10
'
]},
{
type
:
'
choice
'
,
text
:
'
您近两周的睡眠质量如何?
'
,
options
:[
'
非常好
'
,
'
较好
'
,
'
一般
'
,
'
较差
'
,
'
非常差
'
]},
{
type
:
'
choice
'
,
text
:
'
您是否按时服用所有处方药物?
'
,
options
:[
'
每次都按时
'
,
'
偶尔漏服
'
,
'
经常漏服
'
,
'
已停药
'
]},
{
type
:
'
choice
'
,
text
:
'
您近期的日常活动能力如何?
'
,
options
:[
'
与术前相同
'
,
'
略有下降
'
,
'
明显下降
'
,
'
卧床休息
'
]},
{
type
:
'
text
'
,
text
:
'
您目前有哪些主要不适症状?(可填写"无")
'
},
{
type
:
'
choice
'
,
text
:
'
您对本次随访服务是否满意?
'
,
options
:[
'
非常满意
'
,
'
满意
'
,
'
一般
'
,
'
不满意
'
]},
];
function
openFillQuest
(){
document
.
getElementById
(
'
quest-form-body
'
).
innerHTML
=
SAMPLE_QUESTIONS
.
map
((
q
,
i
)
=>
`
<div class="q-item">
<div class="q-num">第
${
i
+
1
}
题 / 共
${
SAMPLE_QUESTIONS
.
length
}
题</div>
<div class="q-text">
${
q
.
text
}
</div>
${
q
.
type
===
'
scale
'
?
`<div class="q-scale">
${
q
.
options
.
map
(
o
=>
`<div class="q-scale-btn" onclick="selectOpt(this)">
${
o
}
</div>`
).
join
(
''
)}
</div>`
:
''
}
${
q
.
type
===
'
choice
'
?
`<div class="q-options">
${
q
.
options
.
map
(
o
=>
`<div class="q-opt" onclick="selectOpt(this)">
${
o
}
</div>`
).
join
(
''
)}
</div>`
:
''
}
${
q
.
type
===
'
text
'
?
`<textarea class="form-textarea" placeholder="请输入…"></textarea>`
:
''
}
</div>
`
).
join
(
''
);
showModal
(
'
modal-fillquest
'
);
}
function
selectOpt
(
el
){
const
parent
=
el
.
parentElement
;
parent
.
querySelectorAll
(
'
.q-opt,.q-scale-btn
'
).
forEach
(
e
=>
e
.
classList
.
remove
(
'
selected
'
));
el
.
classList
.
add
(
'
selected
'
);
}
function
submitQuest
(){
closeModal
(
'
modal-fillquest
'
);
toast
(
'
✅ 问卷已提交,感谢配合!
'
,
'
success
'
);
}
// ════════════════════════════════════
// ANALYTICS
// ════════════════════════════════════
let
analyticsInited
=
false
;
function
renderAnalytics
(){
if
(
analyticsInited
)
return
;
analyticsInited
=
true
;
const
months
=
[
'
8月
'
,
'
9月
'
,
'
10月
'
,
'
11月
'
,
'
12月
'
,
'
1月
'
];
const
opt
=
{
responsive
:
true
,
maintainAspectRatio
:
false
,
plugins
:{
legend
:{
labels
:{
color
:
'
#7a9bbf
'
,
font
:{
size
:
11
}}}},
scales
:{
x
:{
ticks
:{
color
:
'
#7a9bbf
'
}},
y
:{
ticks
:{
color
:
'
#7a9bbf
'
},
grid
:{
color
:
'
rgba(255,255,255,.04)
'
}}}};
new
Chart
(
document
.
getElementById
(
'
monthChart
'
),{
type
:
'
bar
'
,
data
:{
labels
:
months
,
datasets
:[
{
label
:
'
计划
'
,
data
:[
280
,
310
,
295
,
340
,
360
,
348
],
backgroundColor
:
'
rgba(56,189,248,.3)
'
,
borderColor
:
'
#38bdf8
'
,
borderWidth
:
1.5
},
{
label
:
'
完成
'
,
data
:[
241
,
278
,
265
,
301
,
327
,
304
],
backgroundColor
:
'
rgba(13,148,136,.4)
'
,
borderColor
:
'
#0d9488
'
,
borderWidth
:
1.5
}
]
},
options
:
opt
});
new
Chart
(
document
.
getElementById
(
'
healthChart
'
),{
type
:
'
bar
'
,
data
:{
labels
:[
'
<60
'
,
'
60-69
'
,
'
70-79
'
,
'
80-89
'
,
'
90-100
'
],
datasets
:[{
label
:
'
患者数
'
,
data
:[
12
,
38
,
156
,
287
,
94
],
backgroundColor
:[
'
#f43f5e
'
,
'
#f59e0b
'
,
'
#38bdf8
'
,
'
#0d9488
'
,
'
#22c55e
'
],
borderWidth
:
0
}]
},
options
:{...
opt
,
plugins
:{
legend
:{
display
:
false
}}}});
new
Chart
(
document
.
getElementById
(
'
methodChart
'
),{
type
:
'
doughnut
'
,
data
:{
labels
:[
'
电话随访
'
,
'
门诊随访
'
,
'
短信随访
'
,
'
上门随访
'
],
datasets
:[{
data
:[
45
,
28
,
20
,
7
],
backgroundColor
:[
'
#0d9488
'
,
'
#38bdf8
'
,
'
#f59e0b
'
,
'
#22c55e
'
],
borderWidth
:
0
}]
},
options
:{
responsive
:
true
,
maintainAspectRatio
:
false
,
plugins
:{
legend
:{
position
:
'
bottom
'
,
labels
:{
color
:
'
#7a9bbf
'
,
font
:{
size
:
11
},
padding
:
10
}}}}});
new
Chart
(
document
.
getElementById
(
'
responseChart
'
),{
type
:
'
line
'
,
data
:{
labels
:
months
,
datasets
:[{
label
:
'
平均响应(小时)
'
,
data
:[
4.2
,
3.8
,
5.1
,
3.2
,
2.9
,
2.7
],
borderColor
:
'
#f59e0b
'
,
backgroundColor
:
'
rgba(245,158,11,.08)
'
,
fill
:
true
,
tension
:.
4
,
pointRadius
:
4
}]
},
options
:
opt
});
}
// ════════════════════════════════════
// SATISFACTION
// ════════════════════════════════════
function
renderSatisfaction
(){
const
dims
=
[
{
label
:
'
随访及时性
'
,
score
:
4.8
},
{
label
:
'
医护态度
'
,
score
:
4.9
},
{
label
:
'
信息准确度
'
,
score
:
4.6
},
{
label
:
'
问题解决能力
'
,
score
:
4.5
},
{
label
:
'
整体服务体验
'
,
score
:
4.7
},
];
document
.
getElementById
(
'
sat-bars
'
).
innerHTML
=
dims
.
map
(
d
=>
`
<div style="margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
<span style="font-size:12px;color:var(--text-dim);">
${
d
.
label
}
</span>
<span style="font-size:12px;font-weight:600;color:#fff;">
${
d
.
score
}
</span>
</div>
<div class="prog-wrap"><div class="prog-bar teal" style="width:
${
d
.
score
/
5
*
100
}
%"></div></div>
</div>
`
).
join
(
''
);
const
feedbacks
=
[
{
name
:
'
张伟民
'
,
time
:
'
2025-01-14
'
,
star
:
'
★★★★★
'
,
text
:
'
随访很及时,医生态度非常好,让我对康复更有信心。
'
},
{
name
:
'
李秀英
'
,
time
:
'
2025-01-13
'
,
star
:
'
★★★★☆
'
,
text
:
'
总体满意,希望下次可以提前一天提醒,这次差点忘记了。
'
},
{
name
:
'
王建国
'
,
time
:
'
2025-01-12
'
,
star
:
'
★★★★★
'
,
text
:
'
问卷设计得很好,填写简单,而且医生会根据结果给建议。
'
},
{
name
:
'
刘 静
'
,
time
:
'
2025-01-10
'
,
star
:
'
★★★★☆
'
,
text
:
'
服务很专业,外呼时间安排合理,没有打扰我上班。
'
},
];
document
.
getElementById
(
'
feedback-list
'
).
innerHTML
=
feedbacks
.
map
(
f
=>
`
<div style="padding:14px 0;border-bottom:1px solid var(--border2);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
<div class="avatar" style="width:28px;height:28px;font-size:12px;">
${
f
.
name
[
0
]}
</div>
<span style="font-weight:500;font-size:13px;">
${
f
.
name
}
</span>
<span style="color:var(--amber);font-size:13px;">
${
f
.
star
}
</span>
<span style="margin-left:auto;font-size:11px;color:var(--text-faint);">
${
f
.
time
}
</span>
</div>
<p style="font-size:13px;color:var(--text-dim);line-height:1.6;">
${
f
.
text
}
</p>
</div>
`
).
join
(
''
);
}
// ════════════════════════════════════
// PATIENT DRAWER
// ════════════════════════════════════
function
openDrawer
(
pid
){
const
p
=
patients
.
find
(
x
=>
x
.
id
===
pid
);
if
(
!
p
)
return
;
document
.
getElementById
(
'
drawer-name
'
).
textContent
=
`
${
p
.
name
}
(
${
p
.
id
}
)`
;
document
.
getElementById
(
'
drawer-body
'
).
innerHTML
=
`
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:18px;">
${[[
'
年龄性别
'
,
p
.
age
+
'
岁 /
'
+
p
.
sex
],[
'
联系电话
'
,
p
.
phone
],[
'
主要诊断
'
,
p
.
disease
],[
'
责任医生
'
,
p
.
doctor
],[
'
科室
'
,
p
.
dept
],[
'
随访状态
'
,
p
.
status
]].
map
(([
k
,
v
])
=>
`
<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;">
<div style="font-size:10px;color:var(--text-faint);text-transform:uppercase;letter-spacing:.8px;">
${
k
}
</div>
<div style="font-size:13px;font-weight:500;margin-top:3px;">
${
v
}
</div>
</div>
`
).
join
(
''
)}
</div>
<div style="margin-bottom:18px;">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:12px;color:var(--text-dim);">健康评分</span>
<span style="font-size:13px;font-weight:600;color:
${
p
.
score
>=
80
?
'
var(--green)
'
:
p
.
score
>=
60
?
'
var(--amber)
'
:
'
var(--rose)
'
}
;">
${
p
.
score
}
/100</span>
</div>
<div class="prog-wrap"><div class="prog-bar
${
p
.
score
>=
80
?
'
green
'
:
p
.
score
>=
60
?
'
amber
'
:
'
rose
'
}
" style="width:
${
p
.
score
}
%"></div></div>
</div>
<div style="margin-bottom:6px;font-size:12px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.8px;">随访历史</div>
<div class="timeline">
${
p
.
history
.
map
(
h
=>
`
<div class="tl-item">
<div class="tl-time">
${
h
.
date
}
</div>
<div class="tl-title">
${
h
.
type
}
</div>
<div class="tl-desc">
${
h
.
result
}
·
${
h
.
note
}
</div>
</div>
`
).
join
(
''
)}
</div>
<div style="margin-top:16px;">
<div style="font-size:12px;color:var(--text-dim);margin-bottom:4px;">下次随访</div>
<div style="font-size:15px;font-weight:600;color:
${
p
.
nextVisit
<
fmtDate
(
new
Date
())?
'
var(--rose)
'
:
'
var(--teal-light)
'
}
;">
${
p
.
nextVisit
}
</div>
</div>
`
;
document
.
getElementById
(
'
patient-drawer
'
).
classList
.
add
(
'
open
'
);
}
function
closeDrawer
(){
document
.
getElementById
(
'
patient-drawer
'
).
classList
.
remove
(
'
open
'
);}
// ════════════════════════════════════
// MODALS
// ════════════════════════════════════
function
showModal
(
id
){
document
.
getElementById
(
id
).
classList
.
add
(
'
show
'
);}
function
closeModal
(
id
){
document
.
getElementById
(
id
).
classList
.
remove
(
'
show
'
);}
function
openAddPatient
(){
showModal
(
'
modal-addpatient
'
);}
function
openBatchSMS
(){
showModal
(
'
modal-sms
'
);}
function
openBatchCall
(){
toast
(
'
批量外呼任务已加入队列,将在工作时间自动执行
'
,
'
info
'
);}
function
openScheduleModal
(
pid
){
const
sel
=
document
.
getElementById
(
'
sch-patient
'
);
sel
.
innerHTML
=
patients
.
map
(
p
=>
`<option value="
${
p
.
id
}
"
${
p
.
id
===
pid
?
'
selected
'
:
''
}
>
${
p
.
name
}
(
${
p
.
id
}
)</option>`
).
join
(
''
);
const
now
=
new
Date
();
now
.
setMinutes
(
0
);
document
.
getElementById
(
'
sch-time
'
).
value
=
now
.
toISOString
().
slice
(
0
,
16
);
showModal
(
'
modal-schedule
'
);
}
function
openQuestModal
(){
toast
(
'
问卷编辑器(完整版功能)
'
,
'
info
'
);}
function
submitPatient
(){
const
name
=
document
.
getElementById
(
'
np-name
'
).
value
.
trim
();
const
phone
=
document
.
getElementById
(
'
np-phone
'
).
value
.
trim
();
const
disease
=
document
.
getElementById
(
'
np-disease
'
).
value
;
if
(
!
name
||!
phone
){
toast
(
'
请填写姓名和联系电话
'
,
'
error
'
);
return
;}
const
np
=
{
id
:
`P
${
String
(
patients
.
length
+
1001
).
padStart
(
4
,
'
0
'
)}
`
,
name
,
age
:
parseInt
(
document
.
getElementById
(
'
np-age
'
).
value
)
||
45
,
sex
:
document
.
getElementById
(
'
np-sex
'
).
value
,
phone
,
disease
,
doctor
:
document
.
getElementById
(
'
np-doctor
'
).
value
||
pick
(
DOCTORS
),
dept
:
document
.
getElementById
(
'
np-dept
'
).
value
,
status
:
'
待随访
'
,
nextVisit
:
rndDate
(
7
),
score
:
rnd
(
60
,
95
),
note
:
document
.
getElementById
(
'
np-note
'
).
value
,
history
:[]
};
patients
.
unshift
(
np
);
closeModal
(
'
modal-addpatient
'
);
renderPatients
();
toast
(
`✅ 患者
${
name
}
已成功录入`
,
'
success
'
);
}
function
submitSchedule
(){
const
pid
=
document
.
getElementById
(
'
sch-patient
'
).
value
;
const
p
=
patients
.
find
(
x
=>
x
.
id
===
pid
);
if
(
!
p
){
toast
(
'
请选择患者
'
,
'
error
'
);
return
;}
const
fu
=
{
id
:
`FU
${
String
(
followups
.
length
+
2001
).
padStart
(
4
,
'
0
'
)}
`
,
patientId
:
p
.
id
,
patientName
:
p
.
name
,
type
:
document
.
getElementById
(
'
sch-type
'
).
value
,
time
:
document
.
getElementById
(
'
sch-time
'
).
value
,
phone
:
p
.
phone
,
status
:
'
待执行
'
,
executor
:
document
.
getElementById
(
'
sch-executor
'
).
value
||
pick
(
DOCTORS
),
note
:
document
.
getElementById
(
'
sch-note
'
).
value
};
followups
.
unshift
(
fu
);
closeModal
(
'
modal-schedule
'
);
toast
(
`✅ 随访已安排:
${
p
.
name
}
·
${
fu
.
time
}
`
,
'
success
'
);
}
function
sendSMS
(){
closeModal
(
'
modal-sms
'
);
toast
(
'
📱 短信发送中…
'
,
'
info
'
);
setTimeout
(()
=>
toast
(
'
✅ 18条短信已全部发送成功
'
,
'
success
'
),
2000
);
}
function
showNotif
(){
const
notes
=
[
'
张伟民随访逾期 3 天
'
,
'
李秀英外呼未接通(3次)
'
,
'
本月问卷回收率低于目标
'
,
'
系统自动外呼今日完成 47 次
'
,
'
王建国血糖问卷评分异常
'
];
toast
(
'
🔔
'
+
pick
(
notes
),
'
info
'
);
}
// ════════════════════════════════════
// EXPORT
// ════════════════════════════════════
function
exportData
(
type
){
const
headers
=
{
patients
:[
'
患者ID
'
,
'
姓名
'
,
'
年龄
'
,
'
性别
'
,
'
电话
'
,
'
诊断
'
,
'
医生
'
,
'
状态
'
,
'
下次随访
'
],
followup
:[
'
随访ID
'
,
'
患者
'
,
'
类型
'
,
'
时间
'
,
'
状态
'
,
'
执行人
'
],
};
const
rows
=
{
patients
:
patients
.
map
(
p
=>
[
p
.
id
,
p
.
name
,
p
.
age
,
p
.
sex
,
p
.
phone
,
p
.
disease
,
p
.
doctor
,
p
.
status
,
p
.
nextVisit
]),
followup
:
followups
.
map
(
f
=>
[
f
.
id
,
f
.
patientName
,
f
.
type
,
f
.
time
,
f
.
status
,
f
.
executor
]),
};
const
h
=
headers
[
type
]
||
headers
.
patients
;
const
r
=
rows
[
type
]
||
rows
.
patients
;
const
csv
=
[
h
,...
r
].
map
(
row
=>
row
.
map
(
v
=>
`"
${
v
}
"`
).
join
(
'
,
'
)).
join
(
'
\n
'
);
const
blob
=
new
Blob
([
'
\
uFEFF
'
+
csv
],{
type
:
'
text/csv;charset=utf-8;
'
});
const
url
=
URL
.
createObjectURL
(
blob
);
const
a
=
document
.
createElement
(
'
a
'
);
a
.
href
=
url
;
a
.
download
=
`随访数据_
${
type
}
_
${
fmtDate
(
new
Date
())}
.csv`
;
a
.
click
();
URL
.
revokeObjectURL
(
url
);
toast
(
'
📥 文件已下载
'
,
'
success
'
);
}
function
doExport
(
fmt
){
if
(
fmt
===
'
csv
'
||
fmt
===
'
excel
'
){
exportData
(
'
patients
'
);}
else
{
toast
(
`
${
fmt
.
toUpperCase
()}
格式导出(完整版支持)`
,
'
info
'
);}
}
// ════════════════════════════════════
// TOAST
// ════════════════════════════════════
function
toast
(
msg
,
type
=
'
info
'
){
const
icons
=
{
success
:
'
✅
'
,
error
:
'
❌
'
,
info
:
'
ℹ️
'
};
const
el
=
document
.
createElement
(
'
div
'
);
el
.
className
=
`toast
${
type
}
`
;
el
.
innerHTML
=
`<span class="toast-icon">
${
icons
[
type
]
||
'
ℹ️
'
}
</span><span>
${
msg
}
</span>`
;
document
.
getElementById
(
'
toasts
'
).
appendChild
(
el
);
setTimeout
(()
=>
el
.
style
.
opacity
=
'
0
'
,
3500
);
setTimeout
(()
=>
el
.
remove
(),
4000
);
}
// ════════════════════════════════════
// INIT
// ════════════════════════════════════
(
function
init
(){
renderDashboard
();
renderPatients
();
renderFollowups
();
renderCalls
();
renderQuestCards
();
renderSatisfaction
();
// Set default export dates
const
now
=
new
Date
();
document
.
getElementById
(
'
exp-end
'
).
value
=
fmtDate
(
now
);
const
start
=
new
Date
();
start
.
setMonth
(
start
.
getMonth
()
-
1
);
document
.
getElementById
(
'
exp-start
'
).
value
=
fmtDate
(
start
);
// Click outside drawer closes it
document
.
addEventListener
(
'
click
'
,
e
=>
{
const
drawer
=
document
.
getElementById
(
'
patient-drawer
'
);
if
(
drawer
.
classList
.
contains
(
'
open
'
)
&&!
drawer
.
contains
(
e
.
target
)
&&!
e
.
target
.
closest
(
'
[onclick*=openDrawer]
'
))
closeDrawer
();
});
})();
</script>
</body>
</html>
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment