Compare commits

...

2 Commits

Author SHA1 Message Date
liuyun
7802cfdf80 fix(c5): document 页「查看」按钮加导出权限门控
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-15 07:49:54 +08:00
liuyun
532a8cc76c feat(c5): 后台 7 个配置/文书页
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-15 07:46:48 +08:00
11 changed files with 1141 additions and 8 deletions

View File

@ -15,6 +15,12 @@ export interface Agency {
contactPhone: string contactPhone: string
bedCount: number | null bedCount: number | null
signDate: number | null signDate: number | null
/** 质控时限(小时)。 */
qcThreshold: number | null
/** 对接档位:A/B/C。 */
dockingTier: string | null
/** 国家新增项通知开关。 */
newNationalNotify: boolean
createdAt: number createdAt: number
createdBy: string | null createdBy: string | null
updatedAt: number updatedAt: number
@ -35,6 +41,9 @@ export type AgencyUpdateData = Partial<
| 'contactPhone' | 'contactPhone'
| 'bedCount' | 'bedCount'
| 'signDate' | 'signDate'
| 'qcThreshold'
| 'dockingTier'
| 'newNationalNotify'
> >
> >

View File

@ -27,7 +27,7 @@ export interface SvcNational {
version: number version: number
} }
/** L4 机构项目库记录(机构勾选 + 配价)。 */ /** L4 机构项目库记录(机构勾选 + 配价 + 拍照分档)。 */
export interface SvcOrg { export interface SvcOrg {
id: string id: string
tenantCode: string tenantCode: string
@ -36,6 +36,8 @@ export interface SvcOrg {
isEnabled: boolean isEnabled: boolean
price: string | null price: string | null
priceEffective: number | null priceEffective: number | null
/** 拍照分档:must/free/optional。 */
photoTier: string
isNewNational: boolean isNewNational: boolean
createdAt: number createdAt: number
createdBy: string | null createdBy: string | null
@ -69,3 +71,22 @@ export function configSvcOrg(data: ConfigSvcOrgData): Promise<SvcOrg> {
export function listNewNational(signal?: AbortSignal): Promise<SvcNational[]> { export function listNewNational(signal?: AbortSignal): Promise<SvcNational[]> {
return http.get<SvcNational[]>('/svc/org/new-national', { signal }) return http.get<SvcNational[]>('/svc/org/new-national', { signal })
} }
/** 拍照分档批量更新项。 */
export interface UpdatePhotoTierItem {
id: string
/** must/free/optional。 */
photoTier: string
}
/** 拍照分档批量更新结果:must→free/optional 视为「放宽」,收集进 warnings。 */
export interface UpdatePhotoTierResult {
updated: number
warnings: { id: string; from: string; to: string }[]
message?: string
}
/** 批量更新拍照分档。 */
export function updatePhotoTier(items: UpdatePhotoTierItem[]): Promise<UpdatePhotoTierResult> {
return http.post<UpdatePhotoTierResult>('/svc/org/photo-tier/batch', { items })
}

103
src/api/document.ts Normal file
View File

@ -0,0 +1,103 @@
import { http } from '@/api/http'
/**
* 文书档案(一人一档)导出 API。
* 后端返回结构化 JSON(身份证已掩码),前端负责组装下载文件。
* 字段锚点见 shared/data-model spec(document 导出契约)。
*/
/** 导出水印头。 */
export interface DocumentWatermark {
orgName: string
exportTime: string
operator: string
notice: string
}
/** 老人基本信息(@db.Date 字段已转 YYYY-MM-DD,idCardNo 已掩码)。 */
export interface DocumentElderInfo {
name: string
gender: string
birthDate: string | null
idCardNo: string
phone: string | null
address: string | null
insurNo: string | null
careLevel: string | null
treatStatus: string
benefitStart: string | null
benefitEnd: string | null
}
/** 计划项(项目名快照)。 */
export interface DocumentPlanItem {
svcId: string
svcName: string
freqType: string | null
freqValue: number | null
durationMin: number | null
photoTier: string | null
}
/** 服务计划。 */
export interface DocumentPlan {
id: string
planPeriodStart: string | null
planPeriodEnd: string | null
careLevel: string
careMode: string
planStatus: string
items: DocumentPlanItem[]
}
/** 留痕项目明细。 */
export interface DocumentRecordItem {
svcId: string
svcName: string
execResult: string
undoReason: string | null
extraFlag: boolean
}
/** 履约留痕。 */
export interface DocumentRecord {
id: string
checkinTime: string | null
checkoutTime: string | null
durationMin: number | null
recordStatus: string
items: DocumentRecordItem[]
}
/** 异常上报。 */
export interface DocumentAbnormal {
id: string
type: string
desc: string | null
handleStatus: string
}
/** 一人一档结构化导出。 */
export interface DocumentExportData {
watermark: DocumentWatermark
elder: DocumentElderInfo
plans: DocumentPlan[]
records: DocumentRecord[]
abnormals: DocumentAbnormal[]
}
export interface ExportDocumentQuery {
elderId: string
/** 留痕起始日期(YYYY-MM-DD)。 */
startDate?: string
/** 留痕结束日期(YYYY-MM-DD)。 */
endDate?: string
}
/** 按老人(+可选时间范围)导出一人一档。 */
export function exportDocument(query: ExportDocumentQuery, signal?: AbortSignal): Promise<DocumentExportData> {
const params: Record<string, unknown> = { elderId: query.elderId }
if (query.startDate) params.startDate = query.startDate
if (query.endDate) params.endDate = query.endDate
return http.get<DocumentExportData>('/document/export', { params, signal })
}

View File

@ -1,7 +1,109 @@
<template> <template>
<div>服务项目与配价(C1 实现)</div> <div class="catalog-page">
<el-card shadow="never">
<div class="mb-md text-xs text-text-placeholder">
机构只能勾选「是否开展」与「价格」,目录项目名称与来源由属地/国家库决定,不可修改。
</div>
<el-table v-loading="loading" :data="rows" border>
<el-table-column prop="name" label="项目" min-width="180">
<template #default="{ row }">
{{ row.name }}
<el-tag v-if="row.isNewNational" type="warning" size="small" class="ml-xs">国家新增</el-tag>
</template>
</el-table-column>
<el-table-column label="来源" width="90" align="center">
<template #default="{ row }">{{ row.srcType === 'local' ? '属地' : '国家' }}</template>
</el-table-column>
<el-table-column label="是否开展" width="110" align="center">
<template #default="{ row }">
<el-switch
v-model="row.isEnabled"
:loading="togglingId === row.id"
@change="toggleEnabled(row)"
/>
</template>
</el-table-column>
<el-table-column label="价格" width="160">
<template #default="{ row }">
<el-input v-model="row.price" placeholder="预留,本期不参与结算" clearable />
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-permission="'svc-org:config'"
link
type="primary"
:loading="savingId === row.id"
@click="savePrice(row)"
>
保存
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ApiError } from '@/api/http'
import { configSvcOrg } from '@/api/catalog'
import { loadL4Projects, type L4ProjectRow } from '../l4-projects'
defineOptions({ name: 'svc-config-catalog' }) defineOptions({ name: 'svc-config-catalog' })
const rows = ref<L4ProjectRow[]>([])
const loading = ref(false)
const togglingId = ref<string | null>(null)
const savingId = ref<string | null>(null)
async function load(): Promise<void> {
loading.value = true
try {
rows.value = await loadL4Projects()
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
loading.value = false
}
}
/** 开关即存:只改 isEnabled。失败回滚开关。 */
async function toggleEnabled(row: Record<string, any>): Promise<void> {
togglingId.value = row.id
const before = row.isEnabled
try {
await configSvcOrg({ id: row.id, isEnabled: row.isEnabled })
ElMessage.success('已保存')
} catch (e) {
row.isEnabled = !before
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
togglingId.value = null
}
}
/** 配价:只改 price。 */
async function savePrice(row: Record<string, any>): Promise<void> {
savingId.value = row.id
try {
await configSvcOrg({ id: row.id, price: row.price ?? undefined })
ElMessage.success('已保存')
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
savingId.value = null
}
}
onMounted(load)
</script> </script>
<style scoped lang="scss">
.catalog-page {
@apply flex h-full flex-col;
}
</style>

View File

@ -1,7 +1,73 @@
<template> <template>
<div>对接档位(C1 实现)</div> <div class="docking-page">
<el-card shadow="never">
<div v-loading="loading">
<div class="mb-md">
<div class="text-sm text-text-regular">当前对接档位</div>
<div class="mt-sm flex items-center gap-sm">
<el-tag v-if="dockingTier" :type="dockingTier === 'C' ? 'info' : 'success'" size="large">
{{ dockingTier }} 档
</el-tag>
<span v-else class="text-text-placeholder">未设置</span>
<span v-if="dockingTier === 'C'" class="text-xs text-text-placeholder">待接口发布</span>
</div>
</div>
<el-divider content-position="left">切换档位</el-divider>
<el-radio-group v-model="dockingTier" :disabled="saving" @change="save">
<el-radio-button value="A">A 档</el-radio-button>
<el-radio-button value="B">B 档</el-radio-button>
<el-radio-button value="C" disabled>C 档(待接口发布)</el-radio-button>
</el-radio-group>
</div>
</el-card>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ApiError } from '@/api/http'
import { getAgency, updateAgency } from '@/api/agency'
defineOptions({ name: 'svc-config-docking' }) defineOptions({ name: 'svc-config-docking' })
const dockingTier = ref<string>('')
const loading = ref(false)
const saving = ref(false)
async function load(): Promise<void> {
loading.value = true
try {
const agency = await getAgency()
dockingTier.value = agency.dockingTier ?? ''
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
loading.value = false
}
}
async function save(value: string | number | boolean | undefined): Promise<void> {
if (value !== 'A' && value !== 'B') return
saving.value = true
try {
await updateAgency({ dockingTier: value })
ElMessage.success('对接档位已切换')
await load()
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
await load()
} finally {
saving.value = false
}
}
onMounted(load)
</script> </script>
<style scoped lang="scss">
.docking-page {
@apply flex h-full flex-col;
}
</style>

View File

@ -1,7 +1,130 @@
<template> <template>
<div>机构信息(C1 实现)</div> <div class="info-page">
<el-card shadow="never">
<div v-loading="loading">
<el-form label-width="120px" class="info-page__form">
<el-form-item label="机构名称">
<el-input v-model="form.orgName" placeholder="请输入机构名称" />
</el-form-item>
<el-form-item label="地址">
<el-input v-model="form.address" placeholder="请输入地址" />
</el-form-item>
<el-form-item label="法人/联系人">
<el-input v-model="form.contactName" placeholder="请输入法人/联系人" />
</el-form-item>
<el-form-item label="联系方式">
<el-input v-model="form.contactPhone" placeholder="请输入联系方式" />
</el-form-item>
<el-form-item label="参保地">
<el-input :model-value="locLabel" disabled />
</el-form-item>
<el-form-item label="质控阈值(小时)">
<el-input-number v-model="form.qcThreshold" :min="0" :step="1" step-strictly />
<span class="ml-xs text-xs text-text-placeholder">留痕提交后超过该时限未质控即触发提醒</span>
</el-form-item>
<el-form-item label="对接档位">
<el-select v-model="form.dockingTier" placeholder="请选择" style="width: 200px">
<el-option label="A 档" value="A" />
<el-option label="B 档" value="B" />
<el-option label="C 档(待接口发布)" value="C" />
</el-select>
</el-form-item>
<el-form-item label="国家新增项提示">
<el-switch v-model="form.newNationalNotify" />
<span class="ml-xs text-xs text-text-placeholder">出现国家新增服务项目时提示</span>
</el-form-item>
<el-form-item>
<el-button v-permission="'agency:update'" type="primary" :loading="saving" @click="save">保存</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ApiError } from '@/api/http'
import { getAgency, updateAgency } from '@/api/agency'
defineOptions({ name: 'svc-config-info' }) defineOptions({ name: 'svc-config-info' })
interface FormModel {
orgName: string
address: string
contactName: string
contactPhone: string
locCode: string
locName: string
qcThreshold: number | null
dockingTier: string | null
newNationalNotify: boolean
}
const form = reactive<FormModel>({
orgName: '',
address: '',
contactName: '',
contactPhone: '',
locCode: '',
locName: '',
qcThreshold: null,
dockingTier: null,
newNationalNotify: false,
})
const loading = ref(false)
const saving = ref(false)
const locLabel = computed(() => form.locName || form.locCode || '—')
async function load(): Promise<void> {
loading.value = true
try {
const agency = await getAgency()
form.orgName = agency.orgName ?? ''
form.address = agency.address ?? ''
form.contactName = agency.contactName ?? ''
form.contactPhone = agency.contactPhone ?? ''
form.locCode = agency.locCode ?? ''
form.locName = agency.locName ?? ''
form.qcThreshold = agency.qcThreshold ?? null
form.dockingTier = agency.dockingTier ?? null
form.newNationalNotify = agency.newNationalNotify ?? false
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
loading.value = false
}
}
async function save(): Promise<void> {
saving.value = true
try {
await updateAgency({
orgName: form.orgName,
address: form.address,
contactName: form.contactName,
contactPhone: form.contactPhone,
qcThreshold: form.qcThreshold,
dockingTier: form.dockingTier,
newNationalNotify: form.newNationalNotify,
})
ElMessage.success('机构信息已保存')
await load()
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
saving.value = false
}
}
onMounted(load)
</script> </script>
<style scoped lang="scss">
.info-page__form {
@apply max-w-[640px];
}
</style>

View File

@ -0,0 +1,29 @@
import { getAgency } from '@/api/agency'
import { listLocal, listNewNational, listOrg, type SvcOrg } from '@/api/catalog'
/** L4 机构项目 + 解析后的展示名(join L2 属地目录 / L1 国家目录)。 */
export interface L4ProjectRow extends SvcOrg {
name: string
}
/**
* 加载当前机构 L4 项目库,并按 srcType/srcId join 目录解析展示名。
* L4(svc_org)只有 srcType/srcId,无名称,需按来源 join L2/L1 目录。
*/
export async function loadL4Projects(): Promise<L4ProjectRow[]> {
const [agency, orgs, nationals] = await Promise.all([getAgency(), listOrg(), listNewNational()])
const locals = await listLocal(agency.locCode)
const localMap = new Map<string, string>()
const nationalMap = new Map<string, string>()
for (const it of locals) {
if ('localItemName' in it) localMap.set(it.id, it.localItemName)
else if ('natName' in it) nationalMap.set(it.id, it.natName)
}
for (const it of nationals) nationalMap.set(it.id, it.natName)
return orgs.map((o) => {
const name = o.srcType === 'local' ? localMap.get(o.srcId) : nationalMap.get(o.srcId)
return { ...o, name: name ?? o.srcId }
})
}

View File

@ -1,7 +1,171 @@
<template> <template>
<div>属地与目录(C1 实现)</div> <div class="loc-page">
<el-card class="mb-md" shadow="never">
<div v-loading="agencyLoading" class="flex items-center">
<span class="mr-sm">国家新增项提示</span>
<el-switch
v-model="newNationalNotify"
:loading="notifySaving"
@change="saveNotify"
/>
<span class="ml-xs text-xs text-text-placeholder">出现国家新增服务项目时提示</span>
</div>
</el-card>
<el-card shadow="never">
<el-tabs v-model="activeTab">
<el-tab-pane label="国家基准库(L1)" name="national">
<el-table v-loading="loading" :data="nationals" border size="small">
<el-table-column prop="natName" label="项目" min-width="160" />
<el-table-column prop="natCategory" label="分类" width="120" />
<el-table-column prop="natSubCategory" label="子分类" min-width="140">
<template #default="{ row }">{{ row.natSubCategory ?? '—' }}</template>
</el-table-column>
<el-table-column label="建议人员" min-width="120">
<template #default="{ row }">{{ row.recommendStaff ?? '—' }}</template>
</el-table-column>
<el-table-column label="建议时长" width="100">
<template #default="{ row }">{{ row.recommendDuration ?? '—' }}</template>
</el-table-column>
<el-table-column label="医疗项目" width="90" align="center">
<template #default="{ row }">{{ row.isMedical ? '是' : '—' }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="属地目录(L2)" name="local">
<el-table v-loading="loading" :data="locals" border size="small">
<el-table-column prop="localItemName" label="项目" min-width="160" />
<el-table-column prop="localCategory" label="分类" min-width="120">
<template #default="{ row }">{{ row.localCategory ?? '—' }}</template>
</el-table-column>
<el-table-column label="服务时长" width="100">
<template #default="{ row }">{{ row.svcTime ?? '—' }}</template>
</el-table-column>
<el-table-column label="最低频次" width="100">
<template #default="{ row }">{{ row.minFreq ?? '—' }}</template>
</el-table-column>
<el-table-column label="人员资质" min-width="120">
<template #default="{ row }">{{ row.staffQual ?? '—' }}</template>
</el-table-column>
<el-table-column label="医疗项目" width="90" align="center">
<template #default="{ row }">{{ row.isMedical ? '是' : '—' }}</template>
</el-table-column>
<el-table-column label="映射状态" width="100">
<template #default="{ row }">{{ row.mapStatus ?? '—' }}</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && locals.length === 0" description="本参保地暂无属地目录" :image-size="60" />
</el-tab-pane>
<el-tab-pane label="机构项目(L4)" name="org">
<el-table v-loading="loading" :data="orgs" border size="small">
<el-table-column prop="name" label="项目" min-width="160" />
<el-table-column label="来源" width="90" align="center">
<template #default="{ row }">{{ row.srcType === 'local' ? '属地' : '国家' }}</template>
</el-table-column>
<el-table-column label="是否开展" width="90" align="center">
<template #default="{ row }">{{ row.isEnabled ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="价格" width="100" align="right">
<template #default="{ row }">{{ row.price ?? '—' }}</template>
</el-table-column>
<el-table-column label="拍照分档" width="100" align="center">
<template #default="{ row }">{{ photoTierLabel(row.photoTier) }}</template>
</el-table-column>
<el-table-column label="国家新增" width="90" align="center">
<template #default="{ row }">{{ row.isNewNational ? '是' : '—' }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ApiError } from '@/api/http'
import { getAgency, updateAgency } from '@/api/agency'
import { listLocal, listNewNational, type SvcLocal, type SvcNational } from '@/api/catalog'
import { loadL4Projects, type L4ProjectRow } from '../l4-projects'
defineOptions({ name: 'svc-config-loc' }) defineOptions({ name: 'svc-config-loc' })
const activeTab = ref('national')
const nationals = ref<SvcNational[]>([])
const locals = ref<SvcLocal[]>([])
const orgs = ref<L4ProjectRow[]>([])
const loading = ref(false)
const agencyLoading = ref(false)
const notifySaving = ref(false)
const newNationalNotify = ref(false)
const PHOTO_TIER_LABELS: Record<string, string> = {
must: '必拍',
free: '不拍',
optional: '选拍',
}
function photoTierLabel(v: string): string {
return PHOTO_TIER_LABELS[v] ?? v
}
async function loadAgency(): Promise<void> {
agencyLoading.value = true
try {
const agency = await getAgency()
newNationalNotify.value = agency.newNationalNotify ?? false
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
agencyLoading.value = false
}
}
async function load(): Promise<void> {
loading.value = true
try {
const [agency, nationalRes, orgRes] = await Promise.all([
getAgency(),
listNewNational(),
loadL4Projects(),
])
nationals.value = nationalRes
orgs.value = orgRes
const localRes = await listLocal(agency.locCode)
locals.value = localRes.filter((it): it is SvcLocal => 'localItemName' in it)
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
loading.value = false
}
}
async function saveNotify(): Promise<void> {
notifySaving.value = true
try {
await updateAgency({ newNationalNotify: newNationalNotify.value })
ElMessage.success('已保存')
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
notifySaving.value = false
}
}
onMounted(() => {
void loadAgency()
void load()
})
</script> </script>
<style scoped lang="scss">
.loc-page {
@apply flex h-full flex-col;
}
</style>

View File

@ -1,7 +1,141 @@
<template> <template>
<div>拍照分档配置(C1 实现)</div> <div class="photo-page">
<el-card shadow="never">
<div class="mb-md flex items-center gap-sm">
<el-select v-model="targetTier" placeholder="目标分档" style="width: 160px">
<el-option label="必拍(must)" value="must" />
<el-option label="选拍(optional)" value="optional" />
<el-option label="不拍(free)" value="free" />
</el-select>
<el-button
v-permission="'svc-org:config'"
type="primary"
:disabled="selectedRows.length === 0"
:loading="saving"
@click="applyBatch"
>
批量应用(已选 {{ selectedRows.length }} 项)
</el-button>
<span class="text-xs text-text-placeholder">必拍项目放宽为选拍/不拍会增加漏拍风险,不建议放宽。</span>
</div>
<el-table
v-loading="loading"
:data="rows"
border
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="48" />
<el-table-column prop="name" label="项目" min-width="180">
<template #default="{ row }">
{{ row.name }}
<el-tag v-if="row.isNewNational" type="warning" size="small" class="ml-xs">国家新增</el-tag>
</template>
</el-table-column>
<el-table-column label="当前分档" width="120" align="center">
<template #default="{ row }">
<el-tag :type="PHOTO_TIER_TAG_MAP[row.photoTier]?.type ?? 'info'" size="small">
{{ PHOTO_TIER_TAG_MAP[row.photoTier]?.label ?? row.photoTier }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="是否开展" width="100" align="center">
<template #default="{ row }">{{ row.isEnabled ? '是' : '否' }}</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { TagType } from '@/types/crud'
import { ApiError } from '@/api/http'
import { updatePhotoTier } from '@/api/catalog'
import { loadL4Projects, type L4ProjectRow } from '../l4-projects'
defineOptions({ name: 'svc-config-photo' }) defineOptions({ name: 'svc-config-photo' })
const PHOTO_TIER_TAG_MAP: Record<string, { label: string; type: TagType }> = {
must: { label: '必拍', type: 'danger' },
free: { label: '不拍', type: 'info' },
optional: { label: '选拍', type: 'warning' },
}
const rows = ref<L4ProjectRow[]>([])
const selectedRows = ref<L4ProjectRow[]>([])
const targetTier = ref('')
const loading = ref(false)
const saving = ref(false)
const selectedIds = computed(() => selectedRows.value.map((r) => r.id))
function onSelectionChange(rows: L4ProjectRow[]): void {
selectedRows.value = rows
}
async function load(): Promise<void> {
loading.value = true
try {
rows.value = await loadL4Projects()
selectedRows.value = []
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
loading.value = false
}
}
/** 应用前预检:must→free/optional 视为「放宽」,需用户确认(不建议放宽)。 */
async function applyBatch(): Promise<void> {
if (!targetTier.value) {
ElMessage.warning('请选择目标分档')
return
}
const relaxCount = selectedRows.value.filter(
(r) => r.photoTier === 'must' && targetTier.value !== 'must',
).length
if (relaxCount > 0) {
try {
await ElMessageBox.confirm(
`已放宽 ${relaxCount} 项,请确认(必拍项目不建议放宽)。`,
'确认批量调整',
{ type: 'warning', confirmButtonText: '确认调整', cancelButtonText: '取消' },
)
} catch {
return // 用户取消
}
}
saving.value = true
try {
const result = await updatePhotoTier(
selectedIds.value.map((id) => ({ id, photoTier: targetTier.value })),
)
if (result.warnings.length > 0) {
ElMessageBox.alert(
`已放宽 ${result.warnings.length} 项,请确认(不建议放宽)。`,
'放宽提醒',
{ type: 'warning' },
)
} else {
ElMessage.success(`已更新 ${result.updated} 项`)
}
targetTier.value = ''
await load()
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
saving.value = false
}
}
onMounted(load)
</script> </script>
<style scoped lang="scss">
.photo-page {
@apply flex h-full flex-col;
}
</style>

View File

@ -1,7 +1,69 @@
<template> <template>
<div>质控提醒配置(C1 实现)</div> <div class="qc-page">
<el-card shadow="never">
<div v-loading="loading">
<el-form label-width="140px" class="qc-page__form">
<el-form-item label="质控时限(小时)">
<el-input-number v-model="qcThreshold" :min="0" :step="1" step-strictly />
<span class="ml-xs text-xs text-text-placeholder">留痕提交后超过该时限未质控即触发提醒</span>
</el-form-item>
<el-form-item>
<el-button v-permission="'agency:update'" type="primary" :loading="saving" @click="save">
保存
</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ApiError } from '@/api/http'
import { getAgency, updateAgency } from '@/api/agency'
defineOptions({ name: 'svc-config-qc' }) defineOptions({ name: 'svc-config-qc' })
const qcThreshold = ref<number | null>(null)
const loading = ref(false)
const saving = ref(false)
async function load(): Promise<void> {
loading.value = true
try {
const agency = await getAgency()
qcThreshold.value = agency.qcThreshold ?? null
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
loading.value = false
}
}
async function save(): Promise<void> {
if (qcThreshold.value == null) {
ElMessage.warning('请设置质控时限')
return
}
saving.value = true
try {
await updateAgency({ qcThreshold: qcThreshold.value })
ElMessage.success('质控时限已保存')
await load()
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
saving.value = false
}
}
onMounted(load)
</script> </script>
<style scoped lang="scss">
.qc-page__form {
@apply max-w-[560px];
}
</style>

View File

@ -1,7 +1,327 @@
<template> <template>
<div>文书档案(C1–C4 实现)</div> <div class="document-page">
<el-card class="mb-md" shadow="never">
<ty-search-form :schema="searchSchema" :model-value="query" @search="handleSearch" @reset="handleReset" />
</el-card>
<el-card class="document-page__table" shadow="never">
<ty-table
:columns="columns"
:data="list"
:loading="loading"
:total="total"
v-model:page="page"
v-model:page-size="pageSize"
:actions-width="100"
>
<template #actions="{ row }">
<el-button v-permission="'export:export'" link type="primary" @click="openDetail(row)">查看</el-button>
</template>
</ty-table>
</el-card>
<el-dialog v-model="detailVisible" :title="`一人一档(${currentName})`" width="920px" top="6vh">
<div v-loading="detailLoading" class="document-page__detail">
<template v-if="detail">
<el-alert
v-if="detail.watermark"
:title="`${detail.watermark.orgName} · 导出时间 ${formatDateTime(detail.watermark.exportTime)} · ${detail.watermark.notice}`"
type="info"
:closable="false"
class="mb-md"
/>
<el-divider content-position="left">基本信息</el-divider>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="姓名">{{ detail.elder.name }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ labelOf(GENDER_OPTIONS, detail.elder.gender) }}</el-descriptions-item>
<el-descriptions-item label="身份证号">{{ detail.elder.idCardNo }}</el-descriptions-item>
<el-descriptions-item label="出生日期">{{ detail.elder.birthDate ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ detail.elder.phone ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="居住地址">{{ detail.elder.address ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="参保号">{{ detail.elder.insurNo ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="护理等级">{{ labelOf(CARE_LEVEL_OPTIONS, detail.elder.careLevel) }}</el-descriptions-item>
<el-descriptions-item label="待遇状态">
<el-tag :type="TREAT_STATUS_TAG_MAP[detail.elder.treatStatus]?.type ?? 'info'" size="small">
{{ TREAT_STATUS_TAG_MAP[detail.elder.treatStatus]?.label ?? detail.elder.treatStatus }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="待遇起">{{ detail.elder.benefitStart ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="待遇止">{{ detail.elder.benefitEnd ?? '—' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">服务计划</el-divider>
<div v-if="detail.plans.length > 0">
<div v-for="plan in detail.plans" :key="plan.id" class="mb-md">
<div class="mb-xs text-sm text-text-regular">
计划 {{ plan.id }} · {{ plan.planPeriodStart ?? '—' }} ~ {{ plan.planPeriodEnd ?? '—' }}
· {{ labelOf(CARE_MODE_OPTIONS, plan.careMode) }} · {{ planStatusLabel(plan.planStatus) }}
</div>
<el-table :data="plan.items" border size="small">
<el-table-column prop="svcName" label="项目" min-width="160" />
<el-table-column label="频次" min-width="120">
<template #default="{ row }">{{ freqLabel(row) }}</template>
</el-table-column>
<el-table-column label="时长(分)" width="90" align="center">
<template #default="{ row }">{{ row.durationMin ?? '—' }}</template>
</el-table-column>
<el-table-column label="拍照分档" width="100" align="center">
<template #default="{ row }">{{ photoTierLabel(row.photoTier) }}</template>
</el-table-column>
</el-table>
</div>
</div>
<el-empty v-else description="暂无服务计划" :image-size="60" />
<el-divider content-position="left">留痕记录</el-divider>
<el-table v-if="detail.records.length > 0" :data="detail.records" border size="small">
<el-table-column label="签到时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.checkinTime) }}</template>
</el-table-column>
<el-table-column label="签退时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.checkoutTime) }}</template>
</el-table-column>
<el-table-column label="时长(分)" width="90" align="center">
<template #default="{ row }">{{ row.durationMin ?? '—' }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">{{ recordStatusLabel(row.recordStatus) }}</template>
</el-table-column>
<el-table-column label="项目执行" min-width="200">
<template #default="{ row }">
<div v-for="it in row.items" :key="it.svcId" class="text-xs">
{{ it.svcName }}:{{ execResultLabel(it.execResult) }}{{ it.undoReason ? `(${it.undoReason})` : '' }}
</div>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无留痕记录" :image-size="60" />
<el-divider content-position="left">异常上报</el-divider>
<el-table v-if="detail.abnormals.length > 0" :data="detail.abnormals" border size="small">
<el-table-column label="分类" width="130">
<template #default="{ row }">{{ abnormalTypeLabel(row.type) }}</template>
</el-table-column>
<el-table-column label="描述" min-width="180">
<template #default="{ row }">{{ row.desc ?? '—' }}</template>
</el-table-column>
<el-table-column label="处理状态" width="100" align="center">
<template #default="{ row }">{{ handleStatusLabel(row.handleStatus) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无异常上报" :image-size="60" />
</template>
</div>
<template #footer>
<div class="flex w-full items-center gap-sm">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="留痕起始"
end-placeholder="留痕结束"
range-separator="至"
class="flex-1"
/>
<el-button v-permission="'export:export'" type="primary" :loading="exporting" @click="download">
导出 JSON
</el-button>
<el-button @click="detailVisible = false">关闭</el-button>
</div>
</template>
</el-dialog>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ApiError } from '@/api/http'
import type { JsonSchema, TableColumn } from '@/types/crud'
import { useRequest } from '@/composables/useRequest'
import { listElders, type ElderListQuery } from '@/api/elder'
import {
exportDocument,
type DocumentExportData,
} from '@/api/document'
import { ABNORMAL_TYPE_LABEL_MAP, HANDLE_STATUS_TAG_MAP } from '@/api/abnormal'
import { EXEC_RESULT_TAG_MAP } from '@/api/record'
import {
CARE_LEVEL_OPTIONS,
CARE_MODE_OPTIONS,
GENDER_OPTIONS,
TREAT_STATUS_TAG_MAP,
formatDateTime,
labelOf,
} from '@/pages/ltc/elder/constants'
defineOptions({ name: 'document' }) defineOptions({ name: 'document' })
const columns: TableColumn[] = [
{ prop: 'name', label: '姓名', minWidth: 100 },
{ prop: 'gender', label: '性别', width: 70, align: 'center', formatter: (v) => labelOf(GENDER_OPTIONS, v) },
{ prop: 'careLevel', label: '护理等级', minWidth: 140, formatter: (v) => labelOf(CARE_LEVEL_OPTIONS, v) },
{ prop: 'treatStatus', label: '待遇状态', width: 120, align: 'center', type: 'tag', tagMap: TREAT_STATUS_TAG_MAP },
{ prop: 'phone', label: '联系电话', width: 130, formatter: (v) => (v == null || v === '' ? '—' : String(v)) },
]
const searchSchema: JsonSchema = {
type: 'object',
properties: {
name: { type: 'string', title: '姓名', placeholder: '请输入姓名' },
},
}
const query = reactive<Record<string, any>>({ name: '' })
const list = ref<Record<string, any>[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
function toListQuery(): ElderListQuery {
return { page: page.value, pageSize: pageSize.value, name: str(query.name) }
}
const { loading, run: fetchList } = useRequest((signal: AbortSignal) => listElders(toListQuery(), signal), {
manual: true,
onSuccess: (result) => {
list.value = result.list.map((e) => ({ ...e }))
total.value = result.total
},
})
watch([page, pageSize], () => fetchList())
function handleSearch(): void {
if (page.value === 1) fetchList()
else page.value = 1
}
function handleReset(): void {
if (page.value === 1) fetchList()
else page.value = 1
}
// ---- 一人一档详情 ----
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref<DocumentExportData | null>(null)
const currentElderId = ref('')
const currentName = ref('')
const dateRange = ref<[string, string] | null>(null)
const exporting = ref(false)
const PHOTO_TIER_LABELS: Record<string, string> = {
must: '必拍',
free: '不拍',
optional: '选拍',
}
const PLAN_STATUS_LABELS: Record<string, string> = {
active: '生效中',
changed: '已变更',
}
function photoTierLabel(v: string | null): string {
return v ? PHOTO_TIER_LABELS[v] ?? v : '—'
}
function planStatusLabel(v: string): string {
return PLAN_STATUS_LABELS[v] ?? v
}
function freqLabel(item: { freqType?: string | null; freqValue?: number | null }): string {
const v = item.freqValue
switch (item.freqType) {
case 'daily':
return '每日'
case 'weekly_n':
return `每周 ${v ?? '—'} 次`
case 'monthly_n':
return `每月 ${v ?? '—'} 次`
case 'as_needed':
return '按需'
default:
return item.freqType ?? '—'
}
}
function recordStatusLabel(v: string): string {
return v === 'submitted' ? '已提交' : v === 'serving' ? '服务中' : v
}
function execResultLabel(v: string): string {
return EXEC_RESULT_TAG_MAP[v]?.label ?? v
}
function abnormalTypeLabel(v: string): string {
return ABNORMAL_TYPE_LABEL_MAP[v] ?? v
}
function handleStatusLabel(v: string): string {
return HANDLE_STATUS_TAG_MAP[v]?.label ?? v
}
async function openDetail(row: Record<string, any>): Promise<void> {
currentElderId.value = String(row.id)
currentName.value = String(row.name ?? '')
dateRange.value = null
detailVisible.value = true
detail.value = null
detailLoading.value = true
try {
detail.value = await exportDocument({ elderId: currentElderId.value })
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
detailLoading.value = false
}
}
/** 按当前时间范围导出并下载 JSON。 */
async function download(): Promise<void> {
if (!currentElderId.value) return
exporting.value = true
try {
const data = await exportDocument({
elderId: currentElderId.value,
startDate: dateRange.value?.[0],
endDate: dateRange.value?.[1],
})
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `文书档案_${currentName.value}_${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('已导出')
} catch (e) {
if (e instanceof ApiError) ElMessage.error(e.message)
} finally {
exporting.value = false
}
}
onMounted(() => fetchList())
</script> </script>
<style scoped lang="scss">
.document-page {
@apply flex h-full flex-col;
}
.document-page__table {
@apply flex min-h-0 flex-1 flex-col;
:deep(.el-card__body) {
@apply flex min-h-0 flex-1 flex-col;
}
}
.document-page__detail {
@apply max-h-[62vh] overflow-auto;
}
</style>