保留部门树并复用同一统计快照;区域内去重,跨区域和未归属单列,不改配置或主归属。按提问次数降序,保留零提问人员,每页50人。 验证:401项前端测试与类型检查通过,排序、区域去重、跨区域、零使用和123人三页边界红绿验证通过。 同镜像、正式网页和部署尚未验收。
This commit is contained in:
@@ -76,6 +76,16 @@
|
||||
> `0.1.12`),由 `scripts/version.py` 从 git **算出来**;开发交付的独立说明写入本文件“开发快照”章节,并打 `snapshot/vX.Y.Z` 注释标签 ⇒ 每次开发交付均可追溯,
|
||||
> 但"发布版本"仍然只能由人定(CI 不会自动升版本、不会打 tag)。
|
||||
|
||||
## 开发快照 snapshot/v0.1.46 - 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- 区域统计新增“按部门/按区域/按人”分析切换;按部门保留原树形与直属人员。按区域查看已保存区域内人员,按人查看完整去重名册,均按提问次数降序并保留零提问人员,固定50人/页与页码选择,展示区域、所属部门、次数和最近提问时间。
|
||||
|
||||
### Changed
|
||||
|
||||
- 新分析复用同一累计/自然月/自然周快照,不额外创建模型或聚合作业;同区域多部门人员只列一次,跨区域及未归属者单列待核对,不自动选主区域。分析分页与切换只读,不改变原问答、区域映射、部门汇总、已保存周报及外部发送闸门。
|
||||
|
||||
## 开发快照 snapshot/v0.1.45 - 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -6,6 +6,8 @@ import { departmentStatisticsFixture } from './qaDepartmentStatistics.fixture'
|
||||
it('offers all, natural-month and natural-week scopes and never switches the selected-department action to a second mapping page', () => {
|
||||
const html = renderToStaticMarkup(<QARegionalStatistics />)
|
||||
for (const label of ['按累计统计', '按自然月统计', '按自然周统计', '绑定选中部门']) expect(html).toContain(label)
|
||||
for (const label of ['按部门', '按区域', '按人']) expect(html).toContain('>' + label + '<')
|
||||
expect(html).toContain('aria-label="分析维度"')
|
||||
expect(html).not.toContain('设置选中部门的区域')
|
||||
expect(html).not.toContain('返回部门统计')
|
||||
})
|
||||
|
||||
@@ -10,6 +10,8 @@ import { currentQAWeekValue } from './qaWeeks'
|
||||
import { bindOrganizationDepartments } from './qaOrg'
|
||||
import type { RegionDefinition } from './qaOrg'
|
||||
import { QARegions } from './QARegions'
|
||||
import { PeopleAnalysisView } from './QAPeopleAnalysis'
|
||||
import { departmentTime } from './qaPeopleAnalysis'
|
||||
import './qaDepartmentStatistics.css'
|
||||
import { QAPeriodInput } from './QAPeriodInput'
|
||||
export interface DepartmentStatisticsViewProps {
|
||||
@@ -21,16 +23,7 @@ export interface DepartmentStatisticsViewProps {
|
||||
onSelect?: (ids: string[]) => void
|
||||
onCollapse?: (id: string) => void
|
||||
}
|
||||
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 { departmentTime } from './qaPeopleAnalysis'
|
||||
export function defaultStatisticsCollapsed(data: DepartmentStatisticsSnapshot): Set<string> {
|
||||
const direct = new Set(data.people.flatMap((p) => p.department_ids))
|
||||
return new Set(
|
||||
@@ -267,6 +260,7 @@ export function DepartmentRegionBinding({
|
||||
}
|
||||
const currentWeekValue = currentQAWeekValue
|
||||
export function QARegionalStatistics({ onSaved }: { onSaved?: () => void } = {}) {
|
||||
const [analysisMode, setAnalysisMode] = useState<'departments' | 'regions' | 'people'>('departments')
|
||||
const [period, setPeriod] = useState<'all' | 'month' | 'week'>('all'),
|
||||
[month, setMonth] = useState(currentMonth),
|
||||
[week, setWeek] = useState(currentWeekValue),
|
||||
@@ -390,6 +384,19 @@ export function QARegionalStatistics({ onSaved }: { onSaved?: () => void } = {})
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<nav className="qa-analysis-switcher" aria-label="分析维度">
|
||||
{(
|
||||
[
|
||||
['departments', '按部门'],
|
||||
['regions', '按区域'],
|
||||
['people', '按人'],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<button key={mode} type="button" aria-pressed={analysisMode === mode} onClick={() => setAnalysisMode(mode)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<aside
|
||||
className="qa-region-settings-panel"
|
||||
hidden={!settings}
|
||||
@@ -441,36 +448,40 @@ export function QARegionalStatistics({ onSaved }: { onSaved?: () => void } = {})
|
||||
onChange={setWeek}
|
||||
/>
|
||||
) : null}
|
||||
<label>
|
||||
部门
|
||||
<input
|
||||
type="search"
|
||||
aria-label="搜索统计部门"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索部门或完整路径"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
区域
|
||||
<select aria-label="筛选统计区域" value={region} onChange={(e) => setRegion(e.target.value)}>
|
||||
<option value="">全部区域</option>
|
||||
{data?.regions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<DepartmentRegionBinding
|
||||
regions={data?.regions ?? []}
|
||||
selectedCount={selected.length}
|
||||
disabled={busy || binding || settings || catalogDirty}
|
||||
onBind={(id) => void bind(id)}
|
||||
/>
|
||||
<button type="button" disabled={!selected.length} onClick={() => setSelected([])}>
|
||||
取消勾选
|
||||
</button>
|
||||
{analysisMode === 'departments' ? (
|
||||
<>
|
||||
<label>
|
||||
部门
|
||||
<input
|
||||
type="search"
|
||||
aria-label="搜索统计部门"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索部门或完整路径"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
区域
|
||||
<select aria-label="筛选统计区域" value={region} onChange={(e) => setRegion(e.target.value)}>
|
||||
<option value="">全部区域</option>
|
||||
{data?.regions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<DepartmentRegionBinding
|
||||
regions={data?.regions ?? []}
|
||||
selectedCount={selected.length}
|
||||
disabled={busy || binding || settings || catalogDirty}
|
||||
onBind={(id) => void bind(id)}
|
||||
/>
|
||||
<button type="button" disabled={!selected.length} onClick={() => setSelected([])}>
|
||||
取消勾选
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{catalogDirty ? <p className="qa-ops-muted">区域设置有未保存修改,请先保存或放弃后再绑定部门。</p> : null}
|
||||
{bindingError ? (
|
||||
@@ -499,25 +510,42 @@ export function QARegionalStatistics({ onSaved }: { onSaved?: () => void } = {})
|
||||
) : null}
|
||||
{data ? (
|
||||
<>
|
||||
<DepartmentStatisticsView
|
||||
data={data}
|
||||
selected={selected}
|
||||
collapsed={collapsed}
|
||||
search={search}
|
||||
region={region}
|
||||
onSelect={setSelected}
|
||||
onCollapse={(id) =>
|
||||
setCollapsed((old) => {
|
||||
const next = new Set(old)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="qa-department-note">
|
||||
部门汇总含全部子部门并按稳定身份去重;展开仅列本部门直属人员,兼属人数为当前通讯录直接关联的不同部门总数,不包含自动继承的上级。个人访问次数跨兼属部门展示同一数值,不重复累计到部门汇总;收起及区域筛选不改变统计。访问为已入总表的提问事件。
|
||||
</p>
|
||||
{analysisMode === 'departments' ? (
|
||||
<DepartmentStatisticsView
|
||||
data={data}
|
||||
selected={selected}
|
||||
collapsed={collapsed}
|
||||
search={search}
|
||||
region={region}
|
||||
onSelect={setSelected}
|
||||
onCollapse={(id) =>
|
||||
setCollapsed((old) => {
|
||||
const next = new Set(old)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<PeopleAnalysisView
|
||||
key={[
|
||||
analysisMode,
|
||||
data.period,
|
||||
data.month,
|
||||
data.week,
|
||||
data.organization.directory_id,
|
||||
data.organization.revision_id,
|
||||
].join(':')}
|
||||
data={data}
|
||||
mode={analysisMode}
|
||||
/>
|
||||
)}
|
||||
{analysisMode === 'departments' ? (
|
||||
<p className="qa-department-note">
|
||||
部门汇总含全部子部门并按稳定身份去重;展开仅列本部门直属人员,兼属人数为当前通讯录直接关联的不同部门总数,不包含自动继承的上级。个人访问次数跨兼属部门展示同一数值,不重复累计到部门汇总;收起及区域筛选不改变统计。访问为已入总表的提问事件。
|
||||
</p>
|
||||
) : null}
|
||||
{data.unmapped_visits > 0 ? (
|
||||
<p className="qa-department-note">
|
||||
另有 {data.unmapped_visits.toLocaleString('zh-CN')} 次提问未能归入当前通讯录部门,未虚构归属。
|
||||
|
||||
@@ -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(<PeopleRankingTable people={rows} page={2} onPage={() => {}} 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(<PeopleAnalysisView data={data} mode="regions" />)
|
||||
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(<PeopleAnalysisView data={data} mode="people" />)
|
||||
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'))
|
||||
})
|
||||
@@ -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 (
|
||||
<section
|
||||
className="qa-people-ranking"
|
||||
aria-label={label}
|
||||
data-ranking-total={window.total}
|
||||
data-ranking-page={window.page}
|
||||
>
|
||||
<div className="qa-department-table-scroll" tabIndex={0} aria-label="人员排行表格滚动区">
|
||||
<table className="qa-department-table qa-people-ranking-table">
|
||||
<colgroup>
|
||||
<col style={{ width: '6%' }} />
|
||||
<col style={{ width: '19%' }} />
|
||||
<col style={{ width: '16%' }} />
|
||||
<col style={{ width: '29%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '20%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>排名</th>
|
||||
<th>人员</th>
|
||||
<th>区域</th>
|
||||
<th>所属部门</th>
|
||||
<th>提问次数</th>
|
||||
<th>最近提问时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{window.items.map((person, i) => (
|
||||
<tr
|
||||
key={person.key}
|
||||
data-ranking-person-key={person.key}
|
||||
data-ranking={window.start + i + 1}
|
||||
data-visits={person.visits}
|
||||
data-person-region={person.group_key}
|
||||
>
|
||||
<td>{window.start + i + 1}</td>
|
||||
<td className="qa-people-name-cell">
|
||||
<span>{person.name || '未命名人员'}</span>
|
||||
{person.department_ids.length > 1 ? (
|
||||
<small className="qa-people-multi">兼属 {person.department_ids.length} 部门</small>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="qa-people-region-cell">{person.region_label}</td>
|
||||
<td className="qa-people-paths">
|
||||
{person.department_paths.length ? (
|
||||
<details>
|
||||
<summary title={person.department_paths.map((d) => d.path).join(';')}>
|
||||
{person.department_paths.length} 个直属部门
|
||||
</summary>
|
||||
<ul>
|
||||
{person.department_paths.map((d) => (
|
||||
<li key={d.id}>{d.path}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : (
|
||||
'未关联部门'
|
||||
)}
|
||||
</td>
|
||||
<td className="qa-people-question-count">{person.visits.toLocaleString('zh-CN')}</td>
|
||||
<td>{person.last_asked_at ? departmentTime(person.last_asked_at) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!window.total ? <p className="qa-ops-empty">此范围暂无人员,未将其他区域人员混入。</p> : null}
|
||||
</div>
|
||||
<nav className="qa-people-pagination" aria-label="人员分页">
|
||||
<span>
|
||||
{PEOPLE_PAGE_SIZE} 人/页 · 共 {window.total.toLocaleString('zh-CN')} 人 · 显示{' '}
|
||||
{window.total ? window.start + 1 : 0}—{window.end}
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" disabled={window.page === 1} onClick={() => onPage(window.page - 1)}>
|
||||
上一页
|
||||
</button>
|
||||
<label>
|
||||
第{' '}
|
||||
<select aria-label="人员页码" value={window.page} onChange={(e) => onPage(Number(e.target.value))}>
|
||||
{Array.from({ length: window.pages }, (_, i) => (
|
||||
<option value={i + 1} key={i + 1}>
|
||||
{i + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>{' '}
|
||||
/ {window.pages} 页
|
||||
</label>
|
||||
<button type="button" disabled={window.page === window.pages} onClick={() => onPage(window.page + 1)}>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="qa-people-analysis" data-analysis-mode={mode} data-generated-at={data.generated_at}>
|
||||
<div className="qa-people-analysis-heading">
|
||||
{mode === 'regions' ? (
|
||||
<label>
|
||||
按区域查看人员{' '}
|
||||
<select
|
||||
aria-label="人员分析区域"
|
||||
value={group.key}
|
||||
onChange={(e) => {
|
||||
setSelected(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
{analysis.groups.map((g) => (
|
||||
<option key={g.key} value={g.key}>
|
||||
{g.name}({g.people.length} 人)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<h4>全体人员</h4>
|
||||
)}
|
||||
<span>按提问次数从高到低 · 同一人员只列一次 · 保留零提问人员</span>
|
||||
</div>
|
||||
<PeopleRankingTable
|
||||
people={people}
|
||||
page={page}
|
||||
onPage={setPage}
|
||||
label={mode === 'regions' ? '区域人员排行' : '全体人员排行'}
|
||||
/>
|
||||
<p className="qa-department-note">
|
||||
沿用当前已同步通讯录及已保存区域配置;同一区域多部门人员去重,跨区域人员单列“跨区域待核对”,未配置者列“未归属”,不擅自裁决主归属或生成绩效结论。
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user