feat(users): 收口账号权限与登录注册视觉

This commit is contained in:
WuYongJie 2026-05-15 10:41:38 +08:00
parent 38a182bc8d
commit 1f2d2af257
25 changed files with 224 additions and 726 deletions

View File

@ -85,12 +85,12 @@ businessRoles includes DESIGN / PLANNING / PURCHASING / WAREHOUSE
- 旧权限角色迁移为 `role=USER`。
- 原业务身份追加 `businessRoles=['DESIGN']`。
- `User / AccountTemplate / ApprovalWorkflow.businessRoles` 统一补齐为非空数组。
- `User / ApprovalWorkflow.businessRoles` 统一补齐为非空数组。
- 新请求不再允许写入旧设计权限角色;旧值应返回 400,并提示“设计师已迁移为业务类型:设计”。
## 页面展示
- 用户管理、个人中心、交接弹窗、账号模板和审批页面展示“业务类型”。
- 用户管理、个人中心、交接弹窗和审批页面展示“业务类型”。
- 权限角色筛选和表单只出现 4 个权限角色。
- 页面文案应保持低噪音,不用大段说明解释权限体系。

View File

@ -0,0 +1,9 @@
ALTER TABLE "User" DROP CONSTRAINT IF EXISTS "User_accountTemplateId_fkey";
DROP INDEX IF EXISTS "User_accountTemplateId_idx";
ALTER TABLE "User"
DROP COLUMN IF EXISTS "accountTemplateId",
DROP COLUMN IF EXISTS "accountTemplateOverrides";
DROP TABLE IF EXISTS "AccountTemplate";

View File

@ -1041,11 +1041,6 @@ model User {
// 状态
status UserStatus @default(PENDING)
// 岗位模板
accountTemplateId String?
accountTemplate AccountTemplate? @relation(fields: [accountTemplateId], references: [id], onDelete: SetNull)
accountTemplateOverrides Json?
// 关联
createdProducts Product[] @relation("ProductCreator")
ownedSKUs ProductSKU[] @relation("SKUOwner")
@ -1071,7 +1066,6 @@ model User {
addedProductCollaborators ProductCollaborator[] @relation("ProductCollaboratorAddedBy")
feedbacks Feedback[] @relation("FeedbackSubmitter")
assignedFeedbacks Feedback[] @relation("FeedbackAssignee")
createdAccountTemplates AccountTemplate[] @relation("AccountTemplateCreator")
createdApprovalWorkflows ApprovalWorkflow[] @relation("ApprovalWorkflowCreator")
approvalWorkflowChanges ApprovalWorkflowFieldChange[]
@ -1089,7 +1083,6 @@ model User {
@@index([status])
@@index([loginSource])
@@index([employeeDirectoryId])
@@index([accountTemplateId])
}
model EmployeeLookupCache {
@ -1179,30 +1172,6 @@ model Team {
@@index([departmentId])
}
model AccountTemplate {
id String @id @default(cuid())
name String @unique
code String @unique
description String?
isActive Boolean @default(true)
role Role
businessRoles BusinessAccountType[] @default([])
defaultScope Json?
defaultFieldPermissions Json?
createdBy String
creator User @relation("AccountTemplateCreator", fields: [createdBy], references: [id])
users User[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([createdBy])
}
model ApprovalWorkflow {
id String @id @default(cuid())

View File

@ -269,8 +269,8 @@ export default function ThemeManagementClient() {
inspector={
<div className="flex h-full flex-col bg-[#111719]">
<ObsidianInspectorSection title="权限">
<ObsidianDataRow label="当前角色" value={user ? roleLabels[user.role] ?? user.role : '-'} tone="warning" />
<ObsidianDataRow label="所需角色" value="超级管理员" />
<ObsidianDataRow label="当前账号权限" value={user ? roleLabels[user.role] ?? user.role : '-'} tone="warning" />
<ObsidianDataRow label="所需账号权限" value="超级管理员" />
<ObsidianDataRow label="保存接口" value="未开放" tone="warning" />
</ObsidianInspectorSection>
</div>

View File

@ -1,49 +0,0 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { AppError } from '@/server/core/errors'
import { accountTemplateService } from '@/server/services/account-template.service'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await params
try {
const template = await accountTemplateService.getById(id)
return jsonSuccess({ template }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM'] }
)
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await params
const body = await request.json()
try {
const template = await accountTemplateService.update(id, body)
return jsonSuccess({ message: '岗位模板更新成功', template }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN'] }
)
}

View File

@ -1,34 +0,0 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { AppError } from '@/server/core/errors'
import { accountTemplateService } from '@/server/services/account-template.service'
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const templates = await accountTemplateService.list(ctx.user)
return jsonSuccess({ templates }, 200, ctx.requestId)
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM'] }
)
}
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const body = await request.json()
try {
const template = await accountTemplateService.create(body, ctx.user)
return jsonSuccess({ message: '岗位模板创建成功', template }, 201, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ allowedRoles: ['SUPER_ADMIN', 'ADMIN'] }
)
}

View File

@ -41,7 +41,7 @@ export async function PUT(
return handleRequest(request, async (ctx) => {
const { id } = await params
const body = await request.json()
const { email, name, password, departmentId, phone, status, accountTemplateId } = body as {
const { email, name, password, departmentId, phone, status } = body as {
email?: string
name?: string
password?: string
@ -49,7 +49,6 @@ export async function PUT(
departmentId?: string | null
phone?: string | null
status?: UserStatus
accountTemplateId?: string | null
}
const role = parseUserRoleInput(body.role)
const businessRoles = 'businessRoles' in body
@ -59,7 +58,7 @@ export async function PUT(
try {
const updatedUser = await userManagementService.updateUser(
id,
{ email, name, password, role, businessRoles, departmentId, teamId: null, phone, status, accountTemplateId },
{ email, name, password, role, businessRoles, departmentId, teamId: null, phone, status },
ctx.user
)

View File

@ -56,7 +56,7 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json()
const { email, name, password, departmentId, phone, accountTemplateId } = body
const { email, name, password, departmentId, phone } = body
const role = parseUserRoleInput(body.role)
const jobNumber = typeof body.jobNumber === 'string' ? body.jobNumber : undefined
const businessRoles = 'businessRoles' in body
@ -73,7 +73,7 @@ export async function POST(request: NextRequest) {
}
try {
const createInput = { email, name, password, role, departmentId, phone, accountTemplateId }
const createInput = { email, name, password, role, departmentId, phone }
const normalizedCreateInput = {
...createInput,
...(businessRoles ? { businessRoles } : {}),

View File

@ -16,7 +16,6 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { LargeTitle, Body, Footnote, Caption2 } from '@/components/ui/Typography';
import { useToast, ToastContainer } from '@/components/ui/Toast';
import { getApiErrorMessage } from '@/lib/utils';
import type { ApiResponse } from '@/lib/api';
@ -25,6 +24,30 @@ import { APP_BRAND_LOGO_SRC, APP_BRAND_NAME } from '@/lib/branding';
// 登录请求超时时间(毫秒)
const LOGIN_TIMEOUT_MS = 15000;
const authDisplayTitleClass =
'text-[32px] font-semibold leading-[1.18] tracking-normal text-[var(--obs-shell-title)] xl:text-[40px]';
const authDisplaySubtitleClass =
'text-[20px] font-medium leading-8 tracking-normal text-[var(--obs-shell-muted)] xl:text-[24px]';
const authPageTitleClass =
'text-[28px] font-semibold leading-9 tracking-normal text-[var(--obs-shell-title)]';
const authPageSubtitleClass =
'mt-2 text-[14px] font-medium leading-6 tracking-normal text-[var(--obs-shell-muted)]';
const authFieldClass =
'space-y-1.5 [&>label]:mb-0 [&>label]:text-[13px] [&>label]:font-semibold [&>label]:leading-5 [&>label]:tracking-normal [&>label]:text-[var(--obs-shell-muted)]';
const authInputClass =
'text-[14px] font-semibold leading-5 tracking-normal placeholder:text-[var(--obs-shell-subtle)]';
const authLinkClass =
'text-[13px] font-semibold leading-5 tracking-normal text-[var(--obs-shell-title)] hover:underline';
const authMutedTextClass =
'text-[13px] font-medium leading-5 tracking-normal text-[var(--obs-shell-muted)]';
interface LoginSuccessData {
message: string;
redirectPath?: string;
@ -188,18 +211,18 @@ export default function LoginPage() {
/>
{/* 主标题 - 响应式字号 */}
<h1 className="mt-7 xl:mt-[28px] font-bold text-[32px] xl:text-[40px] leading-tight" style={{ color: 'var(--color-label-primary)', fontFamily: 'Manrope, sans-serif' }}>
<h1 className={`mt-7 xl:mt-[28px] ${authDisplayTitleClass}`}>
{APP_BRAND_NAME}
</h1>
{/* 副标题 - 响应式字号 */}
<p className="mt-4 xl:mt-[18px] font-normal text-[20px] xl:text-[24px]" style={{ color: 'var(--color-label-secondary)' }}>
<p className={`mt-4 xl:mt-[18px] ${authDisplaySubtitleClass}`}>
专业的家具装配与展示解决方案
</p>
</div>
{/* 底部辅助文案 */}
<p className="absolute bottom-7 left-0 right-0 text-center text-sm" style={{ color: 'var(--color-label-tertiary)' }}>
<p className="absolute bottom-7 left-0 right-0 text-center text-[12px] font-medium leading-5 tracking-normal text-[var(--obs-shell-subtle)]">
Designed for Assembly
</p>
</div>
@ -224,19 +247,19 @@ export default function LoginPage() {
{/* 移动端:视觉调整后的标题 (加大字号) */}
<div className="lg:hidden">
<h2 className="text-(--color-label-primary) text-[26px] tracking-apple-tighter font-semibold" style={{ fontFamily: 'Manrope, sans-serif' }}>
<h2 className="text-[26px] font-semibold leading-8 tracking-normal text-[var(--obs-shell-title)]">
{APP_BRAND_NAME}
</h2>
</div>
{/* 桌面端:显示功能标题 */}
<div className="hidden lg:block">
<LargeTitle className="text-(--color-label-primary)">
<h1 className={authPageTitleClass}>
登录
</LargeTitle>
<Body className="text-(--color-label-secondary) mt-2">
</h1>
<p className={authPageSubtitleClass}>
进入您的工作台以开始管理产品
</Body>
</p>
</div>
</div>
@ -253,7 +276,8 @@ export default function LoginPage() {
required
variant="filled"
size="large"
className="bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all"
containerClassName={authFieldClass}
className={`bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all ${authInputClass}`}
/>
<div className="space-y-1">
@ -267,12 +291,13 @@ export default function LoginPage() {
required
variant="filled"
size="large"
className="bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all"
containerClassName={authFieldClass}
className={`bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all ${authInputClass}`}
/>
<div className="flex justify-end mt-2">
<div className="flex items-center gap-4">
<Link href="/register" className="text-(--color-blue) hover:underline">
<Footnote as="span">注册账号</Footnote>
<Link href="/register" className={authLinkClass}>
注册账号
</Link>
<Button
type="button"
@ -281,9 +306,9 @@ export default function LoginPage() {
onClick={() => toast.info('请联系管理员重置密码')}
className="!p-0 !min-h-0"
>
<Footnote as="span" className="text-(--color-label-secondary)">
<span className={authMutedTextClass}>
忘记密码?
</Footnote>
</span>
</Button>
</div>
</div>
@ -294,7 +319,7 @@ export default function LoginPage() {
type="submit"
variant="filled"
size="large"
className="w-full rounded-full h-12 text-base font-semibold shadow-apple-lg shadow-apple-blue/20 hover:shadow-apple-blue/30 active:scale-[0.98] transition-all mt-2"
className="w-full rounded-full h-12 text-[14px] font-semibold leading-5 tracking-normal shadow-apple-lg shadow-apple-blue/20 hover:shadow-apple-blue/30 active:scale-[0.98] transition-all mt-2"
loading={isLoading}
>
登录
@ -303,7 +328,7 @@ export default function LoginPage() {
<div className="space-y-3 pt-2">
<div className="flex items-center gap-3 text-(--color-label-tertiary)">
<div className="h-px flex-1 bg-(--color-separator)" />
<Caption2>其他登录方式</Caption2>
<span className="text-[12px] font-medium leading-5 tracking-normal text-[var(--obs-shell-subtle)]">其他登录方式</span>
<div className="h-px flex-1 bg-(--color-separator)" />
</div>
@ -311,7 +336,7 @@ export default function LoginPage() {
type="button"
variant="gray"
size="large"
className="w-full rounded-full h-12 text-base font-semibold gap-3"
className="w-full rounded-full h-12 text-[14px] font-semibold leading-5 tracking-normal gap-3"
loading={isDingtalkLoading}
onClick={handleDingtalkLogin}
>
@ -326,7 +351,7 @@ export default function LoginPage() {
{/* 移动端底部版权 - 绝对定位到底部 */}
<div className="lg:hidden absolute bottom-8 left-0 right-0 text-center text-(--color-label-tertiary)">
<Caption2>{APP_BRAND_NAME}</Caption2>
<span className="text-[12px] font-medium leading-5 tracking-normal text-[var(--obs-shell-subtle)]">{APP_BRAND_NAME}</span>
</div>
</div>
</div>

View File

@ -89,7 +89,7 @@ function ProfileContent() {
<div className="flex h-full flex-col bg-white">
<ObsidianInspectorSection title="身份摘要">
<ObsidianDataRow label="账号状态" value={statusLabel} tone={user?.status === 'ACTIVE' ? 'success' : 'warning'} />
<ObsidianDataRow label="权限角色" value={roleLabel} />
<ObsidianDataRow label="账号权限" value={roleLabel} />
<ObsidianDataRow label="业务类型" value={businessRoleText} />
<ObsidianDataRow label="登录来源" value={user?.loginSource || '系统账号'} />
</ObsidianInspectorSection>
@ -122,7 +122,7 @@ function ProfileContent() {
<ObsidianPanel title="身份令牌" className="xl:col-span-2">
<div className="grid gap-4 p-5 md:grid-cols-3">
<ProfileMetric icon={Fingerprint} label="账号 ID" value={user?.id.slice(0, 8) ?? '-'} />
<ProfileMetric icon={ShieldCheck} label="权限角色" value={roleLabel} />
<ProfileMetric icon={ShieldCheck} label="账号权限" value={roleLabel} />
<ProfileMetric icon={BriefcaseBusiness} label="业务类型" value={businessRoleText} />
</div>
</ObsidianPanel>

View File

@ -5,13 +5,39 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { LargeTitle, Body, Footnote, Caption2 } from '@/components/ui/Typography';
import { useToast, ToastContainer } from '@/components/ui/Toast';
import { getApiErrorMessage } from '@/lib/utils';
import type { ApiResponse } from '@/lib/api';
const REGISTER_TIMEOUT_MS = 15000;
const authDisplayTitleClass =
'text-[36px] font-semibold leading-[1.15] tracking-normal text-[var(--obs-shell-title)] xl:text-[48px]';
const authDisplaySubtitleClass =
'text-[20px] font-medium leading-8 tracking-normal text-[var(--obs-shell-muted)] xl:text-[24px]';
const authPageTitleClass =
'text-[28px] font-semibold leading-9 tracking-normal text-[var(--obs-shell-title)]';
const authPageSubtitleClass =
'mt-2 text-[14px] font-medium leading-6 tracking-normal text-[var(--obs-shell-muted)]';
const authFieldClass =
'space-y-1.5 [&>label]:mb-0 [&>label]:text-[13px] [&>label]:font-semibold [&>label]:leading-5 [&>label]:tracking-normal [&>label]:text-[var(--obs-shell-muted)]';
const authInputClass =
'text-[14px] font-semibold leading-5 tracking-normal placeholder:text-[var(--obs-shell-subtle)]';
const authHelperClass =
'text-[12px] font-medium leading-5 tracking-normal text-[var(--obs-shell-subtle)]';
const authLinkClass =
'text-[13px] font-semibold leading-5 tracking-normal text-[var(--obs-shell-title)] hover:underline';
const authMutedTextClass =
'text-[13px] font-medium leading-5 tracking-normal text-[var(--obs-shell-muted)]';
interface RegisterSuccessData {
message: string;
user: {
@ -101,16 +127,16 @@ export default function RegisterPage() {
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
<h1 className="mt-7 xl:mt-[28px] font-bold text-[36px] xl:text-[48px] leading-tight text-[#111827]">
<h1 className={`mt-7 xl:mt-[28px] ${authDisplayTitleClass}`}>
3D Furniture System
</h1>
<p className="mt-4 xl:mt-[18px] font-normal text-[20px] xl:text-[24px] text-[#4b5563]">
<p className={`mt-4 xl:mt-[18px] ${authDisplaySubtitleClass}`}>
注册后先等待后台激活,再进入系统
</p>
</div>
<p className="absolute bottom-7 left-0 right-0 text-center text-sm text-[#374151]">
<p className="absolute bottom-7 left-0 right-0 text-center text-[12px] font-medium leading-5 tracking-normal text-[var(--obs-shell-subtle)]">
Register For Workspace Access
</p>
</div>
@ -127,10 +153,10 @@ export default function RegisterPage() {
</div>
<div className="hidden lg:block">
<LargeTitle className="text-(--color-label-primary)">注册</LargeTitle>
<Body className="text-(--color-label-secondary) mt-2">
<h1 className={authPageTitleClass}>注册</h1>
<p className={authPageSubtitleClass}>
所有新账号都会先进入待激活状态,管理员审核通过后才能登录
</Body>
</p>
</div>
</div>
@ -147,7 +173,9 @@ export default function RegisterPage() {
helperText="系统会用工号匹配员工库,匹配成功后仍需管理员激活"
variant="filled"
size="large"
className="bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all"
containerClassName={authFieldClass}
messageClassName={authHelperClass}
className={`bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all ${authInputClass}`}
/>
<Input
@ -160,7 +188,8 @@ export default function RegisterPage() {
required
variant="filled"
size="large"
className="bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all"
containerClassName={authFieldClass}
className={`bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all ${authInputClass}`}
/>
<Input
@ -173,7 +202,8 @@ export default function RegisterPage() {
required
variant="filled"
size="large"
className="bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all"
containerClassName={authFieldClass}
className={`bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all ${authInputClass}`}
/>
<Input
@ -187,7 +217,9 @@ export default function RegisterPage() {
helperText="密码最少 6 位即可"
variant="filled"
size="large"
className="bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all"
containerClassName={authFieldClass}
messageClassName={authHelperClass}
className={`bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all ${authInputClass}`}
/>
<Input
@ -200,7 +232,8 @@ export default function RegisterPage() {
required
variant="filled"
size="large"
className="bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all"
containerClassName={authFieldClass}
className={`bg-(--color-background-secondary) border border-(--color-separator) focus:border-(--color-blue) focus:bg-(--color-background-primary) transition-all ${authInputClass}`}
/>
</div>
@ -208,32 +241,32 @@ export default function RegisterPage() {
type="submit"
variant="filled"
size="large"
className="w-full rounded-full h-12 text-base font-semibold shadow-apple-lg shadow-apple-blue/20 hover:shadow-apple-blue/30 active:scale-[0.98] transition-all mt-2"
className="w-full rounded-full h-12 text-[14px] font-semibold leading-5 tracking-normal shadow-apple-lg shadow-apple-blue/20 hover:shadow-apple-blue/30 active:scale-[0.98] transition-all mt-2"
loading={isLoading}
>
注册账号
</Button>
<div className="rounded-apple-lg border border-apple-separator bg-(--color-background-secondary) px-4 py-3">
<Footnote className="text-(--color-label-secondary)">
注册成功后不会自动放行登录。管理员在后台激活并分配角色后,你才能正式进入系统。
</Footnote>
<p className={authMutedTextClass}>
注册成功后不会自动放行登录。管理员在后台激活并分配账号权限后,你才能正式进入系统。
</p>
</div>
<div className="text-center">
<Footnote className="text-(--color-label-secondary)">
<p className={authMutedTextClass}>
已有账号?
{' '}
<Link href="/login" className="text-(--color-blue) hover:underline">
<Link href="/login" className={authLinkClass}>
返回登录
</Link>
</Footnote>
</p>
</div>
</form>
</div>
<div className="lg:hidden absolute bottom-8 left-0 right-0 text-center text-(--color-label-tertiary)">
<Caption2>3D Furniture System v1.0</Caption2>
<span className="text-[12px] font-medium leading-5 tracking-normal text-[var(--obs-shell-subtle)]">3D Furniture System v1.0</span>
</div>
</div>
</div>

View File

@ -40,7 +40,7 @@ import { BUSINESS_ACCOUNT_TYPE_LABELS, BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/l
import { useDebounce } from '@/hooks/usePerformance'
const ROLE_OPTIONS = [
{ value: '', label: '所有权限角色', icon: Users },
{ value: '', label: '所有账号权限', icon: Users },
{ value: 'SUPER_ADMIN', label: '超级管理员', icon: KeyRound },
{ value: 'ADMIN', label: '管理员', icon: ShieldCheck },
{ value: 'SUPERVISOR', label: '主管', icon: Briefcase },
@ -309,7 +309,7 @@ function UsersPageContent() {
}
rail={
<div className="flex h-full flex-col gap-6">
<ObsidianRailGroup title="权限角色">
<ObsidianRailGroup title="账号权限">
{ROLE_OPTIONS.map((option) => (
<ObsidianRailButton
key={option.value}
@ -387,23 +387,6 @@ function UsersPageContent() {
<ObsidianDataRow label="员工姓名" value={selectedUser.employeeDirectory?.name || '未记录'} />
<ObsidianDataRow label="钉钉" value={selectedUser.loginSource === 'DINGTALK' ? '已绑定' : '未绑定'} />
</ObsidianInspectorSection>
<ObsidianInspectorSection title="岗位模板">
<ObsidianDataRow
label="模板来源"
value={selectedUser.accountTemplate?.name || '未套用模板'}
tone={selectedUser.accountTemplate ? 'success' : 'warning'}
/>
<ObsidianDataRow
label="模板编码"
value={selectedUser.accountTemplate?.code || '未设置'}
/>
<ObsidianDataRow
label="人工覆盖项"
value={(selectedUser.accountTemplateOverrides?.overrideFields || []).length > 0
? selectedUser.accountTemplateOverrides!.overrideFields!.join(' / ')
: '无'}
/>
</ObsidianInspectorSection>
<ObsidianInspectorSection title="安全审计">
<ObsidianDataRow label="账号状态" value={statusLabels[selectedUser.status] ?? selectedUser.status} tone={selectedUser.status === 'ACTIVE' ? 'success' : 'warning'} />
<ObsidianDataRow label="登录来源" value={selectedUser.loginSource || '系统账号'} />

View File

@ -33,6 +33,8 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
helperText?: string
/** 错误文本 */
errorText?: string
/** 帮助/错误文本类名 */
messageClassName?: string
/** 是否必填 */
required?: boolean
/** 左侧图标 */
@ -54,6 +56,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
label,
helperText,
errorText,
messageClassName,
required = false,
leftIcon,
rightIcon,
@ -181,16 +184,30 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
{/* 帮助文本或错误文本 */}
{(errorText || helperText) && (
<Caption1
className={cn(
'block',
errorText
? 'text-apple-red'
: 'text-apple-label-secondary'
)}
>
{errorText || helperText}
</Caption1>
messageClassName ? (
<p
className={cn(
'block',
errorText
? 'text-apple-red'
: 'text-apple-label-secondary',
messageClassName,
)}
>
{errorText || helperText}
</p>
) : (
<Caption1
className={cn(
'block',
errorText
? 'text-apple-red'
: 'text-apple-label-secondary'
)}
>
{errorText || helperText}
</Caption1>
)
)}
</div>
)

View File

@ -49,6 +49,8 @@ export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectE
searchPlaceholder?: string
/** 搜索无结果文案 */
emptyText?: string
/** 选中状态色调 */
tone?: 'apple' | 'obsidian'
}
/**
@ -69,6 +71,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
searchable = false,
searchPlaceholder = '搜索',
emptyText = '没有匹配项',
tone = 'apple',
disabled,
value,
onChange,
@ -94,6 +97,14 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
)
: options
const enabledOptions = filteredOptions.filter((o) => !o.disabled)
const selectedOptionClass =
tone === 'obsidian'
? 'text-[var(--obs-shell-title)] bg-[var(--obs-shell-control-bg)]'
: 'text-apple-blue bg-apple-blue/5'
const selectedIconClass =
tone === 'obsidian'
? 'text-[var(--obs-shell-title)]'
: 'text-apple-blue'
// 点击外部关闭
useEffect(() => {
@ -331,12 +342,12 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
className={cn(
'flex items-center gap-2 px-3 py-2 cursor-pointer text-sm transition-colors',
!hasValue
? 'text-apple-blue bg-apple-blue/5'
? selectedOptionClass
: 'text-apple-label-tertiary hover:bg-apple-bg-secondary',
)}
>
<span className="flex-1 truncate">{placeholder}</span>
{!hasValue && <Check className="w-3.5 h-3.5 shrink-0 text-apple-blue" />}
{!hasValue && <Check className={cn('w-3.5 h-3.5 shrink-0', selectedIconClass)} />}
</li>
)}
{filteredOptions.length === 0 ? (
@ -362,7 +373,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
? 'opacity-40 cursor-not-allowed'
: 'cursor-pointer',
!option.disabled && isSelected
? 'text-apple-blue bg-apple-blue/5'
? selectedOptionClass
: !option.disabled && isHighlighted
? 'bg-apple-bg-secondary text-apple-label-primary'
: 'text-apple-label-primary hover:bg-apple-bg-secondary',
@ -370,7 +381,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
>
<span className="flex-1 truncate">{option.label}</span>
{isSelected && (
<Check className="w-3.5 h-3.5 shrink-0 text-apple-blue" />
<Check className={cn('w-3.5 h-3.5 shrink-0', selectedIconClass)} />
)}
</li>
)

View File

@ -161,7 +161,7 @@ export function ActivateUserModal({
setErrors({});
if (!formData.role) {
setErrors({ role: '请选择激活后的角色' });
setErrors({ role: '请选择账号权限' });
return;
}
@ -224,14 +224,14 @@ export function ActivateUserModal({
</h4>
</div>
<p className="mt-3 text-[11px] leading-relaxed text-apple-label-secondary">
账号激活后即可正式登录。你只能把该账号设置为低于你自己的角色等级。
账号激活后即可正式登录。你只能把该账号设置为低于你自己的账号权限等级。
</p>
<div className="mt-3 text-sm font-apple-bold text-apple-label-primary">{user.name}</div>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<Select
label="激活后角色"
label="账号权限"
value={formData.role}
onChange={(event) => setFormData((current) => ({ ...current, role: event.target.value as Role }))}
options={roleOptions}

View File

@ -10,7 +10,7 @@ import Modal from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import Button from '@/components/ui/Button';
import { AlertCircle } from 'lucide-react';
import { AlertCircle, Check } from 'lucide-react';
import { DepartmentTreeSelect, type DepartmentTreeOption } from '@/components/users/DepartmentTreeSelect';
import { BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
import { inferBusinessRolesFromDepartmentPath } from '@/lib/business-role-inference';
@ -23,14 +23,6 @@ interface CreateUserModalProps {
onSuccess: () => void;
}
interface AccountTemplateItem {
id: string;
name: string;
code: string;
role: Role;
businessRoles: BusinessAccountType[];
}
const ROLE_LABELS: Record<Role, string> = {
SUPER_ADMIN: '超级管理员',
ADMIN: '管理员',
@ -38,6 +30,20 @@ const ROLE_LABELS: Record<Role, string> = {
USER: '普通用户(只看 / 下载)',
};
const sectionTitleClass =
'border-b border-[var(--obs-shell-border)] pb-2 text-[12px] font-semibold leading-5 tracking-normal text-[var(--obs-shell-muted)]';
const formColumnClass = 'space-y-4';
const formFieldClass =
'space-y-1.5 [&>label]:mb-0 [&>label]:text-[12px] [&>label]:font-semibold [&>label]:leading-5 [&>label]:tracking-normal [&>label]:text-[var(--obs-shell-muted)]';
const formControlClass =
'h-10 rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-3 py-0 text-[13px] font-semibold leading-5 tracking-normal text-[var(--obs-shell-title)] placeholder:text-[var(--obs-shell-subtle)] focus:border focus:border-[var(--obs-shell-title)] focus:px-3 focus:py-0';
const businessOptionClass =
'flex h-10 cursor-pointer items-center gap-2 rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-3 text-[13px] font-semibold leading-5 tracking-normal text-[var(--obs-shell-title)] transition-colors hover:bg-[var(--obs-shell-hover-bg)]';
function findDepartmentById(
departments: DepartmentTreeOption[],
departmentId: string,
@ -61,13 +67,11 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
businessRoles: [] as BusinessAccountType[],
phone: '',
departmentId: '',
accountTemplateId: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [departments, setDepartments] = useState<DepartmentTreeOption[]>([]);
const [templates, setTemplates] = useState<AccountTemplateItem[]>([]);
const [businessRolesTouched, setBusinessRolesTouched] = useState(false);
useEffect(() => {
@ -97,27 +101,6 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
}
}, [isOpen]);
useEffect(() => {
const loadTemplates = async () => {
try {
const response = await fetch('/api/account-templates', { credentials: 'include' });
if (!response.ok) {
setTemplates([]);
return;
}
const data = await response.json();
setTemplates(data.data?.templates || []);
} catch (error) {
console.error('加载岗位模板失败:', error);
setTemplates([]);
}
};
if (isOpen) {
void loadTemplates();
}
}, [isOpen]);
useEffect(() => {
if (businessRolesTouched || !formData.departmentId) return;
const department = findDepartmentById(departments, formData.departmentId);
@ -139,17 +122,6 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
}));
};
const handleTemplateChange = (templateId: string) => {
const template = templates.find((item) => item.id === templateId);
setFormData((current) => ({
...current,
accountTemplateId: templateId,
role: template?.role || current.role,
businessRoles: template?.businessRoles || current.businessRoles,
}));
if (template) setBusinessRolesTouched(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
@ -184,7 +156,6 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
phone: formData.phone || null,
departmentId: formData.departmentId || null,
teamId: null,
accountTemplateId: formData.accountTemplateId || null,
}),
});
@ -206,46 +177,33 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
isOpen={isOpen}
onClose={onClose}
title="新建账号"
size="medium"
size="large"
>
<form onSubmit={handleSubmit} className="space-y-8 p-2">
<form onSubmit={handleSubmit} className="space-y-6 p-1">
{errors.submit && (
<div className="flex items-center gap-3 rounded-md border border-red-100 bg-red-50 p-4 animate-in fade-in zoom-in-95">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0" />
<p className="text-apple-xs font-apple-bold text-red-700 uppercase tracking-wide">{errors.submit}</p>
<p className="text-[13px] font-semibold leading-5 tracking-normal text-red-700">{errors.submit}</p>
</div>
)}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-6">
<h4 className="text-[10px] font-apple-black text-apple-label-tertiary uppercase tracking-[0.2em] border-b border-apple-separator/10 pb-2">基础信息</h4>
<Input label="工号" value={formData.jobNumber} onChange={(e) => setFormData({ ...formData, jobNumber: e.target.value })} placeholder="员工工号" required errorText={errors.jobNumber} className="bg-white" />
<Input label="用户姓名" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="张三" required errorText={errors.name} className="bg-white" />
<Input label="电子邮箱" type="email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} placeholder="user@example.com" required errorText={errors.email} className="bg-white" />
<Input label="联系电话" value={formData.phone} onChange={(e) => setFormData({ ...formData, phone: e.target.value })} placeholder="13800138000" className="bg-white" />
<div className="grid grid-cols-1 gap-x-7 gap-y-6 md:grid-cols-2">
<div className={formColumnClass}>
<h4 className={sectionTitleClass}>基础信息</h4>
<Input label="工号" value={formData.jobNumber} onChange={(e) => setFormData({ ...formData, jobNumber: e.target.value })} placeholder="员工工号" required errorText={errors.jobNumber} containerClassName={formFieldClass} className={formControlClass} />
<Input label="用户姓名" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="张三" required errorText={errors.name} containerClassName={formFieldClass} className={formControlClass} />
<Input label="电子邮箱" type="email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} placeholder="user@example.com" required errorText={errors.email} containerClassName={formFieldClass} className={formControlClass} />
<Input label="联系电话" value={formData.phone} onChange={(e) => setFormData({ ...formData, phone: e.target.value })} placeholder="13800138000" containerClassName={formFieldClass} className={formControlClass} />
</div>
<div className="space-y-6">
<h4 className="text-[10px] font-apple-black text-apple-label-tertiary uppercase tracking-[0.2em] border-b border-apple-separator/10 pb-2">权限与归属</h4>
<Select label="系统角色" value={formData.role} onChange={(e) => setFormData({ ...formData, role: e.target.value as Role })} options={
<div className={formColumnClass}>
<h4 className={sectionTitleClass}>权限与归属</h4>
<Select label="账号权限" value={formData.role} onChange={(e) => setFormData({ ...formData, role: e.target.value as Role })} options={
allowedRoles.map((role) => ({
value: role,
label: ROLE_LABELS[role],
}))
} required className="bg-white" />
<Select
label="岗位模板"
value={formData.accountTemplateId}
onChange={(e) => handleTemplateChange(e.target.value)}
options={[
{ value: '', label: '不套模板,手动设置' },
...templates.map((template) => ({
value: template.id,
label: `${template.name} (${template.code})`,
})),
]}
className="bg-white"
/>
} required tone="obsidian" containerClassName={formFieldClass} className={formControlClass} />
<DepartmentTreeSelect
label="所属部门"
value={formData.departmentId}
@ -254,19 +212,23 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
setBusinessRolesTouched(false);
setFormData({ ...formData, departmentId });
}}
className="bg-white"
className={formFieldClass}
/>
<div>
<p className="mb-2 text-[10px] font-apple-black uppercase tracking-[0.18em] text-apple-label-tertiary">业务类型</p>
<div className="space-y-1.5">
<p className="text-[12px] font-semibold leading-5 tracking-normal text-[var(--obs-shell-muted)]">业务类型</p>
<div className="grid grid-cols-2 gap-2">
{BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => (
<label key={option.value} className="flex items-center gap-2 rounded-md border border-[#dde3e5] bg-white px-3 py-2 text-xs font-bold text-[#405054]">
<label key={option.value} className={businessOptionClass}>
<input
type="checkbox"
checked={formData.businessRoles.includes(option.value)}
onChange={() => toggleBusinessRole(option.value)}
className="peer sr-only"
/>
{option.label}
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-[4px] border border-[var(--obs-shell-border)] bg-white transition-colors peer-checked:border-[var(--obs-shell-title)] peer-checked:bg-[var(--obs-shell-title)]">
<Check className="h-3 w-3 text-white opacity-0 transition-opacity peer-checked:opacity-100" />
</span>
<span className="truncate">{option.label}</span>
</label>
))}
</div>
@ -274,23 +236,19 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
</div>
</div>
<div className="space-y-5 rounded-md border border-[#dde3e5] bg-[#fbfcfc] p-6">
<div className="flex items-center gap-2 mb-1">
<div className="w-1.5 h-1.5 rounded-full bg-apple-blue shadow-[0_0_8px_rgba(0,122,255,0.4)]" />
<h4 className="text-[10px] font-apple-black text-apple-label-secondary uppercase tracking-widest">安全信息</h4>
</div>
<p className="text-[11px] text-apple-label-secondary">
新账号会先进入“待激活”状态,需要后台审核通过后才能正式登录。
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Input label="登录密码" type="password" value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} placeholder="至少6位" required errorText={errors.password} className="bg-white" />
<Input label="确认密码" type="password" value={formData.confirmPassword} onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })} placeholder="再次输入以核对" required errorText={errors.confirmPassword} className="bg-white" />
<div className="space-y-4 border-t border-[var(--obs-shell-border)] pt-6">
<h4 className={sectionTitleClass}>
安全信息
</h4>
<div className="grid grid-cols-1 gap-x-7 gap-y-4 md:grid-cols-2">
<Input label="登录密码" type="password" value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} placeholder="至少6位" required errorText={errors.password} containerClassName={formFieldClass} className={formControlClass} />
<Input label="确认密码" type="password" value={formData.confirmPassword} onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })} placeholder="再次输入以核对" required errorText={errors.confirmPassword} containerClassName={formFieldClass} className={formControlClass} />
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-apple-separator/5">
<Button type="button" variant="plain" onClick={onClose} disabled={loading} className="rounded-md px-6 uppercase text-[10px] font-apple-black tracking-widest">取消</Button>
<Button type="submit" variant="filled" loading={loading} className="rounded-md px-10 uppercase text-[10px] font-apple-black tracking-widest">创建账号</Button>
<div className="flex items-center justify-end gap-3 border-t border-[var(--obs-shell-border)] pt-4">
<Button type="button" variant="plain" onClick={onClose} disabled={loading} className="rounded-md px-6 text-[13px] font-semibold tracking-normal">取消</Button>
<Button type="submit" variant="filled" loading={loading} className="rounded-md px-8 text-[13px] font-semibold tracking-normal">创建账号</Button>
</div>
</form>
</Modal>

View File

@ -34,18 +34,6 @@ export interface UserTreeItem {
sourceStatus: string;
syncedAt: string;
} | null;
accountTemplateId?: string | null;
accountTemplate?: {
id: string;
name: string;
code: string;
role: string;
businessRoles: BusinessAccountType[];
} | null;
accountTemplateOverrides?: {
overrideFields?: string[];
templateCode?: string;
} | null;
businessRoles?: BusinessAccountType[];
avatar?: string;
phone?: string;

View File

@ -209,8 +209,8 @@ export function DepartmentTreeSelect({
}
return (
<div ref={containerRef} className={cn('relative space-y-2', className)}>
<label className="block text-xs font-bold text-[var(--obs-shell-muted)]">
<div ref={containerRef} className={cn('relative space-y-1.5', className)}>
<label className="block text-[12px] font-semibold leading-5 tracking-normal text-[var(--obs-shell-muted)]">
{label}
{required ? <span className="ml-1 text-red-500">*</span> : null}
</label>
@ -221,7 +221,7 @@ export function DepartmentTreeSelect({
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
className={cn(
'flex min-h-11 w-full items-center gap-3 rounded-[6px] border bg-white px-3 text-left transition-colors',
'flex h-10 min-h-10 w-full items-center gap-2 rounded-[6px] border bg-white px-3 text-left transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--obs-shell-border)]',
hasError
? 'border-red-300'
@ -231,11 +231,11 @@ export function DepartmentTreeSelect({
disabled && 'cursor-not-allowed opacity-60',
)}
>
<Building2 className="h-4 w-4 shrink-0 text-[var(--obs-shell-muted)]" />
<Building2 className="h-3.5 w-3.5 shrink-0 text-[var(--obs-shell-muted)]" />
<span className="min-w-0 flex-1">
<span
className={cn(
'block truncate text-sm font-black',
'block truncate text-[13px] font-semibold leading-5 tracking-normal',
selectedDepartment ? 'text-[var(--obs-shell-title)]' : 'text-[var(--obs-shell-muted)]',
)}
>
@ -243,7 +243,7 @@ export function DepartmentTreeSelect({
</span>
</span>
<ChevronDown
className={cn('h-4 w-4 shrink-0 text-[var(--obs-shell-muted)] transition-transform', open && 'rotate-180')}
className={cn('h-3.5 w-3.5 shrink-0 text-[var(--obs-shell-muted)] transition-transform', open && 'rotate-180')}
/>
</button>

View File

@ -25,18 +25,6 @@ interface UserData {
status: string;
phone?: string | null;
departmentId?: string | null;
accountTemplateId?: string | null;
accountTemplateOverrides?: {
overrideFields?: string[];
} | null;
}
interface AccountTemplateItem {
id: string;
name: string;
code: string;
role: Role;
businessRoles: BusinessAccountType[];
}
interface EditUserModalProps {
@ -73,13 +61,11 @@ export function EditUserModal({
status: user.status,
phone: user.phone || '',
departmentId: user.departmentId || '',
accountTemplateId: user.accountTemplateId || '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [departments, setDepartments] = useState<DepartmentTreeOption[]>([]);
const [templates, setTemplates] = useState<AccountTemplateItem[]>([]);
// 加载部门列表
useEffect(() => {
@ -104,29 +90,6 @@ export function EditUserModal({
}
}, [isOpen]);
useEffect(() => {
const loadTemplates = async () => {
try {
const response = await fetch('/api/account-templates', {
credentials: 'include',
});
if (!response.ok) {
setTemplates([]);
return;
}
const data = await response.json();
setTemplates(data.data?.templates || []);
} catch (error) {
console.error('加载岗位模板失败:', error);
setTemplates([]);
}
};
if (isOpen) {
void loadTemplates();
}
}, [isOpen]);
// 当用户变化时更新表单
useEffect(() => {
setFormData({
@ -139,7 +102,6 @@ export function EditUserModal({
status: user.status,
phone: user.phone || '',
departmentId: user.departmentId || '',
accountTemplateId: user.accountTemplateId || '',
});
}, [user]);
@ -152,16 +114,6 @@ export function EditUserModal({
}));
};
const handleTemplateChange = (templateId: string) => {
const template = templates.find((item) => item.id === templateId);
setFormData((current) => ({
...current,
accountTemplateId: templateId,
role: template?.role || current.role,
businessRoles: template?.businessRoles || current.businessRoles,
}));
};
const availableRoleOptions = [
...new Set<Role>([
...(allowedRoles as Role[]),
@ -219,7 +171,6 @@ export function EditUserModal({
departmentId: string | null;
teamId?: string | null;
password?: string;
accountTemplateId?: string | null;
} = {
email: formData.email,
name: formData.name,
@ -229,7 +180,6 @@ export function EditUserModal({
phone: formData.phone || null,
departmentId: formData.departmentId || null,
teamId: null,
accountTemplateId: formData.accountTemplateId || null,
};
// 只有填写了密码才更新密码
@ -319,7 +269,7 @@ export function EditUserModal({
权限与归属
</h4>
<Select
label="系统角色"
label="账号权限"
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value as Role })}
options={availableRoleOptions}
@ -327,19 +277,6 @@ export function EditUserModal({
disabled={disablePrivilegeFields || user.role === 'SUPER_ADMIN'}
className="bg-white"
/>
<Select
label="岗位模板"
value={formData.accountTemplateId}
onChange={(e) => handleTemplateChange(e.target.value)}
options={[
{ value: '', label: '不套模板,纯手动维护' },
...templates.map((template) => ({
value: template.id,
label: `${template.name} (${template.code})`,
})),
]}
className="bg-white"
/>
<Select
label="当前状态"
value={formData.status}
@ -362,9 +299,6 @@ export function EditUserModal({
className="bg-white"
/>
<div>
<p className="mb-2 text-[11px] font-bold text-[#607073]">
覆盖项:{(user.accountTemplateOverrides?.overrideFields || []).length > 0 ? user.accountTemplateOverrides!.overrideFields!.join(' / ') : '无'}
</p>
<p className="mb-2 text-[10px] font-apple-black uppercase tracking-[0.18em] text-apple-label-tertiary">
业务类型
</p>

View File

@ -372,7 +372,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
</div>
<div>
<div className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">
权限角色
账号权限
</div>
<div className="mt-1 text-sm font-apple-medium text-apple-label-secondary">
{roleText}

View File

@ -22,9 +22,6 @@ export interface UserListItemDTO {
loginSource?: string
employeeDirectoryId?: string | null
employeeDirectory?: UserEmployeeDirectoryDTO | null
accountTemplateId?: string | null
accountTemplate?: UserAccountTemplateDTO | null
accountTemplateOverrides?: Record<string, unknown> | null
departmentId?: string | null
teamId?: string | null
department?: {
@ -55,9 +52,6 @@ export interface UserDetailDTO {
loginSource?: string
employeeDirectoryId?: string | null
employeeDirectory?: UserEmployeeDirectoryDTO | null
accountTemplateId?: string | null
accountTemplate?: UserAccountTemplateDTO | null
accountTemplateOverrides?: Record<string, unknown> | null
isFirstLogin: boolean
department?: {
id: string
@ -137,14 +131,6 @@ export interface UserEmployeeDirectoryDTO {
syncedAt: string
}
export interface UserAccountTemplateDTO {
id: string
name: string
code: string
role: string
businessRoles: BusinessAccountType[]
}
function toEmployeeDirectoryDTO(
employeeDirectory?: {
id: string
@ -183,9 +169,6 @@ export function toUserListItemDTO(
department?: Pick<Department, 'id' | 'name' | 'fullPath'> | null
team?: Pick<Team, 'id' | 'name'> | null
employeeDirectory?: Parameters<typeof toEmployeeDirectoryDTO>[0]
accountTemplateId?: string | null
accountTemplate?: UserAccountTemplateDTO | null
accountTemplateOverrides?: Record<string, unknown> | null
}
): UserListItemDTO {
return {
@ -199,9 +182,6 @@ export function toUserListItemDTO(
loginSource: user.loginSource,
employeeDirectoryId: user.employeeDirectoryId,
employeeDirectory: toEmployeeDirectoryDTO(user.employeeDirectory),
accountTemplateId: user.accountTemplateId,
accountTemplate: user.accountTemplate ?? null,
accountTemplateOverrides: user.accountTemplateOverrides ?? null,
departmentId: user.departmentId,
teamId: user.teamId,
department: user.department
@ -223,9 +203,6 @@ export function toUserDetailDTO(
department?: Pick<Department, 'id' | 'name' | 'fullPath'> | null
team?: Pick<Team, 'id' | 'name'> | null
employeeDirectory?: Parameters<typeof toEmployeeDirectoryDTO>[0]
accountTemplateId?: string | null
accountTemplate?: UserAccountTemplateDTO | null
accountTemplateOverrides?: Record<string, unknown> | null
_count?: { createdProducts: number; ownedSKUs: number }
}
): UserDetailDTO {
@ -241,9 +218,6 @@ export function toUserDetailDTO(
loginSource: user.loginSource,
employeeDirectoryId: user.employeeDirectoryId,
employeeDirectory: toEmployeeDirectoryDTO(user.employeeDirectory),
accountTemplateId: user.accountTemplateId,
accountTemplate: user.accountTemplate ?? null,
accountTemplateOverrides: user.accountTemplateOverrides ?? null,
isFirstLogin: user.isFirstLogin,
department: user.department
? {

View File

@ -175,15 +175,6 @@ class UserRepositoryClass extends BaseRepository<
team: {
select: { id: true, name: true },
},
accountTemplate: {
select: {
id: true,
name: true,
code: true,
role: true,
businessRoles: true,
},
},
employeeDirectory: {
select: {
id: true,
@ -217,15 +208,6 @@ class UserRepositoryClass extends BaseRepository<
team: {
select: { id: true, name: true },
},
accountTemplate: {
select: {
id: true,
name: true,
code: true,
role: true,
businessRoles: true,
},
},
employeeDirectory: {
select: {
id: true,

View File

@ -1,253 +0,0 @@
import { prisma } from '@/lib/prisma'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import {
parseBusinessRolesInput,
parseUserRoleInput,
} from '@/server/validators/business-role.validator'
import { Prisma, type BusinessAccountType, type Role } from '@prisma/client'
export interface AccountTemplateInput {
name: string
code: string
description?: string | null
role: Role
businessRoles?: BusinessAccountType[]
defaultScope?: Prisma.InputJsonValue | null
defaultFieldPermissions?: Prisma.InputJsonValue | null
isActive?: boolean
}
export interface AccountTemplateDTO {
id: string
name: string
code: string
description: string | null
role: Role
businessRoles: BusinessAccountType[]
defaultScope: Prisma.JsonValue | null
defaultFieldPermissions: Prisma.JsonValue | null
isActive: boolean
createdAt: string
updatedAt: string
}
const DEFAULT_TEMPLATES: Array<AccountTemplateInput> = [
{
name: '设计岗位模板',
code: 'design-default',
description: '默认设计账号模板,负责产品研发、结构设计和视觉建模相关工作。',
role: 'USER',
businessRoles: ['DESIGN'],
defaultScope: { visibility: 'workflow-and-users' },
},
{
name: '企划岗位模板',
code: 'planning-default',
description: '默认企划账号模板,负责需求整理和前置信息填写。',
role: 'USER',
businessRoles: ['PLANNING'],
defaultScope: { visibility: 'workflow-and-users' },
},
{
name: '采购岗位模板',
code: 'purchasing-default',
description: '默认采购账号模板,负责供应链与采购字段维护。',
role: 'USER',
businessRoles: ['PURCHASING'],
defaultScope: { visibility: 'workflow-and-users' },
},
{
name: '仓库岗位模板',
code: 'warehouse-default',
description: '默认仓库账号模板,负责仓储字段与入库确认。',
role: 'USER',
businessRoles: ['WAREHOUSE'],
defaultScope: { visibility: 'workflow-and-users' },
},
{
name: '供应商协作模板',
code: 'supplier-default',
description: '默认供应商协作账号模板,仅开放流程字段协作范围。',
role: 'USER',
businessRoles: ['SUPPLIER'],
defaultScope: { visibility: 'workflow-only' },
},
]
class AccountTemplateService {
private toDTO(template: {
id: string
name: string
code: string
description: string | null
role: Role
businessRoles: BusinessAccountType[]
defaultScope: Prisma.JsonValue | null
defaultFieldPermissions: Prisma.JsonValue | null
isActive: boolean
createdAt: Date
updatedAt: Date
}): AccountTemplateDTO {
return {
id: template.id,
name: template.name,
code: template.code,
description: template.description,
role: template.role,
businessRoles: template.businessRoles,
defaultScope: template.defaultScope,
defaultFieldPermissions: template.defaultFieldPermissions,
isActive: template.isActive,
createdAt: template.createdAt.toISOString(),
updatedAt: template.updatedAt.toISOString(),
}
}
async ensureDefaultTemplates(currentUserId: string): Promise<void> {
for (const template of DEFAULT_TEMPLATES) {
await prisma.accountTemplate.upsert({
where: { code: template.code },
update: {
description: template.description ?? null,
role: template.role,
businessRoles: template.businessRoles ?? [],
defaultScope: template.defaultScope ?? Prisma.JsonNull,
isActive: true,
},
create: {
name: template.name,
code: template.code,
description: template.description ?? null,
role: template.role,
businessRoles: template.businessRoles ?? [],
defaultScope: template.defaultScope ?? Prisma.JsonNull,
defaultFieldPermissions: template.defaultFieldPermissions ?? Prisma.JsonNull,
isActive: true,
createdBy: currentUserId,
},
})
}
}
async list(currentUser: AuthUser): Promise<AccountTemplateDTO[]> {
await this.ensureDefaultTemplates(currentUser.id)
const templates = await prisma.accountTemplate.findMany({
orderBy: [{ isActive: 'desc' }, { updatedAt: 'desc' }],
})
return templates.map((template) => this.toDTO(template))
}
async getById(id: string): Promise<AccountTemplateDTO> {
const template = await prisma.accountTemplate.findUnique({ where: { id } })
if (!template) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '岗位模板不存在')
}
return this.toDTO(template)
}
async create(input: AccountTemplateInput, currentUser: AuthUser): Promise<AccountTemplateDTO> {
if (!input.name.trim() || !input.code.trim()) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '模板名称和编码不能为空')
}
const role = parseUserRoleInput(input.role, 'role') ?? 'USER'
const businessRoles = parseBusinessRolesInput(input.businessRoles, { optional: true }) ?? []
const template = await prisma.accountTemplate.create({
data: {
name: input.name.trim(),
code: input.code.trim(),
description: input.description?.trim() || null,
role,
businessRoles,
defaultScope: input.defaultScope ?? Prisma.JsonNull,
defaultFieldPermissions: input.defaultFieldPermissions ?? Prisma.JsonNull,
isActive: input.isActive ?? true,
createdBy: currentUser.id,
},
})
return this.toDTO(template)
}
async update(id: string, input: Partial<AccountTemplateInput>): Promise<AccountTemplateDTO> {
const existing = await prisma.accountTemplate.findUnique({ where: { id } })
if (!existing) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '岗位模板不存在')
}
const role = 'role' in input ? parseUserRoleInput(input.role, 'role') : undefined
const businessRoles = 'businessRoles' in input
? parseBusinessRolesInput(input.businessRoles, { optional: true })
: undefined
const template = await prisma.accountTemplate.update({
where: { id },
data: {
name: input.name?.trim(),
code: input.code?.trim(),
description: input.description === undefined ? undefined : input.description?.trim() || null,
role,
businessRoles,
defaultScope: input.defaultScope === undefined ? undefined : input.defaultScope ?? Prisma.JsonNull,
defaultFieldPermissions:
input.defaultFieldPermissions === undefined
? undefined
: input.defaultFieldPermissions ?? Prisma.JsonNull,
isActive: input.isActive,
},
})
return this.toDTO(template)
}
async buildUserTemplateAssignment(input: {
accountTemplateId?: string | null
role?: Role
businessRoles?: BusinessAccountType[]
}): Promise<{
accountTemplateId?: string | null
role?: Role
businessRoles?: BusinessAccountType[]
accountTemplateOverrides?: Prisma.InputJsonValue | undefined
}> {
if (!input.accountTemplateId) {
return {
accountTemplateId: input.accountTemplateId ?? null,
role: input.role,
businessRoles: input.businessRoles,
accountTemplateOverrides: undefined,
}
}
const template = await prisma.accountTemplate.findUnique({
where: { id: input.accountTemplateId },
})
if (!template || !template.isActive) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '所选岗位模板不可用')
}
const effectiveRole = input.role ?? template.role
const effectiveBusinessRoles = input.businessRoles ?? template.businessRoles
const overrideFields: string[] = []
if (effectiveRole !== template.role) overrideFields.push('role')
if (JSON.stringify(effectiveBusinessRoles) !== JSON.stringify(template.businessRoles)) {
overrideFields.push('businessRoles')
}
return {
accountTemplateId: template.id,
role: effectiveRole,
businessRoles: effectiveBusinessRoles,
accountTemplateOverrides: {
templateCode: template.code,
overrideFields,
},
}
}
}
export const accountTemplateService = new AccountTemplateService()

View File

@ -514,7 +514,6 @@ class UserManagementService {
departmentId?: string
teamId?: string
phone?: string
accountTemplateId?: string | null
},
currentUser: AuthUser
) {
@ -561,7 +560,6 @@ class UserManagementService {
departmentId: effectiveDepartmentId,
teamId: undefined,
phone: input.phone ?? employee?.mobile ?? undefined,
accountTemplateId: input.accountTemplateId ?? null,
isFirstLogin: false,
status: 'PENDING',
employeeDirectoryId: employee?.id ?? undefined,
@ -599,7 +597,6 @@ class UserManagementService {
teamId?: string | null
phone?: string | null
status?: UserStatus
accountTemplateId?: string | null
},
currentUser: AuthUser
) {

View File

@ -1,7 +1,6 @@
import { hashPassword, validatePasswordStrength, verifyPassword } from '@/lib/password'
import { AppError, ErrorCode } from '@/server/core/errors'
import { accountTemplateService } from './account-template.service'
import type { BusinessAccountType, EmployeeSourceStatus, Prisma, Role, UserStatus } from '@prisma/client'
import type { BusinessAccountType, EmployeeSourceStatus, Role, UserStatus } from '@prisma/client'
import { departmentRepository } from '../repositories/department.repository'
import { userRepository } from '../repositories/user.repository'
@ -14,7 +13,6 @@ export interface CreateUserInput {
departmentId?: string
teamId?: string
phone?: string
accountTemplateId?: string | null
isFirstLogin?: boolean
status?: UserStatus
loginSource?: string
@ -36,7 +34,6 @@ export interface UpdateUserInput {
phone?: string | null
status?: UserStatus
avatar?: string | null
accountTemplateId?: string | null
}
export interface UserListOptions {
@ -79,15 +76,6 @@ export interface UserDTO {
sourceStatus: EmployeeSourceStatus
syncedAt: Date
} | null
accountTemplateId: string | null
accountTemplate: {
id: string
name: string
code: string
role: Role
businessRoles: BusinessAccountType[]
} | null
accountTemplateOverrides: Prisma.JsonValue | null
avatar: string | null
phone: string | null
departmentId: string | null
@ -143,15 +131,6 @@ class UserService {
sourceStatus: EmployeeSourceStatus
syncedAt: Date
} | null
accountTemplateId?: string | null
accountTemplate?: {
id: string
name: string
code: string
role: Role
businessRoles: BusinessAccountType[]
} | null
accountTemplateOverrides?: Prisma.JsonValue | null
avatar: string | null
phone: string | null
departmentId: string | null
@ -172,9 +151,6 @@ class UserService {
activationRequired: user.status === 'PENDING',
employeeDirectoryId: user.employeeDirectoryId ?? null,
employeeDirectory: user.employeeDirectory ?? null,
accountTemplateId: user.accountTemplateId ?? null,
accountTemplate: user.accountTemplate ?? null,
accountTemplateOverrides: user.accountTemplateOverrides ?? null,
avatar: user.avatar,
phone: user.phone,
departmentId: user.departmentId,
@ -294,17 +270,12 @@ class UserService {
}
const hashedPassword = await hashPassword(input.password)
const templateAssignment = await accountTemplateService.buildUserTemplateAssignment({
accountTemplateId: input.accountTemplateId,
role: input.role,
businessRoles: input.businessRoles,
})
const created = await userRepository.createWithScalars({
email: input.email,
name: input.name,
password: hashedPassword,
role: templateAssignment.role ?? 'USER',
businessRoles: templateAssignment.businessRoles ?? [],
role: input.role ?? 'USER',
businessRoles: input.businessRoles ?? [],
departmentId: input.departmentId,
teamId: null,
phone: input.phone,
@ -316,8 +287,6 @@ class UserService {
dingtalkUnionId: input.dingtalkUnionId,
dingtalkBoundAt: input.dingtalkBoundAt,
avatar: input.avatar,
accountTemplateId: templateAssignment.accountTemplateId,
accountTemplateOverrides: templateAssignment.accountTemplateOverrides ?? undefined,
})
const user = await userRepository.findByIdWithRelations(created.id)
@ -360,31 +329,17 @@ class UserService {
hashedPassword = await hashPassword(input.password)
}
const templateAssignment =
input.accountTemplateId !== undefined || input.role !== undefined || input.businessRoles !== undefined
? await accountTemplateService.buildUserTemplateAssignment({
accountTemplateId: input.accountTemplateId ?? existing.accountTemplateId ?? null,
role: input.role,
businessRoles: input.businessRoles,
})
: null
await userRepository.updateWithScalars(id, {
email: input.email,
name: input.name,
password: hashedPassword,
role: templateAssignment?.role ?? input.role,
businessRoles: templateAssignment?.businessRoles ?? input.businessRoles,
role: input.role,
businessRoles: input.businessRoles,
departmentId: input.departmentId,
teamId: input.teamId === undefined ? undefined : null,
phone: input.phone,
status: input.status,
avatar: input.avatar,
accountTemplateId: templateAssignment?.accountTemplateId ?? input.accountTemplateId,
accountTemplateOverrides:
templateAssignment?.accountTemplateOverrides === undefined
? undefined
: templateAssignment.accountTemplateOverrides,
})
const updatedUser = await userRepository.findByIdWithRelations(id)