DESKTOP-6LTVLN7\Liumouren 1 день тому
батько
коміт
92050f5439

+ 70 - 3
src/api/dutyDms.ts

@@ -9,6 +9,7 @@ import type {
   ContractRow,
   DemoRole,
   EventLevel,
+  EventLog,
   EventRow,
   EventStatus,
   FeedbackLog,
@@ -118,10 +119,30 @@ type ExtraField = {
 
 /** Demo 会写、但建栏目时可能漏掉的列。启动时按模型补齐。已有同义列的走映射,不另开列。 */
 const EXTRA_FIELDS: Partial<Record<DmsKind, ExtraField[]>> = {
-  site: [{ key: 'fence_poly', alias: '围栏多边形', type: 'text', frontType: 'text', describe: '围栏顶点 JSON [[lat,lng],...]', sequence: 11 }],
-  attend: [{ key: 'fail_reasons', alias: '打卡失败原因', type: 'text', frontType: 'text', describe: '打卡校验失败原因', sequence: 30 }],
+  site: [
+    { key: 'fence_poly', alias: '围栏多边形', type: 'text', frontType: 'text', describe: '围栏顶点 JSON [[lat,lng],...]', sequence: 11 },
+    { key: 'owner_name', alias: '驻点负责人', type: 'text', frontType: 'varchar', describe: '督导姓名', sequence: 12 },
+  ],
+  person: [{ key: 'post', alias: '岗位', type: 'text', frontType: 'varchar', describe: '门岗/巡逻等', sequence: 21 }],
+  attend: [
+    { key: 'fail_reasons', alias: '打卡失败原因', type: 'text', frontType: 'text', describe: '打卡校验失败原因', sequence: 30 },
+    { key: 'source', alias: '数据来源', type: 'text', frontType: 'varchar', describe: 'self/proxy/seed', sequence: 31 },
+  ],
   finance: [{ key: 'locked', alias: '月账锁定', type: 'boolean', frontType: 'switch', describe: '锁定后不能改未开票', sequence: 20 }],
-  schedule: [{ key: 'crew_nos', alias: '应到工号', type: 'text', frontType: 'text', describe: '排班应到人员工号,逗号分隔', sequence: 8 }],
+  schedule: [
+    { key: 'crew_nos', alias: '应到工号', type: 'text', frontType: 'text', describe: '排班应到人员工号,逗号分隔', sequence: 8 },
+    { key: 'start_time', alias: '上班时间', type: 'text', frontType: 'varchar', describe: '', sequence: 9 },
+    { key: 'end_time', alias: '下班时间', type: 'text', frontType: 'varchar', describe: '', sequence: 10 },
+    { key: 'punch_window', alias: '打卡时间窗', type: 'text', frontType: 'varchar', describe: '', sequence: 11 },
+    { key: 'published', alias: '已发布', type: 'text', frontType: 'varchar', describe: '1 已发布 / 0 草稿', sequence: 12 },
+  ],
+  event: [
+    { key: 'body', alias: '事项说明', type: 'text', frontType: 'text', describe: '', sequence: 20 },
+    { key: 'person_no', alias: '涉及工号', type: 'text', frontType: 'varchar', describe: '', sequence: 21 },
+    { key: 'due_at', alias: '完成时限', type: 'text', frontType: 'varchar', describe: '', sequence: 22 },
+    { key: 'handle_note', alias: '处理结论', type: 'text', frontType: 'text', describe: '', sequence: 23 },
+    { key: 'timeline', alias: '处理记录', type: 'text', frontType: 'text', describe: '流程 JSON', sequence: 24 },
+  ],
   user: [
     { key: 'account', alias: '登录名', type: 'text', frontType: 'varchar', describe: '系统登录名', sequence: 1 },
     { key: 'user_name', alias: '姓名', type: 'text', frontType: 'varchar', describe: '', sequence: 2 },
@@ -348,6 +369,26 @@ function parseFeedbackLogs(value: unknown): FeedbackLog[] {
   }
 }
 
+function parseEventLogs(value: unknown): EventLog[] {
+  const text = asText(value).trim()
+  if (!text) return []
+  try {
+    const parsed = JSON.parse(text) as unknown
+    if (!Array.isArray(parsed)) return []
+    return parsed.map((item) => {
+      const row = (item || {}) as Record<string, unknown>
+      return {
+        at: asDateTime(row.at) || asText(row.at),
+        actor: asText(row.actor),
+        action: asText(row.action) || '处理',
+        note: asText(row.note),
+      }
+    })
+  } catch {
+    return []
+  }
+}
+
 function asFeedbackStatus(value: unknown): FeedbackStatus {
   const text = asText(value)
   return FEEDBACK_STATUS.includes(text as FeedbackStatus) ? (text as FeedbackStatus) : '待处理'
@@ -849,6 +890,7 @@ export async function pullDuty(): Promise<PulledDuty> {
       lng: asNum(pick(row, 'c_lng')),
       fenceM: asNum(pick(row, 'c_fence_m'), 200),
       fencePoly: parseFencePoly(pick(row, 'c_fence_poly')),
+      ownerName: asText(pick(row, 'c_owner_name')) || undefined,
       photoName: asFileName(pick(row, 'c_photo')) || undefined,
       photoUrl: asFileUrl(pick(row, 'c_photo')) || undefined,
     }
@@ -897,6 +939,7 @@ export async function pullDuty(): Promise<PulledDuty> {
       partyMember: asBool(pick(row, 'c_party_member')),
       veteran: asBool(pick(row, 'c_veteran')),
       appRegistered: asBool(pick(row, 'c_app_registered')),
+      post: asText(pick(row, 'c_post')) || undefined,
       photoName: asFileName(pick(row, 'c_photo')) || undefined,
       photoUrl: asFileUrl(pick(row, 'c_photo')) || undefined,
       fileName: asFileName(pick(row, 'c_scan_file')) || undefined,
@@ -958,6 +1001,10 @@ export async function pullDuty(): Promise<PulledDuty> {
       hourType: asHourType(pick(row, 'c_hour_type')),
       holidaySecondary: asText(pick(row, 'c_holiday_rule')),
       crewNos: parseCrewNos(pick(row, 'c_crew_nos')),
+      startTime: asText(pick(row, 'c_start_time')) || undefined,
+      endTime: asText(pick(row, 'c_end_time')) || undefined,
+      punchWindow: asText(pick(row, 'c_punch_window')) || undefined,
+      published: pick(row, 'c_published') == null ? true : asBool(pick(row, 'c_published')),
     } satisfies ScheduleRow
   })
 
@@ -1001,6 +1048,9 @@ export async function pullDuty(): Promise<PulledDuty> {
             },
           }
         : {}),
+      source: (['self', 'proxy', 'seed'].includes(asText(pick(row, 'c_source')))
+        ? asText(pick(row, 'c_source'))
+        : 'seed') as AttendanceRow['source'],
     } satisfies AttendanceRow
   })
 
@@ -1100,6 +1150,11 @@ export async function pullDuty(): Promise<PulledDuty> {
       hrSynced: asBool(pick(row, 'c_hr_synced')),
       fileName: asFileName(pick(row, 'c_attach', 'c_file_name')) || undefined,
       fileUrl: asFileUrl(pick(row, 'c_attach', 'c_file_name')) || undefined,
+      personId: personByNo(people, asText(pick(row, 'c_person_no')))?.id || asText(pick(row, 'c_person_no')) || undefined,
+      body: asText(pick(row, 'c_body')) || undefined,
+      dueAt: asText(pick(row, 'c_due_at')) || undefined,
+      handleNote: asText(pick(row, 'c_handle_note')) || undefined,
+      logs: parseEventLogs(pick(row, 'c_timeline')),
     } satisfies EventRow
   })
 
@@ -1264,6 +1319,7 @@ function payloadOf(kind: DmsKind, row: unknown, ctx: PersistCtx): { title: strin
         c_party_member: person.partyMember,
         c_veteran: person.veteran,
         c_app_registered: person.appRegistered,
+        c_post: person.post || '',
         c_photo: dumpAttach(person.photoName, person.photoUrl),
         c_scan_file: dumpAttach(person.fileName, person.fileUrl),
       },
@@ -1288,6 +1344,7 @@ function payloadOf(kind: DmsKind, row: unknown, ctx: PersistCtx): { title: strin
         c_lat: site.lat,
         c_lng: site.lng,
         c_fence_poly: dumpFencePoly(site.fencePoly),
+        c_owner_name: site.ownerName || '',
         c_photo: dumpAttach(site.photoName, site.photoUrl),
       },
     }
@@ -1349,6 +1406,10 @@ function payloadOf(kind: DmsKind, row: unknown, ctx: PersistCtx): { title: strin
         c_hour_type: item.hourType,
         c_holiday_rule: item.holidaySecondary,
         c_crew_nos: (item.crewNos || []).join(','),
+        c_start_time: item.startTime || '08:00',
+        c_end_time: item.endTime || '16:00',
+        c_punch_window: item.punchWindow || '07:30–08:30',
+        c_published: item.published === false ? '0' : '1',
       },
     }
   }
@@ -1380,6 +1441,7 @@ function payloadOf(kind: DmsKind, row: unknown, ctx: PersistCtx): { title: strin
         c_fence_ok: item.gates?.fence ?? (item.status !== '越界' && item.status !== '未出勤'),
         c_window_ok: item.gates?.window ?? !['迟到', '早退', '未出勤'].includes(item.status),
         c_fail_reasons: (item.failReasons || []).join(','),
+        c_source: item.source || 'seed',
         ...(item.lat != null ? { c_lat: item.lat } : {}),
         ...(item.lng != null ? { c_lng: item.lng } : {}),
         ...(item.accM != null ? { c_acc_m: item.accM } : {}),
@@ -1510,6 +1572,11 @@ function payloadOf(kind: DmsKind, row: unknown, ctx: PersistCtx): { title: strin
         c_hr_synced: item.hrSynced,
         c_happen_at: sqlDateTime(item.createdAt),
         c_attach: dumpAttach(item.fileName, item.fileUrl),
+        c_body: item.body || '',
+        c_person_no: ctx.people.find((p) => p.id === item.personId)?.no || item.personId || '',
+        c_due_at: item.dueAt || '',
+        c_handle_note: item.handleNote || '',
+        c_timeline: JSON.stringify(item.logs || []),
       },
     }
   }

+ 10 - 1
src/components/AdminTable.vue

@@ -10,19 +10,28 @@ const props = withDefaults(
     fill?: boolean
     pageSize?: number
     searchExtra?: (row: unknown) => string
+    initialSearch?: string
   }>(),
   {
     searchPlaceholder: '搜索',
     search: true,
     fill: false,
     pageSize: 10,
+    initialSearch: '',
   },
 )
 
-const kw = ref('')
+const kw = ref(props.initialSearch || '')
 const page = ref(1)
 const size = ref(props.pageSize)
 
+watch(
+  () => props.initialSearch,
+  (v) => {
+    if (v) kw.value = v
+  },
+)
+
 watch(
   () => [props.data, kw.value, size.value] as const,
   () => {

+ 3 - 3
src/mock/permissions.ts

@@ -21,8 +21,8 @@ export const MENU_TREE: MenuNode[] = [
     key: 'master',
     label: '基础数据',
     children: [
-      { key: 'master.people', label: '人员', path: '/admin/master?tab=people' },
-      { key: 'master.sites', label: '驻点', path: '/admin/master?tab=sites' },
+      { key: 'master.people', label: '人员管理', path: '/admin/master?tab=people' },
+      { key: 'master.sites', label: '驻点管理', path: '/admin/master?tab=sites' },
       { key: 'master.contracts', label: '合同', path: '/admin/master?tab=contracts' },
       { key: 'master.train', label: '培训', path: '/admin/master?tab=train' },
     ],
@@ -32,7 +32,7 @@ export const MENU_TREE: MenuNode[] = [
     label: '考勤管理',
     children: [
       { key: 'duty.schedule', label: '排班计划', path: '/admin/duty?tab=schedule' },
-      { key: 'duty.attend', label: '打卡汇总', path: '/admin/duty?tab=attend' },
+      { key: 'duty.attend', label: '考勤明细', path: '/admin/duty?tab=attend' },
       { key: 'duty.ot', label: '加班结算', path: '/admin/duty?tab=ot' },
       { key: 'duty.split', label: '跨月跨驻点', path: '/admin/duty?tab=split' },
       { key: 'duty.ident', label: '身份核算', path: '/admin/duty?tab=ident' },

+ 95 - 2
src/mock/seed.ts

@@ -42,6 +42,7 @@ export const sites: Site[] = [
   site({ id: 's4', name: '徐汇滨江绿地', code: 'XH-033', branchId: 'three', address: '徐汇区龙腾大道', contractHeadcount: 22, policeName: '滨江派出所', policePhone: '021-64380000', lat: 31.1868, lng: 121.4682, fenceM: 200 }),
   site({ id: 's5', name: '静安寺商圈', code: 'JA-007', branchId: 'two', address: '静安区南京西路', contractHeadcount: 36, policeName: '静安寺派出所', policePhone: '021-62480000', lat: 31.2236, lng: 121.4458, fenceM: 160 }),
   site({ id: 's6', name: '人防指挥备用点', code: 'RF-001', branchId: 'renfang', address: '黄浦区人民大道', contractHeadcount: 16, policeName: '南京东路派出所', policePhone: '021-63220000', lat: 31.2307, lng: 121.4737, fenceM: 130 }),
+  site({ id: 's7', name: '城市服务中心A驻点', code: 'CS-A01', branchId: 'one', address: '黄浦区人民大道 200 号', contractHeadcount: 18, policeName: '南京东路派出所', policePhone: '021-63221100', lat: 31.2316, lng: 121.4752, fenceM: 160, ownerName: '李海' }),
 ]
 
 function p(
@@ -61,6 +62,7 @@ const extraPacks: { siteId: string; names: Array<[string, '男' | '女']> }[] =
   { siteId: 's4', names: [['汪洋', '男'], ['田丽', '女'], ['任杰', '男'], ['姜涛', '男'], ['范敏', '女'], ['方伟', '男'], ['石磊', '男'], ['纪芳', '女'], ['齐勇', '男'], ['项丽', '女'], ['蓝伟', '男'], ['池敏', '女']] },
   { siteId: 's5', names: [['姚芳', '女'], ['谭军', '男'], ['廖静', '女'], ['邹伟', '男'], ['熊丽', '女'], ['金涛', '男'], ['陆敏', '女'], ['郝强', '男'], ['易芳', '女'], ['申伟', '男'], ['宫丽', '女'], ['路强', '男'], ['皮娜', '女'], ['冉杰', '男']] },
   { siteId: 's6', names: [['孔伟', '男'], ['白静', '女'], ['崔军', '男'], ['康丽', '女'], ['毛伟', '男'], ['邱芳', '女'], ['解强', '男'], ['翟丽', '女'], ['尚伟', '男'], ['栾芳', '女']] },
+  { siteId: 's7', names: [['陈波', '男'], ['吴倩', '女'], ['高磊', '男']] },
 ]
 
 export function extraPeople(): Person[] {
@@ -92,6 +94,7 @@ export function extraPeople(): Person[] {
         partyMember: i % 8 === 0,
         veteran: i % 9 === 0,
         appRegistered: i % 6 !== 0,
+        post: pack.siteId === 's7' ? (i % 2 ? '巡逻' : '门岗') : undefined,
       }))
       i += 1
     }
@@ -110,6 +113,28 @@ export const people: Person[] = [
   p({ id: 'p8', name: '孙婷', no: 'RF10330', branchId: 'two', siteId: 's2', identity: '自有', identityFrom: '2021-11-11', identityHistory: [{ identity: '自有', from: '2021-11-11' }], siteHistory: [{ siteId: 's2', from: '2021-11-11' }], onDuty: true, gender: '女', age: 35, education: '大专', certificate: '上岗证', phone: '13800001008', hiredAt: '2021-11-11', partyMember: false, veteran: false, appRegistered: true }),
   p({ id: 'p9', name: '吴磊', no: 'RF10412', branchId: 'one', siteId: 's3', identity: '劳务', identityFrom: '2024-07-01', identityHistory: [{ identity: '劳务', from: '2024-07-01' }], siteHistory: [{ siteId: 's3', from: '2024-07-01' }], onDuty: true, gender: '男', age: 38, education: '高中', certificate: '监控证', phone: '13800001009', hiredAt: '2024-07-01', partyMember: false, veteran: true, appRegistered: false }),
   p({ id: 'p10', name: '郑浩', no: 'RF10501', branchId: 'three', siteId: 's4', identity: '供应商', identityFrom: '2025-01-15', identityHistory: [{ identity: '供应商', from: '2025-01-15' }], siteHistory: [{ siteId: 's4', from: '2025-01-15' }], onDuty: true, gender: '男', age: 31, education: '中专', certificate: '上岗证', phone: '13800001010', hiredAt: '2025-01-15', partyMember: false, veteran: false, appRegistered: true }),
+  p({
+    id: 'p-zm',
+    name: '张明',
+    no: 'RF10801',
+    branchId: 'one',
+    siteId: 's7',
+    identity: '自有',
+    identityFrom: '2024-03-01',
+    identityHistory: [{ identity: '劳务', from: '2023-03-01', to: '2024-02-29' }, { identity: '自有', from: '2024-03-01' }],
+    siteHistory: [{ siteId: 's1', from: '2023-03-01', to: '2024-02-29' }, { siteId: 's7', from: '2024-03-01' }],
+    onDuty: true,
+    gender: '男',
+    age: 28,
+    education: '高中',
+    certificate: '上岗证',
+    phone: '13800001081',
+    hiredAt: '2023-03-01',
+    partyMember: false,
+    veteran: false,
+    appRegistered: true,
+    post: '门岗',
+  }),
   ...extraPeople(),
 ]
 
@@ -120,11 +145,12 @@ export const contracts: ContractRow[] = [
   { id: 'c4', code: 'HT-2023-009', siteId: 's6', taxRate: '6%', otPrice: 38, amount: '70 万/年', headcount: 16, startDate: '2023-01-01', endDate: '2026-06-30', clause: '已过期示例', fileName: '扫描件待补' },
   { id: 'c5', code: 'HT-2026-040', siteId: 's3', taxRate: '6%', otPrice: 48, amount: '680 万/年', headcount: 120, startDate: '2026-01-01', endDate: '2027-12-31', clause: '机场安检缺员按合同单价扣减', fileName: '扫描件待补' },
   { id: 'c6', code: 'HT-2026-041', siteId: 's5', taxRate: '6%', otPrice: 41, amount: '210 万/年', headcount: 36, startDate: '2026-04-01', endDate: '2027-12-31', clause: '商圈夜间加岗按加班单价', fileName: '扫描件待补' },
+  { id: 'c7', code: 'HT-2026-A01', siteId: 's7', taxRate: '6%', otPrice: 43, amount: '120 万/年', headcount: 18, startDate: '2026-01-01', endDate: '2027-12-31', clause: '城市服务中心A门岗缺员按合同单价扣减', fileName: '扫描件待补' },
 ]
 
 export const HOLIDAY_DAY = '2026-10-01'
 export const WEEK_DUTY_DAYS = ['2026-09-28', '2026-09-29', '2026-09-30']
-const PREFER_NOS = ['RF10021', 'RF10022', 'RF10045', 'RF10102', 'RF10201', 'RF10008', 'RF10330', 'RF10412', 'RF10501']
+const PREFER_NOS = ['RF10801', 'RF10021', 'RF10022', 'RF10045', 'RF10102', 'RF10201', 'RF10008', 'RF10330', 'RF10412', 'RF10501']
 
 export function onDutyAt(siteId: string, list: Person[] = people) {
   return list.filter((p) => p.siteId === siteId && p.onDuty)
@@ -150,6 +176,7 @@ const SCHEDULE_SPEC: Array<Omit<ScheduleRow, 'crewNos'>> = [
   { id: 'sc4', siteId: 's4', postMode: '三人两岗', cycle: '上 5 休 2', workForm: '站岗 / 巡逻', hourType: '本班', holidaySecondary: '国定假按法定加班' },
   { id: 'sc5', siteId: 's5', postMode: '三人两岗', cycle: '上 5 休 2', workForm: '巡逻 / 安检', hourType: '本班', holidaySecondary: '国定假按法定加班' },
   { id: 'sc6', siteId: 's6', postMode: '两人一岗', cycle: '上 5 休 2', workForm: '站岗', hourType: '本班', holidaySecondary: '国定假按法定加班' },
+  { id: 'sc7', siteId: 's7', postMode: '一人一岗', cycle: '上 5 休 2', workForm: '站岗', hourType: '本班', holidaySecondary: '国定假按法定加班', startTime: '08:00', endTime: '16:00', punchWindow: '07:30–08:30', published: true },
 ]
 
 export function buildSchedules(list: Person[] = people): ScheduleRow[] {
@@ -194,7 +221,23 @@ export function holidayCrew(list: Person[] = people, schs: ScheduleRow[] = sched
 }
 
 export function holidayDutyAttendance(list: Person[] = people, schs: ScheduleRow[] = schedules): AttendanceRow[] {
-  return holidayCrew(list, schs).map((person, i) => holidayRow(person, i, HOLIDAY_DAY, true, schs))
+  const rows = holidayCrew(list, schs).map((person, i) => holidayRow(person, i, HOLIDAY_DAY, true, schs))
+  const zm = rows.find((a) => a.personId === 'p-zm')
+  if (zm) {
+    const site = sites.find((s) => s.id === 's7')
+    Object.assign(zm, {
+      status: '越界' as const,
+      hours: 8,
+      checkIn: '08:12',
+      checkOut: '16:00',
+      failReasons: ['超出驻点电子围栏'],
+      gates: { live: true, face: true, fence: false, window: true },
+      source: 'self' as const,
+      lat: (site?.lat || zm.lat || 31.23) + 0.018,
+      lng: (site?.lng || zm.lng || 121.47) + 0.016,
+    })
+  }
+  return rows
 }
 
 export function weekDutyAttendance(list: Person[] = people, schs: ScheduleRow[] = schedules): AttendanceRow[] {
@@ -203,6 +246,7 @@ export function weekDutyAttendance(list: Person[] = people, schs: ScheduleRow[]
   WEEK_DUTY_DAYS.forEach((date, d) => {
     crew.forEach((person, i) => {
       if (person.id === 'p10' && date === '2026-09-30') return
+      if (person.id === 'p-zm' && date === '2026-09-30') return
       out.push(holidayRow(person, i + d * 3, date, false, schs))
     })
   })
@@ -224,6 +268,7 @@ export const attendance: AttendanceRow[] = [
   { id: 'a8', personId: 'p8', siteId: 's2', date: '2026-08-31', checkIn: '07:56', checkOut: '19:00', status: '出勤中', hourType: '公司付加班', overtimeApply: true, hours: 11, holiday: false, crossMonth: false, lat: 31.1978, lng: 121.3272, accM: 40 },
   { id: 'a9', personId: 'p9', siteId: 's3', date: '2026-08-31', checkIn: '08:00', checkOut: '16:02', status: '早退', hourType: '本班', overtimeApply: false, hours: 8, holiday: false, crossMonth: false, lat: 31.1506, lng: 121.8072, accM: 55 },
   { id: 'a10', personId: 'p10', siteId: 's4', date: '2026-09-30', checkIn: '20:00', checkOut: '04:00', status: '出勤中', hourType: '本班', overtimeApply: false, hours: 8, holiday: false, crossMonth: true, lat: 31.1864, lng: 121.4690, accM: 44 },
+  { id: 'a-zm-cross', personId: 'p-zm', siteId: 's7', date: '2026-09-30', checkIn: '20:00', checkOut: '04:00', status: '出勤中', hourType: '本班', overtimeApply: false, hours: 8, holiday: false, crossMonth: true, lat: 31.2316, lng: 121.4752, accM: 32, source: 'seed' },
   { id: 'a12', personId: 'p3', siteId: 's1', date: '2026-08-30', checkIn: '08:00', checkOut: '16:00', status: '出勤中', hourType: '本班', overtimeApply: false, hours: 8, holiday: false, crossMonth: false },
   ...extraAttendance(),
 ]
@@ -241,6 +286,7 @@ export const finance: FinanceRow[] = [
   { id: 'f10', siteId: 's4', month: '2026-10', invoiced: 10.1, uninvoiced: 0.5, ownCost: 6.4, vendorCost: 2.9, equipCost: 0.3, reimbursed: 0.4, revenue: 8.9 },
   { id: 'f11', siteId: 's5', month: '2026-10', invoiced: 16.8, uninvoiced: 0.9, ownCost: 9.4, vendorCost: 4.5, equipCost: 0.5, reimbursed: 0.2, revenue: 17.6 },
   { id: 'f12', siteId: 's6', month: '2026-10', invoiced: 7.4, uninvoiced: 0.3, ownCost: 4.9, vendorCost: 0.6, equipCost: 0.2, reimbursed: 0.1, revenue: 7.6 },
+  { id: 'f13', siteId: 's7', month: '2026-10', invoiced: 9.6, uninvoiced: 0.4, ownCost: 5.8, vendorCost: 1.6, equipCost: 0.3, reimbursed: 0.2, revenue: 10.4 },
 ]
 
 export const notices: NoticeRow[] = [
@@ -277,6 +323,17 @@ export const notices: NoticeRow[] = [
     body: '虹桥枢纽 10 月 3 日 14:00 消防复训,应到人员带上岗证原件。覆盖率目标 88%。缺训不能顶安检岗。附件为场地图,请在作业端打开本通知后视为已读。',
     readBy: [],
   },
+  {
+    id: 'n-zm',
+    title: '越界待复核 · 张明',
+    scope: '城市服务中心A驻点',
+    sentAt: '2026-10-01 08:13',
+    read: 0,
+    unread: 0,
+    kind: '拍照打卡',
+    body: '张明于 08:12 在城市服务中心A驻点打卡,系统判定当前位置超出电子围栏,未计入正常出勤。请本点督导复核现场说明或要求重新打卡。',
+    readBy: [],
+  },
 ]
 
 export const events: EventRow[] = [
@@ -284,6 +341,38 @@ export const events: EventRow[] = [
   { id: 'e2', level: '一般', siteId: 's1', title: '访客未登记进入大堂', status: '已闭环', createdAt: '2026-09-29 19:12', hrSynced: true },
   { id: 'e3', level: '非常重要', siteId: 's3', title: '夜间越界告警已核验', status: '已闭环', createdAt: '2026-09-29 02:18', hrSynced: true },
   { id: 'e4', level: '非常重要', siteId: 's2', title: '国庆值班越界待核验', status: '待处理', createdAt: '2026-10-01 07:58', hrSynced: true },
+  {
+    id: 'e-zm',
+    level: '重要',
+    siteId: 's7',
+    title: '越界打卡 · 张明 · 城市服务中心A驻点',
+    status: '待处理',
+    createdAt: '2026-10-01 08:13',
+    hrSynced: true,
+    personId: 'p-zm',
+    body: '张明于 08:12 打卡,定位超出城市服务中心A驻点电子围栏。未计入正常出勤,可提交说明或等待督导复核。',
+    dueAt: '2026-10-01 12:00',
+    logs: [{ at: '2026-10-01 08:13', actor: '系统', action: '生成异常', note: '围栏校验未通过' }],
+  },
+  {
+    id: 'e-zm-done',
+    level: '一般',
+    siteId: 's7',
+    title: '越界复核已闭环 · 张明 · 外围交接',
+    status: '已闭环',
+    createdAt: '2026-09-30 09:20',
+    hrSynced: true,
+    personId: 'p-zm',
+    body: '历史示例:张明临时到驻点外围执行交接任务。原始越界记录保留。',
+    dueAt: '2026-09-30 12:00',
+    handleNote: '情况属实,按临时任务核验通过。原始越界记录保留,不删改打卡点。',
+    logs: [
+      { at: '2026-09-30 09:20', actor: '系统', action: '生成异常', note: '超出驻点电子围栏' },
+      { at: '2026-09-30 09:40', actor: '张明 · 保安队员', action: '补充说明', note: '临时到驻点外围执行交接任务,已补拍现场照片。' },
+      { at: '2026-09-30 10:05', actor: '李海 · 驻点督导', action: '下发整改', note: '请 12:00 前补现场照片并说明任务来源。' },
+      { at: '2026-09-30 10:28', actor: '李海 · 驻点督导', action: '复核闭环', note: '情况属实,原始越界保留。' },
+    ],
+  },
 ]
 
 export const alertRules: AlertRule[] = [
@@ -307,6 +396,7 @@ export const supplierPays: SupplierPay[] = [
   { id: 'sp7', siteId: 's4', month: '2026-10', quota: 2.9, paid: 2.1 },
   { id: 'sp8', siteId: 's5', month: '2026-10', quota: 4.5, paid: 1.4 },
   { id: 'sp9', siteId: 's6', month: '2026-10', quota: 0.6, paid: 0.3 },
+  { id: 'sp10', siteId: 's7', month: '2026-10', quota: 1.6, paid: 0.8 },
 ]
 
 export const trainings: TrainingRow[] = [
@@ -318,6 +408,7 @@ export const trainings: TrainingRow[] = [
   { id: 't6', siteId: 's4', title: '滨江夜间巡逻', month: '2026-10', coverage: 80 },
   { id: 't7', siteId: 's5', title: '商圈客流疏导', month: '2026-10', coverage: 84 },
   { id: 't8', siteId: 's6', title: '人防备勤条例', month: '2026-10', coverage: 96 },
+  { id: 't9', siteId: 's7', title: '门岗礼仪与围栏纪律', month: '2026-10', coverage: 91 },
 ]
 
 export const alarms: AlarmLog[] = []
@@ -402,6 +493,8 @@ function guardAccount(id: string, account: string, personId: string): SysUser {
 
 export const sysUsers: SysUser[] = [
   { id: 'u1', account: 'linqm', name: '林启明', role: 'hq', branchId: 'all', siteId: '', personId: '', phone: '13800002001', enabled: true, duty: roleDuty('hq') },
+  guardAccount('u-zm', 'zhangming', 'p-zm'),
+  { id: 'u-lh', account: 'lihai', name: '李海', role: 'site', branchId: 'one', siteId: 's7', personId: '', phone: '13800002081', enabled: true, duty: roleDuty('site') },
   { id: 'u2', account: 'zhoujh', name: '周建华', role: 'branch', branchId: 'two', siteId: '', personId: '', phone: '13800002002', enabled: true, duty: roleDuty('branch') },
   { id: 'u3', account: 'mawg', name: '马卫国', role: 'site', branchId: 'two', siteId: 's2', personId: '', phone: '13800002003', enabled: true, duty: roleDuty('site') },
   { id: 'u4', account: 'sunting', name: '孙婷', role: 'guard', branchId: 'two', siteId: 's2', personId: 'p8', phone: '13800001008', enabled: true, duty: roleDuty('guard') },

+ 165 - 7
src/stores/duty.ts

@@ -778,6 +778,10 @@ export const useDutyStore = defineStore('duty', () => {
         dueCount,
         actualCount,
         missCount: Math.max(0, dueCount - actualCount),
+        startTime: sch?.startTime || '08:00',
+        endTime: sch?.endTime || '16:00',
+        punchWindow: sch?.punchWindow || '07:30–08:30',
+        published: sch?.published !== false && !!sch,
         crewPreview: names.length ? `${names.join('、')}${crew.length > 4 ? ` 等 ${crew.length} 人` : ''}` : '暂无应到',
         crewText: crew.map((p) => `${p.name}(${p.no})`).join('、') || '暂无在册',
         rosterFrom: listed.length ? `排班名单 · 业务日 ${demoDay.value}` : '在册人员(未排班)',
@@ -791,6 +795,14 @@ export const useDutyStore = defineStore('duty', () => {
   function personName(id: string) {
     return personMap.value[id]?.name ?? id
   }
+  function linkedAccount(personId: string) {
+    return sysUsers.value.find((u) => u.personId === personId)
+  }
+  function accountStatus(personId: string) {
+    const u = linkedAccount(personId)
+    if (!u) return '未开通账号'
+    return u.enabled ? '已启用' : '已停用'
+  }
   function branchName(id: BranchId) {
     return branches.value.find((b) => b.id === id)?.name ?? id
   }
@@ -1585,6 +1597,93 @@ export const useDutyStore = defineStore('duty', () => {
     logAction('一键报警', `${personNameText} · ${site.name} · ${site.policePhone}`)
     return log
   }
+  function raiseFenceException(person: Person, site: Site, att: AttendanceRow) {
+    const title = `越界打卡 · ${person.name} · ${site.name}`
+    const at = bizNowText(demoDay.value)
+    const body = `${person.name} 于 ${att.checkIn} 在 ${site.name} 打卡,系统判定当前位置超出电子围栏,未计入正常出勤。可提交说明或等待督导复核。`
+    const existing = events.value.find((e) => e.personId === person.id && e.title.startsWith('越界打卡') && e.status !== '已闭环')
+    const log = { at, actor: '系统', action: '生成异常', note: (att.failReasons || []).join('、') || '超出驻点电子围栏' }
+    if (existing) {
+      existing.body = body
+      existing.status = '待处理'
+      existing.createdAt = at
+      existing.hrSynced = true
+      existing.logs = [...(existing.logs || []), log]
+      save('event', existing.id, existing)
+    } else {
+      const ev: EventRow = {
+        id: `e-fence-${person.id}`,
+        level: '重要',
+        siteId: site.id,
+        title,
+        status: '待处理',
+        createdAt: at,
+        hrSynced: true,
+        personId: person.id,
+        body,
+        dueAt: `${demoDay.value} 12:00`,
+        logs: [log],
+      }
+      events.value.unshift(ev)
+      save('event', ev.id, ev)
+    }
+    const nTitle = `越界待复核 · ${person.name}`
+    const noticeHit = notices.value.find((n) => n.title === nTitle)
+    if (noticeHit) {
+      noticeHit.body = body
+      noticeHit.sentAt = at
+      noticeHit.readBy = []
+      syncNoticeCounts(noticeHit)
+      save('notice', noticeHit.id, noticeHit)
+    } else {
+      const notice: NoticeRow = {
+        id: `n-fence-${person.id}`,
+        title: nTitle,
+        scope: site.name,
+        sentAt: at,
+        read: 0,
+        unread: 0,
+        kind: '拍照打卡',
+        body,
+        readBy: [],
+      }
+      notices.value.unshift(notice)
+      save('notice', notice.id, notice)
+    }
+  }
+  function handleDutyEvent(id: string, input: { action: '整改' | '闭环' | '退回' | '补充'; note: string; dueAt?: string }) {
+    const e = events.value.find((x) => x.id === id)
+    if (!e) return false
+    const own = Boolean(currentPersonId.value && e.personId === currentPersonId.value)
+    if (input.action === '补充') {
+      if (!own && !canAct('event.flow')) return deny('没有补充说明权限', '补充说明')
+    } else if (!requireAct('event.flow', '处理异常')) return false
+    if (!guardSite(e.siteId, '处理异常')) return false
+    const note = input.note.trim()
+    if (!note) return deny('请填写处理说明', '处理异常')
+    const at = bizNowText(demoDay.value)
+    const actor = actorLabel.value
+    const nextStatus: Record<typeof input.action, EventStatus> = {
+      整改: '整改中',
+      闭环: '已闭环',
+      退回: '待处理',
+      补充: e.status === '已闭环' ? '已闭环' : '整改中',
+    }
+    const actionLabel: Record<typeof input.action, string> = {
+      整改: '下发整改',
+      闭环: '复核闭环',
+      退回: '退回重办',
+      补充: '补充说明',
+    }
+    e.status = nextStatus[input.action]
+    e.handleNote = note
+    if (input.dueAt) e.dueAt = input.dueAt
+    if (e.status !== '待处理') e.hrSynced = true
+    e.logs = [...(e.logs || []), { at, actor, action: actionLabel[input.action], note }]
+    save('event', e.id, e)
+    logAction('处理异常', `${e.title} → ${e.status}`)
+    return true
+  }
   function patchUninvoiced(id: string, value: number) {
     const f = finance.value.find((x) => x.id === id)
     if (!f) return false
@@ -1737,14 +1836,16 @@ export const useDutyStore = defineStore('duty', () => {
           : { lat: site.lat, lng: site.lng }
     const inside = insideFence(pt, site)
     const judged = evaluateCheckIn({ face: input.face, inside, time: input.time })
+    const reasons = judged.failReasons.map((r) => (r === '围栏外' ? '超出驻点电子围栏' : r))
+    const sch = schedules.value.find((s) => s.siteId === site.id)
     const payload: Omit<AttendanceRow, 'id'> = {
       personId: person.id,
       siteId: site.id,
       date: demoDay.value,
       checkIn: input.time,
-      checkOut: judged.hours > 0 ? '16:00' : '-',
+      checkOut: judged.hours > 0 ? (sch?.endTime || '16:00') : '-',
       status: judged.status,
-      hourType: '本班',
+      hourType: sch?.hourType || '本班',
       overtimeApply: false,
       hours: judged.hours,
       holiday: isNationalHoliday(demoDay.value),
@@ -1752,8 +1853,9 @@ export const useDutyStore = defineStore('duty', () => {
       lat: pt.lat,
       lng: pt.lng,
       accM: input.accM ?? (inside ? 18 : 46),
-      failReasons: judged.failReasons,
+      failReasons: reasons,
       gates: judged.gates,
+      source: proxy ? 'proxy' : 'self',
     }
     const existing = attendance.value.find((a) => a.personId === person.id && a.date === demoDay.value)
     let saved: AttendanceRow
@@ -1767,7 +1869,8 @@ export const useDutyStore = defineStore('duty', () => {
       save('attend', saved.id, saved)
     }
     syncSettle(saved)
-    const detail = judged.failReasons.length ? judged.failReasons.join('、') : '校验通过'
+    if (judged.status === '越界') raiseFenceException(person, site, saved)
+    const detail = reasons.length ? reasons.join('、') : '校验通过'
     logAction(actName, `${person.name} ${judged.status} · ${detail}`)
     lastDeny.value = ''
     return { ok: true as const, message: `${person.name} ${judged.status} · ${detail}`, ...judged }
@@ -1946,10 +2049,27 @@ export const useDutyStore = defineStore('duty', () => {
       return false
     })
 
+    for (const row of seed.sites) {
+      upsert(sites, 'site', row, (s) => s.code === row.code || s.id === row.id)
+    }
+
     const extras = extraPeople()
     let addedPeople = 0
-    for (const person of extras) {
-      if (people.value.some((p) => p.id === person.id || p.no === person.no)) continue
+    for (const person of [...seed.people.filter((p) => p.no === 'RF10801'), ...extras]) {
+      const hit = people.value.find((p) => p.no === person.no || (p.id === person.id && p.no === person.no))
+      if (hit) {
+        if (person.no === 'RF10801') {
+          const siteId = remapSite(person.siteId) || hit.siteId
+          const before = `${hit.name}|${hit.post}|${hit.siteId}`
+          hit.name = person.name
+          hit.post = person.post || hit.post
+          hit.siteId = siteId
+          hit.identityHistory = person.identityHistory
+          hit.siteHistory = person.siteHistory
+          if (persist && before !== `${hit.name}|${hit.post}|${hit.siteId}`) save('person', hit.id, hit)
+        }
+        continue
+      }
       const siteId = remapSite(person.siteId)
       if (!siteId) continue
       const site = sites.value.find((s) => s.id === siteId)
@@ -2078,6 +2198,14 @@ export const useDutyStore = defineStore('duty', () => {
       a10.crossMonth = true
       if (persist) save('attend', a10.id, a10)
     }
+    const zmCross = seed.attendance.find((a) => a.id === 'a-zm-cross')
+    if (zmCross) {
+      const person = people.value.find((p) => p.no === 'RF10801' || p.id === 'p-zm')
+      const siteId = person ? remapSite(person.siteId) || person.siteId : remapSite(zmCross.siteId)
+      if (person && siteId) {
+        upsert(attendance, 'attend', { ...zmCross, personId: person.id, siteId }, (a) => a.personId === person.id && a.date === zmCross.date && a.crossMonth)
+      }
+    }
     const seedAtt = extraAttendance()
     for (const row of seedAtt) {
       const extra = extras.find((p) => p.id === row.personId)
@@ -2122,6 +2250,26 @@ export const useDutyStore = defineStore('duty', () => {
       trimmed += 1
     }
 
+    const zm = people.value.find((p) => p.no === 'RF10801' || p.name === '张明')
+    if (zm) {
+      const site = sites.value.find((s) => s.id === zm.siteId)
+      const row = attendance.value.find((a) => a.personId === zm.id && a.date === HOLIDAY_DAY)
+      if (row && site) {
+        Object.assign(row, {
+          status: '越界',
+          hours: 8,
+          checkIn: row.checkIn && row.checkIn !== '-' ? row.checkIn : '08:12',
+          checkOut: '16:00',
+          failReasons: ['超出驻点电子围栏'],
+          gates: { live: true, face: true, fence: false, window: true },
+          source: row.source || 'self',
+          lat: site.lat + 0.018,
+          lng: site.lng + 0.016,
+        })
+        if (persist) save('attend', row.id, row)
+      }
+    }
+
     if (addedPeople || addedAtt || trimmed || dropped) {
       logAction('补齐样本', `人员 ${addedPeople}、考勤 ${addedAtt}、去掉脏数据 ${dropped + trimmed}`)
       if (persist) {
@@ -2326,7 +2474,14 @@ export const useDutyStore = defineStore('duty', () => {
     })
   })
   const scopedAlarms = computed(() => alarms.value.filter((a) => filteredSiteIds.value.has(a.siteId)))
-  const scopedEvents = computed(() => events.value.filter((e) => filteredSiteIds.value.has(e.siteId)))
+  const scopedEvents = computed(() => {
+    const rows = events.value.filter((e) => filteredSiteIds.value.has(e.siteId))
+    if (demoRole.value === 'guard' && currentPersonId.value) {
+      const name = personName(currentPersonId.value)
+      return rows.filter((e) => e.personId === currentPersonId.value || e.title.includes(name))
+    }
+    return rows
+  })
   const scopedFeedbacks = computed(() =>
     [...feedbacks.value].filter(canSeeFeedback).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
   )
@@ -2455,6 +2610,8 @@ export const useDutyStore = defineStore('duty', () => {
     attendTrend,
     siteName,
     personName,
+    linkedAccount,
+    accountStatus,
     branchName,
     loginAs,
     logout,
@@ -2492,6 +2649,7 @@ export const useDutyStore = defineStore('duty', () => {
     updateEvent,
     removeEvent,
     advanceEvent,
+    handleDutyEvent,
     addNotice,
     openNotice,
     isNoticeRead,

+ 22 - 0
src/types.ts

@@ -47,6 +47,8 @@ export interface Site {
   fenceM: number
   /** 打卡围栏顶点。有 3 点以上时按面判断,否则退回圆心+半径。 */
   fencePoly?: FencePt[]
+  /** 驻点负责人(督导姓名,演示档案用) */
+  ownerName?: string
   photoName?: string
   photoUrl?: string
 }
@@ -83,6 +85,8 @@ export interface Person {
   partyMember: boolean
   veteran: boolean
   appRegistered: boolean
+  /** 岗位,如门岗 / 巡逻。职务在账号上,不写这里。 */
+  post?: string
   photoName?: string
   photoUrl?: string
   fileName?: string
@@ -114,6 +118,10 @@ export interface ScheduleRow {
   holidaySecondary: string
   /** 今日该来的人工号。空则回退为该驻点在册人员。 */
   crewNos?: string[]
+  startTime?: string
+  endTime?: string
+  punchWindow?: string
+  published?: boolean
 }
 
 export interface AttendanceRow {
@@ -135,6 +143,8 @@ export interface AttendanceRow {
   /** 打卡校验未通过的原因,给人事和督导看,不只给一个「未出勤」。 */
   failReasons?: string[]
   gates?: CheckGates
+  /** 作业端本人 / 后台代打 / 样本 */
+  source?: 'self' | 'proxy' | 'seed'
 }
 
 export interface CheckGates {
@@ -192,6 +202,13 @@ export interface RolePerm {
   actions: string[]
 }
 
+export interface EventLog {
+  at: string
+  actor: string
+  action: string
+  note: string
+}
+
 export interface EventRow {
   id: string
   level: EventLevel
@@ -202,6 +219,11 @@ export interface EventRow {
   hrSynced: boolean
   fileName?: string
   fileUrl?: string
+  personId?: string
+  body?: string
+  dueAt?: string
+  handleNote?: string
+  logs?: EventLog[]
 }
 
 export interface FeedbackAttach {

+ 70 - 14
src/views/admin/Collab.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import { computed, reactive, ref, watch } from 'vue'
+import { useRoute } from 'vue-router'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { useDutyStore } from '@/stores/duty'
 import { useQueryTab } from '@/composables/useQueryTab'
@@ -12,7 +13,9 @@ import NoticeSheet from '@/components/NoticeSheet.vue'
 import type { EventLevel, EventRow, NoticeKind } from '@/types'
 
 const store = useDutyStore()
+const route = useRoute()
 const tab = useQueryTab('notice')
+const tableQ = computed(() => (typeof route.query.q === 'string' ? route.query.q : ''))
 const { open: noticeViewOpen, current: noticeCurrent, show: showNotice } = useNoticeDetail()
 
 const noticeOpen = ref(false)
@@ -24,9 +27,15 @@ const eventForm = reactive({ level: '一般' as EventLevel, siteId: 's1', title:
 const alarmForm = reactive({ siteId: 's1', personId: 'p1' })
 const invert = ref('考勤未汇总即提交结算')
 const eventView = ref<EventRow | null>(null)
+const handleForm = reactive({
+  action: '整改' as '整改' | '闭环' | '退回' | '补充',
+  note: '临时到驻点外围执行交接任务,请 12:00 前补现场照片或重新打卡。',
+  dueAt: '',
+})
 function eventSearch(row: unknown) {
   const e = row as EventRow
-  return store.siteName(e.siteId)
+  const person = e.personId ? store.personName(e.personId) : ''
+  return `${store.siteName(e.siteId)} ${person} ${e.body || ''} ${e.handleNote || ''}`
 }
 function alarmSearch(row: unknown) {
   const a = row as { siteId?: string }
@@ -135,6 +144,27 @@ async function nextStatus(id: string) {
   if (eventView.value?.id === id) eventView.value = store.events.find((e) => e.id === id) || eventView.value
 }
 
+function openHandle(row: EventRow) {
+  const live = store.events.find((e) => e.id === row.id) || row
+  eventView.value = live
+  handleForm.action = live.status === '整改中' ? '闭环' : live.status === '已闭环' ? '补充' : '整改'
+  handleForm.note = live.status === '待处理'
+    ? '临时到驻点外围执行交接任务,请 12:00 前补现场照片或重新打卡。'
+    : live.handleNote || '情况属实,原始越界记录保留,不删改打卡点。'
+  handleForm.dueAt = live.dueAt || `${store.demoDay} 12:00`
+}
+
+async function submitHandle() {
+  if (!eventView.value) return
+  const ok = store.handleDutyEvent(eventView.value.id, {
+    action: handleForm.action,
+    note: handleForm.note,
+    dueAt: handleForm.dueAt,
+  })
+  if (!(await afterWrite(ok, `已${handleForm.action}并写入中台(原始异常保留)`))) return
+  eventView.value = store.events.find((e) => e.id === eventView.value!.id) || eventView.value
+}
+
 async function alarm() {
   const person = store.people.find((p) => p.id === alarmForm.personId)
   const name = person?.name || '未知队员'
@@ -243,29 +273,31 @@ function callTeam() {
     </div>
 
     <div v-else-if="tab === 'event'" class="pane-fill">
-      <p class="note">一般 / 重要 / 非常重要。点「去整改」会通知人事;再点「闭环」结束。</p>
-      <AdminTable fill :data="store.scopedEvents" search-placeholder="搜事项 / 驻点" :search-extra="eventSearch">
+      <p class="note">越界打卡会同步生成本页待办。点「处理」下发整改、时限和复核结论;原始异常不会删除。</p>
+      <AdminTable fill :data="store.scopedEvents" search-placeholder="搜事项 / 人员 / 驻点" :search-extra="eventSearch" :initial-search="tableQ">
         <template #toolbar>
         <el-button v-if="store.canAct('event.create')" type="primary" data-testid="add-event" @click="openEvent">上报事件</el-button>
         </template>
-        <el-table-column prop="level" label="级别" width="100" />
-        <el-table-column label="驻点"><template #default="{ row }">{{ store.siteName(row.siteId) }}</template></el-table-column>
+        <el-table-column prop="level" label="级别" width="88" />
+        <el-table-column label="驻点" width="160"><template #default="{ row }">{{ store.siteName(row.siteId) }}</template></el-table-column>
+        <el-table-column label="人员" width="88"><template #default="{ row }">{{ row.personId ? store.personName(row.personId) : '—' }}</template></el-table-column>
         <el-table-column prop="title" label="事项">
           <template #default="{ row }">
-            <el-button link type="primary" @click="eventView = row">{{ row.title }}</el-button>
+            <el-button link type="primary" @click="openHandle(row)">{{ row.title }}</el-button>
           </template>
         </el-table-column>
         <el-table-column prop="status" label="状态" width="90" />
+        <el-table-column label="时限" width="150"><template #default="{ row }">{{ row.dueAt || '—' }}</template></el-table-column>
         <el-table-column label="同步人事" width="90"><template #default="{ row }">{{ row.hrSynced ? '是' : '否' }}</template></el-table-column>
         <el-table-column label="附件" width="120">
           <template #default="{ row }"><FileCell :name="row.fileName" :url="row.fileUrl" /></template>
         </el-table-column>
-        <el-table-column label="流转" width="168" class-name="col-ops">
+        <el-table-column label="流转" width="200" class-name="col-ops">
           <template #default="{ row }">
             <div class="tbl-ops">
+              <el-button v-if="store.canAct('event.flow')" size="small" data-testid="event-handle" @click="openHandle(row)">处理</el-button>
               <el-button v-if="store.canAct('event.flow') && row.status === '待处理'" size="small" data-testid="event-next" @click="nextStatus(row.id)">去整改</el-button>
               <el-button v-else-if="store.canAct('event.flow') && row.status === '整改中'" size="small" data-testid="event-next" @click="nextStatus(row.id)">闭环</el-button>
-              <span v-else-if="row.status === '已闭环'">已完成</span>
               <el-button v-if="store.canAct('event.delete')" link type="danger" @click="delEvent(row.id, row.title)">删除</el-button>
             </div>
           </template>
@@ -414,17 +446,40 @@ function callTeam() {
     </template>
   </el-dialog>
   <NoticeSheet v-model="noticeViewOpen" :notice="noticeCurrent" />
-  <el-dialog :model-value="!!eventView" title="事件详情" width="520px" append-to-body @close="eventView = null">
+  <el-dialog :model-value="!!eventView" title="异常处理" width="640px" append-to-body @close="eventView = null">
     <template v-if="eventView">
-      <p>{{ eventView.level }} · {{ store.siteName(eventView.siteId) }} · {{ eventView.status }}</p>
-      <p>{{ eventView.createdAt }} · 人事{{ eventView.hrSynced ? '已同步' : '未同步' }}</p>
-      <p>{{ eventView.title }}</p>
+      <p class="dlg-hint">{{ eventView.level }} · {{ store.siteName(eventView.siteId) }} · {{ eventView.personId ? store.personName(eventView.personId) : '' }} · {{ eventView.status }} · {{ eventView.createdAt }}</p>
+      <p>{{ eventView.body || eventView.title }}</p>
+      <p v-if="eventView.dueAt">完成时限 {{ eventView.dueAt }}</p>
+      <p v-if="eventView.handleNote">处理结论 {{ eventView.handleNote }}</p>
       <FileCell v-if="eventView.fileName" :name="eventView.fileName" :url="eventView.fileUrl" />
+      <div class="sec">完整操作记录</div>
+      <el-timeline v-if="eventView.logs?.length">
+        <el-timeline-item v-for="(log, i) in eventView.logs" :key="i" :timestamp="log.at">
+          {{ log.actor }} · {{ log.action }} · {{ log.note }}
+        </el-timeline-item>
+      </el-timeline>
+      <p v-else class="dlg-hint">尚无处理记录。系统不会删除原始异常,也不会静默改打卡点。</p>
+      <el-form v-if="store.canAct('event.flow')" label-width="96px" style="margin-top: 8px">
+        <el-form-item label="处理方式">
+          <el-select v-model="handleForm.action" style="width: 100%">
+            <el-option label="下发整改(补充说明 / 现场照片 / 重新打卡)" value="整改" />
+            <el-option label="复核通过并闭环" value="闭环" />
+            <el-option label="退回重新处理" value="退回" />
+            <el-option label="登记补充材料" value="补充" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="完成时限">
+          <el-input v-model="handleForm.dueAt" placeholder="如 2026-10-01 12:00" />
+        </el-form-item>
+        <el-form-item label="处理说明" required>
+          <el-input v-model="handleForm.note" type="textarea" :rows="3" />
+        </el-form-item>
+      </el-form>
     </template>
     <template #footer>
       <el-button @click="eventView = null">关闭</el-button>
-      <el-button v-if="eventView && store.canAct('event.flow') && eventView.status === '待处理'" type="primary" @click="nextStatus(eventView.id)">去整改</el-button>
-      <el-button v-else-if="eventView && store.canAct('event.flow') && eventView.status === '整改中'" type="primary" @click="nextStatus(eventView.id)">闭环</el-button>
+      <el-button v-if="eventView && store.canAct('event.flow')" type="primary" data-testid="event-submit-handle" @click="submitHandle">提交</el-button>
     </template>
   </el-dialog>
 </template>
@@ -433,5 +488,6 @@ function callTeam() {
 .toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
 h4 { margin: 16px 0 8px; color: #0b2a4a; }
 .dlg-hint { margin: 0 0 14px; color: #4a6270; font-size: 13px; line-height: 1.6; }
+.sec { margin: 12px 0 8px; font-weight: 600; color: #0b2a4a; }
 .up-tip { color: #7a909c; font-size: 12px; margin-top: 4px; }
 </style>

+ 113 - 4
src/views/admin/DutyOps.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { computed, reactive, ref } from 'vue'
-import { useRouter } from 'vue-router'
+import { useRouter, useRoute } from 'vue-router'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import NumField from '@/components/NumField.vue'
 import AdminTable from '@/components/AdminTable.vue'
@@ -13,7 +13,23 @@ import type { AttendStatus, AttendanceRow, HourType, ScheduleRow } from '@/types
 
 const store = useDutyStore()
 const router = useRouter()
+const route = useRoute()
 const tab = useQueryTab('schedule')
+const tableQ = computed(() => (typeof route.query.q === 'string' ? route.query.q : ''))
+const schDetail = ref<null | {
+  siteId: string
+  site: string
+  code: string
+  postMode: string
+  workForm: string
+  hourType: string
+  startTime: string
+  endTime: string
+  punchWindow: string
+  published: boolean
+  crew: { id: string; name: string; no: string; post?: string }[]
+  rosterFrom: string
+}>(null)
 function attendSearch(row: unknown) {
   const a = row as AttendanceRow
   return `${store.personName(a.personId)} ${store.siteName(a.siteId)} ${(a.failReasons || []).join(' ')}`
@@ -44,6 +60,10 @@ const sch = reactive({
   hourType: '本班' as HourType,
   holidaySecondary: '国定假按法定加班',
   crewIds: [] as string[],
+  startTime: '08:00',
+  endTime: '16:00',
+  punchWindow: '07:30–08:30',
+  published: true,
 })
 
 const attOpen = ref(false)
@@ -71,6 +91,31 @@ const summary = computed(() => [
   { k: '异常(人)', v: store.exceptionCount },
   { k: '加班费合计(元)', v: store.settlements.filter((s) => s.date === store.demoDay).reduce((n, s) => n + s.pay, 0) },
 ])
+const siteDaySummary = computed(() => {
+  const q = tableQ.value.trim()
+  return store.scheduleBoard
+    .filter((b) => {
+      if (!q) return true
+      if (`${b.site}${b.code}${b.crewText}`.includes(q)) return true
+      return store.people.some((p) => p.siteId === b.siteId && (p.name.includes(q) || p.no.includes(q)))
+    })
+    .map((b) => {
+    const att = store.dayAttendance.filter((a) => a.siteId === b.siteId)
+    const hours = att.filter((a) => a.status !== '未出勤' && (a.hourType === '本班')).reduce((n, a) => n + a.hours, 0)
+    const ot = att.filter((a) => a.status !== '未出勤' && (a.hourType !== '本班' || a.holiday)).reduce((n, a) => n + a.hours, 0)
+    const cost = store.costBySite.find((c) => c.siteId === b.siteId)
+    return {
+      site: b.site,
+      code: b.code,
+      due: b.dueCount,
+      actual: b.actualCount,
+      hours: Number(hours.toFixed(1)),
+      ot: Number(ot.toFixed(1)),
+      bad: att.filter((a) => ['越界', '迟到', '早退', '未出勤'].includes(a.status)).length,
+      cost: cost ? `${cost.hours} 小时 / 加班费 ${cost.otPay} 元` : '—',
+    }
+    })
+})
 const otSummary = computed(() => {
   const day = store.settlements.filter((s) => s.date === store.demoDay)
   const month = store.settlements.filter((s) => s.date.startsWith(store.demoMonth))
@@ -133,6 +178,10 @@ function openSch(row?: ScheduleRow) {
     sch.hourType = row.hourType
     sch.holidaySecondary = row.holidaySecondary
     sch.crewIds = crewIdsOf(row.siteId, row.crewNos)
+    sch.startTime = row.startTime || '08:00'
+    sch.endTime = row.endTime || '16:00'
+    sch.punchWindow = row.punchWindow || '07:30–08:30'
+    sch.published = row.published !== false
   } else {
     sch.siteId = siteId
     sch.postMode = '三人两岗'
@@ -141,6 +190,10 @@ function openSch(row?: ScheduleRow) {
     sch.hourType = '本班'
     sch.holidaySecondary = '国定假按法定加班'
     sch.crewIds = crewIdsOf(siteId)
+    sch.startTime = '08:00'
+    sch.endTime = '16:00'
+    sch.punchWindow = '07:30–08:30'
+    sch.published = true
   }
   schOpen.value = true
 }
@@ -172,6 +225,10 @@ async function addSch() {
     hourType: sch.hourType,
     holidaySecondary: sch.holidaySecondary,
     crewNos: crewNosOf(sch.siteId, sch.crewIds),
+    startTime: sch.startTime,
+    endTime: sch.endTime,
+    punchWindow: sch.punchWindow,
+    published: sch.published,
   }
   if (schMode.value === 'edit') {
     if (!(await toastWrite(store.updateSchedule(schEditId.value, payload), '排班已写入中台'))) return
@@ -285,7 +342,7 @@ function exportCost() {
   <div class="page-fill">
     <div v-if="tab === 'schedule'" class="pane-fill">
       <p class="note">排班按驻点。勾选「今天该来的人」会写进中台;没有名单时列表仍显示该点在职人员。格子只展示应到/实到,点开看名单。你只能看到权限内驻点。</p>
-      <AdminTable fill :data="store.scheduleBoard" search-placeholder="搜驻点 / 人员 / 编号" :search-extra="scheduleSearch">
+      <AdminTable fill :data="store.scheduleBoard" search-placeholder="搜驻点 / 人员 / 编号" :search-extra="scheduleSearch" :initial-search="tableQ">
         <template #toolbar>
           <el-button v-if="store.canAct('schedule.edit')" type="primary" data-testid="add-schedule" @click="openSch()">新增排班</el-button>
           <CsvImport
@@ -312,11 +369,18 @@ function exportCost() {
         <el-table-column prop="postMode" label="岗位配置" width="110" />
         <el-table-column prop="cycle" label="上班周期" width="110" />
         <el-table-column prop="workForm" label="工作形式" />
+        <el-table-column label="班次" width="140">
+          <template #default="{ row }">{{ row.startTime }}–{{ row.endTime }}</template>
+        </el-table-column>
         <el-table-column prop="hourType" label="工时分类" width="110" />
+        <el-table-column label="发布" width="80">
+          <template #default="{ row }">{{ row.published ? '已发布' : '草稿' }}</template>
+        </el-table-column>
         <el-table-column prop="holidaySecondary" label="节假日二级" />
-        <el-table-column label="操作" width="120" fixed="right" class-name="col-ops">
+        <el-table-column label="操作" width="200" fixed="right" class-name="col-ops">
           <template #default="{ row }">
             <div class="tbl-ops">
+              <el-button link type="primary" @click="schDetail = row">查看班次详情</el-button>
               <el-button v-if="store.canAct('schedule.edit') && row.hasSchedule" link type="primary" @click="openBoard(row)">编辑</el-button>
               <el-button v-else-if="store.canAct('schedule.edit')" link type="primary" @click="openBoard(row)">补排班</el-button>
               <el-button v-if="store.canAct('schedule.edit') && row.hasSchedule" link type="danger" @click="delSchBySite(row.siteId, row.site)">删除</el-button>
@@ -340,13 +404,27 @@ function exportCost() {
         <el-button v-if="store.canAct('field.punch')" data-testid="go-field" @click="router.push('/admin/field')">后台代打卡</el-button>
         <el-button v-if="store.canAct('attend.export')" data-testid="export-attend" @click="exportAttend">导出考勤</el-button>
       </div>
-      <AdminTable fill :data="store.filteredAttendance" search-placeholder="搜队员 / 驻点 / 失败原因" :search-extra="attendSearch">
+      <el-table :data="siteDaySummary" size="small" border style="margin-bottom: 12px" max-height="220">
+        <el-table-column prop="site" label="驻点" min-width="160" />
+        <el-table-column prop="due" label="应到(人)" width="90" />
+        <el-table-column prop="actual" label="实到(人)" width="90" />
+        <el-table-column prop="hours" label="正常工时(小时)" width="130" />
+        <el-table-column prop="ot" label="加班工时(小时)" width="130" />
+        <el-table-column prop="bad" label="异常(人)" width="90" />
+        <el-table-column prop="cost" label="驻点成本(月)" min-width="180" />
+      </el-table>
+      <AdminTable fill :data="store.filteredAttendance" search-placeholder="搜队员 / 驻点 / 失败原因" :search-extra="attendSearch" :initial-search="tableQ">
         <el-table-column label="队员"><template #default="{ row }">{{ store.personName(row.personId) }}</template></el-table-column>
         <el-table-column label="驻点"><template #default="{ row }">{{ store.siteName(row.siteId) }}</template></el-table-column>
         <el-table-column prop="date" label="日期" width="120" />
         <el-table-column prop="checkIn" label="上班" width="80" />
         <el-table-column prop="checkOut" label="下班" width="80" />
         <el-table-column prop="hours" label="工时(小时)" width="100" />
+        <el-table-column prop="hourType" label="工时类型" width="110" />
+        <el-table-column label="来源" width="88">
+          <template #default="{ row }">{{ row.source === 'self' ? '作业端' : row.source === 'proxy' ? '代打卡' : '样本' }}</template>
+        </el-table-column>
+        <el-table-column label="跨月" width="70"><template #default="{ row }">{{ row.crossMonth ? '是' : '否' }}</template></el-table-column>
         <el-table-column label="状态" width="90">
           <template #default="{ row }"><el-tag :type="statusColor[row.status]" size="small">{{ row.status }}</el-tag></template>
         </el-table-column>
@@ -454,6 +532,15 @@ function exportCost() {
           <el-option v-for="w in WORK_FORMS" :key="w" :label="w" :value="w" />
         </el-select>
       </el-form-item>
+      <el-form-item label="班次">
+        <div style="display:flex;gap:8px;width:100%">
+          <el-input v-model="sch.startTime" placeholder="08:00" />
+          <el-input v-model="sch.endTime" placeholder="16:00" />
+        </div>
+      </el-form-item>
+      <el-form-item label="打卡时间窗">
+        <el-input v-model="sch.punchWindow" placeholder="07:30–08:30" />
+      </el-form-item>
       <el-form-item label="默认工时分类" required>
         <el-select v-model="sch.hourType" style="width: 100%">
           <el-option v-for="h in HOUR_TYPES" :key="h" :label="h" :value="h" />
@@ -471,6 +558,28 @@ function exportCost() {
     </template>
   </el-dialog>
 
+  <el-dialog :model-value="!!schDetail" title="班次详情" width="560px" append-to-body @close="schDetail = null">
+    <template v-if="schDetail">
+      <p class="dlg-hint">{{ schDetail.site }} · {{ schDetail.code }} · {{ schDetail.published ? '已发布' : '草稿' }}</p>
+      <p>岗位 {{ schDetail.postMode }} · 工作形式 {{ schDetail.workForm }} · 工时 {{ schDetail.hourType }}</p>
+      <p>班次 {{ schDetail.startTime }}–{{ schDetail.endTime }} · 打卡窗 {{ schDetail.punchWindow }}</p>
+      <p>名单来源 {{ schDetail.rosterFrom }}</p>
+      <el-table :data="schDetail.crew" size="small" max-height="280">
+        <el-table-column prop="name" label="姓名" width="90" />
+        <el-table-column prop="no" label="工号" width="110" />
+        <el-table-column label="岗位" width="80"><template #default="{ row }">{{ row.post || '—' }}</template></el-table-column>
+        <el-table-column label="今日">
+          <template #default="{ row }">
+            {{ store.attendance.find((a) => a.personId === row.id && a.date === store.demoDay)?.status || '未打卡' }}
+          </template>
+        </el-table-column>
+      </el-table>
+    </template>
+    <template #footer>
+      <el-button @click="schDetail = null">关闭</el-button>
+    </template>
+  </el-dialog>
+
   <el-dialog v-model="attOpen" :title="attMode === 'create' ? '补录考勤' : '编辑考勤'" width="560px" append-to-body>
     <p class="dlg-hint">演示补录。坐标默认取驻点中心,一张图按此落点。未申请加班计入本班。</p>
     <el-form label-width="108px">

+ 54 - 10
src/views/admin/MasterData.vue

@@ -8,13 +8,16 @@ import CsvImport from '@/components/CsvImport.vue'
 import DmsAttach from '@/components/DmsAttach.vue'
 import FileCell from '@/components/FileCell.vue'
 import { afterWrite, delConfirmText } from '@/composables/usePersistWrite'
+import { useRoute } from 'vue-router'
 import { CSV_TEMPLATES } from '@/utils/csv'
 import { useDutyStore } from '@/stores/duty'
 import { useQueryTab } from '@/composables/useQueryTab'
 import type { BranchId, ContractRow, FencePt, Identity, Person, Site, TrainingRow } from '@/types'
 
 const store = useDutyStore()
+const route = useRoute()
 const tab = useQueryTab('people')
+const tableQ = computed(() => (typeof route.query.q === 'string' ? route.query.q : ''))
 
 const IDENTITIES: Identity[] = ['自有', '劳务', 'HRO', '供应商', '实习']
 const EDU = ['初中', '高中', '中专', '大专', '本科']
@@ -49,12 +52,16 @@ const personForm = reactive({
   fileName: '',
   fileUrl: '',
   onDuty: true,
+  post: '门岗',
 })
 
 const siteOpen = ref(false)
 const siteMapOn = ref(false)
-const siteMode = ref<'create' | 'edit'>('create')
+const siteMode = ref<'create' | 'edit' | 'view'>('create')
 const siteEditId = ref('')
+const siteReadonly = computed(() => siteMode.value === 'view')
+const siteTitle = computed(() => (siteMode.value === 'create' ? '新增驻点' : siteMode.value === 'view' ? '驻点档案' : '编辑驻点'))
+const siteContract = computed(() => store.contracts.find((c) => c.siteId === siteEditId.value))
 const siteForm = reactive({
   name: '',
   code: '',
@@ -69,6 +76,7 @@ const siteForm = reactive({
   fencePoly: [] as FencePt[],
   photoName: '',
   photoUrl: '',
+  ownerName: '',
 })
 
 const contractOpen = ref(false)
@@ -167,6 +175,7 @@ function emptyPerson() {
   personForm.fileName = ''
   personForm.fileUrl = ''
   personForm.onDuty = true
+  personForm.post = '门岗'
 }
 
 function fillPerson(row: Person) {
@@ -189,6 +198,7 @@ function fillPerson(row: Person) {
   personForm.fileName = row.fileName ?? ''
   personForm.fileUrl = row.fileUrl ?? ''
   personForm.onDuty = row.onDuty
+  personForm.post = row.post || '门岗'
 }
 
 function openPerson(mode: PersonMode, row?: Person) {
@@ -238,6 +248,7 @@ async function submitPerson() {
       fileName: personForm.fileName || undefined,
       fileUrl: personForm.fileUrl || undefined,
       onDuty: personForm.onDuty,
+      post: personForm.post.trim() || '门岗',
     }
     if (old && old.siteId !== personForm.siteId) {
       const hist = old.siteHistory.map((h) => ({ ...h }))
@@ -273,6 +284,7 @@ async function submitPerson() {
       photoUrl: personForm.photoUrl || undefined,
       fileName: personForm.fileName || undefined,
       fileUrl: personForm.fileUrl || undefined,
+      post: personForm.post.trim() || '门岗',
     }), '人员已写入中台。要在一张图落点,请到勤务作业补录当日考勤。'))) return
   }
   personOpen.value = false
@@ -292,9 +304,10 @@ function emptySite() {
   siteForm.fencePoly = []
   siteForm.photoName = ''
   siteForm.photoUrl = ''
+  siteForm.ownerName = ''
 }
 
-function openSite(mode: 'create' | 'edit', row?: Site) {
+function openSite(mode: 'create' | 'edit' | 'view', row?: Site) {
   siteMode.value = mode
   siteEditId.value = row?.id ?? ''
   if (row) {
@@ -311,6 +324,7 @@ function openSite(mode: 'create' | 'edit', row?: Site) {
     siteForm.fencePoly = (row.fencePoly || []).map((p) => ({ ...p }))
     siteForm.photoName = row.photoName ?? ''
     siteForm.photoUrl = row.photoUrl ?? ''
+    siteForm.ownerName = row.ownerName || ''
   } else emptySite()
   siteOpen.value = true
 }
@@ -338,6 +352,7 @@ async function submitSite() {
     fencePoly: siteForm.fencePoly.length >= 3 ? siteForm.fencePoly.map((p) => ({ ...p })) : [],
     photoName: siteForm.photoName || undefined,
     photoUrl: siteForm.photoUrl || undefined,
+    ownerName: siteForm.ownerName.trim() || undefined,
   }
   if (siteMode.value === 'edit') {
     if (!(await toastWrite(store.updateSite(siteEditId.value, payload), '驻点档案已写入中台'))) return
@@ -496,6 +511,7 @@ async function delTrain(row: TrainingRow) {
         :data="personRows"
         search-placeholder="搜姓名 / 工号 / 驻点"
         :search-extra="personSearch"
+        :initial-search="tableQ"
       >
         <template #toolbar>
         <el-button v-if="store.canAct('person.create')" type="primary" data-testid="add-person" @click="openPerson('create')">新增人员</el-button>
@@ -511,7 +527,9 @@ async function delTrain(row: TrainingRow) {
         <el-table-column prop="name" label="姓名" width="88" />
         <el-table-column label="分公司" width="108"><template #default="{ row }">{{ store.branchName(row.branchId) }}</template></el-table-column>
         <el-table-column label="当前驻点" min-width="140"><template #default="{ row }">{{ store.siteName(row.siteId) }}</template></el-table-column>
-        <el-table-column prop="identity" label="身份" width="80" />
+        <el-table-column prop="identity" label="人员类别" width="88" />
+        <el-table-column label="岗位" width="80"><template #default="{ row }">{{ row.post || '—' }}</template></el-table-column>
+        <el-table-column label="账号" width="88"><template #default="{ row }">{{ store.accountStatus(row.id) }}</template></el-table-column>
         <el-table-column label="今日" width="72">
           <template #default="{ row }">{{ store.dueNos.has(row.no) ? '应到' : (row.onDuty ? '轮休' : '—') }}</template>
         </el-table-column>
@@ -552,7 +570,7 @@ async function delTrain(row: TrainingRow) {
         </el-form-item>
         <el-form-item><el-button v-if="store.canAct('site.edit')" data-testid="save-phone" @click="savePhone">保存</el-button></el-form-item>
       </el-form>
-      <AdminTable fill :data="store.siteStaffing" search-placeholder="搜驻点 / 编号 / 地址">
+      <AdminTable fill :data="store.siteStaffing" search-placeholder="搜驻点 / 编号 / 地址" :initial-search="tableQ">
         <template #toolbar>
           <el-button v-if="store.canAct('site.create')" type="primary" data-testid="add-site" @click="openSite('create')">新增驻点</el-button>
           <CsvImport
@@ -565,6 +583,8 @@ async function delTrain(row: TrainingRow) {
         <el-table-column prop="code" label="编号" width="100" />
         <el-table-column prop="name" label="驻点" />
         <el-table-column label="分公司" width="110"><template #default="{ row }">{{ store.branchName(row.branchId) }}</template></el-table-column>
+        <el-table-column prop="contractCode" label="合同 / 项目编号" width="140" />
+        <el-table-column label="负责人" width="88"><template #default="{ row }">{{ row.ownerName || '—' }}</template></el-table-column>
         <el-table-column prop="address" label="地址" />
         <el-table-column prop="contractHeadcount" label="合同编制(人)" width="118" />
         <el-table-column prop="onFile" label="在册(人)" width="88" />
@@ -582,9 +602,10 @@ async function delTrain(row: TrainingRow) {
             {{ row.fencePoly && row.fencePoly.length >= 3 ? `面 · ${row.fencePoly.length}点` : `${row.fenceM} 米` }}
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="120" fixed="right" class-name="col-ops">
+        <el-table-column label="操作" width="168" fixed="right" class-name="col-ops">
           <template #default="{ row }">
             <div class="tbl-ops">
+              <el-button link type="primary" @click="openSite('view', row)">查看</el-button>
               <el-button v-if="store.canAct('site.edit')" link type="primary" @click="openSite('edit', row)">编辑</el-button>
               <el-button v-if="store.canAct('site.delete')" link type="danger" @click="delSite(row)">删除</el-button>
             </div>
@@ -712,6 +733,11 @@ async function delTrain(row: TrainingRow) {
             </el-select>
           </el-form-item>
         </el-col>
+        <el-col :span="12">
+          <el-form-item label="岗位">
+            <el-input v-model="personForm.post" maxlength="12" placeholder="如门岗" />
+          </el-form-item>
+        </el-col>
         <el-col :span="12">
           <el-form-item label="在职" required>
             <el-tag v-if="personReadonly" :type="personForm.onDuty ? 'success' : 'info'" size="small">{{ personForm.onDuty ? '在职' : '离职' }}</el-tag>
@@ -722,6 +748,9 @@ async function delTrain(row: TrainingRow) {
 
       <div v-if="personMode !== 'create' && viewingPerson" class="sec">身份与驻点履历 <small>改身份请用「调整身份」,这里只展示</small></div>
       <el-row v-if="personMode !== 'create' && viewingPerson" :gutter="12">
+        <el-col :span="24">
+          <p class="dlg-hint">任职生效 {{ viewingPerson.siteHistory[viewingPerson.siteHistory.length - 1]?.from || viewingPerson.hiredAt }} · 账号状态 {{ store.accountStatus(viewingPerson.id) }}</p>
+        </el-col>
         <el-col :span="12">
           <div class="tl-lab">用工身份</div>
           <el-timeline>
@@ -846,15 +875,15 @@ async function delTrain(row: TrainingRow) {
   <el-dialog
     v-model="siteOpen"
     class="site-dlg"
-    :title="siteMode === 'create' ? '新增驻点' : '编辑驻点'"
+    :title="siteTitle"
     width="820px"
     destroy-on-close
     append-to-body
     @opened="siteMapOn = true"
     @closed="siteMapOn = false"
   >
-    <p class="dlg-hint">派出所电话供一键报警读取。打卡围栏在地图上画面或画圆,可全屏编辑。</p>
-    <el-form label-width="108px">
+    <p class="dlg-hint">{{ siteReadonly ? '演示请只看围栏,不要改点保存。派出所电话供一键报警。' : '派出所电话供一键报警读取。打卡围栏在地图上画面或画圆,可全屏编辑。' }}</p>
+    <el-form label-width="108px" :disabled="siteReadonly">
       <div class="sec">基本信息</div>
       <el-row :gutter="12">
         <el-col :span="12">
@@ -879,6 +908,21 @@ async function delTrain(row: TrainingRow) {
             <NumField v-model="siteForm.contractHeadcount" suffix="人" />
           </el-form-item>
         </el-col>
+        <el-col :span="12">
+          <el-form-item label="负责人">
+            <el-input v-model="siteForm.ownerName" maxlength="20" placeholder="驻点督导" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="合同编号">
+            <el-input :model-value="siteContract?.code || '未建项'" disabled />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="项目编号">
+            <el-input :model-value="siteContract?.code || '未建项'" disabled />
+          </el-form-item>
+        </el-col>
         <el-col :span="24">
           <el-form-item label="地址" required>
             <el-input v-model="siteForm.address" data-testid="site-address" />
@@ -940,8 +984,8 @@ async function delTrain(row: TrainingRow) {
       </el-row>
     </el-form>
     <template #footer>
-      <el-button @click="siteOpen = false">取消</el-button>
-      <el-button type="primary" data-testid="site-submit" @click="submitSite">确定</el-button>
+      <el-button @click="siteOpen = false">{{ siteReadonly ? '关闭' : '取消' }}</el-button>
+      <el-button v-if="!siteReadonly" type="primary" data-testid="site-submit" @click="submitSite">确定</el-button>
     </template>
   </el-dialog>
 

+ 70 - 21
src/views/mp/Inbox.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, ref } from 'vue'
+import { computed, reactive, ref } from 'vue'
 import { afterWrite } from '@/composables/usePersistWrite'
 import { useNoticeDetail } from '@/composables/useNoticeDetail'
 import NoticeSheet from '@/components/NoticeSheet.vue'
@@ -11,15 +11,34 @@ const me = computed(() => store.actorProfile)
 const { open: noticeOpen, current: noticeCurrent, show: showNotice } = useNoticeDetail()
 const eventOpen = ref(false)
 const event = ref<EventRow | null>(null)
+const handleForm = reactive({
+  action: '整改' as '整改' | '闭环' | '退回' | '补充',
+  note: '临时到驻点外围执行交接任务,已补拍现场照片。',
+  dueAt: '',
+})
 
 function openEvent(row: EventRow) {
-  event.value = row
+  const live = store.events.find((e) => e.id === row.id) || row
+  event.value = live
   eventOpen.value = true
+  handleForm.action = store.demoRole === 'guard' ? '补充' : live.status === '整改中' ? '闭环' : live.status === '已闭环' ? '补充' : '整改'
+  handleForm.note = store.demoRole === 'guard'
+    ? '临时到驻点外围执行交接任务,已补拍现场照片。'
+    : live.status === '待处理'
+      ? '临时到驻点外围执行交接任务,请 12:00 前补现场照片或重新打卡。'
+      : live.handleNote || '情况属实,原始越界记录保留。'
+  handleForm.dueAt = live.dueAt || `${store.demoDay} 12:00`
 }
 
-async function next(id: string) {
-  await afterWrite(store.advanceEvent(id), '事件已流转并写入中台')
-  event.value = store.events.find((e) => e.id === id) || event.value
+async function submitHandle() {
+  if (!event.value) return
+  const ok = store.handleDutyEvent(event.value.id, {
+    action: handleForm.action,
+    note: handleForm.note,
+    dueAt: handleForm.dueAt,
+  })
+  if (!(await afterWrite(ok, `已${handleForm.action}并写入中台`))) return
+  event.value = store.events.find((e) => e.id === event.value!.id) || event.value
 }
 
 async function alarm() {
@@ -37,6 +56,8 @@ function levelClass(level: string) {
   if (level === '重要') return 'warn'
   return 'ok'
 }
+
+const canHandle = computed(() => store.canAct('event.flow') || store.demoRole === 'guard')
 </script>
 
 <template>
@@ -58,32 +79,47 @@ function levelClass(level: string) {
       </button>
       <p v-if="!store.scopedNotices.length" class="mp-hint">当前切片暂无通知。</p>
     </section>
-    <section v-if="store.demoRole !== 'guard'" class="mp-card">
-      <h3>待整改</h3>
-      <button v-for="e in store.scopedEvents" :key="e.id" class="item" type="button" @click="openEvent(e)">
+    <section class="mp-card">
+      <h3>{{ store.demoRole === 'guard' ? '我的异常' : '待整改' }}</h3>
+      <p class="mp-hint">含人员、驻点、时间、异常类型和处理入口。已闭环记录保留原始异常。</p>
+      <button v-for="e in store.scopedEvents" :key="e.id" class="item" type="button" data-testid="mp-event" @click="openEvent(e)">
         <div class="ttl">
           <i class="mp-chip" :class="levelClass(e.level)">{{ e.level }}</i>
           <b>{{ e.title }}</b>
         </div>
-        <p>{{ store.siteName(e.siteId) }} · {{ e.status }}</p>
+        <p>{{ store.siteName(e.siteId) }} · {{ e.status }} · {{ e.createdAt }}{{ e.personId ? ` · ${store.personName(e.personId)}` : '' }}</p>
       </button>
-      <p v-if="!store.scopedEvents.length" class="mp-hint">当前切片暂无待整改事件。</p>
+      <p v-if="!store.scopedEvents.length" class="mp-hint">当前切片暂无异常事件。</p>
     </section>
     <button v-if="store.demoRole === 'guard' || store.demoRole === 'site'" class="mp-btn ghost" type="button" @click="alarm">
       一键报警 · 拨派出所
     </button>
     <NoticeSheet v-model="noticeOpen" :notice="noticeCurrent" />
     <el-dialog v-model="eventOpen" :title="event?.title || '事件'" width="90%" append-to-body>
-      <p v-if="event">{{ event.level }} · {{ store.siteName(event.siteId) }} · {{ event.status }} · {{ event.createdAt }}</p>
-      <p>人事{{ event?.hrSynced ? '已同步' : '未同步' }}</p>
-      <button
-        v-if="event && event.status !== '已闭环'"
-        class="mp-btn mini"
-        type="button"
-        @click="next(event.id)"
-      >
-        {{ event.status === '待处理' ? '去整改' : '闭环' }}
-      </button>
+      <template v-if="event">
+        <p>{{ event.level }} · {{ store.siteName(event.siteId) }} · {{ event.status }} · {{ event.createdAt }}</p>
+        <p v-if="event.personId">人员 {{ store.personName(event.personId) }}</p>
+        <p>{{ event.body || '无说明' }}</p>
+        <p v-if="event.dueAt">完成时限 {{ event.dueAt }}</p>
+        <p v-if="event.handleNote">处理结论 {{ event.handleNote }}</p>
+        <p class="mp-hint">操作记录</p>
+        <p v-for="(log, i) in event.logs || []" :key="i" class="log">{{ log.at }} · {{ log.actor }} · {{ log.action }} · {{ log.note }}</p>
+        <template v-if="canHandle">
+          <label class="lab">处理方式</label>
+          <select v-model="handleForm.action" class="sel">
+            <option v-if="store.demoRole === 'guard'" value="补充">补充说明 / 现场照片</option>
+            <option v-if="store.canAct('event.flow')" value="整改">下发整改</option>
+            <option v-if="store.canAct('event.flow')" value="闭环">复核闭环</option>
+            <option v-if="store.canAct('event.flow')" value="退回">退回重办</option>
+            <option v-if="store.canAct('event.flow')" value="补充">登记补充材料</option>
+          </select>
+          <label class="lab">完成时限</label>
+          <input v-model="handleForm.dueAt" class="inp" placeholder="时限" />
+          <label class="lab">处理说明</label>
+          <textarea v-model="handleForm.note" class="inp area" rows="3" />
+          <button class="mp-btn mini" type="button" data-testid="mp-event-submit" @click="submitHandle">提交</button>
+        </template>
+      </template>
     </el-dialog>
   </div>
 </template>
@@ -111,6 +147,19 @@ function levelClass(level: string) {
   padding: 6px 12px;
   font-size: 12px;
   box-shadow: none;
+  margin-top: 8px;
+}
+.mp-btn.ghost { margin-top: 8px; margin-bottom: 20px; }
+.log { margin: 0 0 6px; color: #cfeaf2; font-size: 12px; line-height: 1.5; }
+.lab { display: block; margin: 10px 0 4px; color: #8ec8d8; font-size: 12px; }
+.sel, .inp {
+  width: 100%;
+  border: 1px solid rgba(110, 199, 216, 0.28);
+  background: rgba(6, 21, 38, 0.6);
+  color: #eaf6fa;
+  border-radius: 8px;
+  padding: 8px 10px;
+  font-size: 13px;
 }
-.mp-btn.ghost { margin-top: 4px; }
+.area { resize: vertical; }
 </style>

+ 64 - 3
src/views/mp/Punch.vue

@@ -27,6 +27,8 @@ const last = computed(() =>
   self.value ? store.attendance.find((a) => a.personId === self.value!.id && a.date === store.demoDay) : undefined,
 )
 const windowOk = computed(() => clock.value >= '07:30' && clock.value <= '08:30')
+const myBoard = computed(() => store.scheduleBoard.find((r) => r.siteId === site.value?.id))
+const punchWindow = computed(() => myBoard.value?.punchWindow || '07:30–08:30')
 const inside = computed(() => {
   if (!site.value || !loc.done) return false
   return insideFence({ lat: loc.lat, lng: loc.lng }, site.value)
@@ -38,7 +40,7 @@ const dist = computed(() => {
 const gates = computed(() => [
   { k: '人脸', ok: faceOk.value, text: faceOk.value ? '已核验' : '待拍照核验' },
   { k: '围栏', ok: loc.done && inside.value, text: !loc.done ? loc.label : inside.value ? `围内 ${dist.value}m` : `围外 ${dist.value}m` },
-  { k: '时间窗', ok: windowOk.value, text: windowOk.value ? '07:30–08:30' : `${clock.value} 已过窗` },
+  { k: '时间窗', ok: windowOk.value, text: windowOk.value ? punchWindow.value : `${clock.value} 已过窗` },
 ])
 const roster = computed(() => {
   const siteId = store.currentSiteId || store.filteredSites[0]?.id
@@ -190,6 +192,43 @@ async function punch() {
   if (res.ok) await afterWrite(true, res.message)
 }
 
+async function demoPunch(kind: 'in' | 'out') {
+  const personId = store.currentPersonId || self.value?.id
+  if (!personId) {
+    tip.value = '当前账号未关联人员档案,请到「我的」切换为队员账号'
+    return
+  }
+  faceOk.value = true
+  faceFile.value = kind === 'in' ? '演示人脸-围内.jpg' : '演示人脸-越界.jpg'
+  busy.value = true
+  const res = store.simulateCheckIn({
+    personId,
+    face: true,
+    outside: kind === 'out',
+    time: kind === 'out' ? '08:12' : '08:05',
+    source: 'self',
+    lat: kind === 'in' ? site.value?.lat : undefined,
+    lng: kind === 'in' ? site.value?.lng : undefined,
+    accM: kind === 'in' ? 16 : 48,
+  })
+  if (kind === 'in' && site.value) {
+    loc.lat = site.value.lat
+    loc.lng = site.value.lng
+    loc.accM = 16
+    loc.done = true
+    loc.label = '演示定位 · 驻点中心'
+  } else if (kind === 'out' && site.value) {
+    loc.lat = site.value.lat + 0.018
+    loc.lng = site.value.lng + 0.016
+    loc.accM = 48
+    loc.done = true
+    loc.label = '演示定位 · 围栏外'
+  }
+  tip.value = res.message
+  busy.value = false
+  if (res.ok) await afterWrite(true, res.message)
+}
+
 async function alarm(siteId?: string, name?: string) {
   const sid = siteId || site.value?.id
   const who = name || self.value?.name || '现场'
@@ -243,7 +282,7 @@ onUnmounted(() => { if (timer) window.clearInterval(timer) })
           </div>
           <span class="day">业务日 {{ store.demoDay }}</span>
         </div>
-        <p class="mp-hint">{{ site.name }} · {{ site.code }} · {{ site.address }} · 上班窗 07:30–08:30</p>
+        <p class="mp-hint">{{ site.name }} · {{ site.code }} · {{ self.post || '门岗' }} · 班次 {{ myBoard?.startTime || '08:00' }}–{{ myBoard?.endTime || '16:00' }} · 打卡窗 {{ punchWindow }}</p>
         <div class="gates">
           <em v-for="g in gates" :key="g.k" :class="g.ok ? 'ok' : 'bad'">{{ g.k }} {{ g.text }}</em>
         </div>
@@ -258,9 +297,19 @@ onUnmounted(() => { if (timer) window.clearInterval(timer) })
           <small>{{ clock }}</small>
         </button>
         <p class="now" :class="statusClass(last?.status)">
-          {{ last ? `${last.status}${last.failReasons?.length ? ' · ' + last.failReasons.join('、') : ''} · ${last.checkIn}` : '今日未打卡' }}
+          {{ last ? `${last.status === '出勤中' ? '打卡成功' : '打卡异常'} · ${last.status}${last.failReasons?.length ? ' · ' + last.failReasons.join('、') : ''} · ${last.checkIn}` : '今日未打卡' }}
         </p>
+        <div v-if="last" class="result">
+          <p><b>{{ last.status === '出勤中' ? '打卡成功' : '打卡异常' }}</b></p>
+          <p>时间 {{ last.checkIn }} · 驻点 {{ site.name }}</p>
+          <p>校验 人脸{{ last.gates?.face ? '通过' : '未过' }} / 围栏{{ last.gates?.fence ? '通过' : '未过' }} / 时间窗{{ last.gates?.window ? '通过' : '未过' }}</p>
+          <p>当前状态 {{ last.status }}{{ last.failReasons?.length ? ` · ${last.failReasons.join('、')}` : '' }}</p>
+        </div>
         <p class="loc">{{ loc.label }}{{ loc.done ? ` · ${inside ? '围栏内' : '围栏外'}` : '' }}</p>
+        <div class="demo-ops">
+          <button class="mp-btn" type="button" data-testid="mp-demo-in" :disabled="busy" @click="demoPunch('in')">演示:围内打卡</button>
+          <button class="mp-btn ghost" type="button" data-testid="mp-demo-out" :disabled="busy" @click="demoPunch('out')">演示:越界打卡</button>
+        </div>
         <button class="mp-btn ghost" type="button" @click="alarm()">一键报警 · 拨派出所</button>
       </section>
     </template>
@@ -382,6 +431,18 @@ onUnmounted(() => { if (timer) window.clearInterval(timer) })
 .now.bad { color: #fca5a5; }
 .now.warn { color: #f0dd9a; }
 .loc { margin: 0 0 10px; font-size: 11px; color: #8ec8d8; }
+.result {
+  text-align: left;
+  margin: 8px 0 10px;
+  padding: 10px 12px;
+  border-radius: 10px;
+  background: rgba(0, 183, 212, 0.08);
+  border: 1px solid rgba(0, 183, 212, 0.22);
+}
+.result p { margin: 0 0 4px; font-size: 12px; color: #cfeaf2; line-height: 1.5; }
+.result b { color: #fff; }
+.demo-ops { display: flex; gap: 8px; margin-bottom: 8px; }
+.demo-ops .mp-btn { flex: 1; }
 .kpi {
   display: grid;
   grid-template-columns: repeat(3, 1fr);

+ 4 - 1
src/views/mp/Shift.vue

@@ -33,6 +33,9 @@ async function fill(siteId: string) {
       <p class="mp-kv"><span>今日应到 / 实到</span>{{ s.dueCount }} 人 / {{ s.actualCount }} 人</p>
       <p class="mp-kv"><span>名单来源</span>{{ s.rosterFrom }}</p>
       <p class="mp-kv"><span>岗位 / 周期</span>{{ s.postMode }} · {{ s.cycle }}</p>
+      <p class="mp-kv"><span>班次</span>{{ s.startTime }}–{{ s.endTime }} · {{ s.published ? '已发布' : '草稿' }}</p>
+      <p class="mp-kv"><span>打卡时间窗</span>{{ s.punchWindow }}</p>
+      <p class="mp-kv"><span>工时分类</span>{{ s.hourType }}</p>
       <p class="mp-kv"><span>工作形式</span>{{ s.workForm }}</p>
       <p class="mp-kv"><span>节假日</span>{{ s.holidaySecondary }}</p>
       <HoverClip :more="Math.max(0, s.crew.length - 8)" :title="`${s.site} 全名单`" :width="280">
@@ -40,7 +43,7 @@ async function fill(siteId: string) {
           <li v-for="p in s.crew.slice(0, 8)" :key="p.id">
             <b>{{ p.name }}</b>
             <em>{{ p.no }}</em>
-            <i>用工身份 {{ p.identity }}</i>
+            <i>{{ p.post || '门岗' }} · {{ p.identity }}</i>
           </li>
         </ul>
         <template #extra>

+ 17 - 1
src/views/screen/DutyMap.vue

@@ -68,6 +68,11 @@ const visible = computed(() => {
 
 const current = computed(() => onMap.value.find((x) => x.person.id === picked.value) ?? onMap.value[0])
 const exceptions = computed(() => onMap.value.filter((r) => r.status !== '出勤中'))
+const personEvents = computed(() => {
+  const p = current.value?.person
+  if (!p) return []
+  return store.events.filter((e) => e.personId === p.id || e.title.includes(p.name))
+})
 const siteBoard = computed(() =>
   sites.value.map((s) => {
     const rows = onMap.value.filter((r) => r.site.id === s.id)
@@ -433,7 +438,16 @@ onUnmounted(() => {
           <div><span>距驻点</span>{{ current.dist }}m · {{ current.inside ? '围内' : '围外' }}</div>
           <div class="wide"><span>失败原因</span>{{ current.failReasons.length ? current.failReasons.join('、') : '校验通过' }}</div>
         </div>
-        <p class="hint">点位取打卡上报坐标,非秒级跟随</p>
+        <p class="hint">点位取打卡上报坐标,非秒级跟随。同一驻点围栏与打卡校验共用配置。</p>
+        <div v-if="personEvents.length" class="evs">
+          <div class="sc-hd sub">处理记录</div>
+          <button v-for="e in personEvents" :key="e.id" type="button" class="row" @click="router.push('/admin/collab?tab=event&q=' + current.person.name)">
+            <div>
+              <strong>{{ e.status }} · {{ e.title }}</strong>
+              <span>{{ e.createdAt }}{{ e.handleNote ? ` · ${e.handleNote}` : '' }}</span>
+            </div>
+          </button>
+        </div>
         <button class="alarm" type="button" data-testid="map-alarm" @click="callPolice">拨打派出所</button>
       </div>
       <div class="glass">
@@ -610,6 +624,8 @@ onUnmounted(() => {
 }
 .loc span { display: block; color: #6b93a3; font-size: 11px; margin-bottom: 2px; }
 .hint { margin: 6px 0 0; font-size: 11px; color: #6b93a3; }
+.evs { margin-top: 8px; }
+.evs .row { width: 100%; border: 0; background: transparent; color: inherit; text-align: left; padding: 6px 0; cursor: pointer; }
 .alarm {
   margin-top: 8px; width: 100%;
   border: 1px solid rgba(209, 90, 90, 0.55);

+ 121 - 0
src/views/screen/Productivity.vue

@@ -1,13 +1,63 @@
 <script setup lang="ts">
 import { computed, ref } from 'vue'
+import { useRouter } from 'vue-router'
 import VChart from '@/components/VChart.vue'
 import { useDutyStore } from '@/stores/duty'
 import { axisStyle, gridRoom, legendTop } from '@/utils/chartTheme'
 import type { Period } from '@/types'
 
 const store = useDutyStore()
+const router = useRouter()
 const period = ref<Period>('day')
 const k = computed(() => store.prodKpis)
+const pickedSiteId = ref('')
+const personPeek = ref<null | { name: string; no: string; lines: string[] }>(null)
+
+const siteRows = computed(() =>
+  store.scheduleBoard.map((b) => {
+    const att = store.dayAttendance.filter((a) => a.siteId === b.siteId)
+    const hours = att.filter((a) => a.status !== '未出勤').reduce((n, a) => n + a.hours, 0)
+    const bad = att.filter((a) => ['越界', '迟到', '早退', '未出勤'].includes(a.status)).length
+    const rate = b.dueCount ? Math.round((b.actualCount / b.dueCount) * 1000) / 10 : 0
+    const miss = b.dueCount ? Math.round((b.missCount / b.dueCount) * 1000) / 10 : 0
+    return {
+      ...b,
+      hours: Number(hours.toFixed(1)),
+      bad,
+      rate,
+      miss,
+    }
+  }),
+)
+const pickedSite = computed(() => siteRows.value.find((s) => s.siteId === pickedSiteId.value) || null)
+const pickedCrew = computed(() => {
+  const site = pickedSite.value
+  if (!site) return []
+  return site.crew.map((p) => {
+    const a = store.attendance.find((x) => x.personId === p.id && x.date === store.demoDay)
+    const ev = store.events.filter((e) => e.personId === p.id || e.title.includes(p.name))
+    return { p, a, ev }
+  })
+})
+
+function openPerson(row: (typeof pickedCrew.value)[number]) {
+  const a = row.a
+  const evLines = row.ev.length
+    ? row.ev.map((e) => `${e.createdAt} · ${e.status} · ${e.title}${e.handleNote ? ` · ${e.handleNote}` : ''}`)
+    : ['当日无异常处理记录']
+  personPeek.value = {
+    name: row.p.name,
+    no: row.p.no,
+    lines: [
+      `驻点 ${pickedSite.value?.site || ''} · 岗位 ${row.p.post || '门岗'}`,
+      a
+        ? `当日打卡 ${a.checkIn}–${a.checkOut} · ${a.status} · ${a.hours} 小时 · ${a.hourType} · 来源 ${a.source === 'self' ? '作业端' : a.source === 'proxy' ? '代打卡' : '样本'}`
+        : '当日无打卡',
+      a?.failReasons?.length ? `失败原因 ${a.failReasons.join('、')}` : '校验通过或未打卡',
+      ...evLines,
+    ],
+  }
+}
 
 const cards = computed(() => [
   { label: '上岗率', value: `${k.value.rate}%`, sub: `目标 95% · 排班 ${k.value.scheduled} 人 / 实际 ${k.value.actual} 人`, tone: '' },
@@ -139,6 +189,42 @@ const trendOption = computed(() => {
     </div>
     <div class="sc-alert" v-if="store.firedAlerts.length">{{ store.firedAlerts.join(';') }}</div>
     <div class="sc-alert ok" v-else>当前范围无预警命中。</div>
+    <div class="sc-panel sites">
+      <h3 class="sc-hd">驻点明细 · 点击下钻到人员</h3>
+      <div class="site-grid">
+        <button
+          v-for="s in siteRows"
+          :key="s.siteId"
+          type="button"
+          class="site-card"
+          :class="{ on: pickedSiteId === s.siteId, warn: s.bad > 0 }"
+          data-testid="prod-site"
+          @click="pickedSiteId = s.siteId"
+        >
+          <b>{{ s.site }}</b>
+          <span>排班 {{ s.dueCount }} 人 · 实到 {{ s.actualCount }} 人</span>
+          <span>上岗率 {{ s.rate }}% · 缺员率 {{ s.miss }}%</span>
+          <span>工时 {{ s.hours }} 小时 · 异常 {{ s.bad }} 人</span>
+        </button>
+      </div>
+    </div>
+    <el-drawer :model-value="!!pickedSite" :title="pickedSite?.site || '驻点'" size="420px" append-to-body @close="pickedSiteId = ''">
+      <p v-if="pickedSite" class="drawer-tip">
+        排班 {{ pickedSite.dueCount }} 人 / 实到 {{ pickedSite.actualCount }} 人 · 上岗率 {{ pickedSite.rate }}% · 异常 {{ pickedSite.bad }} 人
+      </p>
+      <button v-for="row in pickedCrew" :key="row.p.id" type="button" class="crew" @click="openPerson(row)">
+        <b>{{ row.p.name }}</b>
+        <i>{{ row.a?.status || '未打卡' }}</i>
+        <em>{{ row.p.no }} · {{ row.p.post || '门岗' }} · {{ row.a ? `${row.a.checkIn} · ${row.a.hours}小时` : '无记录' }}</em>
+      </button>
+    </el-drawer>
+    <el-dialog :model-value="!!personPeek" :title="personPeek ? `${personPeek.name} · ${personPeek.no}` : ''" width="520px" append-to-body @close="personPeek = null">
+      <p v-for="line in personPeek?.lines || []" :key="line" class="peek">{{ line }}</p>
+      <template #footer>
+        <el-button @click="personPeek = null">关闭</el-button>
+        <el-button type="primary" @click="router.push('/admin/collab?tab=event&q=' + (personPeek?.name || ''))">打开处理记录</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -182,6 +268,41 @@ const trendOption = computed(() => {
   overflow: hidden;
 }
 .page :deep(.sc-alert) { margin-top: 0; flex: none; }
+.sites { flex: none; max-height: 210px; overflow: auto; }
+.site-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 8px;
+  padding: 0 12px 12px;
+}
+.site-card {
+  text-align: left;
+  border: 1px solid rgba(110, 199, 216, 0.22);
+  background: rgba(6, 21, 38, 0.45);
+  color: #cfeaf2;
+  border-radius: 10px;
+  padding: 10px 12px;
+  cursor: pointer;
+}
+.site-card b { display: block; color: #fff; margin-bottom: 4px; font-size: 13px; }
+.site-card span { display: block; font-size: 11px; color: #8ec8d8; line-height: 1.5; }
+.site-card.on, .site-card.warn { border-color: rgba(201, 162, 39, 0.55); }
+.crew {
+  display: grid;
+  grid-template-columns: 1fr auto;
+  width: 100%;
+  border: 0;
+  border-bottom: 1px solid #e8eef2;
+  background: transparent;
+  text-align: left;
+  padding: 10px 0;
+  cursor: pointer;
+}
+.crew b { color: #0b2a4a; }
+.crew i { font-style: normal; color: #c9a227; }
+.crew em { grid-column: 1 / -1; color: #6b93a3; font-size: 12px; font-style: normal; }
+.drawer-tip { margin: 0 0 12px; color: #4a6270; }
+.peek { margin: 0 0 8px; line-height: 1.6; color: #234; }
 @media (max-width: 1200px) {
   .page { height: auto; }
   .kpis { grid-template-columns: repeat(3, 1fr); }