fix(data): 清理旧数据并保护服务端写入
This commit is contained in:
@@ -3,6 +3,25 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v
|
||||
let apiAvailable: boolean | null = null;
|
||||
let probePromise: Promise<boolean> | null = null;
|
||||
|
||||
function getErrorMessage(body: unknown, status: number) {
|
||||
if (typeof body === 'object' && body !== null && 'message' in body) {
|
||||
const message = (body as { message?: unknown }).message;
|
||||
if (typeof message === 'string') return message;
|
||||
if (Array.isArray(message)) return message.join(', ');
|
||||
}
|
||||
return `Request failed: ${status}`;
|
||||
}
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly body: unknown,
|
||||
) {
|
||||
super(getErrorMessage(body, status));
|
||||
this.name = 'ApiRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
async function checkApi(): Promise<boolean> {
|
||||
if (apiAvailable !== null) return apiAvailable;
|
||||
if (probePromise) return probePromise;
|
||||
@@ -29,7 +48,7 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
if (!res) throw new Error('API 不可用');
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new Error(error.message || `请求失败: ${res.status}`);
|
||||
throw new ApiRequestError(res.status, error);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -56,7 +75,7 @@ export const api = {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message || `请求失败: ${res.status}`);
|
||||
throw new ApiRequestError(res.status, err);
|
||||
}
|
||||
return res.json();
|
||||
} finally {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { resolveAuthUserFromMembers, toAuthUser, type AuthUser } from './auth-user';
|
||||
import { findMemberByUsernameCredentials, resolveAuthUserFromMembers, toAuthUser, type AuthUser } from './auth-user';
|
||||
import type { Member } from './members';
|
||||
|
||||
const member: Member = {
|
||||
id: 'm-8',
|
||||
name: '超级管理员',
|
||||
departmentId: 'dept-1',
|
||||
username: 'admin',
|
||||
departmentId: '',
|
||||
roleId: 'role-admin',
|
||||
phone: '13200132008',
|
||||
email: 'admin@company.com',
|
||||
email: '',
|
||||
password: 'Ftb@2024',
|
||||
createdAt: '2024-01-01',
|
||||
};
|
||||
@@ -19,10 +20,10 @@ test('toAuthUser removes password and keeps stable member identity', () => {
|
||||
assert.deepEqual(toAuthUser(member), {
|
||||
id: 'm-8',
|
||||
name: '超级管理员',
|
||||
departmentId: 'dept-1',
|
||||
username: 'admin',
|
||||
departmentId: '',
|
||||
roleId: 'role-admin',
|
||||
phone: '13200132008',
|
||||
email: 'admin@company.com',
|
||||
email: '',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,9 +31,9 @@ test('resolveAuthUserFromMembers refreshes stale display names by member id', ()
|
||||
const staleUser: AuthUser = {
|
||||
id: 'm-8',
|
||||
name: '陈十',
|
||||
departmentId: 'dept-1',
|
||||
username: 'chenshi',
|
||||
departmentId: '',
|
||||
roleId: 'role-admin',
|
||||
phone: '13200132008',
|
||||
email: 'old@company.com',
|
||||
};
|
||||
|
||||
@@ -40,18 +41,26 @@ test('resolveAuthUserFromMembers refreshes stale display names by member id', ()
|
||||
|
||||
assert.equal(resolved?.id, 'm-8');
|
||||
assert.equal(resolved?.name, '超级管理员');
|
||||
assert.equal(resolved?.email, 'admin@company.com');
|
||||
assert.equal(resolved?.username, 'admin');
|
||||
assert.equal(resolved?.email, '');
|
||||
});
|
||||
|
||||
test('resolveAuthUserFromMembers returns null when the account no longer exists', () => {
|
||||
const staleUser: AuthUser = {
|
||||
id: 'm-9',
|
||||
name: '离职用户',
|
||||
username: 'lizhiyonghu',
|
||||
departmentId: 'dept-2',
|
||||
roleId: 'role-dev',
|
||||
phone: '13900139000',
|
||||
email: 'old@company.com',
|
||||
};
|
||||
|
||||
assert.equal(resolveAuthUserFromMembers(staleUser, [member]), null);
|
||||
});
|
||||
|
||||
test('findMemberByUsernameCredentials uses username instead of phone for login', () => {
|
||||
const matched = findMemberByUsernameCredentials('admin', 'Ftb@2024', [member]);
|
||||
|
||||
assert.equal(matched?.id, 'm-8');
|
||||
assert.equal(findMemberByUsernameCredentials('13200132008', 'Ftb@2024', [member]), null);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { Member } from './members';
|
||||
import { generateUsernameFromName, normalizeUsername } from './members';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
roleId: string;
|
||||
departmentId: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@@ -13,9 +14,9 @@ export function toAuthUser(member: Member): AuthUser {
|
||||
return {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
username: normalizeUsername(member.username) || generateUsernameFromName(member.name, []),
|
||||
roleId: member.roleId,
|
||||
departmentId: member.departmentId,
|
||||
phone: member.phone,
|
||||
email: member.email,
|
||||
};
|
||||
}
|
||||
@@ -24,3 +25,16 @@ export function resolveAuthUserFromMembers(user: AuthUser, members: Member[]): A
|
||||
const member = members.find((m) => m.id === user.id);
|
||||
return member ? toAuthUser(member) : null;
|
||||
}
|
||||
|
||||
export function findMemberByUsernameCredentials(
|
||||
username: string,
|
||||
password: string,
|
||||
members: Member[],
|
||||
): Member | null {
|
||||
const normalizedUsername = normalizeUsername(username);
|
||||
if (!normalizedUsername || !password) return null;
|
||||
return members.find((member) => (
|
||||
normalizeUsername(member.username) === normalizedUsername &&
|
||||
(member.password || 'Ftb@2024') === password
|
||||
)) ?? null;
|
||||
}
|
||||
|
||||
78
apps/web/lib/member-list.test.ts
Normal file
78
apps/web/lib/member-list.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { filterMembersForList, getDisplayedAccountPassword, resolveLatestEditingMember } from './member-list';
|
||||
import type { Department, Member } from './members';
|
||||
|
||||
const departments: Department[] = [
|
||||
{ id: 'dept-1', name: '产品部', order: 1, createdAt: '2024-01-01' },
|
||||
{ id: 'dept-2', name: '技术部', order: 2, createdAt: '2024-01-01' },
|
||||
{ id: 'dept-2-1', name: '前端组', parentId: 'dept-2', order: 1, createdAt: '2024-01-01' },
|
||||
];
|
||||
|
||||
function member(id: string, name: string, departmentId: string): Member {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
username: name.toLowerCase(),
|
||||
departmentId,
|
||||
roleId: 'role-dev',
|
||||
phone: '',
|
||||
email: '',
|
||||
password: 'Ftb@2024',
|
||||
createdAt: '2024-01-01',
|
||||
};
|
||||
}
|
||||
|
||||
test('filterMembersForList matches member name keywords within the selected department tree', () => {
|
||||
const members = [
|
||||
member('m-1', '张三', 'dept-1'),
|
||||
member('m-2', '李四', 'dept-2-1'),
|
||||
member('m-3', '李四方', 'dept-2'),
|
||||
member('m-4', '王五', ''),
|
||||
];
|
||||
|
||||
const result = filterMembersForList({
|
||||
members,
|
||||
departments,
|
||||
activeDeptId: 'dept-2',
|
||||
nameKeyword: '李四',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.map((item) => item.id), ['m-2', 'm-3']);
|
||||
});
|
||||
|
||||
test('filterMembersForList keeps unassigned admin visible only in all members when searching by name', () => {
|
||||
const members = [
|
||||
member('m-8', '超级管理员', ''),
|
||||
member('m-1', '张三', 'dept-1'),
|
||||
];
|
||||
|
||||
const allResult = filterMembersForList({
|
||||
members,
|
||||
departments,
|
||||
activeDeptId: null,
|
||||
nameKeyword: '超级',
|
||||
});
|
||||
const deptResult = filterMembersForList({
|
||||
members,
|
||||
departments,
|
||||
activeDeptId: 'dept-1',
|
||||
nameKeyword: '超级',
|
||||
});
|
||||
|
||||
assert.deepEqual(allResult.map((item) => item.id), ['m-8']);
|
||||
assert.deepEqual(deptResult, []);
|
||||
});
|
||||
|
||||
test('resolveLatestEditingMember uses the current member record for password display', () => {
|
||||
const selected = member('m-1', '张三', 'dept-1');
|
||||
const latest = { ...selected, password: 'New@2026' };
|
||||
|
||||
assert.equal(resolveLatestEditingMember(selected, [latest])?.password, 'New@2026');
|
||||
});
|
||||
|
||||
test('getDisplayedAccountPassword shows the latest member password before generated fallback', () => {
|
||||
assert.equal(getDisplayedAccountPassword(member('m-1', '张三', 'dept-1'), 'Generated@2026'), 'Ftb@2024');
|
||||
assert.equal(getDisplayedAccountPassword(null, 'Generated@2026'), 'Generated@2026');
|
||||
});
|
||||
50
apps/web/lib/member-list.ts
Normal file
50
apps/web/lib/member-list.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Department, Member } from './members';
|
||||
|
||||
interface FilterMembersForListInput {
|
||||
members: Member[];
|
||||
departments: Department[];
|
||||
activeDeptId: string | null;
|
||||
nameKeyword: string;
|
||||
}
|
||||
|
||||
function collectDepartmentTreeIds(departments: Department[], departmentId: string): Set<string> {
|
||||
const ids = new Set<string>([departmentId]);
|
||||
let changed = true;
|
||||
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const department of departments) {
|
||||
if (department.parentId && ids.has(department.parentId) && !ids.has(department.id)) {
|
||||
ids.add(department.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function filterMembersForList({
|
||||
members,
|
||||
departments,
|
||||
activeDeptId,
|
||||
nameKeyword,
|
||||
}: FilterMembersForListInput): Member[] {
|
||||
const keyword = nameKeyword.trim().toLowerCase();
|
||||
const visibleDepartmentIds = activeDeptId ? collectDepartmentTreeIds(departments, activeDeptId) : null;
|
||||
|
||||
return members.filter((member) => {
|
||||
if (visibleDepartmentIds && !visibleDepartmentIds.has(member.departmentId)) return false;
|
||||
if (!keyword) return true;
|
||||
return member.name.toLowerCase().includes(keyword);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveLatestEditingMember(editingMember: Member | null, members: Member[]): Member | null {
|
||||
if (!editingMember) return null;
|
||||
return members.find((member) => member.id === editingMember.id) ?? editingMember;
|
||||
}
|
||||
|
||||
export function getDisplayedAccountPassword(member: Member | null, generatedPassword: string): string {
|
||||
return member?.password || generatedPassword;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
resolveMemberDisplayName,
|
||||
isMemberReference,
|
||||
} from './member-system';
|
||||
import type { Member } from './members';
|
||||
import { ensureMemberUsernames, type Member } from './members';
|
||||
|
||||
const oldAdmin: Member = {
|
||||
id: SYSTEM_ADMIN_MEMBER_ID,
|
||||
@@ -31,6 +31,9 @@ test('normalizes the built-in admin account when old data named it as a regular
|
||||
assert.equal(result.members.length, 1);
|
||||
assert.equal(result.members[0].id, SYSTEM_ADMIN_MEMBER_ID);
|
||||
assert.equal(result.members[0].name, SYSTEM_ADMIN_MEMBER_NAME);
|
||||
assert.equal(result.members[0].username, 'admin');
|
||||
assert.equal(result.members[0].departmentId, '');
|
||||
assert.equal(result.members[0].email, '');
|
||||
assert.equal(result.members[0].roleId, SYSTEM_ADMIN_ROLE_ID);
|
||||
assert.equal(result.members[0].isSystem, true);
|
||||
assert.equal(result.members[0].phone, oldAdmin.phone);
|
||||
@@ -43,20 +46,48 @@ test('adds a built-in admin account when member data has none', () => {
|
||||
assert.equal(result.members.length, 1);
|
||||
assert.equal(result.members[0].id, SYSTEM_ADMIN_MEMBER_ID);
|
||||
assert.equal(result.members[0].name, SYSTEM_ADMIN_MEMBER_NAME);
|
||||
assert.equal(result.members[0].username, 'admin');
|
||||
assert.equal(result.members[0].departmentId, '');
|
||||
assert.equal(result.members[0].email, '');
|
||||
assert.equal(result.members[0].roleId, SYSTEM_ADMIN_ROLE_ID);
|
||||
});
|
||||
|
||||
test('keeps the built-in admin username when another cached member already uses admin', () => {
|
||||
const regularMember: Member = {
|
||||
id: 'm-1',
|
||||
name: '张三',
|
||||
username: 'admin',
|
||||
departmentId: 'dept-1',
|
||||
roleId: 'role-dev',
|
||||
phone: '',
|
||||
email: '',
|
||||
password: 'Ftb@2024',
|
||||
createdAt: '2024-01-01',
|
||||
};
|
||||
|
||||
const ensured = ensureSystemAdminMember([regularMember, oldAdmin]);
|
||||
const usernameEnsured = ensureMemberUsernames(ensured.members);
|
||||
|
||||
assert.equal(usernameEnsured.members[0].id, SYSTEM_ADMIN_MEMBER_ID);
|
||||
assert.equal(usernameEnsured.members[0].username, 'admin');
|
||||
assert.equal(usernameEnsured.members[1].username, 'admin1');
|
||||
});
|
||||
|
||||
test('protects the built-in admin account from identity and role edits', () => {
|
||||
const patch = sanitizeSystemAdminPatch(SYSTEM_ADMIN_MEMBER_ID, {
|
||||
name: '陈十',
|
||||
username: 'chenshi',
|
||||
roleId: 'role-dev',
|
||||
departmentId: 'dept-3',
|
||||
phone: '13900139000',
|
||||
email: 'admin@company.com',
|
||||
});
|
||||
|
||||
assert.equal(patch.name, undefined);
|
||||
assert.equal(patch.username, undefined);
|
||||
assert.equal(patch.roleId, undefined);
|
||||
assert.equal(patch.departmentId, undefined);
|
||||
assert.equal(patch.email, undefined);
|
||||
assert.equal(patch.phone, '13900139000');
|
||||
});
|
||||
|
||||
|
||||
@@ -3,16 +3,17 @@ import type { Member } from './members';
|
||||
export const SYSTEM_ADMIN_MEMBER_ID = 'm-8';
|
||||
export const SYSTEM_ADMIN_MEMBER_NAME = '超级管理员';
|
||||
export const SYSTEM_ADMIN_ROLE_ID = 'role-admin';
|
||||
export const SYSTEM_ADMIN_DEPARTMENT_ID = 'dept-1';
|
||||
export const SYSTEM_ADMIN_DEPARTMENT_ID = '';
|
||||
const LEGACY_SYSTEM_ADMIN_NAMES = ['陈十'];
|
||||
|
||||
export const SYSTEM_ADMIN_MEMBER: Member = {
|
||||
id: SYSTEM_ADMIN_MEMBER_ID,
|
||||
name: SYSTEM_ADMIN_MEMBER_NAME,
|
||||
username: 'admin',
|
||||
departmentId: SYSTEM_ADMIN_DEPARTMENT_ID,
|
||||
roleId: SYSTEM_ADMIN_ROLE_ID,
|
||||
phone: '13200132008',
|
||||
email: 'admin@company.com',
|
||||
email: '',
|
||||
password: 'Ftb@2024',
|
||||
createdAt: '2024-01-01',
|
||||
isSystem: true,
|
||||
@@ -24,10 +25,11 @@ function normalizeSystemAdminMember(member: Partial<Member>): Member {
|
||||
...member,
|
||||
id: SYSTEM_ADMIN_MEMBER_ID,
|
||||
name: SYSTEM_ADMIN_MEMBER_NAME,
|
||||
username: SYSTEM_ADMIN_MEMBER.username,
|
||||
departmentId: SYSTEM_ADMIN_DEPARTMENT_ID,
|
||||
roleId: SYSTEM_ADMIN_ROLE_ID,
|
||||
phone: member.phone || SYSTEM_ADMIN_MEMBER.phone,
|
||||
email: member.email || SYSTEM_ADMIN_MEMBER.email,
|
||||
email: SYSTEM_ADMIN_MEMBER.email,
|
||||
password: member.password || SYSTEM_ADMIN_MEMBER.password,
|
||||
createdAt: member.createdAt || SYSTEM_ADMIN_MEMBER.createdAt,
|
||||
isSystem: true,
|
||||
@@ -43,7 +45,9 @@ export function ensureSystemAdminMember(members: Member[]): { members: Member[];
|
||||
const current = members[index];
|
||||
const normalized = normalizeSystemAdminMember(current);
|
||||
const changed =
|
||||
index !== 0 ||
|
||||
current.name !== normalized.name ||
|
||||
current.username !== normalized.username ||
|
||||
current.departmentId !== normalized.departmentId ||
|
||||
current.roleId !== normalized.roleId ||
|
||||
current.phone !== normalized.phone ||
|
||||
@@ -55,14 +59,14 @@ export function ensureSystemAdminMember(members: Member[]): { members: Member[];
|
||||
if (!changed) return { members, changed: false };
|
||||
|
||||
return {
|
||||
members: members.map((m, i) => (i === index ? normalized : m)),
|
||||
members: [normalized, ...members.filter((_, i) => i !== index)],
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeSystemAdminPatch(id: string, data: Partial<Member>): Partial<Member> {
|
||||
if (id !== SYSTEM_ADMIN_MEMBER_ID) return data;
|
||||
const { name, departmentId, roleId, isSystem, ...allowed } = data;
|
||||
const { name, username, departmentId, roleId, email, isSystem, ...allowed } = data;
|
||||
return { ...allowed, isSystem: true };
|
||||
}
|
||||
|
||||
|
||||
48
apps/web/lib/members.test.ts
Normal file
48
apps/web/lib/members.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
generateUsernameFromName,
|
||||
ensureMemberUsernames,
|
||||
type Member,
|
||||
} from './members';
|
||||
|
||||
function member(overrides: Partial<Member>): Member {
|
||||
return {
|
||||
id: overrides.id ?? 'm-1',
|
||||
name: overrides.name ?? '张三',
|
||||
departmentId: overrides.departmentId ?? 'dept-1',
|
||||
roleId: overrides.roleId ?? 'role-dev',
|
||||
phone: overrides.phone ?? '',
|
||||
email: overrides.email ?? '',
|
||||
password: overrides.password ?? 'Ftb@2024',
|
||||
createdAt: overrides.createdAt ?? '2026-07-01',
|
||||
username: overrides.username,
|
||||
};
|
||||
}
|
||||
|
||||
test('generateUsernameFromName uses lowercase full pinyin for Chinese names', () => {
|
||||
assert.equal(generateUsernameFromName('张三', []), 'zhangsan');
|
||||
assert.equal(generateUsernameFromName(' 欧阳娜娜 ', []), 'ouyangnana');
|
||||
});
|
||||
|
||||
test('generateUsernameFromName appends zero-padded duplicate suffixes', () => {
|
||||
assert.equal(generateUsernameFromName('张三', ['zhangsan']), 'zhangsan1');
|
||||
assert.equal(generateUsernameFromName('张三', ['zhangsan', 'zhangsan1']), 'zhangsan01');
|
||||
assert.equal(generateUsernameFromName('张三', ['zhangsan', 'zhangsan1', 'zhangsan01']), 'zhangsan001');
|
||||
});
|
||||
|
||||
test('generateUsernameFromName keeps existing username when editing another member', () => {
|
||||
assert.equal(generateUsernameFromName('张三', ['zhangsan'], 'lisi'), 'lisi');
|
||||
});
|
||||
|
||||
test('ensureMemberUsernames fills missing usernames without syncing renamed members', () => {
|
||||
const result = ensureMemberUsernames([
|
||||
member({ id: 'm-1', name: '张三', username: 'lisi' }),
|
||||
member({ id: 'm-2', name: '张三' }),
|
||||
]);
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.members[0].username, 'lisi');
|
||||
assert.equal(result.members[1].username, 'zhangsan');
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { pinyin } from 'pinyin-pro';
|
||||
|
||||
export interface Department {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -9,6 +11,7 @@ export interface Department {
|
||||
export interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
username?: string;
|
||||
departmentId: string;
|
||||
roleId: string;
|
||||
phone: string;
|
||||
@@ -65,3 +68,62 @@ export function maskPhone(phone: string): string {
|
||||
if (!phone || phone.length < 7) return phone;
|
||||
return phone.slice(0, 3) + '****' + phone.slice(7);
|
||||
}
|
||||
|
||||
export function normalizeUsername(username: string | undefined | null): string {
|
||||
return (username ?? '').trim().replace(/\s+/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function nameToUsernameBase(name: string): string {
|
||||
const raw = name.trim();
|
||||
if (!raw) return 'USER';
|
||||
const letters = pinyin(raw, { toneType: 'none', type: 'array' })
|
||||
.join('')
|
||||
.replace(/[^a-zA-Z0-9]/g, '')
|
||||
.toLowerCase();
|
||||
return letters || 'USER';
|
||||
}
|
||||
|
||||
function duplicateSuffix(attempt: number): string {
|
||||
return attempt === 1 ? '1' : `${'0'.repeat(attempt - 1)}1`;
|
||||
}
|
||||
|
||||
function uniqueUsernameFromBase(base: string, existingUsernames: Iterable<string>): string {
|
||||
const normalizedBase = normalizeUsername(base) || 'USER';
|
||||
const used = new Set(Array.from(existingUsernames, normalizeUsername).filter(Boolean));
|
||||
if (!used.has(normalizedBase)) return normalizedBase;
|
||||
|
||||
for (let attempt = 1; attempt < 1000; attempt += 1) {
|
||||
const candidate = `${normalizedBase}${duplicateSuffix(attempt)}`;
|
||||
if (!used.has(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return `${normalizedBase}${Date.now()}`;
|
||||
}
|
||||
|
||||
export function generateUsernameFromName(
|
||||
name: string,
|
||||
existingUsernames: Iterable<string>,
|
||||
currentUsername?: string,
|
||||
): string {
|
||||
const normalizedCurrent = normalizeUsername(currentUsername);
|
||||
if (normalizedCurrent) return normalizedCurrent;
|
||||
return uniqueUsernameFromBase(nameToUsernameBase(name), existingUsernames);
|
||||
}
|
||||
|
||||
export function ensureMemberUsernames(members: Member[]): { members: Member[]; changed: boolean } {
|
||||
const used = new Set<string>();
|
||||
let changed = false;
|
||||
const normalizedMembers = members.map((member) => {
|
||||
const requested = normalizeUsername(member.username);
|
||||
const username = requested
|
||||
? uniqueUsernameFromBase(requested, used)
|
||||
: generateUsernameFromName(member.name, used);
|
||||
|
||||
used.add(username);
|
||||
if (member.username === username) return member;
|
||||
changed = true;
|
||||
return { ...member, username };
|
||||
});
|
||||
|
||||
return { members: normalizedMembers, changed };
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ function member(id: string, name: string, departmentId: string, roleId: string):
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
username: id.toUpperCase(),
|
||||
departmentId,
|
||||
roleId,
|
||||
phone: '',
|
||||
@@ -89,9 +90,9 @@ function toAuthUser(member: Member): AuthUser {
|
||||
return {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
username: member.username ?? member.id.toUpperCase(),
|
||||
roleId: member.roleId,
|
||||
departmentId: member.departmentId,
|
||||
phone: member.phone,
|
||||
email: member.email,
|
||||
};
|
||||
}
|
||||
|
||||
41
apps/web/lib/requirement-form-options.test.ts
Normal file
41
apps/web/lib/requirement-form-options.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
filterRequirementProjectOptions,
|
||||
filterRequirementVersionOptions,
|
||||
} from './requirement';
|
||||
|
||||
const projects = [
|
||||
{ id: 'project-1', name: 'Mobile App', productId: 'product-1' },
|
||||
{ id: 'project-2', name: 'Admin Portal', productId: 'product-1' },
|
||||
{ id: 'project-3', name: 'Data Console', productId: 'product-2' },
|
||||
];
|
||||
|
||||
const versions = [
|
||||
{ id: 'version-1', name: 'V1.0 Checkout', projectId: 'project-1' },
|
||||
{ id: 'version-2', name: 'V1.1 Profile', projectId: 'project-1' },
|
||||
{ id: 'version-3', name: 'V2.0 Admin', projectId: 'project-2' },
|
||||
];
|
||||
|
||||
test('filters requirement project options by selected product and keyword', () => {
|
||||
assert.deepEqual(filterRequirementProjectOptions(projects, '', '').map((project) => project.id), []);
|
||||
assert.deepEqual(filterRequirementProjectOptions(projects, 'product-1', '').map((project) => project.id), [
|
||||
'project-1',
|
||||
'project-2',
|
||||
]);
|
||||
assert.deepEqual(filterRequirementProjectOptions(projects, 'product-1', 'portal').map((project) => project.id), [
|
||||
'project-2',
|
||||
]);
|
||||
});
|
||||
|
||||
test('filters requirement version options by selected project and keyword', () => {
|
||||
assert.deepEqual(filterRequirementVersionOptions(versions, '', '').map((version) => version.id), []);
|
||||
assert.deepEqual(filterRequirementVersionOptions(versions, 'project-1', 'v1').map((version) => version.id), [
|
||||
'version-1',
|
||||
'version-2',
|
||||
]);
|
||||
assert.deepEqual(filterRequirementVersionOptions(versions, 'project-2', 'admin').map((version) => version.id), [
|
||||
'version-3',
|
||||
]);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
|
||||
import type { Requirement } from './requirement';
|
||||
import {
|
||||
buildRequirementScopeTree,
|
||||
filterRequirementScopeTreeByKeyword,
|
||||
filterRequirementsByScope,
|
||||
type RequirementScopeSelection,
|
||||
} from './requirement-scope';
|
||||
@@ -84,3 +85,21 @@ test('builds product and project tree counts from requirements', () => {
|
||||
assert.equal(tree[0].projects[1].count.total, 1);
|
||||
assert.equal(tree[1].count.total, 1);
|
||||
});
|
||||
|
||||
test('filters requirement scope tree by product or project keyword', () => {
|
||||
const tree = buildRequirementScopeTree(overview, [
|
||||
req('req-1', 'product-1', 'project-1', 'pending_review'),
|
||||
req('req-2', 'product-1', 'project-2', 'developing'),
|
||||
req('req-3', 'product-2', 'project-3', 'closed'),
|
||||
]);
|
||||
|
||||
const productMatched = filterRequirementScopeTreeByKeyword(tree, 'crm');
|
||||
assert.deepEqual(productMatched.map((product) => product.id), ['product-1']);
|
||||
assert.deepEqual(productMatched[0].projects.map((project) => project.id), ['project-1', 'project-2']);
|
||||
|
||||
const projectMatched = filterRequirementScopeTreeByKeyword(tree, 'admin');
|
||||
assert.deepEqual(projectMatched.map((product) => product.id), ['product-1']);
|
||||
assert.deepEqual(projectMatched[0].projects.map((project) => project.id), ['project-2']);
|
||||
|
||||
assert.equal(filterRequirementScopeTreeByKeyword(tree, 'missing').length, 0);
|
||||
});
|
||||
|
||||
@@ -58,6 +58,29 @@ export function buildRequirementScopeTree(
|
||||
});
|
||||
}
|
||||
|
||||
export function filterRequirementScopeTreeByKeyword(
|
||||
tree: RequirementProductScopeNode[],
|
||||
keyword: string,
|
||||
): RequirementProductScopeNode[] {
|
||||
const query = normalizeScopeKeyword(keyword);
|
||||
if (!query) return tree;
|
||||
|
||||
const filtered: RequirementProductScopeNode[] = [];
|
||||
for (const product of tree) {
|
||||
if (normalizeScopeKeyword(product.name).includes(query)) {
|
||||
filtered.push(product);
|
||||
continue;
|
||||
}
|
||||
|
||||
const projects = product.projects.filter((project) => normalizeScopeKeyword(project.name).includes(query));
|
||||
if (projects.length > 0) {
|
||||
filtered.push({ ...product, projects });
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function countRequirements(requirements: Requirement[]): RequirementScopeCount {
|
||||
const byStatus: Partial<Record<RequirementStatus, number>> = {};
|
||||
for (const requirement of requirements) {
|
||||
@@ -68,3 +91,7 @@ function countRequirements(requirements: Requirement[]): RequirementScopeCount {
|
||||
byStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeScopeKeyword(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
35
apps/web/lib/requirement-source-target.test.ts
Normal file
35
apps/web/lib/requirement-source-target.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { SourceTarget } from './requirement';
|
||||
import {
|
||||
filterSourceTargetsByKeyword,
|
||||
formatSourceTargetSelection,
|
||||
parseSourceTargetSelection,
|
||||
} from './requirement';
|
||||
|
||||
const targets: SourceTarget[] = [
|
||||
{ id: 'st-1', name: 'Alpha Bank', sourceType: 'customer', createdAt: '2026-07-01' },
|
||||
{ id: 'st-2', name: 'Beta Retail', sourceType: 'customer', createdAt: '2026-07-01' },
|
||||
{ id: 'st-3', name: 'Product Team', sourceType: 'internal', createdAt: '2026-07-01' },
|
||||
];
|
||||
|
||||
test('parses and formats requirement source target multi-selection', () => {
|
||||
assert.deepEqual(parseSourceTargetSelection('Alpha Bank、 Beta Retail, Product Team,Alpha Bank'), [
|
||||
'Alpha Bank',
|
||||
'Beta Retail',
|
||||
'Product Team',
|
||||
]);
|
||||
assert.equal(formatSourceTargetSelection(['Alpha Bank', '', 'Beta Retail', 'Alpha Bank']), 'Alpha Bank、Beta Retail');
|
||||
});
|
||||
|
||||
test('filters source targets by source type and keyword', () => {
|
||||
assert.deepEqual(
|
||||
filterSourceTargetsByKeyword(targets, 'customer', 'bank').map((target) => target.id),
|
||||
['st-1'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterSourceTargetsByKeyword(targets, 'internal', '').map((target) => target.id),
|
||||
['st-3'],
|
||||
);
|
||||
});
|
||||
@@ -17,6 +17,80 @@ export interface SourceTarget {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function parseSourceTargetSelection(value?: string | null): string[] {
|
||||
if (!value) return [];
|
||||
return uniqueNonEmptyNames(value.split(/[、,,]/));
|
||||
}
|
||||
|
||||
export function formatSourceTargetSelection(names: string[]): string {
|
||||
return uniqueNonEmptyNames(names).join('、');
|
||||
}
|
||||
|
||||
export function filterSourceTargetsByKeyword(
|
||||
targets: SourceTarget[],
|
||||
sourceType: SourceType,
|
||||
keyword: string,
|
||||
): SourceTarget[] {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return targets.filter((target) => {
|
||||
if (target.sourceType !== sourceType) return false;
|
||||
if (!query) return true;
|
||||
return target.name.toLowerCase().includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
export interface RequirementProjectOption {
|
||||
id: string;
|
||||
name: string;
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export interface RequirementVersionOption {
|
||||
id: string;
|
||||
name: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function filterRequirementProjectOptions(
|
||||
projects: RequirementProjectOption[],
|
||||
productId: string,
|
||||
keyword: string,
|
||||
): RequirementProjectOption[] {
|
||||
if (!productId) return [];
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return projects.filter((project) => {
|
||||
if (project.productId !== productId) return false;
|
||||
if (!query) return true;
|
||||
return project.name.toLowerCase().includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
export function filterRequirementVersionOptions(
|
||||
versions: RequirementVersionOption[],
|
||||
projectId: string,
|
||||
keyword: string,
|
||||
): RequirementVersionOption[] {
|
||||
if (!projectId) return [];
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return versions.filter((version) => {
|
||||
if (version.projectId !== projectId) return false;
|
||||
if (!query) return true;
|
||||
return version.name.toLowerCase().includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueNonEmptyNames(names: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const name of names) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface Requirement {
|
||||
id: string;
|
||||
code: string;
|
||||
|
||||
92
apps/web/lib/server-data.test.ts
Normal file
92
apps/web/lib/server-data.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { loadServerData, saveServerData, ServerDataConflictError } from './server-data';
|
||||
|
||||
type MockResponse = {
|
||||
status: number;
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
test('server data saves include the latest loaded AppData version', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
|
||||
const responses: MockResponse[] = [
|
||||
{ status: 200, body: {} },
|
||||
{
|
||||
status: 200,
|
||||
body: { key: 'dev-tasks', value: [{ id: 'old' }], version: 'version-1' },
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
body: { key: 'dev-tasks', value: [{ id: 'next' }], version: 'version-2' },
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
body: { key: 'dev-tasks', value: [{ id: 'again' }], version: 'version-3' },
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
body: { key: 'bugs', value: [], version: 'bugs-version-1' },
|
||||
},
|
||||
{
|
||||
status: 409,
|
||||
body: {
|
||||
code: 'APP_DATA_CONFLICT',
|
||||
key: 'dev-tasks',
|
||||
currentValue: [{ id: 'server-copy' }],
|
||||
currentVersion: 'version-4',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = responses.shift();
|
||||
if (!response) throw new Error('Unexpected fetch call');
|
||||
calls.push({
|
||||
url: String(input),
|
||||
method: init?.method ?? 'GET',
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
});
|
||||
return new Response(JSON.stringify(response.body), {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await assert.doesNotReject(async () => {
|
||||
const loaded = await loadServerData<Array<{ id: string }>>('dev-tasks');
|
||||
assert.deepEqual(loaded, [{ id: 'old' }]);
|
||||
});
|
||||
|
||||
await saveServerData('dev-tasks', [{ id: 'next' }]);
|
||||
assert.deepEqual(calls[2].body, {
|
||||
value: [{ id: 'next' }],
|
||||
version: 'version-1',
|
||||
});
|
||||
|
||||
await saveServerData('dev-tasks', [{ id: 'again' }]);
|
||||
assert.deepEqual(calls[3].body, {
|
||||
value: [{ id: 'again' }],
|
||||
version: 'version-2',
|
||||
});
|
||||
|
||||
await saveServerData('bugs', []);
|
||||
assert.deepEqual(calls[4].body, { value: [] });
|
||||
|
||||
await assert.rejects(
|
||||
() => saveServerData('dev-tasks', [{ id: 'stale' }]),
|
||||
(error: unknown) => {
|
||||
if (!(error instanceof ServerDataConflictError)) return false;
|
||||
return error.key === 'dev-tasks' && error.currentVersion === 'version-4';
|
||||
},
|
||||
);
|
||||
assert.deepEqual(calls[5].body, {
|
||||
value: [{ id: 'stale' }],
|
||||
version: 'version-3',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api } from './api';
|
||||
import { api, ApiRequestError } from './api';
|
||||
|
||||
export type ServerDataKey =
|
||||
| 'products-overview'
|
||||
@@ -19,13 +19,72 @@ export type ServerDataKey =
|
||||
interface ServerDataResponse<T> {
|
||||
key: ServerDataKey;
|
||||
value: T | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
type ServerDataConflictBody<T> = {
|
||||
code: 'APP_DATA_CONFLICT';
|
||||
key: ServerDataKey;
|
||||
currentValue: T | null;
|
||||
currentVersion: string | null;
|
||||
};
|
||||
|
||||
const serverDataVersions = new Map<ServerDataKey, string | null>();
|
||||
|
||||
export class ServerDataConflictError<T = unknown> extends Error {
|
||||
constructor(
|
||||
public readonly key: ServerDataKey,
|
||||
public readonly currentValue: T | null,
|
||||
public readonly currentVersion: string | null,
|
||||
) {
|
||||
super('Server data was changed by another user. Reload before saving again.');
|
||||
this.name = 'ServerDataConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadServerData<T>(key: ServerDataKey): Promise<T | null> {
|
||||
const res = await api.get<ServerDataResponse<T>>(`/data/${key}`);
|
||||
serverDataVersions.set(key, res.version ?? null);
|
||||
return res.value;
|
||||
}
|
||||
|
||||
export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<void> {
|
||||
await api.put(`/data/${key}`, { value });
|
||||
const payload: { value: T; version?: string | null } = { value };
|
||||
if (serverDataVersions.has(key)) {
|
||||
payload.version = serverDataVersions.get(key) ?? null;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.put<ServerDataResponse<T>>(`/data/${key}`, payload);
|
||||
serverDataVersions.set(key, res.version ?? null);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ApiRequestError &&
|
||||
error.status === 409 &&
|
||||
isServerDataConflictBody<T>(error.body, key)
|
||||
) {
|
||||
throw new ServerDataConflictError(
|
||||
error.body.key,
|
||||
error.body.currentValue,
|
||||
error.body.currentVersion,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isServerDataConflictBody<T>(
|
||||
body: unknown,
|
||||
key: ServerDataKey,
|
||||
): body is ServerDataConflictBody<T> {
|
||||
return (
|
||||
typeof body === 'object' &&
|
||||
body !== null &&
|
||||
(body as { code?: unknown }).code === 'APP_DATA_CONFLICT' &&
|
||||
(body as { key?: unknown }).key === key &&
|
||||
('currentVersion' in body
|
||||
? typeof (body as { currentVersion?: unknown }).currentVersion === 'string' ||
|
||||
(body as { currentVersion?: unknown }).currentVersion === null
|
||||
: true)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,18 @@ test('wenfan xiaobao page provides records, chat, and voice input surfaces', ()
|
||||
assert.match(page, /: 'bg-white hover:bg-\[var\(--bg-subtle\)\]'/);
|
||||
});
|
||||
|
||||
test('wenfan xiaobao page does not ship seeded conversation history', () => {
|
||||
const page = readFileSync(join(process.cwd(), 'app/wenfan-xiaobao/page.tsx'), 'utf8');
|
||||
|
||||
assert.match(page, /useState<WenfanConversation\[\]>\(\[\]\)/);
|
||||
assert.doesNotMatch(page, /INITIAL_CONVERSATIONS/);
|
||||
assert.doesNotMatch(page, /history-product-create/);
|
||||
assert.doesNotMatch(page, /history-version-requirements/);
|
||||
assert.doesNotMatch(page, /history-dev-task-submit/);
|
||||
assert.doesNotMatch(page, /history-test-bug/);
|
||||
assert.doesNotMatch(page, /history-activity-log/);
|
||||
});
|
||||
|
||||
test('wenfan help screenshot script targets version detail tabs and failed test case bug entry', () => {
|
||||
const script = readFileSync(join(process.cwd(), 'scripts/capture-wenfan-help-screenshots.mjs'), 'utf8');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user