fix(data): 清理旧数据并保护服务端写入
This commit is contained in:
@@ -76,6 +76,9 @@ describe('clean app data seed', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'm-8',
|
id: 'm-8',
|
||||||
name: '超级管理员',
|
name: '超级管理员',
|
||||||
|
username: 'admin',
|
||||||
|
email: '',
|
||||||
|
departmentId: '',
|
||||||
roleId: 'role-admin',
|
roleId: 'role-admin',
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
password: 'Ftb@2024',
|
password: 'Ftb@2024',
|
||||||
|
|||||||
@@ -377,13 +377,14 @@ const roles: JsonSeedValue[] = [
|
|||||||
const superAdminMember: JsonSeedValue = {
|
const superAdminMember: JsonSeedValue = {
|
||||||
id: 'm-8',
|
id: 'm-8',
|
||||||
name: '超级管理员',
|
name: '超级管理员',
|
||||||
email: 'chenshi@company.com',
|
username: 'admin',
|
||||||
phone: '13200132008',
|
email: '',
|
||||||
|
phone: '',
|
||||||
roleId: 'role-admin',
|
roleId: 'role-admin',
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
password: 'Ftb@2024',
|
password: 'Ftb@2024',
|
||||||
createdAt: '2024-01-01',
|
createdAt: '2024-01-01',
|
||||||
departmentId: 'dept-1',
|
departmentId: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const passwordRule: JsonSeedValue = {
|
const passwordRule: JsonSeedValue = {
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ export class DataController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put(':key')
|
@Put(':key')
|
||||||
put(@Param('key') key: string, @Body('value') value: unknown) {
|
put(
|
||||||
return this.dataService.put(key, value);
|
@Param('key') key: string,
|
||||||
|
@Body('value') value: unknown,
|
||||||
|
@Body('version') version?: string | null,
|
||||||
|
) {
|
||||||
|
return this.dataService.put(key, value, version);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
import { DataService } from './data.service';
|
import { DataService } from './data.service';
|
||||||
|
|
||||||
describe('DataService', () => {
|
describe('DataService', () => {
|
||||||
const makeService = () => {
|
const makeService = () => {
|
||||||
const prisma = {
|
const prisma = {
|
||||||
appData: {
|
appData: {
|
||||||
|
create: jest.fn(),
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
|
updateMany: jest.fn(),
|
||||||
upsert: jest.fn(),
|
upsert: jest.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -22,6 +24,23 @@ describe('DataService', () => {
|
|||||||
await expect(service.get('products-overview')).resolves.toEqual({
|
await expect(service.get('products-overview')).resolves.toEqual({
|
||||||
key: 'products-overview',
|
key: 'products-overview',
|
||||||
value: null,
|
value: null,
|
||||||
|
version: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the AppData version for a stored value', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
const updatedAt = new Date('2026-07-02T08:00:00.000Z');
|
||||||
|
prisma.appData.findUnique.mockResolvedValue({
|
||||||
|
key: 'products-overview',
|
||||||
|
value: [{ id: 'p1' }],
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.get('products-overview')).resolves.toEqual({
|
||||||
|
key: 'products-overview',
|
||||||
|
value: [{ id: 'p1' }],
|
||||||
|
version: updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,11 +53,13 @@ describe('DataService', () => {
|
|||||||
it('upserts JSON values for allowed keys', async () => {
|
it('upserts JSON values for allowed keys', async () => {
|
||||||
const { prisma, service } = makeService();
|
const { prisma, service } = makeService();
|
||||||
const value = [{ id: 'p1', name: 'Product 1' }];
|
const value = [{ id: 'p1', name: 'Product 1' }];
|
||||||
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value });
|
const updatedAt = new Date('2026-07-02T08:01:00.000Z');
|
||||||
|
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
||||||
|
|
||||||
await expect(service.put('products-overview', value)).resolves.toEqual({
|
await expect(service.put('products-overview', value)).resolves.toEqual({
|
||||||
key: 'products-overview',
|
key: 'products-overview',
|
||||||
value,
|
value,
|
||||||
|
version: updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
expect(prisma.appData.upsert).toHaveBeenCalledWith({
|
expect(prisma.appData.upsert).toHaveBeenCalledWith({
|
||||||
where: { key: 'products-overview' },
|
where: { key: 'products-overview' },
|
||||||
@@ -51,20 +72,94 @@ describe('DataService', () => {
|
|||||||
const { prisma, service } = makeService();
|
const { prisma, service } = makeService();
|
||||||
const value: unknown[] = [];
|
const value: unknown[] = [];
|
||||||
prisma.appData.upsert.mockImplementation(({ where }) =>
|
prisma.appData.upsert.mockImplementation(({ where }) =>
|
||||||
Promise.resolve({ key: where.key, value }),
|
Promise.resolve({ key: where.key, value, updatedAt: new Date('2026-07-02T08:02:00.000Z') }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(service.put('task-worklogs', value)).resolves.toEqual({
|
await expect(service.put('task-worklogs', value)).resolves.toMatchObject({ key: 'task-worklogs', value });
|
||||||
key: 'task-worklogs',
|
await expect(service.put('work-activities', value)).resolves.toMatchObject({ key: 'work-activities', value });
|
||||||
value,
|
await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toMatchObject({
|
||||||
});
|
|
||||||
await expect(service.put('work-activities', value)).resolves.toEqual({
|
|
||||||
key: 'work-activities',
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toEqual({
|
|
||||||
key: 'overtime',
|
key: 'overtime',
|
||||||
value,
|
value,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates only when the supplied version matches the stored row version', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
const previousVersion = '2026-07-02T08:03:00.000Z';
|
||||||
|
const nextUpdatedAt = new Date('2026-07-02T08:04:00.000Z');
|
||||||
|
const nextValue = [{ id: 'p2', name: 'Product 2' }];
|
||||||
|
prisma.appData.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
prisma.appData.findUnique.mockResolvedValue({
|
||||||
|
key: 'products-overview',
|
||||||
|
value: nextValue,
|
||||||
|
updatedAt: nextUpdatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.put('products-overview', nextValue, previousVersion)).resolves.toEqual({
|
||||||
|
key: 'products-overview',
|
||||||
|
value: nextValue,
|
||||||
|
version: nextUpdatedAt.toISOString(),
|
||||||
|
});
|
||||||
|
expect(prisma.appData.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
key: 'products-overview',
|
||||||
|
updatedAt: new Date(previousVersion),
|
||||||
|
},
|
||||||
|
data: { value: nextValue },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects stale AppData versions without overwriting the current value', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.appData.updateMany.mockResolvedValue({ count: 0 });
|
||||||
|
prisma.appData.findUnique.mockResolvedValue({
|
||||||
|
key: 'products-overview',
|
||||||
|
value: [{ id: 'current' }],
|
||||||
|
updatedAt: new Date('2026-07-02T08:05:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.put('products-overview', [{ id: 'stale' }], '2026-07-02T08:03:00.000Z'),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a missing row only when the client loaded a null version', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
const value = [{ id: 'p1' }];
|
||||||
|
const updatedAt = new Date('2026-07-02T08:06:00.000Z');
|
||||||
|
prisma.appData.create.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
||||||
|
|
||||||
|
await expect(service.put('products-overview', value, null)).resolves.toEqual({
|
||||||
|
key: 'products-overview',
|
||||||
|
value,
|
||||||
|
version: updatedAt.toISOString(),
|
||||||
|
});
|
||||||
|
expect(prisma.appData.create).toHaveBeenCalledWith({
|
||||||
|
data: { key: 'products-overview', value },
|
||||||
|
});
|
||||||
|
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects create-only writes when another client created the row first', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.appData.create.mockRejectedValue({ code: 'P2002' });
|
||||||
|
prisma.appData.findUnique.mockResolvedValue({
|
||||||
|
key: 'products-overview',
|
||||||
|
value: [{ id: 'current' }],
|
||||||
|
updatedAt: new Date('2026-07-02T08:07:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.put('products-overview', [{ id: 'new' }], null)).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid AppData versions', async () => {
|
||||||
|
const { service } = makeService();
|
||||||
|
|
||||||
|
await expect(service.put('products-overview', [], 'not-a-date')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { isAppDataKey } from './data-keys';
|
import { isAppDataKey } from './data-keys';
|
||||||
|
|
||||||
|
type AppDataRow = {
|
||||||
|
key: string;
|
||||||
|
value: Prisma.JsonValue;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataService {
|
export class DataService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
@@ -10,18 +16,48 @@ export class DataService {
|
|||||||
async get(key: string) {
|
async get(key: string) {
|
||||||
this.ensureAllowedKey(key);
|
this.ensureAllowedKey(key);
|
||||||
const row = await this.prisma.appData.findUnique({ where: { key } });
|
const row = await this.prisma.appData.findUnique({ where: { key } });
|
||||||
return { key, value: row?.value ?? null };
|
return this.toResponse(key, row);
|
||||||
}
|
}
|
||||||
|
|
||||||
async put(key: string, value: unknown) {
|
async put(key: string, value: unknown, version?: string | null) {
|
||||||
this.ensureAllowedKey(key);
|
this.ensureAllowedKey(key);
|
||||||
const jsonValue = value as Prisma.InputJsonValue;
|
const jsonValue = value as Prisma.InputJsonValue;
|
||||||
|
|
||||||
|
if (version === null) {
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.appData.create({
|
||||||
|
data: { key, value: jsonValue },
|
||||||
|
});
|
||||||
|
return this.toResponse(key, row);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isUniqueConflict(error)) {
|
||||||
|
await this.throwConflict(key);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version !== undefined) {
|
||||||
|
const updatedAt = this.parseVersion(version);
|
||||||
|
const result = await this.prisma.appData.updateMany({
|
||||||
|
where: { key, updatedAt },
|
||||||
|
data: { value: jsonValue },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.count !== 1) {
|
||||||
|
await this.throwConflict(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await this.prisma.appData.findUnique({ where: { key } });
|
||||||
|
return this.toResponse(key, row);
|
||||||
|
}
|
||||||
|
|
||||||
const row = await this.prisma.appData.upsert({
|
const row = await this.prisma.appData.upsert({
|
||||||
where: { key },
|
where: { key },
|
||||||
update: { value: jsonValue },
|
update: { value: jsonValue },
|
||||||
create: { key, value: jsonValue },
|
create: { key, value: jsonValue },
|
||||||
});
|
});
|
||||||
return { key: row.key, value: row.value };
|
return this.toResponse(key, row);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureAllowedKey(key: string) {
|
private ensureAllowedKey(key: string) {
|
||||||
@@ -29,4 +65,41 @@ export class DataService {
|
|||||||
throw new BadRequestException(`Unsupported data key: ${key}`);
|
throw new BadRequestException(`Unsupported data key: ${key}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseVersion(version: string) {
|
||||||
|
const updatedAt = new Date(version);
|
||||||
|
if (!Number.isFinite(updatedAt.getTime())) {
|
||||||
|
throw new BadRequestException('Invalid AppData version');
|
||||||
|
}
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toResponse(key: string, row: AppDataRow | null) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
value: row?.value ?? null,
|
||||||
|
version: row?.updatedAt.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async throwConflict(key: string): Promise<never> {
|
||||||
|
const row = await this.prisma.appData.findUnique({ where: { key } });
|
||||||
|
const current = this.toResponse(key, row);
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'APP_DATA_CONFLICT',
|
||||||
|
message: 'AppData was changed by another client. Reload before saving again.',
|
||||||
|
key,
|
||||||
|
currentValue: current.value,
|
||||||
|
currentVersion: current.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUniqueConflict(error: unknown) {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code?: unknown }).code === 'P2002'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, X, ChevronRight, FolderOpen, Settings } from 'lucide-react';
|
import { Plus, Pencil, Trash2, X, ChevronRight, FolderOpen, Settings, Search } from 'lucide-react';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { maskPhone, generatePassword } from '@/lib/members';
|
import { generatePassword, generateUsernameFromName } from '@/lib/members';
|
||||||
import type { Member, Department, PasswordRule } from '@/lib/members';
|
import type { Member, Department, PasswordRule } from '@/lib/members';
|
||||||
import { isSystemAdminMember } from '@/lib/member-system';
|
import { isSystemAdminMember } from '@/lib/member-system';
|
||||||
|
import { filterMembersForList, getDisplayedAccountPassword, resolveLatestEditingMember } from '@/lib/member-list';
|
||||||
import { RouteGuard } from '@/components/auth/Guard';
|
import { RouteGuard } from '@/components/auth/Guard';
|
||||||
|
|
||||||
export default function MembersPage() {
|
export default function MembersPage() {
|
||||||
@@ -22,6 +23,7 @@ function MembersPageContent() {
|
|||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
const refreshUser = useAuthStore((s) => s.refreshUser);
|
const refreshUser = useAuthStore((s) => s.refreshUser);
|
||||||
const [activeDeptId, setActiveDeptId] = useState<string | null>(null);
|
const [activeDeptId, setActiveDeptId] = useState<string | null>(null);
|
||||||
|
const [nameKeyword, setNameKeyword] = useState('');
|
||||||
const [showMemberModal, setShowMemberModal] = useState(false);
|
const [showMemberModal, setShowMemberModal] = useState(false);
|
||||||
const [editingMember, setEditingMember] = useState<Member | null>(null);
|
const [editingMember, setEditingMember] = useState<Member | null>(null);
|
||||||
const [showDeptModal, setShowDeptModal] = useState(false);
|
const [showDeptModal, setShowDeptModal] = useState(false);
|
||||||
@@ -35,10 +37,12 @@ function MembersPageContent() {
|
|||||||
const childDepts = (parentId: string) => departments.filter((d) => d.parentId === parentId).sort((a, b) => a.order - b.order);
|
const childDepts = (parentId: string) => departments.filter((d) => d.parentId === parentId).sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
const filteredMembers = useMemo(() => {
|
const filteredMembers = useMemo(() => {
|
||||||
if (!activeDeptId) return members;
|
return filterMembersForList({ members, departments, activeDeptId, nameKeyword });
|
||||||
const deptIds = [activeDeptId, ...departments.filter((d) => d.parentId === activeDeptId).map((d) => d.id)];
|
}, [members, departments, activeDeptId, nameKeyword]);
|
||||||
return members.filter((m) => deptIds.includes(m.departmentId));
|
const latestEditingMember = useMemo(
|
||||||
}, [members, activeDeptId, departments]);
|
() => resolveLatestEditingMember(editingMember, members),
|
||||||
|
[editingMember, members],
|
||||||
|
);
|
||||||
|
|
||||||
const activeDeptName = activeDeptId ? departments.find((d) => d.id === activeDeptId)?.name ?? '全部' : '全部成员';
|
const activeDeptName = activeDeptId ? departments.find((d) => d.id === activeDeptId)?.name ?? '全部' : '全部成员';
|
||||||
const roleName = (roleId: string) => roles.find((r) => r.id === roleId)?.name ?? '-';
|
const roleName = (roleId: string) => roles.find((r) => r.id === roleId)?.name ?? '-';
|
||||||
@@ -131,9 +135,18 @@ function MembersPageContent() {
|
|||||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{filteredMembers.length}</span>
|
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{filteredMembers.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" />
|
||||||
|
<input
|
||||||
|
value={nameKeyword}
|
||||||
|
onChange={(e) => setNameKeyword(e.target.value)}
|
||||||
|
placeholder="搜索姓名"
|
||||||
|
className="h-8 w-44 rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button onClick={() => setShowPwdRuleModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
|
<button onClick={() => setShowPwdRuleModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
|
||||||
<Settings className="h-3.5 w-3.5" strokeWidth={2} />
|
<Settings className="h-3.5 w-3.5" strokeWidth={2} />
|
||||||
初始密码设置
|
账号密码设置
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { setEditingMember(null); setShowMemberModal(true); }} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
|
<button onClick={() => { setEditingMember(null); setShowMemberModal(true); }} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
|
||||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||||
@@ -153,9 +166,9 @@ function MembersPageContent() {
|
|||||||
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
|
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
|
||||||
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
|
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
|
||||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">姓名</th>
|
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">姓名</th>
|
||||||
|
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">用户名</th>
|
||||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">角色</th>
|
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">角色</th>
|
||||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">部门</th>
|
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">部门</th>
|
||||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">手机号</th>
|
|
||||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">企业邮箱</th>
|
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">企业邮箱</th>
|
||||||
<th className="px-4 py-2.5 text-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">操作</th>
|
<th className="px-4 py-2.5 text-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -164,11 +177,11 @@ function MembersPageContent() {
|
|||||||
{filteredMembers.map((m) => (
|
{filteredMembers.map((m) => (
|
||||||
<tr key={m.id} className="border-b border-[var(--line-soft)] last:border-0 hover:bg-[var(--bg-subtle)] transition-colors">
|
<tr key={m.id} className="border-b border-[var(--line-soft)] last:border-0 hover:bg-[var(--bg-subtle)] transition-colors">
|
||||||
<td className="px-4 py-3 font-medium text-[var(--ink)]">{m.name}</td>
|
<td className="px-4 py-3 font-medium text-[var(--ink)]">{m.name}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-[var(--ink-soft)]">{m.username ?? '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="inline-flex items-center rounded-md bg-[var(--bg-subtle)] px-2 py-0.5 text-[11px] font-medium text-[var(--ink-soft)]">{roleName(m.roleId)}</span>
|
<span className="inline-flex items-center rounded-md bg-[var(--bg-subtle)] px-2 py-0.5 text-[11px] font-medium text-[var(--ink-soft)]">{roleName(m.roleId)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[var(--ink-soft)]">{departments.find((d) => d.id === m.departmentId)?.name ?? '-'}</td>
|
<td className="px-4 py-3 text-[var(--ink-soft)]">{departments.find((d) => d.id === m.departmentId)?.name ?? '-'}</td>
|
||||||
<td className="px-4 py-3 tabular-nums text-[var(--ink-soft)]">{maskPhone(m.phone)}</td>
|
|
||||||
<td className="px-4 py-3 text-[var(--ink-soft)]">{m.email}</td>
|
<td className="px-4 py-3 text-[var(--ink-soft)]">{m.email}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
@@ -193,9 +206,10 @@ function MembersPageContent() {
|
|||||||
{/* Member Modal */}
|
{/* Member Modal */}
|
||||||
{showMemberModal && (
|
{showMemberModal && (
|
||||||
<MemberModal
|
<MemberModal
|
||||||
initial={editingMember}
|
initial={latestEditingMember}
|
||||||
departments={departments}
|
departments={departments}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
|
existingUsernames={members.map((m) => m.username ?? '')}
|
||||||
defaultDeptId={activeDeptId}
|
defaultDeptId={activeDeptId}
|
||||||
passwordRule={passwordRule}
|
passwordRule={passwordRule}
|
||||||
onClose={() => setShowMemberModal(false)}
|
onClose={() => setShowMemberModal(false)}
|
||||||
@@ -237,10 +251,11 @@ function MembersPageContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MemberModal({ initial, departments, roles, defaultDeptId, passwordRule, onClose, onSubmit }: {
|
function MemberModal({ initial, departments, roles, existingUsernames, defaultDeptId, passwordRule, onClose, onSubmit }: {
|
||||||
initial: Member | null;
|
initial: Member | null;
|
||||||
departments: Department[];
|
departments: Department[];
|
||||||
roles: { id: string; name: string }[];
|
roles: { id: string; name: string }[];
|
||||||
|
existingUsernames: string[];
|
||||||
defaultDeptId: string | null;
|
defaultDeptId: string | null;
|
||||||
passwordRule: PasswordRule;
|
passwordRule: PasswordRule;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -249,15 +264,23 @@ function MemberModal({ initial, departments, roles, defaultDeptId, passwordRule,
|
|||||||
const [name, setName] = useState(initial?.name ?? '');
|
const [name, setName] = useState(initial?.name ?? '');
|
||||||
const [departmentId, setDepartmentId] = useState(initial?.departmentId ?? defaultDeptId ?? '');
|
const [departmentId, setDepartmentId] = useState(initial?.departmentId ?? defaultDeptId ?? '');
|
||||||
const [roleId, setRoleId] = useState(initial?.roleId ?? '');
|
const [roleId, setRoleId] = useState(initial?.roleId ?? '');
|
||||||
const [phone, setPhone] = useState(initial?.phone ?? '');
|
|
||||||
const [email, setEmail] = useState(initial?.email ?? '');
|
const [email, setEmail] = useState(initial?.email ?? '');
|
||||||
const [password] = useState(() => initial?.password ?? generatePassword(passwordRule));
|
const [generatedPassword] = useState(() => generatePassword(passwordRule));
|
||||||
|
const password = getDisplayedAccountPassword(initial, generatedPassword);
|
||||||
const isSystemMember = initial ? isSystemAdminMember(initial) : false;
|
const isSystemMember = initial ? isSystemAdminMember(initial) : false;
|
||||||
|
const usernamePreview = initial
|
||||||
|
? (initial.username || generateUsernameFromName(initial.name, existingUsernames))
|
||||||
|
: (name.trim() ? generateUsernameFromName(name, existingUsernames) : '');
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim() || !departmentId || !roleId) return;
|
if (!name.trim() || !departmentId || !roleId) return;
|
||||||
onSubmit({ name: name.trim(), departmentId, roleId, phone: phone.trim(), email: email.trim(), password });
|
const data: Partial<Member> = { name: name.trim(), departmentId, roleId, email: email.trim(), password };
|
||||||
|
if (!initial) {
|
||||||
|
data.username = usernamePreview;
|
||||||
|
data.phone = '';
|
||||||
|
}
|
||||||
|
onSubmit(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -277,6 +300,15 @@ function MemberModal({ initial, departments, roles, defaultDeptId, passwordRule,
|
|||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">姓名 *</label>
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">姓名 *</label>
|
||||||
<input value={name} onChange={(e) => setName(e.target.value)} required disabled={isSystemMember} placeholder="请输入姓名" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none disabled:bg-zinc-50 disabled:text-[var(--ink-soft)]" />
|
<input value={name} onChange={(e) => setName(e.target.value)} required disabled={isSystemMember} placeholder="请输入姓名" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none disabled:bg-zinc-50 disabled:text-[var(--ink-soft)]" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">用户名</label>
|
||||||
|
<div className="flex h-9 w-full items-center rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3">
|
||||||
|
<span className="flex-1 font-mono text-[13px] text-[var(--ink)]">{usernamePreview || '输入姓名后自动生成'}</span>
|
||||||
|
{!initial && (
|
||||||
|
<span className="text-[10px] text-[var(--ink-muted)]">自动生成</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">部门 *</label>
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">部门 *</label>
|
||||||
@@ -293,16 +325,12 @@ function MemberModal({ initial, departments, roles, defaultDeptId, passwordRule,
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">手机号</label>
|
|
||||||
<input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="11位手机号" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">企业邮箱</label>
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">企业邮箱</label>
|
||||||
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@company.com" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@company.com" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">初始密码</label>
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">账号密码</label>
|
||||||
<div className="flex items-center h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3">
|
<div className="flex items-center h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3">
|
||||||
<span className="flex-1 text-[13px] font-mono text-[var(--ink)]">{password}</span>
|
<span className="flex-1 text-[13px] font-mono text-[var(--ink)]">{password}</span>
|
||||||
{!initial && (
|
{!initial && (
|
||||||
@@ -385,7 +413,7 @@ function PasswordRuleModal({ rule, onClose, onSave }: {
|
|||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||||
<div className="w-full max-w-sm rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
<div className="w-full max-w-sm rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">初始密码规则设置</h3>
|
<h3 className="text-[13px] font-semibold text-[var(--ink)]">账号密码规则设置</h3>
|
||||||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { Eye, EyeOff, LayoutGrid, Lock, Phone } from 'lucide-react';
|
import { Eye, EyeOff, LayoutGrid, Lock, User } from 'lucide-react';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { login } = useAuthStore();
|
const { login } = useAuthStore();
|
||||||
const [phone, setPhone] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [remember, setRemember] = useState(false);
|
const [remember, setRemember] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
@@ -19,18 +19,18 @@ export default function LoginPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
if (!phone.trim() || !password.trim()) {
|
if (!username.trim() || !password.trim()) {
|
||||||
setError('请输入手机号和密码');
|
setError('请输入用户名和密码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const success = await login(phone.trim(), password, remember);
|
const success = await login(username.trim(), password, remember);
|
||||||
if (success) {
|
if (success) {
|
||||||
router.push('/products');
|
router.push('/products');
|
||||||
} else {
|
} else {
|
||||||
setError('手机号或密码错误');
|
setError('用户名或密码错误');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -51,15 +51,13 @@ export default function LoginPage() {
|
|||||||
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)]">
|
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)]">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">手机号</label>
|
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">用户名</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Phone className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)]" />
|
<User className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)]" />
|
||||||
<input
|
<input
|
||||||
type="tel"
|
value={username}
|
||||||
value={phone}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
placeholder="请输入用户名"
|
||||||
placeholder="请输入手机号"
|
|
||||||
maxLength={11}
|
|
||||||
className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-3 text-[14px] text-[var(--ink)] transition-all placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-3 text-[14px] text-[var(--ink)] transition-all placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +111,7 @@ export default function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-4 text-center text-[11px] text-[var(--ink-muted)]">
|
<p className="mt-4 text-center text-[11px] text-[var(--ink-muted)]">
|
||||||
默认账号:13200132008 / Ftb@2024(超级管理员)
|
默认账号:admin / Ftb@2024(超级管理员)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { LogOut, User, KeyRound, Eye, EyeOff, Phone, Mail, Loader2, Check } from 'lucide-react';
|
import { LogOut, User, KeyRound, Eye, EyeOff, Mail, Loader2, Check } from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { FieldError } from '@/components/FieldError';
|
import { FieldError } from '@/components/FieldError';
|
||||||
@@ -60,9 +60,7 @@ export default function ProfilePage() {
|
|||||||
{role?.name ?? '-'} <span className="mx-1.5 text-white/40">·</span> {deptName}
|
{role?.name ?? '-'} <span className="mx-1.5 text-white/40">·</span> {deptName}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-3 mt-2 text-xs text-white/70">
|
<div className="flex items-center gap-3 mt-2 text-xs text-white/70">
|
||||||
{user.phone && (
|
<span className="flex items-center gap-1"><User className="h-3 w-3" />{user.username}</span>
|
||||||
<span className="flex items-center gap-1"><Phone className="h-3 w-3" />{user.phone}</span>
|
|
||||||
)}
|
|
||||||
{user.email && (
|
{user.email && (
|
||||||
<span className="flex items-center gap-1"><Mail className="h-3 w-3" />{user.email}</span>
|
<span className="flex items-center gap-1"><Mail className="h-3 w-3" />{user.email}</span>
|
||||||
)}
|
)}
|
||||||
@@ -113,9 +111,8 @@ function InfoForm() {
|
|||||||
const member = members.find((m) => m.id === user.id);
|
const member = members.find((m) => m.id === user.id);
|
||||||
|
|
||||||
const [name, setName] = useState(member?.name ?? user.name);
|
const [name, setName] = useState(member?.name ?? user.name);
|
||||||
const [phone, setPhone] = useState(member?.phone ?? user.phone);
|
|
||||||
const [email, setEmail] = useState(member?.email ?? user.email);
|
const [email, setEmail] = useState(member?.email ?? user.email);
|
||||||
const [errors, setErrors] = useState<{ name?: string; phone?: string; email?: string }>({});
|
const [errors, setErrors] = useState<{ name?: string; email?: string }>({});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [savedAt, setSavedAt] = useState(0);
|
const [savedAt, setSavedAt] = useState(0);
|
||||||
|
|
||||||
@@ -133,7 +130,6 @@ function InfoForm() {
|
|||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setName(member?.name ?? user.name);
|
setName(member?.name ?? user.name);
|
||||||
setPhone(member?.phone ?? user.phone);
|
|
||||||
setEmail(member?.email ?? user.email);
|
setEmail(member?.email ?? user.email);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
};
|
};
|
||||||
@@ -143,8 +139,6 @@ function InfoForm() {
|
|||||||
const errs: typeof errors = {};
|
const errs: typeof errors = {};
|
||||||
if (!name.trim()) errs.name = '请输入姓名';
|
if (!name.trim()) errs.name = '请输入姓名';
|
||||||
else if (name.trim().length > 20) errs.name = '姓名最长 20 字符';
|
else if (name.trim().length > 20) errs.name = '姓名最长 20 字符';
|
||||||
if (!phone.trim()) errs.phone = '请输入手机号';
|
|
||||||
else if (!/^1[3-9]\d{9}$/.test(phone.trim())) errs.phone = '手机号格式错误';
|
|
||||||
if (!email.trim()) errs.email = '请输入邮箱';
|
if (!email.trim()) errs.email = '请输入邮箱';
|
||||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) errs.email = '邮箱格式错误';
|
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) errs.email = '邮箱格式错误';
|
||||||
setErrors(errs);
|
setErrors(errs);
|
||||||
@@ -152,7 +146,7 @@ function InfoForm() {
|
|||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
updateMember(user.id, { name: name.trim(), phone: phone.trim(), email: email.trim() });
|
updateMember(user.id, { name: name.trim(), email: email.trim() });
|
||||||
refreshUser();
|
refreshUser();
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setSavedAt(Date.now());
|
setSavedAt(Date.now());
|
||||||
@@ -168,9 +162,8 @@ function InfoForm() {
|
|||||||
<input value={name} onChange={(e) => setName(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
<input value={name} onChange={(e) => setName(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||||
<FieldError message={errors.name} />
|
<FieldError message={errors.name} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="手机号" required>
|
<Field label="用户名">
|
||||||
<input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="11位手机号" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
<input value={member?.username ?? user.username} disabled className="h-9 w-full rounded-lg border border-[var(--line)] bg-zinc-50 px-3 font-mono text-[13px] text-[var(--ink-soft)] cursor-not-allowed" />
|
||||||
<FieldError message={errors.phone} />
|
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="邮箱" required>
|
<Field label="邮箱" required>
|
||||||
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@company.com" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@company.com" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { flattenProjects, flattenVersions } from '@/lib/derive';
|
|||||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
||||||
import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement';
|
import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement';
|
||||||
import { deriveReqDevStatus, canEditRequirement, canCloseRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
|
import { deriveReqDevStatus, canEditRequirement, canCloseRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
|
||||||
import { buildRequirementScopeTree, filterRequirementsByScope, type RequirementProductScopeNode, type RequirementScopeSelection } from '@/lib/requirement-scope';
|
import { buildRequirementScopeTree, filterRequirementScopeTreeByKeyword, filterRequirementsByScope, type RequirementProductScopeNode, type RequirementScopeSelection } from '@/lib/requirement-scope';
|
||||||
import { sortRequirementsByCreatedAt, type RequirementDateSort } from '@/lib/requirement-sort';
|
import { sortRequirementsByCreatedAt, type RequirementDateSort } from '@/lib/requirement-sort';
|
||||||
import { Pagination, usePagination } from '@/components/Pagination';
|
import { Pagination, usePagination } from '@/components/Pagination';
|
||||||
import { RequirementModal } from '@/components/requirement/RequirementModal';
|
import { RequirementModal } from '@/components/requirement/RequirementModal';
|
||||||
@@ -173,6 +173,7 @@ function RequirementsPageContent() {
|
|||||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
|
|
||||||
const [selectedScope, setSelectedScope] = useState<RequirementScopeSelection>({ type: 'all' });
|
const [selectedScope, setSelectedScope] = useState<RequirementScopeSelection>({ type: 'all' });
|
||||||
|
const [scopeSearch, setScopeSearch] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||||
@@ -202,6 +203,7 @@ function RequirementsPageContent() {
|
|||||||
}, [selectedScopeKey]);
|
}, [selectedScopeKey]);
|
||||||
|
|
||||||
const scopeTree = useMemo(() => buildRequirementScopeTree(overview, requirements), [overview, requirements]);
|
const scopeTree = useMemo(() => buildRequirementScopeTree(overview, requirements), [overview, requirements]);
|
||||||
|
const searchedScopeTree = useMemo(() => filterRequirementScopeTreeByKeyword(scopeTree, scopeSearch), [scopeTree, scopeSearch]);
|
||||||
const scopedRequirements = useMemo(() => filterRequirementsByScope(requirements, selectedScope), [requirements, selectedScope]);
|
const scopedRequirements = useMemo(() => filterRequirementsByScope(requirements, selectedScope), [requirements, selectedScope]);
|
||||||
const scopedVersions = useMemo(() => {
|
const scopedVersions = useMemo(() => {
|
||||||
if (selectedScope.type === 'product') return allVersions.filter((v) => v.productId === selectedScope.productId);
|
if (selectedScope.type === 'product') return allVersions.filter((v) => v.productId === selectedScope.productId);
|
||||||
@@ -303,6 +305,18 @@ function RequirementsPageContent() {
|
|||||||
{requirements.length}
|
{requirements.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="shrink-0 border-b border-[var(--line)] px-3 py-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
||||||
|
<input
|
||||||
|
value={scopeSearch}
|
||||||
|
onChange={(event) => setScopeSearch(event.target.value)}
|
||||||
|
placeholder="搜索产品或项目"
|
||||||
|
aria-label="搜索产品或项目"
|
||||||
|
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[12px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedScope({ type: 'all' })}
|
onClick={() => setSelectedScope({ type: 'all' })}
|
||||||
@@ -316,7 +330,7 @@ function RequirementsPageContent() {
|
|||||||
<span className="flex-1 text-left">全部需求</span>
|
<span className="flex-1 text-left">全部需求</span>
|
||||||
<ScopeCount count={requirements.length} />
|
<ScopeCount count={requirements.length} />
|
||||||
</button>
|
</button>
|
||||||
{scopeTree.map((product) => (
|
{searchedScopeTree.map((product) => (
|
||||||
<RequirementProductNode
|
<RequirementProductNode
|
||||||
key={product.id}
|
key={product.id}
|
||||||
product={product}
|
product={product}
|
||||||
@@ -324,6 +338,11 @@ function RequirementsPageContent() {
|
|||||||
onSelect={setSelectedScope}
|
onSelect={setSelectedScope}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{searchedScopeTree.length === 0 && scopeSearch.trim() && (
|
||||||
|
<div className="mt-3 rounded-lg border border-dashed border-[var(--line)] px-3 py-4 text-center text-[12px] text-[var(--ink-muted)]">
|
||||||
|
没有匹配的产品或项目
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -70,18 +70,10 @@ const STARTER_QUESTIONS = getFallbackHelpSuggestions();
|
|||||||
|
|
||||||
type WenfanConversation = WenfanConversationRecord<ChatMessage>;
|
type WenfanConversation = WenfanConversationRecord<ChatMessage>;
|
||||||
|
|
||||||
const INITIAL_CONVERSATIONS: WenfanConversation[] = [
|
|
||||||
{ id: 'history-product-create', title: '怎么新建产品?', messages: INITIAL_MESSAGES },
|
|
||||||
{ id: 'history-version-requirements', title: '怎么把需求纳入版本?', messages: INITIAL_MESSAGES },
|
|
||||||
{ id: 'history-dev-task-submit', title: '开发任务怎么提测?', messages: INITIAL_MESSAGES },
|
|
||||||
{ id: 'history-test-bug', title: '测试用例失败后怎么提 Bug?', messages: INITIAL_MESSAGES },
|
|
||||||
{ id: 'history-activity-log', title: '日志记录会记录哪些行为?', messages: INITIAL_MESSAGES },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function WenfanXiaobaoPage() {
|
export default function WenfanXiaobaoPage() {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>(INITIAL_MESSAGES);
|
const [messages, setMessages] = useState<ChatMessage[]>(INITIAL_MESSAGES);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [conversations, setConversations] = useState<WenfanConversation[]>(INITIAL_CONVERSATIONS);
|
const [conversations, setConversations] = useState<WenfanConversation[]>([]);
|
||||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
||||||
|
|
||||||
const visibleHistory = useMemo(() => conversations.slice(0, 12), [conversations]);
|
const visibleHistory = useMemo(() => conversations.slice(0, 12), [conversations]);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { Search, X } from 'lucide-react';
|
||||||
import type { Requirement, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
|
import type { Requirement, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
|
||||||
import type { Priority } from '@/lib/derive';
|
import type { Priority } from '@/lib/derive';
|
||||||
import { SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement';
|
import { filterRequirementProjectOptions, filterRequirementVersionOptions, filterSourceTargetsByKeyword, formatSourceTargetSelection, parseSourceTargetSelection, SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement';
|
||||||
|
|
||||||
interface RequirementModalProps {
|
interface RequirementModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -50,7 +50,7 @@ export function RequirementModal({
|
|||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [sourceType, setSourceType] = useState<SourceType>('customer');
|
const [sourceType, setSourceType] = useState<SourceType>('customer');
|
||||||
const [sourceTarget, setSourceTarget] = useState('');
|
const [selectedSourceTargets, setSelectedSourceTargets] = useState<string[]>([]);
|
||||||
const [productId, setProductId] = useState('');
|
const [productId, setProductId] = useState('');
|
||||||
const [projectId, setProjectId] = useState('');
|
const [projectId, setProjectId] = useState('');
|
||||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
||||||
@@ -60,16 +60,25 @@ export function RequirementModal({
|
|||||||
const [parentId, setParentId] = useState('');
|
const [parentId, setParentId] = useState('');
|
||||||
const [versionId, setVersionId] = useState('');
|
const [versionId, setVersionId] = useState('');
|
||||||
|
|
||||||
|
const [sourceTypeDropOpen, setSourceTypeDropOpen] = useState(false);
|
||||||
|
const [sourceTargetDropOpen, setSourceTargetDropOpen] = useState(false);
|
||||||
|
const [sourceTargetSearch, setSourceTargetSearch] = useState('');
|
||||||
|
const [productDropOpen, setProductDropOpen] = useState(false);
|
||||||
|
const [projectDropOpen, setProjectDropOpen] = useState(false);
|
||||||
|
const [projectSearch, setProjectSearch] = useState('');
|
||||||
|
const [versionDropOpen, setVersionDropOpen] = useState(false);
|
||||||
|
const [versionSearch, setVersionSearch] = useState('');
|
||||||
|
const [typeDropOpen, setTypeDropOpen] = useState(false);
|
||||||
const [platformDropOpen, setPlatformDropOpen] = useState(false);
|
const [platformDropOpen, setPlatformDropOpen] = useState(false);
|
||||||
|
|
||||||
const filteredProjects = useMemo(
|
const filteredProjects = useMemo(
|
||||||
() => productId ? projects.filter((p) => p.productId === productId) : projects,
|
() => filterRequirementProjectOptions(projects, productId, projectSearch),
|
||||||
[projects, productId],
|
[projects, productId, projectSearch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredVersions = useMemo(
|
const filteredVersions = useMemo(
|
||||||
() => projectId ? versions.filter((v) => v.projectId === projectId) : [],
|
() => filterRequirementVersionOptions(versions, projectId, versionSearch),
|
||||||
[versions, projectId],
|
[versions, projectId, versionSearch],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,7 +86,16 @@ export function RequirementModal({
|
|||||||
setTitle(initial.title);
|
setTitle(initial.title);
|
||||||
setDescription(initial.description || '');
|
setDescription(initial.description || '');
|
||||||
setSourceType(initial.sourceType || 'customer');
|
setSourceType(initial.sourceType || 'customer');
|
||||||
setSourceTarget(initial.sourceTarget || '');
|
setSelectedSourceTargets(parseSourceTargetSelection(initial.sourceTarget));
|
||||||
|
setSourceTypeDropOpen(false);
|
||||||
|
setSourceTargetSearch('');
|
||||||
|
setSourceTargetDropOpen(false);
|
||||||
|
setProductDropOpen(false);
|
||||||
|
setProjectSearch('');
|
||||||
|
setProjectDropOpen(false);
|
||||||
|
setVersionSearch('');
|
||||||
|
setVersionDropOpen(false);
|
||||||
|
setTypeDropOpen(false);
|
||||||
setProductId(initial.productId || '');
|
setProductId(initial.productId || '');
|
||||||
setProjectId(initial.projectId || '');
|
setProjectId(initial.projectId || '');
|
||||||
setVersionId(initial.versionId || '');
|
setVersionId(initial.versionId || '');
|
||||||
@@ -90,7 +108,16 @@ export function RequirementModal({
|
|||||||
setTitle('');
|
setTitle('');
|
||||||
setDescription('');
|
setDescription('');
|
||||||
setSourceType('customer');
|
setSourceType('customer');
|
||||||
setSourceTarget('');
|
setSelectedSourceTargets([]);
|
||||||
|
setSourceTypeDropOpen(false);
|
||||||
|
setSourceTargetSearch('');
|
||||||
|
setSourceTargetDropOpen(false);
|
||||||
|
setProductDropOpen(false);
|
||||||
|
setProjectSearch('');
|
||||||
|
setProjectDropOpen(false);
|
||||||
|
setVersionSearch('');
|
||||||
|
setVersionDropOpen(false);
|
||||||
|
setTypeDropOpen(false);
|
||||||
setProductId('');
|
setProductId('');
|
||||||
setProjectId('');
|
setProjectId('');
|
||||||
setVersionId('');
|
setVersionId('');
|
||||||
@@ -104,7 +131,13 @@ export function RequirementModal({
|
|||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const filteredTargets = sourceTargets.filter(t => t.sourceType === sourceType);
|
const filteredTargets = filterSourceTargetsByKeyword(sourceTargets, sourceType, sourceTargetSearch);
|
||||||
|
|
||||||
|
const toggleSourceTarget = (name: string) => {
|
||||||
|
setSelectedSourceTargets((prev) =>
|
||||||
|
prev.includes(name) ? prev.filter((item) => item !== name) : [...prev, name]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const togglePlatform = (id: string) => {
|
const togglePlatform = (id: string) => {
|
||||||
setSelectedPlatforms((prev) =>
|
setSelectedPlatforms((prev) =>
|
||||||
@@ -118,7 +151,7 @@ export function RequirementModal({
|
|||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
sourceType,
|
sourceType,
|
||||||
sourceTarget: sourceTarget || undefined,
|
sourceTarget: formatSourceTargetSelection(selectedSourceTargets) || undefined,
|
||||||
productId,
|
productId,
|
||||||
projectId,
|
projectId,
|
||||||
versionId: versionId || undefined,
|
versionId: versionId || undefined,
|
||||||
@@ -132,6 +165,31 @@ export function RequirementModal({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sourceTargetDisplay =
|
||||||
|
selectedSourceTargets.length > 0
|
||||||
|
? formatSourceTargetSelection(selectedSourceTargets)
|
||||||
|
: '请选择';
|
||||||
|
|
||||||
|
const productDisplay = productId
|
||||||
|
? products.find((product) => product.id === productId)?.name ?? '请选择产品'
|
||||||
|
: '请选择产品';
|
||||||
|
|
||||||
|
const projectDisplay = projectId
|
||||||
|
? projects.find((project) => project.id === projectId)?.name ?? '请选择项目'
|
||||||
|
: productId
|
||||||
|
? '请选择项目'
|
||||||
|
: '先选择产品';
|
||||||
|
|
||||||
|
const versionDisplay = versionId
|
||||||
|
? versions.find((version) => version.id === versionId)?.name ?? '请选择版本'
|
||||||
|
: projectId
|
||||||
|
? '请选择版本'
|
||||||
|
: '先选择项目';
|
||||||
|
|
||||||
|
const typeDisplay = typeId
|
||||||
|
? types.find((type) => type.id === typeId)?.name ?? '请选择类型'
|
||||||
|
: '请选择类型';
|
||||||
|
|
||||||
const platformDisplay =
|
const platformDisplay =
|
||||||
selectedPlatforms.length > 0
|
selectedPlatforms.length > 0
|
||||||
? platforms
|
? platforms
|
||||||
@@ -194,20 +252,37 @@ export function RequirementModal({
|
|||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
||||||
来源类型
|
来源类型
|
||||||
</label>
|
</label>
|
||||||
<select
|
<div className="relative">
|
||||||
value={sourceType}
|
<button
|
||||||
onChange={(e) => {
|
type="button"
|
||||||
setSourceType(e.target.value as SourceType);
|
onClick={() => setSourceTypeDropOpen(!sourceTypeDropOpen)}
|
||||||
setSourceTarget('');
|
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-left text-[var(--ink)] truncate"
|
||||||
}}
|
|
||||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
|
||||||
>
|
>
|
||||||
{SOURCE_TYPES.map((st) => (
|
{SOURCE_TYPE_LABEL[sourceType]}
|
||||||
<option key={st} value={st}>
|
</button>
|
||||||
{SOURCE_TYPE_LABEL[st]}
|
{sourceTypeDropOpen && (
|
||||||
</option>
|
<div className="absolute left-0 top-10 z-20 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||||
|
{SOURCE_TYPES.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSourceType(item);
|
||||||
|
setSelectedSourceTargets([]);
|
||||||
|
setSourceTargetSearch('');
|
||||||
|
setSourceTargetDropOpen(false);
|
||||||
|
setSourceTypeDropOpen(false);
|
||||||
|
}}
|
||||||
|
className={`block w-full truncate px-3 py-1.5 text-left text-[13px] hover:bg-[var(--bg-subtle)] ${
|
||||||
|
sourceType === item ? 'font-medium text-[var(--accent)]' : 'text-[var(--ink)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{SOURCE_TYPE_LABEL[item]}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
@@ -222,18 +297,49 @@ export function RequirementModal({
|
|||||||
管理
|
管理
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<div className="relative">
|
||||||
value={sourceTarget}
|
<button
|
||||||
onChange={(e) => setSourceTarget(e.target.value)}
|
type="button"
|
||||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
onClick={() => setSourceTargetDropOpen(!sourceTargetDropOpen)}
|
||||||
|
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-left text-[var(--ink)] truncate"
|
||||||
>
|
>
|
||||||
<option value="">请选择</option>
|
{sourceTargetDisplay}
|
||||||
{filteredTargets.map((t) => (
|
</button>
|
||||||
<option key={t.id} value={t.name}>
|
{sourceTargetDropOpen && (
|
||||||
{t.name}
|
<div className="absolute left-0 top-10 z-20 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-md)]">
|
||||||
</option>
|
<div className="border-b border-[var(--line)] p-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
||||||
|
<input
|
||||||
|
value={sourceTargetSearch}
|
||||||
|
onChange={(event) => setSourceTargetSearch(event.target.value)}
|
||||||
|
placeholder="搜索"
|
||||||
|
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[12px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-40 overflow-y-auto py-1">
|
||||||
|
{filteredTargets.map((target) => (
|
||||||
|
<label
|
||||||
|
key={target.id}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 hover:bg-[var(--bg-subtle)] cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSourceTargets.includes(target.name)}
|
||||||
|
onChange={() => toggleSourceTarget(target.name)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-[var(--line)] accent-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px] text-[var(--ink)]">{target.name}</span>
|
||||||
|
</label>
|
||||||
))}
|
))}
|
||||||
</select>
|
{filteredTargets.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-[12px] text-[var(--ink-muted)]">暂无匹配</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -243,46 +349,144 @@ export function RequirementModal({
|
|||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
||||||
所属产品 <span className="text-red-500">*</span>
|
所属产品 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<div className="relative">
|
||||||
value={productId}
|
<button
|
||||||
onChange={(e) => { setProductId(e.target.value); setProjectId(''); setVersionId(''); }}
|
type="button"
|
||||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
onClick={() => setProductDropOpen(!productDropOpen)}
|
||||||
|
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-left text-[var(--ink)] truncate"
|
||||||
>
|
>
|
||||||
<option value="">请选择产品</option>
|
{productDisplay}
|
||||||
{products.map((p) => (
|
</button>
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
{productDropOpen && (
|
||||||
|
<div className="absolute left-0 top-10 z-20 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||||
|
{products.map((product) => (
|
||||||
|
<button
|
||||||
|
key={product.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setProductId(product.id);
|
||||||
|
setProjectId('');
|
||||||
|
setVersionId('');
|
||||||
|
setProjectSearch('');
|
||||||
|
setProjectDropOpen(false);
|
||||||
|
setVersionSearch('');
|
||||||
|
setVersionDropOpen(false);
|
||||||
|
setProductDropOpen(false);
|
||||||
|
}}
|
||||||
|
className={`block w-full truncate px-3 py-1.5 text-left text-[13px] hover:bg-[var(--bg-subtle)] ${
|
||||||
|
productId === product.id ? 'font-medium text-[var(--accent)]' : 'text-[var(--ink)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{product.name}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</select>
|
{products.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-[12px] text-[var(--ink-muted)]">暂无产品</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
||||||
所属项目 <span className="text-red-500">*</span>
|
所属项目 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<div className="relative">
|
||||||
value={projectId}
|
<button
|
||||||
onChange={(e) => { setProjectId(e.target.value); setVersionId(''); }}
|
type="button"
|
||||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
disabled={!productId}
|
||||||
|
onClick={() => productId && setProjectDropOpen(!projectDropOpen)}
|
||||||
|
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-left text-[var(--ink)] truncate disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<option value="">请选择项目</option>
|
{projectDisplay}
|
||||||
{filteredProjects.map((p) => (
|
</button>
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
{projectDropOpen && productId && (
|
||||||
|
<div className="absolute left-0 top-10 z-20 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-md)]">
|
||||||
|
<div className="border-b border-[var(--line)] p-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
||||||
|
<input
|
||||||
|
value={projectSearch}
|
||||||
|
onChange={(event) => setProjectSearch(event.target.value)}
|
||||||
|
placeholder="搜索项目"
|
||||||
|
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[12px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-40 overflow-y-auto py-1">
|
||||||
|
{filteredProjects.map((project) => (
|
||||||
|
<button
|
||||||
|
key={project.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setProjectId(project.id);
|
||||||
|
setVersionId('');
|
||||||
|
setProjectSearch('');
|
||||||
|
setProjectDropOpen(false);
|
||||||
|
setVersionSearch('');
|
||||||
|
setVersionDropOpen(false);
|
||||||
|
}}
|
||||||
|
className="block w-full truncate px-3 py-1.5 text-left text-[13px] text-[var(--ink)] hover:bg-[var(--bg-subtle)]"
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</select>
|
{filteredProjects.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-[12px] text-[var(--ink-muted)]">暂无匹配</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
||||||
所属版本
|
所属版本
|
||||||
</label>
|
</label>
|
||||||
<select
|
<div className="relative">
|
||||||
value={versionId}
|
<button
|
||||||
onChange={(e) => setVersionId(e.target.value)}
|
type="button"
|
||||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
disabled={!projectId}
|
||||||
|
onClick={() => projectId && setVersionDropOpen(!versionDropOpen)}
|
||||||
|
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-left text-[var(--ink)] truncate disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<option value="">请选择版本</option>
|
{versionDisplay}
|
||||||
{filteredVersions.map((v) => (
|
</button>
|
||||||
<option key={v.id} value={v.id}>{v.name}</option>
|
{versionDropOpen && projectId && (
|
||||||
|
<div className="absolute left-0 top-10 z-20 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-md)]">
|
||||||
|
<div className="border-b border-[var(--line)] p-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
||||||
|
<input
|
||||||
|
value={versionSearch}
|
||||||
|
onChange={(event) => setVersionSearch(event.target.value)}
|
||||||
|
placeholder="搜索版本"
|
||||||
|
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[12px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-40 overflow-y-auto py-1">
|
||||||
|
{filteredVersions.map((version) => (
|
||||||
|
<button
|
||||||
|
key={version.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setVersionId(version.id);
|
||||||
|
setVersionSearch('');
|
||||||
|
setVersionDropOpen(false);
|
||||||
|
}}
|
||||||
|
className="block w-full truncate px-3 py-1.5 text-left text-[13px] text-[var(--ink)] hover:bg-[var(--bg-subtle)]"
|
||||||
|
>
|
||||||
|
{version.name}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</select>
|
{filteredVersions.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-[12px] text-[var(--ink-muted)]">暂无匹配</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -342,18 +546,37 @@ export function RequirementModal({
|
|||||||
管理
|
管理
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<div className="relative">
|
||||||
value={typeId}
|
<button
|
||||||
onChange={(e) => setTypeId(e.target.value)}
|
type="button"
|
||||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
onClick={() => setTypeDropOpen(!typeDropOpen)}
|
||||||
|
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-left text-[var(--ink)] truncate"
|
||||||
>
|
>
|
||||||
<option value="">请选择类型</option>
|
{typeDisplay}
|
||||||
{types.map((t) => (
|
</button>
|
||||||
<option key={t.id} value={t.id}>
|
{typeDropOpen && (
|
||||||
{t.name}
|
<div className="absolute left-0 top-10 z-20 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||||
</option>
|
{types.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTypeId(type.id);
|
||||||
|
setTypeDropOpen(false);
|
||||||
|
}}
|
||||||
|
className={`block w-full truncate px-3 py-1.5 text-left text-[13px] hover:bg-[var(--bg-subtle)] ${
|
||||||
|
typeId === type.id ? 'font-medium text-[var(--accent)]' : 'text-[var(--ink)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{type.name}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</select>
|
{types.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-[12px] text-[var(--ink-muted)]">暂无类型</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 7. 优先级 (button group) */}
|
{/* 7. 优先级 (button group) */}
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v
|
|||||||
let apiAvailable: boolean | null = null;
|
let apiAvailable: boolean | null = null;
|
||||||
let probePromise: Promise<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> {
|
async function checkApi(): Promise<boolean> {
|
||||||
if (apiAvailable !== null) return apiAvailable;
|
if (apiAvailable !== null) return apiAvailable;
|
||||||
if (probePromise) return probePromise;
|
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) throw new Error('API 不可用');
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const error = await res.json().catch(() => ({}));
|
const error = await res.json().catch(() => ({}));
|
||||||
throw new Error(error.message || `请求失败: ${res.status}`);
|
throw new ApiRequestError(res.status, error);
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -56,7 +75,7 @@ export const api = {
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(err.message || `请求失败: ${res.status}`);
|
throw new ApiRequestError(res.status, err);
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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';
|
import type { Member } from './members';
|
||||||
|
|
||||||
const member: Member = {
|
const member: Member = {
|
||||||
id: 'm-8',
|
id: 'm-8',
|
||||||
name: '超级管理员',
|
name: '超级管理员',
|
||||||
departmentId: 'dept-1',
|
username: 'admin',
|
||||||
|
departmentId: '',
|
||||||
roleId: 'role-admin',
|
roleId: 'role-admin',
|
||||||
phone: '13200132008',
|
phone: '13200132008',
|
||||||
email: 'admin@company.com',
|
email: '',
|
||||||
password: 'Ftb@2024',
|
password: 'Ftb@2024',
|
||||||
createdAt: '2024-01-01',
|
createdAt: '2024-01-01',
|
||||||
};
|
};
|
||||||
@@ -19,10 +20,10 @@ test('toAuthUser removes password and keeps stable member identity', () => {
|
|||||||
assert.deepEqual(toAuthUser(member), {
|
assert.deepEqual(toAuthUser(member), {
|
||||||
id: 'm-8',
|
id: 'm-8',
|
||||||
name: '超级管理员',
|
name: '超级管理员',
|
||||||
departmentId: 'dept-1',
|
username: 'admin',
|
||||||
|
departmentId: '',
|
||||||
roleId: 'role-admin',
|
roleId: 'role-admin',
|
||||||
phone: '13200132008',
|
email: '',
|
||||||
email: 'admin@company.com',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,9 +31,9 @@ test('resolveAuthUserFromMembers refreshes stale display names by member id', ()
|
|||||||
const staleUser: AuthUser = {
|
const staleUser: AuthUser = {
|
||||||
id: 'm-8',
|
id: 'm-8',
|
||||||
name: '陈十',
|
name: '陈十',
|
||||||
departmentId: 'dept-1',
|
username: 'chenshi',
|
||||||
|
departmentId: '',
|
||||||
roleId: 'role-admin',
|
roleId: 'role-admin',
|
||||||
phone: '13200132008',
|
|
||||||
email: 'old@company.com',
|
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?.id, 'm-8');
|
||||||
assert.equal(resolved?.name, '超级管理员');
|
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', () => {
|
test('resolveAuthUserFromMembers returns null when the account no longer exists', () => {
|
||||||
const staleUser: AuthUser = {
|
const staleUser: AuthUser = {
|
||||||
id: 'm-9',
|
id: 'm-9',
|
||||||
name: '离职用户',
|
name: '离职用户',
|
||||||
|
username: 'lizhiyonghu',
|
||||||
departmentId: 'dept-2',
|
departmentId: 'dept-2',
|
||||||
roleId: 'role-dev',
|
roleId: 'role-dev',
|
||||||
phone: '13900139000',
|
|
||||||
email: 'old@company.com',
|
email: 'old@company.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(resolveAuthUserFromMembers(staleUser, [member]), null);
|
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 type { Member } from './members';
|
||||||
|
import { generateUsernameFromName, normalizeUsername } from './members';
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
username: string;
|
||||||
roleId: string;
|
roleId: string;
|
||||||
departmentId: string;
|
departmentId: string;
|
||||||
phone: string;
|
|
||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,9 +14,9 @@ export function toAuthUser(member: Member): AuthUser {
|
|||||||
return {
|
return {
|
||||||
id: member.id,
|
id: member.id,
|
||||||
name: member.name,
|
name: member.name,
|
||||||
|
username: normalizeUsername(member.username) || generateUsernameFromName(member.name, []),
|
||||||
roleId: member.roleId,
|
roleId: member.roleId,
|
||||||
departmentId: member.departmentId,
|
departmentId: member.departmentId,
|
||||||
phone: member.phone,
|
|
||||||
email: member.email,
|
email: member.email,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -24,3 +25,16 @@ export function resolveAuthUserFromMembers(user: AuthUser, members: Member[]): A
|
|||||||
const member = members.find((m) => m.id === user.id);
|
const member = members.find((m) => m.id === user.id);
|
||||||
return member ? toAuthUser(member) : null;
|
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,
|
resolveMemberDisplayName,
|
||||||
isMemberReference,
|
isMemberReference,
|
||||||
} from './member-system';
|
} from './member-system';
|
||||||
import type { Member } from './members';
|
import { ensureMemberUsernames, type Member } from './members';
|
||||||
|
|
||||||
const oldAdmin: Member = {
|
const oldAdmin: Member = {
|
||||||
id: SYSTEM_ADMIN_MEMBER_ID,
|
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.length, 1);
|
||||||
assert.equal(result.members[0].id, SYSTEM_ADMIN_MEMBER_ID);
|
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].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].roleId, SYSTEM_ADMIN_ROLE_ID);
|
||||||
assert.equal(result.members[0].isSystem, true);
|
assert.equal(result.members[0].isSystem, true);
|
||||||
assert.equal(result.members[0].phone, oldAdmin.phone);
|
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.length, 1);
|
||||||
assert.equal(result.members[0].id, SYSTEM_ADMIN_MEMBER_ID);
|
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].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].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', () => {
|
test('protects the built-in admin account from identity and role edits', () => {
|
||||||
const patch = sanitizeSystemAdminPatch(SYSTEM_ADMIN_MEMBER_ID, {
|
const patch = sanitizeSystemAdminPatch(SYSTEM_ADMIN_MEMBER_ID, {
|
||||||
name: '陈十',
|
name: '陈十',
|
||||||
|
username: 'chenshi',
|
||||||
roleId: 'role-dev',
|
roleId: 'role-dev',
|
||||||
departmentId: 'dept-3',
|
departmentId: 'dept-3',
|
||||||
phone: '13900139000',
|
phone: '13900139000',
|
||||||
|
email: 'admin@company.com',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(patch.name, undefined);
|
assert.equal(patch.name, undefined);
|
||||||
|
assert.equal(patch.username, undefined);
|
||||||
assert.equal(patch.roleId, undefined);
|
assert.equal(patch.roleId, undefined);
|
||||||
assert.equal(patch.departmentId, undefined);
|
assert.equal(patch.departmentId, undefined);
|
||||||
|
assert.equal(patch.email, undefined);
|
||||||
assert.equal(patch.phone, '13900139000');
|
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_ID = 'm-8';
|
||||||
export const SYSTEM_ADMIN_MEMBER_NAME = '超级管理员';
|
export const SYSTEM_ADMIN_MEMBER_NAME = '超级管理员';
|
||||||
export const SYSTEM_ADMIN_ROLE_ID = 'role-admin';
|
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 = ['陈十'];
|
const LEGACY_SYSTEM_ADMIN_NAMES = ['陈十'];
|
||||||
|
|
||||||
export const SYSTEM_ADMIN_MEMBER: Member = {
|
export const SYSTEM_ADMIN_MEMBER: Member = {
|
||||||
id: SYSTEM_ADMIN_MEMBER_ID,
|
id: SYSTEM_ADMIN_MEMBER_ID,
|
||||||
name: SYSTEM_ADMIN_MEMBER_NAME,
|
name: SYSTEM_ADMIN_MEMBER_NAME,
|
||||||
|
username: 'admin',
|
||||||
departmentId: SYSTEM_ADMIN_DEPARTMENT_ID,
|
departmentId: SYSTEM_ADMIN_DEPARTMENT_ID,
|
||||||
roleId: SYSTEM_ADMIN_ROLE_ID,
|
roleId: SYSTEM_ADMIN_ROLE_ID,
|
||||||
phone: '13200132008',
|
phone: '13200132008',
|
||||||
email: 'admin@company.com',
|
email: '',
|
||||||
password: 'Ftb@2024',
|
password: 'Ftb@2024',
|
||||||
createdAt: '2024-01-01',
|
createdAt: '2024-01-01',
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
@@ -24,10 +25,11 @@ function normalizeSystemAdminMember(member: Partial<Member>): Member {
|
|||||||
...member,
|
...member,
|
||||||
id: SYSTEM_ADMIN_MEMBER_ID,
|
id: SYSTEM_ADMIN_MEMBER_ID,
|
||||||
name: SYSTEM_ADMIN_MEMBER_NAME,
|
name: SYSTEM_ADMIN_MEMBER_NAME,
|
||||||
|
username: SYSTEM_ADMIN_MEMBER.username,
|
||||||
departmentId: SYSTEM_ADMIN_DEPARTMENT_ID,
|
departmentId: SYSTEM_ADMIN_DEPARTMENT_ID,
|
||||||
roleId: SYSTEM_ADMIN_ROLE_ID,
|
roleId: SYSTEM_ADMIN_ROLE_ID,
|
||||||
phone: member.phone || SYSTEM_ADMIN_MEMBER.phone,
|
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,
|
password: member.password || SYSTEM_ADMIN_MEMBER.password,
|
||||||
createdAt: member.createdAt || SYSTEM_ADMIN_MEMBER.createdAt,
|
createdAt: member.createdAt || SYSTEM_ADMIN_MEMBER.createdAt,
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
@@ -43,7 +45,9 @@ export function ensureSystemAdminMember(members: Member[]): { members: Member[];
|
|||||||
const current = members[index];
|
const current = members[index];
|
||||||
const normalized = normalizeSystemAdminMember(current);
|
const normalized = normalizeSystemAdminMember(current);
|
||||||
const changed =
|
const changed =
|
||||||
|
index !== 0 ||
|
||||||
current.name !== normalized.name ||
|
current.name !== normalized.name ||
|
||||||
|
current.username !== normalized.username ||
|
||||||
current.departmentId !== normalized.departmentId ||
|
current.departmentId !== normalized.departmentId ||
|
||||||
current.roleId !== normalized.roleId ||
|
current.roleId !== normalized.roleId ||
|
||||||
current.phone !== normalized.phone ||
|
current.phone !== normalized.phone ||
|
||||||
@@ -55,14 +59,14 @@ export function ensureSystemAdminMember(members: Member[]): { members: Member[];
|
|||||||
if (!changed) return { members, changed: false };
|
if (!changed) return { members, changed: false };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
members: members.map((m, i) => (i === index ? normalized : m)),
|
members: [normalized, ...members.filter((_, i) => i !== index)],
|
||||||
changed: true,
|
changed: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeSystemAdminPatch(id: string, data: Partial<Member>): Partial<Member> {
|
export function sanitizeSystemAdminPatch(id: string, data: Partial<Member>): Partial<Member> {
|
||||||
if (id !== SYSTEM_ADMIN_MEMBER_ID) return data;
|
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 };
|
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 {
|
export interface Department {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -9,6 +11,7 @@ export interface Department {
|
|||||||
export interface Member {
|
export interface Member {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
username?: string;
|
||||||
departmentId: string;
|
departmentId: string;
|
||||||
roleId: string;
|
roleId: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
@@ -65,3 +68,62 @@ export function maskPhone(phone: string): string {
|
|||||||
if (!phone || phone.length < 7) return phone;
|
if (!phone || phone.length < 7) return phone;
|
||||||
return phone.slice(0, 3) + '****' + phone.slice(7);
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
username: id.toUpperCase(),
|
||||||
departmentId,
|
departmentId,
|
||||||
roleId,
|
roleId,
|
||||||
phone: '',
|
phone: '',
|
||||||
@@ -89,9 +90,9 @@ function toAuthUser(member: Member): AuthUser {
|
|||||||
return {
|
return {
|
||||||
id: member.id,
|
id: member.id,
|
||||||
name: member.name,
|
name: member.name,
|
||||||
|
username: member.username ?? member.id.toUpperCase(),
|
||||||
roleId: member.roleId,
|
roleId: member.roleId,
|
||||||
departmentId: member.departmentId,
|
departmentId: member.departmentId,
|
||||||
phone: member.phone,
|
|
||||||
email: member.email,
|
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 type { Requirement } from './requirement';
|
||||||
import {
|
import {
|
||||||
buildRequirementScopeTree,
|
buildRequirementScopeTree,
|
||||||
|
filterRequirementScopeTreeByKeyword,
|
||||||
filterRequirementsByScope,
|
filterRequirementsByScope,
|
||||||
type RequirementScopeSelection,
|
type RequirementScopeSelection,
|
||||||
} from './requirement-scope';
|
} 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[0].projects[1].count.total, 1);
|
||||||
assert.equal(tree[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 {
|
function countRequirements(requirements: Requirement[]): RequirementScopeCount {
|
||||||
const byStatus: Partial<Record<RequirementStatus, number>> = {};
|
const byStatus: Partial<Record<RequirementStatus, number>> = {};
|
||||||
for (const requirement of requirements) {
|
for (const requirement of requirements) {
|
||||||
@@ -68,3 +91,7 @@ function countRequirements(requirements: Requirement[]): RequirementScopeCount {
|
|||||||
byStatus,
|
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;
|
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 {
|
export interface Requirement {
|
||||||
id: string;
|
id: string;
|
||||||
code: 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 =
|
export type ServerDataKey =
|
||||||
| 'products-overview'
|
| 'products-overview'
|
||||||
@@ -19,13 +19,72 @@ export type ServerDataKey =
|
|||||||
interface ServerDataResponse<T> {
|
interface ServerDataResponse<T> {
|
||||||
key: ServerDataKey;
|
key: ServerDataKey;
|
||||||
value: T | null;
|
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> {
|
export async function loadServerData<T>(key: ServerDataKey): Promise<T | null> {
|
||||||
const res = await api.get<ServerDataResponse<T>>(`/data/${key}`);
|
const res = await api.get<ServerDataResponse<T>>(`/data/${key}`);
|
||||||
|
serverDataVersions.set(key, res.version ?? null);
|
||||||
return res.value;
|
return res.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<void> {
|
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\)\]'/);
|
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', () => {
|
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');
|
const script = readFileSync(join(process.cwd(), 'scripts/capture-wenfan-help-screenshots.mjs'), 'utf8');
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@ftb/shared": "workspace:*",
|
"@ftb/shared": "workspace:*",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
|
"pinyin-pro": "^3.28.1",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"zustand": "^4.5.0"
|
"zustand": "^4.5.0"
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ const OUTPUT_DIR = resolve('apps/web/public/help/wenfan-xiaobao');
|
|||||||
const AUTH_USER = {
|
const AUTH_USER = {
|
||||||
id: 'm-8',
|
id: 'm-8',
|
||||||
name: '超级管理员',
|
name: '超级管理员',
|
||||||
|
username: 'admin',
|
||||||
roleId: 'role-admin',
|
roleId: 'role-admin',
|
||||||
departmentId: 'dept-1',
|
departmentId: '',
|
||||||
phone: '13200132008',
|
email: '',
|
||||||
email: 'admin@company.com',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const versionDetailRoute = (context) => (context.versionId ? `/versions/${context.versionId}` : '/versions');
|
const versionDetailRoute = (context) => (context.versionId ? `/versions/${context.versionId}` : '/versions');
|
||||||
|
|||||||
@@ -3,14 +3,15 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { loadServerData } from '@/lib/server-data';
|
import { loadServerData } from '@/lib/server-data';
|
||||||
import type { Member } from '@/lib/members';
|
import type { Member } from '@/lib/members';
|
||||||
|
import { ensureMemberUsernames } from '@/lib/members';
|
||||||
import { SYSTEM_ADMIN_MEMBER, ensureSystemAdminMember } from '@/lib/member-system';
|
import { SYSTEM_ADMIN_MEMBER, ensureSystemAdminMember } from '@/lib/member-system';
|
||||||
import { resolveAuthUserFromMembers, toAuthUser, type AuthUser } from '@/lib/auth-user';
|
import { findMemberByUsernameCredentials, resolveAuthUserFromMembers, toAuthUser, type AuthUser } from '@/lib/auth-user';
|
||||||
import { useMemberStore } from './useMemberStore';
|
import { useMemberStore } from './useMemberStore';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
login: (phone: string, password: string, remember: boolean) => Promise<boolean>;
|
login: (username: string, password: string, remember: boolean) => Promise<boolean>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
checkAuth: () => void;
|
checkAuth: () => void;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
@@ -40,11 +41,15 @@ function clearAuthStorage() {
|
|||||||
async function loadMembersForAuth(): Promise<Member[]> {
|
async function loadMembersForAuth(): Promise<Member[]> {
|
||||||
try {
|
try {
|
||||||
const stored = await loadServerData<StoredMembersData>('members');
|
const stored = await loadServerData<StoredMembersData>('members');
|
||||||
if (stored?.members?.length) return ensureSystemAdminMember(stored.members).members;
|
if (stored?.members?.length) {
|
||||||
|
const ensured = ensureSystemAdminMember(stored.members);
|
||||||
|
return ensureMemberUsernames(ensured.members).members;
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const inMemoryMembers = useMemberStore.getState().members;
|
const inMemoryMembers = useMemberStore.getState().members;
|
||||||
return inMemoryMembers.length > 0 ? ensureSystemAdminMember(inMemoryMembers).members : FALLBACK_MEMBERS;
|
const members = inMemoryMembers.length > 0 ? ensureSystemAdminMember(inMemoryMembers).members : FALLBACK_MEMBERS;
|
||||||
|
return ensureMemberUsernames(members).members;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStoredAuthUser(user: AuthUser, remember: boolean, set: (state: Partial<AuthState>) => void) {
|
async function refreshStoredAuthUser(user: AuthUser, remember: boolean, set: (state: Partial<AuthState>) => void) {
|
||||||
@@ -65,9 +70,9 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
|
||||||
login: async (phone, password, remember) => {
|
login: async (username, password, remember) => {
|
||||||
const members = await loadMembersForAuth();
|
const members = await loadMembersForAuth();
|
||||||
const member = members.find((m) => m.phone === phone && (m.password || 'Ftb@2024') === password);
|
const member = findMemberByUsernameCredentials(username, password, members);
|
||||||
if (!member) return false;
|
if (!member) return false;
|
||||||
|
|
||||||
const user = toAuthUser(member);
|
const user = toAuthUser(member);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Department, Member, RoleItem, PasswordRule } from '@/lib/members';
|
import type { Department, Member, RoleItem, PasswordRule } from '@/lib/members';
|
||||||
import { DEFAULT_PASSWORD_RULE, generatePassword } from '@/lib/members';
|
import { DEFAULT_PASSWORD_RULE, ensureMemberUsernames, generatePassword, generateUsernameFromName } from '@/lib/members';
|
||||||
import {
|
import {
|
||||||
SYSTEM_ADMIN_MEMBER,
|
SYSTEM_ADMIN_MEMBER,
|
||||||
SYSTEM_ADMIN_ROLE_ID,
|
SYSTEM_ADMIN_ROLE_ID,
|
||||||
@@ -87,14 +87,15 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
|||||||
roles.length !== rolesWithNewDefaults.length ||
|
roles.length !== rolesWithNewDefaults.length ||
|
||||||
roles.some((role, index) => JSON.stringify(role) !== JSON.stringify(rolesWithNewDefaults[index]));
|
roles.some((role, index) => JSON.stringify(role) !== JSON.stringify(rolesWithNewDefaults[index]));
|
||||||
const ensured = ensureSystemAdminMember(cached.members);
|
const ensured = ensureSystemAdminMember(cached.members);
|
||||||
|
const usernameEnsured = ensureMemberUsernames(ensured.members);
|
||||||
const next = {
|
const next = {
|
||||||
departments: cached.departments,
|
departments: cached.departments,
|
||||||
members: ensured.members,
|
members: usernameEnsured.members,
|
||||||
roles,
|
roles,
|
||||||
passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE,
|
passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE,
|
||||||
};
|
};
|
||||||
set(next);
|
set(next);
|
||||||
if (ensured.changed || rolesChanged) saveStored(next);
|
if (ensured.changed || usernameEnsured.changed || rolesChanged) saveStored(next);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -121,7 +122,13 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
createMember: (data) => {
|
createMember: (data) => {
|
||||||
const member: Member = { ...data, id: `m-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
|
const member: Member = {
|
||||||
|
...data,
|
||||||
|
username: data.username || generateUsernameFromName(data.name, get().members.map((m) => m.username ?? '')),
|
||||||
|
phone: data.phone ?? '',
|
||||||
|
id: `m-${Date.now()}`,
|
||||||
|
createdAt: new Date().toISOString().slice(0, 10),
|
||||||
|
};
|
||||||
const members = [...get().members, member];
|
const members = [...get().members, member];
|
||||||
set({ members });
|
set({ members });
|
||||||
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ DevTask 没有"已完成"状态,"已提测"就是终态——开发交付完
|
|||||||
**V2.1(当前):** 通用服务端文档表 `app_data`
|
**V2.1(当前):** 通用服务端文档表 `app_data`
|
||||||
- 后端:`apps/server/src/modules/data/` 提供 `GET/PUT /api/v1/data/:key`
|
- 后端:`apps/server/src/modules/data/` 提供 `GET/PUT /api/v1/data/:key`
|
||||||
- 数据库:Prisma `AppData` 模型,表名 `app_data`,`key` 为主键,`value` 为 JSONB
|
- 数据库:Prisma `AppData` 模型,表名 `app_data`,`key` 为主键,`value` 为 JSONB
|
||||||
|
- 一致性:`GET` 返回 `updatedAt` 派生的 `version`;前端保存时带上最近读取的 `version`,后端用 `key + updatedAt` 原子更新,版本不匹配返回 `409 APP_DATA_CONFLICT`
|
||||||
- 前端:各 Zustand store 保持现有数据形状,通过 `apps/web/lib/server-data.ts` 读写服务端
|
- 前端:各 Zustand store 保持现有数据形状,通过 `apps/web/lib/server-data.ts` 读写服务端
|
||||||
- 覆盖范围:产品/项目/版本树、需求池、调研/产品方案/UI 计划、开发任务、测试用例、Bug、成员/角色/部门、任务类型、任务工时日志、加班记录
|
- 覆盖范围:产品/项目/版本树、需求池、调研/产品方案/UI 计划、开发任务、测试用例、Bug、成员/角色/部门、任务类型、任务工时日志、加班记录
|
||||||
- 浏览器仅保留登录会话(`ftb_auth_session` / `ftb_auth_persist`),不再作为业务数据主存储
|
- 浏览器仅保留登录会话(`ftb_auth_session` / `ftb_auth_persist`),不再作为业务数据主存储
|
||||||
|
|||||||
@@ -476,3 +476,17 @@
|
|||||||
- HTTPS 先交给云负载均衡、CDN、宿主机证书工具或外层 Nginx 终止;Compose 内置 Nginx 保持 HTTP 反代基线。
|
- HTTPS 先交给云负载均衡、CDN、宿主机证书工具或外层 Nginx 终止;Compose 内置 Nginx 保持 HTTP 反代基线。
|
||||||
|
|
||||||
**理由**:Docker Compose 足够覆盖当前单机云服务器和本地服务器形态,部署成本低、可读性强,也符合现阶段 2 核 4G 云主机目标。同域反代能减少 CORS 和公网端口暴露面。本地服务器默认 8080,避免占用 80 端口或要求管理员权限;云服务器继续使用 80 作为外层入口。HTTPS 证书自动续期和域名接入在不同云环境差异较大,先作为外层能力处理,避免把生产部署模板绑死在某一种证书方案上。
|
**理由**:Docker Compose 足够覆盖当前单机云服务器和本地服务器形态,部署成本低、可读性强,也符合现阶段 2 核 4G 云主机目标。同域反代能减少 CORS 和公网端口暴露面。本地服务器默认 8080,避免占用 80 端口或要求管理员权限;云服务器继续使用 80 作为外层入口。HTTPS 证书自动续期和域名接入在不同云环境差异较大,先作为外层能力处理,避免把生产部署模板绑死在某一种证书方案上。
|
||||||
|
|
||||||
|
## 39. AppData 文档写入采用乐观锁,先阻止静默覆盖
|
||||||
|
|
||||||
|
**问题**:V2.1 阶段业务数据仍按模块存成 `app_data.value` 整份 JSON 文档。多人同时打开同一模块后,如果 A 和 B 都基于旧副本编辑,原来的无条件 `upsert` 会让后保存的人覆盖先保存的人,尤其是任务、Bug、成员等高频写入数据。
|
||||||
|
|
||||||
|
**决策**:
|
||||||
|
- `GET /api/v1/data/:key` 返回 `version`,由 `AppData.updatedAt.toISOString()` 派生;空文档返回 `version: null`。
|
||||||
|
- 前端 `server-data.ts` 在读取和保存成功后缓存每个 key 的最新 `version`。
|
||||||
|
- 前端保存已读取过的 key 时提交 `{ value, version }`;尚未读取过的兼容路径仍可提交 `{ value }` 走旧式 upsert。
|
||||||
|
- 后端收到字符串 `version` 时使用 `updateMany({ where: { key, updatedAt }, data: { value } })` 原子比较并更新;`count !== 1` 返回 `409 APP_DATA_CONFLICT`。
|
||||||
|
- 后端收到 `version: null` 时只允许创建不存在的行;如果其他客户端已经创建,返回同样的 `409 APP_DATA_CONFLICT`。
|
||||||
|
- 冲突响应返回 `currentVersion` 和 `currentValue`,但当前前端不自动合并、不自动重试,避免把旧本地副本用新版本号再次覆盖服务端数据。
|
||||||
|
|
||||||
|
**理由**:这是 AppData 阶段成本最低、收益最高的一致性补强。它不能提供字段级协同编辑,但能阻止最危险的“静默最后写入覆盖”。后续拆成关系表和领域 API 后,再在具体实体上做更细粒度的事务、唯一约束、审计日志和冲突合并 UI。
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
|
|
||||||
### 已完成(按时间倒序)
|
### 已完成(按时间倒序)
|
||||||
|
|
||||||
|
**2026-07-02**
|
||||||
|
- `app_data` 读写增加乐观锁版本:`GET` 返回 `version`,前端保存携带最近版本,后端用 `key + updatedAt` 原子更新
|
||||||
|
- stale version / create race 返回 `409 APP_DATA_CONFLICT`,阻止多人同时编辑时的静默覆盖
|
||||||
|
- 新增后端 AppData 并发写入回归测试和前端 `server-data` 版本缓存测试
|
||||||
|
|
||||||
**2026-07-01**
|
**2026-07-01**
|
||||||
- 补齐云服务器生产部署基线:`Dockerfile.web`、`Dockerfile.server`、`docker-compose.prod.yml`、Nginx 反代模板和 `.env.production.example`
|
- 补齐云服务器生产部署基线:`Dockerfile.web`、`Dockerfile.server`、`docker-compose.prod.yml`、Nginx 反代模板和 `.env.production.example`
|
||||||
- 补齐本地服务器/局域网部署基线:`docker-compose.local.yml`、`.env.local-server.example`、`deploy:local:*` 脚本
|
- 补齐本地服务器/局域网部署基线:`docker-compose.local.yml`、`.env.local-server.example`、`deploy:local:*` 脚本
|
||||||
|
|||||||
@@ -135,6 +135,13 @@
|
|||||||
- 涉及 UI 改动:`curl http://localhost:3000/<path>` 检查 200
|
- 涉及 UI 改动:`curl http://localhost:3000/<path>` 检查 200
|
||||||
- 不会自动跑 dev server,假定它已经运行
|
- 不会自动跑 dev server,假定它已经运行
|
||||||
|
|
||||||
|
## AppData 并发写入流程
|
||||||
|
|
||||||
|
- 前端通过 `loadServerData(key)` 读取业务文档时,必须缓存响应里的 `version`。
|
||||||
|
- 前端通过 `saveServerData(key, value)` 保存已读取过的 key 时,必须把最近一次成功读取/保存得到的 `version` 一起提交。
|
||||||
|
- 后端只在 `key + updatedAt(version)` 匹配时更新;如果其他用户已经先保存,返回 `409 APP_DATA_CONFLICT`,响应包含当前服务端 `currentVersion` 和 `currentValue`。
|
||||||
|
- 收到 `ServerDataConflictError` 时,不要自动重试覆盖。当前处理策略是阻止静默覆盖,后续 UI 冲突合并能力再单独补。
|
||||||
|
|
||||||
## 与我相关(Workspace)数据流
|
## 与我相关(Workspace)数据流
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -105,6 +105,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: ^14.2.0
|
specifier: ^14.2.0
|
||||||
version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
pinyin-pro:
|
||||||
|
specifier: ^3.28.1
|
||||||
|
version: 3.28.1
|
||||||
react:
|
react:
|
||||||
specifier: ^18.3.0
|
specifier: ^18.3.0
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
@@ -2285,6 +2288,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
pinyin-pro@3.28.1:
|
||||||
|
resolution: {integrity: sha512-oqz8ulwRgtUXRi0vbqEfGNly19zpyCxYrjhkk5TibGcgSW6eNwS5woajCXRwqURi8Ehc2yOFTiB4uNoZ+NJOnA==}
|
||||||
|
|
||||||
pirates@4.0.7:
|
pirates@4.0.7:
|
||||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -5544,6 +5550,8 @@ snapshots:
|
|||||||
|
|
||||||
pify@2.3.0: {}
|
pify@2.3.0: {}
|
||||||
|
|
||||||
|
pinyin-pro@3.28.1: {}
|
||||||
|
|
||||||
pirates@4.0.7: {}
|
pirates@4.0.7: {}
|
||||||
|
|
||||||
pkg-dir@4.2.0:
|
pkg-dir@4.2.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user