- 部门汇总含全部子部门并按稳定身份去重;展开仅列本部门直属人员,兼属人数为当前通讯录直接关联的不同部门总数,不包含自动继承的上级。个人访问次数跨兼属部门展示同一数值,不重复累计到部门汇总;收起及区域筛选不改变统计。访问为已入总表的提问事件。
-
+ {analysisMode === 'departments' ? (
+
+ setCollapsed((old) => {
+ const next = new Set(old)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+ />
+ ) : (
+
+ )}
+ {analysisMode === 'departments' ? (
+
+ 部门汇总含全部子部门并按稳定身份去重;展开仅列本部门直属人员,兼属人数为当前通讯录直接关联的不同部门总数,不包含自动继承的上级。个人访问次数跨兼属部门展示同一数值,不重复累计到部门汇总;收起及区域筛选不改变统计。访问为已入总表的提问事件。
+
+ ) : null}
{data.unmapped_visits > 0 ? (
另有 {data.unmapped_visits.toLocaleString('zh-CN')} 次提问未能归入当前通讯录部门,未虚构归属。
diff --git a/web/src/QAPeopleAnalysis.test.tsx b/web/src/QAPeopleAnalysis.test.tsx
new file mode 100644
index 0000000..acfaa13
--- /dev/null
+++ b/web/src/QAPeopleAnalysis.test.tsx
@@ -0,0 +1,41 @@
+import { it, expect } from 'vitest'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { PeopleAnalysisView, PeopleRankingTable } from './QAPeopleAnalysis'
+import { peopleAnalysis } from './qaPeopleAnalysis'
+import { parseDepartmentStatistics } from './qaDepartmentStatistics'
+import { departmentStatisticsFixture } from './qaDepartmentStatistics.fixture'
+const fixture = () =>
+ parseDepartmentStatistics(structuredClone(departmentStatisticsFixture), { period: 'all' }).snapshot!
+it('renders at most fifty ranked people with actual question counts and no department write controls', () => {
+ const template = peopleAnalysis(fixture()).people[0]
+ const rows = Array.from({ length: 123 }, (_, i) => ({
+ ...template,
+ key: 'person-' + i.toString(16).padStart(64, '0'),
+ visits: 122 - i,
+ }))
+ const html = renderToStaticMarkup( {}} label="全体人员" />)
+ expect(html.match(/data-ranking-person-key=/g)).toHaveLength(50)
+ expect(html).toContain('data-ranking="51"')
+ expect(html).toContain('data-ranking="100"')
+ expect(html).not.toContain('data-ranking="101"')
+ expect(html).toContain('50 人/页')
+ expect(html).toContain('上一页')
+ expect(html).toContain('下一页')
+ expect(html).not.toContain('checkbox')
+ expect(html).not.toContain('绑定选中部门')
+})
+it('region analysis lists the saved region members once and all-person analysis retains unassigned and zero-use people', () => {
+ const data = fixture()
+ const region = renderToStaticMarkup()
+ expect(region).toContain('按区域查看人员')
+ expect(region.match(/data-ranking-person-key=/g)).toHaveLength(2)
+ expect(region).toContain('Fixture Alpha')
+ expect(region).not.toContain('Fixture Beta')
+ expect(region).toContain('跨区域待核对')
+ expect(region).toContain('未归属')
+ const all = renderToStaticMarkup()
+ expect(all.match(/data-ranking-person-key=/g)).toHaveLength(3)
+ expect(all).toContain('未命名人员')
+ expect(all).toContain('Fixture Beta')
+ expect(all.indexOf('Fixture Beta')).toBeLessThan(all.indexOf('Fixture Alpha'))
+})
diff --git a/web/src/QAPeopleAnalysis.tsx b/web/src/QAPeopleAnalysis.tsx
new file mode 100644
index 0000000..6a8dd27
--- /dev/null
+++ b/web/src/QAPeopleAnalysis.tsx
@@ -0,0 +1,161 @@
+import { useMemo, useState } from 'react'
+import type { DepartmentStatisticsSnapshot } from './qaDepartmentStatistics'
+import { peopleAnalysis, peoplePage, PEOPLE_PAGE_SIZE, departmentTime } from './qaPeopleAnalysis'
+import type { PersonRankingRow } from './qaPeopleAnalysis'
+
+export function PeopleRankingTable({
+ people,
+ page,
+ onPage,
+ label,
+}: {
+ people: PersonRankingRow[]
+ page: number
+ onPage: (n: number) => void
+ label: string
+}) {
+ const window = peoplePage(people, page)
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 排名 |
+ 人员 |
+ 区域 |
+ 所属部门 |
+ 提问次数 |
+ 最近提问时间 |
+
+
+
+ {window.items.map((person, i) => (
+
+ | {window.start + i + 1} |
+
+ {person.name || '未命名人员'}
+ {person.department_ids.length > 1 ? (
+ 兼属 {person.department_ids.length} 部门
+ ) : null}
+ |
+ {person.region_label} |
+
+ {person.department_paths.length ? (
+
+ d.path).join(';')}>
+ {person.department_paths.length} 个直属部门
+
+
+ {person.department_paths.map((d) => (
+ - {d.path}
+ ))}
+
+
+ ) : (
+ '未关联部门'
+ )}
+ |
+ {person.visits.toLocaleString('zh-CN')} |
+ {person.last_asked_at ? departmentTime(person.last_asked_at) : '—'} |
+
+ ))}
+
+
+ {!window.total ?
此范围暂无人员,未将其他区域人员混入。
: null}
+
+
+
+ )
+}
+
+export function PeopleAnalysisView({ data, mode }: { data: DepartmentStatisticsSnapshot; mode: 'regions' | 'people' }) {
+ const analysis = useMemo(() => peopleAnalysis(data), [data]),
+ [selected, setSelected] = useState(''),
+ [page, setPage] = useState(1)
+ const group =
+ analysis.groups.find((g) => g.key === selected) ??
+ analysis.groups.find((g) => g.people.length) ??
+ analysis.groups[0]
+ const people = mode === 'people' ? analysis.people : group.people
+ return (
+
+
+ {mode === 'regions' ? (
+
+ ) : (
+
全体人员
+ )}
+ 按提问次数从高到低 · 同一人员只列一次 · 保留零提问人员
+
+
+
+ 沿用当前已同步通讯录及已保存区域配置;同一区域多部门人员去重,跨区域人员单列“跨区域待核对”,未配置者列“未归属”,不擅自裁决主归属或生成绩效结论。
+
+
+ )
+}
diff --git a/web/src/qaDepartmentStatistics.css b/web/src/qaDepartmentStatistics.css
index b34a2f6..20f400c 100644
--- a/web/src/qaDepartmentStatistics.css
+++ b/web/src/qaDepartmentStatistics.css
@@ -1,3 +1,127 @@
+.qa-analysis-switcher {
+ display: inline-flex;
+ gap: 4px;
+ padding: 4px;
+ margin: 0 0 8px;
+ border: 1px solid #e5ebf2;
+ border-radius: 6px;
+ background: #f1f5f9;
+}
+.qa-analysis-switcher button {
+ border: 0;
+ background: transparent;
+ color: #64748b;
+ padding: 7px 18px;
+ border-radius: 4px;
+}
+.qa-analysis-switcher button[aria-pressed='true'] {
+ background: #fff;
+ color: #2563eb;
+ box-shadow: 0 1px 3px #33415514;
+ font-weight: 600;
+}
+.qa-analysis-switcher button:focus-visible {
+ outline: 2px solid #3b82f6;
+ outline-offset: 1px;
+}
+.qa-people-analysis {
+ min-width: 0;
+}
+.qa-people-analysis-heading {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: center;
+ margin: 0 0 10px;
+}
+.qa-people-analysis-heading h4 {
+ margin: 0;
+ font-size: 15px;
+}
+.qa-people-analysis-heading label {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ font-size: 13px;
+}
+.qa-people-analysis-heading select {
+ max-width: 100%;
+ min-height: 32px;
+}
+.qa-people-analysis-heading > span {
+ color: #718096;
+ font-size: 12px;
+}
+.qa-people-ranking-table {
+ min-width: 1000px;
+}
+.qa-people-ranking-table .qa-people-name-cell,
+.qa-people-ranking-table .qa-people-region-cell,
+.qa-people-ranking-table .qa-people-paths {
+ text-align: left;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ line-height: 1.6;
+}
+.qa-people-multi {
+ display: inline-block;
+ color: #64748b;
+ border: 1px solid #cbd5e1;
+ border-radius: 4px;
+ padding: 0 5px;
+ margin-left: 6px;
+ font-size: 11px;
+ font-weight: 400;
+}
+.qa-people-paths summary {
+ cursor: pointer;
+ color: #536c88;
+}
+.qa-people-paths ul {
+ padding-left: 16px;
+ margin: 6px 0 0;
+}
+.qa-people-question-count {
+ font-weight: 600;
+}
+.qa-people-pagination {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 0 0;
+ color: #64748b;
+ font-size: 12px;
+}
+.qa-people-pagination > div {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+}
+.qa-people-pagination select {
+ min-height: 30px;
+}
+@media (max-width: 680px) {
+ .qa-people-analysis-heading {
+ align-items: flex-start;
+ }
+ .qa-people-analysis-heading label {
+ flex-wrap: wrap;
+ }
+ .qa-people-analysis-heading select {
+ max-width: 280px;
+ }
+ .qa-people-pagination > div {
+ width: 100%;
+ justify-content: space-between;
+ }
+ .qa-analysis-switcher button {
+ padding: 7px 15px;
+ }
+}
+
.qa-regional-statistics {
min-width: 0;
color: #334155;
diff --git a/web/src/qaPeopleAnalysis.test.ts b/web/src/qaPeopleAnalysis.test.ts
new file mode 100644
index 0000000..11aee72
--- /dev/null
+++ b/web/src/qaPeopleAnalysis.test.ts
@@ -0,0 +1,60 @@
+import { it, expect } from 'vitest'
+import { peopleAnalysis, peoplePage } from './qaPeopleAnalysis'
+import { parseDepartmentStatistics } from './qaDepartmentStatistics'
+import { departmentStatisticsFixture } from './qaDepartmentStatistics.fixture'
+const fixture = () =>
+ parseDepartmentStatistics(structuredClone(departmentStatisticsFixture), { period: 'all' }).snapshot!
+it('ranks every unique roster person by questions descending including zero use without mutating the snapshot', () => {
+ const data = fixture(),
+ before = JSON.stringify(data),
+ result = peopleAnalysis(data)
+ expect(result.people.map((p) => p.visits)).toEqual([3, 2, 0])
+ expect(new Set(result.people.map((p) => p.key)).size).toBe(3)
+ expect(result.people.find((p) => p.visits === 0)?.name).toBe('')
+ expect(JSON.stringify(data)).toBe(before)
+})
+it('groups same-region memberships once and separates cross-region and unassigned without choosing a primary', () => {
+ const data = fixture()
+ data.regions.push({ id: 'industrial', name: 'Industrial' }, { id: 'empty', name: 'Empty' })
+ data.rows.find((d) => d.id === 'root')!.region = 'industrial'
+ const result = peopleAnalysis(data),
+ groups = new Map(result.groups.map((g) => [g.key, g]))
+ expect(groups.get('region:edu')?.people.map((p) => p.visits)).toEqual([0])
+ expect(groups.get('region:industrial')?.people.map((p) => p.visits)).toEqual([3])
+ expect(groups.get('state:cross_region')?.people.map((p) => p.visits)).toEqual([2])
+ expect(groups.get('region:empty')?.people).toEqual([])
+ expect(result.people.find((p) => p.visits === 2)?.region_label).toContain('跨区域待核对')
+ expect(
+ result.groups
+ .flatMap((g) => g.people)
+ .map((p) => p.key)
+ .sort()
+ ).toEqual(data.people.map((p) => p.key).sort())
+ const initial = peopleAnalysis(fixture())
+ expect(initial.groups.find((g) => g.key === 'region:edu')?.people).toHaveLength(2)
+ expect(initial.groups.find((g) => g.key === 'state:unassigned')?.people).toHaveLength(1)
+})
+it('paginates all people at exactly fifty with no gaps, correct final page and deterministic ties', () => {
+ const template = peopleAnalysis(fixture()).people[0]
+ const data = Array.from({ length: 123 }, (_, i) => ({
+ ...template,
+ key: 'person-' + i.toString(16).padStart(64, '0'),
+ visits: 122 - i,
+ }))
+ const pages = [1, 2, 3].map((n) => peoplePage(data, n))
+ expect(pages.map((p) => p.items.length)).toEqual([50, 50, 23])
+ expect(pages.map((p) => p.start)).toEqual([0, 50, 100])
+ expect(pages.flatMap((p) => p.items.map((r) => r.key))).toEqual(data.map((r) => r.key))
+ expect(peoplePage(data, 999).page).toBe(3)
+ expect(peoplePage(data, -1).page).toBe(1)
+ expect(peoplePage(data, NaN).page).toBe(1)
+ expect(peoplePage([], 1)).toMatchObject({ items: [], total: 0, start: 0, end: 0, page: 1, pages: 1 })
+ const same = fixture()
+ same.people.forEach((p) => {
+ p.name = '同名'
+ p.visits = 1
+ })
+ const first = peopleAnalysis(same).people.map((p) => p.key)
+ same.people.reverse()
+ expect(peopleAnalysis(same).people.map((p) => p.key)).toEqual(first)
+})
diff --git a/web/src/qaPeopleAnalysis.ts b/web/src/qaPeopleAnalysis.ts
new file mode 100644
index 0000000..723cbb4
--- /dev/null
+++ b/web/src/qaPeopleAnalysis.ts
@@ -0,0 +1,78 @@
+import type { DepartmentPersonStatistic, DepartmentStatisticsSnapshot } from './qaDepartmentStatistics'
+export const departmentTime = (value: string) =>
+ new Date(value).toLocaleString('zh-CN', {
+ timeZone: 'Asia/Shanghai',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ })
+export const PEOPLE_PAGE_SIZE = 50
+export type PersonRankingRow = DepartmentPersonStatistic & {
+ region_ids: string[]
+ region_state: 'matched' | 'cross_region' | 'unassigned'
+ group_key: string
+ region_label: string
+ department_paths: Array<{ id: string; path: string }>
+}
+export type PeopleAnalysisGroup = { key: string; name: string; people: PersonRankingRow[]; visits: number }
+export function peopleAnalysis(data: DepartmentStatisticsSnapshot): {
+ people: PersonRankingRow[]
+ groups: PeopleAnalysisGroup[]
+} {
+ const nodes = new Map(data.rows.map((d) => [d.id, d]))
+ const people: PersonRankingRow[] = data.people
+ .map((person) => ({
+ ...person,
+ department_ids: [...person.department_ids],
+ region_ids: [],
+ region_state: 'unassigned' as const,
+ group_key: 'state:unassigned',
+ region_label: '未归属',
+ department_paths: person.department_ids.map((id) => {
+ const names: string[] = []
+ let current = id
+ while (current) {
+ const d = nodes.get(current)!
+ names.unshift(d.name)
+ current = d.parent_id
+ }
+ return { id, path: names.join('/') }
+ }),
+ }))
+ .sort((a, b) => b.visits - a.visits || a.name.localeCompare(b.name, 'zh-CN') || a.key.localeCompare(b.key))
+ const labels = new Map(data.regions.map((r) => [r.id, r.name]))
+ const groups: PeopleAnalysisGroup[] = [
+ ...data.regions.map((r) => ({ key: 'region:' + r.id, name: r.name, people: [] as PersonRankingRow[], visits: 0 })),
+ { key: 'state:cross_region', name: '跨区域待核对', people: [], visits: 0 },
+ { key: 'state:unassigned', name: '未归属', people: [], visits: 0 },
+ ]
+ const byKey = new Map(groups.map((g) => [g.key, g]))
+ for (const person of people) {
+ person.region_ids = [...new Set(person.department_ids.map((id) => nodes.get(id)!.region).filter(Boolean))].sort()
+ person.region_state =
+ person.region_ids.length === 1 ? 'matched' : person.region_ids.length > 1 ? 'cross_region' : 'unassigned'
+ person.group_key =
+ person.region_state === 'matched' ? 'region:' + person.region_ids[0] : 'state:' + person.region_state
+ person.region_label =
+ person.region_state === 'matched'
+ ? labels.get(person.region_ids[0])!
+ : person.region_state === 'cross_region'
+ ? '跨区域待核对(' + person.region_ids.map((id) => labels.get(id)).join('、') + ')'
+ : '未归属'
+ const group = byKey.get(person.group_key)!
+ group.people.push(person)
+ group.visits += person.visits
+ }
+ return { people, groups }
+}
+export function peoplePage(people: PersonRankingRow[], requested: number) {
+ const total = people.length,
+ pages = Math.max(1, Math.ceil(total / PEOPLE_PAGE_SIZE))
+ const page = Math.max(1, Math.min(pages, Number.isFinite(requested) ? Math.trunc(requested) : 1))
+ const start = (page - 1) * PEOPLE_PAGE_SIZE,
+ end = Math.min(total, start + PEOPLE_PAGE_SIZE)
+ return { items: people.slice(start, end), page, pages, total, start, end }
+}