feat: 全量开放钉钉登录并升级资产盘点

This commit is contained in:
WuYongJie 2026-07-17 14:51:30 +08:00
parent 2e316a7104
commit c29751059e
33 changed files with 1123 additions and 66 deletions

View File

@ -0,0 +1,11 @@
-- 盘点任务增加截止时间,供钉钉派发和移动端明确展示。
ALTER TABLE `inventory_task`
ADD COLUMN `deadline_at` DATETIME(3) NULL AFTER `created_by`,
ADD COLUMN `identity_mode` VARCHAR(20) NOT NULL DEFAULT 'legacy' AFTER `status`;
-- 历史盘点链接继续兼容手工确认身份;本次发布后新任务默认强制使用钉钉身份。
ALTER TABLE `inventory_task`
MODIFY COLUMN `identity_mode` VARCHAR(20) NOT NULL DEFAULT 'dingtalk';
CREATE INDEX `inventory_task_status_deadline_at_idx`
ON `inventory_task`(`status`, `deadline_at`);

View File

@ -449,6 +449,7 @@ model InventoryTask {
title String @db.VarChar(150)
description String? @db.Text
status String @default("active") @db.VarChar(20)
identity_mode String @default("dingtalk") @db.VarChar(20)
public_token String @unique @db.VarChar(120)
scope_department_id Int?
scope_department_name String? @db.VarChar(100)
@ -461,6 +462,7 @@ model InventoryTask {
exception_count Int @default(0)
unassigned_count Int @default(0)
created_by Int
deadline_at DateTime?
closed_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -476,6 +478,7 @@ model InventoryTask {
@@index([scope_department_id, status])
@@index([scope_asset_type_id, status])
@@index([created_by])
@@index([status, deadline_at])
@@map("inventory_task")
}

View File

@ -6,6 +6,8 @@ import { loginWithYouyidianTicket } from '../dingtalkController'
const originalFetch = global.fetch
const originalFindFirst = prisma.user.findFirst
const originalFindUnique = prisma.user.findUnique
const originalUserUpdate = prisma.user.update
const originalPersonFindUnique = prisma.person.findUnique
const envKeys = [
'YYD_NOTIFICATION_CENTER_URL',
'YYD_NOTIFICATION_PROJECT_CODE',
@ -75,12 +77,17 @@ test.after(() => {
global.fetch = originalFetch
;(prisma.user.findFirst as any) = originalFindFirst
;(prisma.user.findUnique as any) = originalFindUnique
;(prisma.user.update as any) = originalUserUpdate
;(prisma.person.findUnique as any) = originalPersonFindUnique
for (const key of envKeys) {
if (originalEnv[key] === undefined) delete process.env[key]
else process.env[key] = originalEnv[key]
}
})
;(prisma.user.update as any) = async (args: any) => ({ id: args.where.id })
;(prisma.person.findUnique as any) = async () => null
test('一次性票据兑换成功后建立绑定userid的会话并303进入内部完成页', async () => {
configureLogin()
let capturedUrl = ''
@ -128,7 +135,7 @@ test('一次性票据兑换成功后建立绑定userid的会话并303进入内
assert.equal(capturedUrl.includes('A'.repeat(43)), false)
})
test('票据用户未绑定启用且在职账号时不建立会话', async () => {
test('票据用户不在启用花名册时不建立会话', async () => {
configureLogin()
global.fetch = (async () => new Response(JSON.stringify({
dingtalkUserId: 'ding-user-1',
@ -147,7 +154,7 @@ test('票据用户未绑定启用且在职账号时不建立会话', async () =>
assert.equal(userLookups, 1)
assert.equal(req.session.user, undefined)
assert.equal(state.statusCode, 403)
assert.match(state.body, /未绑定启用且在职/)
assert.match(state.body, /未匹配到启用且在职/)
})
test('通知中心返回外部跳转地址时按服务异常拒绝建立会话', async () => {

View File

@ -11,6 +11,10 @@ test('钉钉免登只接受白名单内的系统路径', () => {
'/workflows/disposals/123?source=ding'
)
assert.equal(normalizeInternalRedirect('/my-assets'), '/my-assets')
assert.equal(
normalizeInternalRedirect('/inventory/task/secure-public-token'),
'/inventory/task/secure-public-token'
)
})
test('钉钉免登拒绝外链、协议相对地址和非白名单路径', () => {

View File

@ -55,6 +55,7 @@ test('业务人员选项只返回工作所需字段,不泄露联系方式和
assert.equal(capturedArgs.skip, 20)
assert.equal(capturedArgs.take, 20)
assert.equal(JSON.stringify(capturedArgs.where).includes('魔礼红'), true)
assert.equal(JSON.stringify(capturedArgs.where).includes('__public_asset__'), true)
const option = state.body.data.list[0]
assert.equal(option.dingtalk_ready, true)

View File

@ -12,7 +12,7 @@ import {
prepareNotificationCenterResumePayload,
redeemYouyidianLoginTicket
} from '../services/youyidianNotificationClient'
import { findEligibleDingTalkLoginUser } from '../services/dingtalkBrokerLoginService'
import { findOrProvisionEligibleDingTalkLoginUser } from '../services/dingtalkBrokerLoginService'
import {
buildDingTalkBrokerCompletionPath,
normalizeInternalRedirect
@ -101,12 +101,16 @@ export const loginWithYouyidianTicket = async (req: Request, res: Response) => {
if (!isNotificationCenterLoginAllowedForUser(redeemed.dingtalkUserId)) {
return sendBrokerLoginError(res, 403, '当前钉钉账号不在本次免登灰度范围内,请使用原登录方式。')
}
const user = await findEligibleDingTalkLoginUser(redeemed.dingtalkUserId)
const user = await findOrProvisionEligibleDingTalkLoginUser(redeemed.dingtalkUserId)
if (!user) {
return sendBrokerLoginError(res, 403, '当前钉钉账号未绑定启用且在职的固定资产系统账号,请联系管理员。')
return sendBrokerLoginError(res, 403, '当前钉钉账号未匹配到启用且在职的花名册人员,请联系管理员。')
}
await resolveAuthUserById(user.id)
await prisma.user.update({
where: { id: user.id },
data: { last_login_at: new Date() }
})
await establishUserSession(req, user.id, 'dingtalk', redeemed.dingtalkUserId)
return res.redirect(303, buildDingTalkBrokerCompletionPath(safeRedirect))
} catch (error: any) {

View File

@ -27,7 +27,9 @@ export const previewInventoryTaskController = async (req: Request, res: Response
asset_type_id: toOptionalNumber(req.body?.asset_type_id),
asset_type_ids: req.body?.asset_type_ids,
storage_location_id: toOptionalNumber(req.body?.storage_location_id),
storage_location_ids: req.body?.storage_location_ids
storage_location_ids: req.body?.storage_location_ids,
person_id: toOptionalNumber(req.body?.person_id),
person_ids: req.body?.person_ids
})
const result = await previewInventoryTask(normalizedScope)
successResponse(res, result, '预览成功')
@ -49,16 +51,20 @@ export const createInventoryTaskController = async (req: Request, res: Response)
asset_type_id: toOptionalNumber(req.body?.asset_type_id),
asset_type_ids: req.body?.asset_type_ids,
storage_location_id: toOptionalNumber(req.body?.storage_location_id),
storage_location_ids: req.body?.storage_location_ids
storage_location_ids: req.body?.storage_location_ids,
person_id: toOptionalNumber(req.body?.person_id),
person_ids: req.body?.person_ids
})
const result = await createInventoryTask(
{
title: req.body?.title,
description: req.body?.description,
deadline_at: req.body?.deadline_at,
department_ids: normalizedScope.departmentIds,
asset_type_ids: normalizedScope.assetTypeIds,
storage_location_ids: normalizedScope.storageLocationIds
storage_location_ids: normalizedScope.storageLocationIds,
person_ids: normalizedScope.personIds
},
currentUserId
)
@ -187,9 +193,9 @@ export const submitAdminInventoryScanController = async (req: Request, res: Resp
}
const item = await submitAdminInventoryScan(taskId, equipmentCode, currentUserId)
successResponse(res, item, '管理员代扫成功')
successResponse(res, item, '管理人员代扫成功')
} catch (error: any) {
const message = error.message || '管理员扫码盘点失败'
const message = error.message || '管理人员扫码盘点失败'
errorResponse(
res,
message,

View File

@ -2,7 +2,10 @@ import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
import { isSystemReservedPerson } from '../utils/systemReservedPerson'
import {
isSystemReservedPerson,
PUBLIC_ASSET_PERSON_SOURCE_USERID
} from '../utils/systemReservedPerson'
const departmentSelect = {
id: true,
@ -510,7 +513,17 @@ export const getPersonOptions = async (req: Request, res: Response) => {
const requestedPageSize = req.query.page_size ?? req.query.pageSize
const pageSize = Math.min(parsePositiveInteger(requestedPageSize, 50), 100)
const status = includeInactive ? undefined : (req.query.status || 'active')
const where = buildPersonWhere(req.query.keyword, status)
const where = {
AND: [
buildPersonWhere(req.query.keyword, status),
{
OR: [
{ source_userid: null },
{ source_userid: { not: PUBLIC_ASSET_PERSON_SOURCE_USERID } }
]
}
]
}
const [persons, total] = await Promise.all([
prisma.person.findMany({
where,

View File

@ -1,7 +1,10 @@
import type { Request, Response } from 'express'
import { errorResponse, successResponse } from '../utils/response'
import { resolveAuthUserById } from '../utils/authIdentity'
import { findEligibleDingTalkSessionUser } from '../services/dingtalkBrokerLoginService'
import {
confirmInventoryParticipant,
confirmLegacyInventoryParticipant,
confirmInventoryParticipantByPerson,
getInventoryTaskPublicInfo,
getParticipantItems,
getParticipantTaskContext,
@ -12,7 +15,7 @@ import {
const INVENTORY_SESSION_KEY = 'inventoryParticipant'
const getInventorySession = (req: Request) => ((req.session as any)?.[INVENTORY_SESSION_KEY] || null) as
const getInventorySession = (req: Request) => (((req as any).session as any)?.[INVENTORY_SESSION_KEY] || null) as
| {
taskId: number
participantId: number
@ -28,12 +31,38 @@ const setInventorySession = (
publicToken: string
}
) => {
;(req.session as any)[INVENTORY_SESSION_KEY] = payload
;((req as any).session as any)[INVENTORY_SESSION_KEY] = payload
}
const clearInventorySession = (req: Request) => {
if ((req.session as any)?.[INVENTORY_SESSION_KEY]) {
delete (req.session as any)[INVENTORY_SESSION_KEY]
if (((req as any).session as any)?.[INVENTORY_SESSION_KEY]) {
delete ((req as any).session as any)[INVENTORY_SESSION_KEY]
}
}
const ensureCurrentInventoryIdentity = async (req: Request, participantPersonId: number | null) => {
const sessionUser = ((req as any).session as any)?.user
const userId = Number(sessionUser?.id)
if (!Number.isInteger(userId) || userId <= 0) {
throw new Error('请重新使用钉钉登录后进入盘点任务')
}
if (sessionUser?.auth_source === 'dingtalk') {
const dingtalkUserId = String(sessionUser?.dingtalk_userid || '').trim()
const eligibleUser = await findEligibleDingTalkSessionUser(userId, dingtalkUserId)
if (!eligibleUser) {
;((req as any).session as any).user = undefined
throw new Error('钉钉身份或员工绑定已失效,请重新登录')
}
}
const currentUser = await resolveAuthUserById(userId)
if (
!participantPersonId ||
currentUser.person_id !== participantPersonId ||
currentUser.person?.status !== 'active'
) {
throw new Error('当前登录账号与盘点责任人不一致,请重新进入本人任务')
}
}
@ -49,6 +78,15 @@ const ensureInventorySession = async (req: Request) => {
throw new Error('盘点会话已失效,请重新确认身份')
}
if (taskContext.task.identity_mode === 'dingtalk') {
try {
await ensureCurrentInventoryIdentity(req, taskContext.participant.person_id)
} catch (error) {
clearInventorySession(req)
throw error
}
}
return {
session,
...taskContext
@ -72,7 +110,7 @@ export const searchPublicInventoryParticipantsController = async (req: Request,
successResponse(res, list, '获取成功')
} catch (error: any) {
const message = error.message || '搜索盘点人员失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 500)
errorResponse(res, message, message.includes('不存在') ? 404 : message.includes('钉钉登录') ? 403 : 500)
}
}
@ -83,7 +121,7 @@ export const confirmPublicInventoryParticipantController = async (req: Request,
return errorResponse(res, '请选择盘点人员', 422)
}
const data = await confirmInventoryParticipant(req.params.token, participantId)
const data = await confirmLegacyInventoryParticipant(req.params.token, participantId)
setInventorySession(req, {
taskId: data.task_id,
participantId: data.participant_id,
@ -92,7 +130,27 @@ export const confirmPublicInventoryParticipantController = async (req: Request,
successResponse(res, data, '确认成功')
} catch (error: any) {
const message = error.message || '确认盘点身份失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 422)
errorResponse(res, message, message.includes('不存在') ? 404 : message.includes('钉钉登录') ? 403 : 422)
}
}
export const confirmCurrentInventoryParticipantController = async (req: Request, res: Response) => {
try {
const personId = Number(res.locals.user?.person_id)
const data = await confirmInventoryParticipantByPerson(req.params.token, personId)
setInventorySession(req, {
taskId: data.task_id,
participantId: data.participant_id,
publicToken: req.params.token
})
successResponse(res, data, '已按当前钉钉身份进入盘点任务')
} catch (error: any) {
const message = error.message || '确认当前盘点身份失败'
errorResponse(
res,
message,
message.includes('不存在') ? 404 : message.includes('未登录') ? 401 : 403
)
}
}
@ -106,11 +164,13 @@ export const getPublicInventoryMeController = async (req: Request, res: Response
id: task.id,
title: task.title,
status: task.status,
identity_mode: task.identity_mode,
scope_department_name: task.scope_department_name,
scope_asset_type_name: task.scope_asset_type_name,
completed_count: task.completed_count,
exception_count: task.exception_count,
items_count: task.items_count,
deadline_at: task.deadline_at,
closed_at: task.closed_at
},
participant

View File

@ -1,6 +1,7 @@
import { Router } from 'express'
import {
confirmPublicInventoryParticipantController,
confirmCurrentInventoryParticipantController,
getPublicInventoryItemsController,
getPublicInventoryMeController,
getPublicInventoryTaskController,
@ -8,12 +9,14 @@ import {
submitPublicInventoryExceptionController,
submitPublicInventoryScanController
} from '../controllers/publicInventoryController'
import { protect } from '../middleware/auth'
const router = Router()
router.get('/tasks/:token', getPublicInventoryTaskController)
router.get('/tasks/:token/participants', searchPublicInventoryParticipantsController)
router.post('/tasks/:token/participants/confirm', confirmPublicInventoryParticipantController)
router.post('/tasks/:token/participants/confirm-me', protect(), confirmCurrentInventoryParticipantController)
router.get('/me', getPublicInventoryMeController)
router.get('/me/items', getPublicInventoryItemsController)
router.post('/me/scan', submitPublicInventoryScanController)

View File

@ -0,0 +1,137 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
ensureOrdinaryUserForActivePerson,
provisionDingTalkLoginUser
} from '../dingtalkUserProvisionService'
const activePerson = {
id: 18,
real_name: '测试姓名',
nickname: '测试花名',
source_userid: 'ding-user-sensitive'
}
test('在职花名册人员首次钉钉登录只自动建立普通用户', async () => {
let createdData: any = null
const client = {
person: {
findUnique: async () => ({ id: activePerson.id, status: 'active' }),
findFirst: async () => activePerson
},
user: {
findUnique: async () => null,
create: async (args: any) => {
createdData = args.data
return { id: 91, status: args.data.status, role: args.data.role }
}
}
}
const user = await provisionDingTalkLoginUser(activePerson.source_userid, client as any)
assert.equal(user?.id, 91)
assert.equal(createdData.role, 'user')
assert.equal(createdData.status, 'active')
assert.equal(createdData.person_id, activePerson.id)
assert.equal(createdData.name, activePerson.nickname)
assert.match(createdData.username, /^ding_[a-f0-9]{32}$/)
assert.equal(createdData.username.includes(activePerson.source_userid), false)
assert.match(createdData.password, /^\$2[aby]\$/)
})
test('已有管理员绑定时复用原角色,绝不降级或新建账号', async () => {
let creates = 0
const client = {
person: {
findFirst: async () => activePerson
},
user: {
findUnique: async () => ({ id: 6, status: 'active', role: 'admin' }),
create: async () => {
creates += 1
return null
}
}
}
const user = await ensureOrdinaryUserForActivePerson(
activePerson.id,
activePerson.source_userid,
client as any
)
assert.deepEqual(user, { id: 6, status: 'active', role: 'admin' })
assert.equal(creates, 0)
})
test('离职人员或已停用绑定账号不能通过自动建号重新进入', async () => {
const inactivePersonClient = {
person: {
findUnique: async () => ({ id: activePerson.id, status: 'inactive' })
},
user: {}
}
assert.equal(
await provisionDingTalkLoginUser(activePerson.source_userid, inactivePersonClient as any),
null
)
const inactiveUserClient = {
person: { findFirst: async () => activePerson },
user: {
findUnique: async () => ({ id: 7, status: 'inactive', role: 'user' })
}
}
assert.equal(
await ensureOrdinaryUserForActivePerson(activePerson.id, activePerson.source_userid, inactiveUserClient as any),
null
)
})
test('钉钉 userid 必须与花名册精确匹配', async () => {
let userLookups = 0
const client = {
person: {
findFirst: async () => null
},
user: {
findUnique: async () => {
userLookups += 1
return null
}
}
}
assert.equal(
await ensureOrdinaryUserForActivePerson(activePerson.id, 'another-ding-user', client as any),
null
)
assert.equal(userLookups, 0)
})
test('同一员工并发首次触达时唯一约束失败的一方复用已创建账号', async () => {
let lookups = 0
const client = {
person: { findFirst: async () => activePerson },
user: {
findUnique: async () => {
lookups += 1
return lookups === 1 ? null : { id: 92, status: 'active', role: 'user' }
},
create: async () => {
const error: any = new Error('unique')
error.code = 'P2002'
throw error
}
}
}
const user = await ensureOrdinaryUserForActivePerson(
activePerson.id,
activePerson.source_userid,
client as any
)
assert.deepEqual(user, { id: 92, status: 'active', role: 'user' })
assert.equal(lookups, 2)
})

View File

@ -0,0 +1,136 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { getPublicInventoryMeController } from '../../controllers/publicInventoryController'
import {
confirmInventoryParticipantByPerson,
searchTaskParticipants
} from '../inventoryService'
test('已登录员工只能按绑定 person_id 进入自己的盘点清单', async () => {
const originalTaskFindUnique = prisma.inventoryTask.findUnique
const originalParticipantFindFirst = prisma.inventoryTaskParticipant.findFirst
let participantWhere: any = null
try {
;(prisma.inventoryTask.findUnique as any) = async () => ({ id: 33 })
;(prisma.inventoryTaskParticipant.findFirst as any) = async (args: any) => {
participantWhere = args.where
return null
}
await assert.rejects(
confirmInventoryParticipantByPerson('public-task-token', 108),
/当前登录人员不在本次盘点范围内/
)
assert.deepEqual(participantWhere, {
task_id: 33,
person_id: 108
})
} finally {
;(prisma.inventoryTask.findUnique as any) = originalTaskFindUnique
;(prisma.inventoryTaskParticipant.findFirst as any) = originalParticipantFindFirst
}
})
test('未绑定员工档案的系统账号不能自动确认盘点身份', async () => {
await assert.rejects(
confirmInventoryParticipantByPerson('public-task-token', 0),
/未绑定员工档案/
)
})
test('新盘点任务拒绝通过共享链接搜索并冒充其他参与人', async () => {
const originalTaskFindUnique = prisma.inventoryTask.findUnique
const originalParticipantFindMany = prisma.inventoryTaskParticipant.findMany
let participantQueries = 0
try {
;(prisma.inventoryTask.findUnique as any) = async () => ({
id: 33,
status: 'active',
identity_mode: 'dingtalk'
})
;(prisma.inventoryTaskParticipant.findMany as any) = async () => {
participantQueries += 1
return []
}
await assert.rejects(
searchTaskParticipants('public-task-token', '其他员工'),
/必须使用钉钉登录/
)
assert.equal(participantQueries, 0)
} finally {
;(prisma.inventoryTask.findUnique as any) = originalTaskFindUnique
;(prisma.inventoryTaskParticipant.findMany as any) = originalParticipantFindMany
}
})
test('新盘点任务后续请求持续核验当前账号与盘点责任人一致', async () => {
const originalTaskFindUnique = prisma.inventoryTask.findUnique
const originalParticipantFindFirst = prisma.inventoryTaskParticipant.findFirst
const originalUserFindUnique = prisma.user.findUnique
try {
;(prisma.inventoryTask.findUnique as any) = async () => ({
id: 33,
public_token: 'public-task-token',
identity_mode: 'dingtalk',
status: 'active'
})
;(prisma.inventoryTaskParticipant.findFirst as any) = async () => ({
id: 77,
task_id: 33,
person_id: 108
})
;(prisma.user.findUnique as any) = async () => ({
id: 9,
username: 'bound-user',
name: '绑定用户',
role: 'user',
status: 'active',
person_id: 109,
person: {
id: 109,
real_name: '其他员工',
nickname: '其他员工',
status: 'active',
source_employee_status: '在职',
source_userid: 'ding-other-user'
}
})
const state = { status: 200, body: null as any }
const response = {
status(code: number) {
state.status = code
return this
},
json(body: any) {
state.body = body
return this
}
}
const request = {
session: {
user: { id: 9 },
inventoryParticipant: {
taskId: 33,
participantId: 77,
publicToken: 'public-task-token'
}
}
}
await getPublicInventoryMeController(request as any, response as any)
assert.equal(state.status, 401)
assert.match(state.body.message, /当前登录账号与盘点责任人不一致/)
assert.equal((request.session as any).inventoryParticipant, undefined)
} finally {
;(prisma.inventoryTask.findUnique as any) = originalTaskFindUnique
;(prisma.inventoryTaskParticipant.findFirst as any) = originalParticipantFindFirst
;(prisma.user.findUnique as any) = originalUserFindUnique
}
})

View File

@ -1,6 +1,8 @@
import prisma from '../utils/prisma'
import { provisionDingTalkLoginUser } from './dingtalkUserProvisionService'
type UserLookupClient = Pick<typeof prisma, 'user'>
type LoginProvisionClient = Pick<typeof prisma, 'user' | 'person'>
export const findEligibleDingTalkLoginUser = async (
dingtalkUserId: string,
@ -47,3 +49,12 @@ export const findEligibleDingTalkSessionUser = async (
select: { id: true }
})
}
export const findOrProvisionEligibleDingTalkLoginUser = async (
dingtalkUserId: string,
client: LoginProvisionClient = prisma
) => {
const existing = await findEligibleDingTalkLoginUser(dingtalkUserId, client)
if (existing) return existing
return provisionDingTalkLoginUser(dingtalkUserId, client as any)
}

View File

@ -0,0 +1,95 @@
import { createHash, randomBytes } from 'crypto'
import bcrypt from 'bcryptjs'
import type { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { isUserRole } from '../utils/permissions'
type ProvisionClient = Prisma.TransactionClient | typeof prisma
let disabledLocalPasswordHash: Promise<string> | null = null
const getDisabledLocalPasswordHash = () => {
if (!disabledLocalPasswordHash) {
// 钉钉自动建立的普通账号没有可用的本地密码。随机明文不会保存,
// 因此不能通过用户名/密码绕过钉钉身份校验。
disabledLocalPasswordHash = bcrypt.hash(randomBytes(48).toString('base64url'), 10)
}
return disabledLocalPasswordHash
}
const buildDingTalkUsername = (dingtalkUserId: string) =>
`ding_${createHash('sha256').update(dingtalkUserId).digest('hex').slice(0, 32)}`
const isEligibleExistingUser = (user: { status: string; role: string } | null) =>
Boolean(user && user.status === 'active' && isUserRole(user.role))
export const ensureOrdinaryUserForActivePerson = async (
personId: number,
expectedDingTalkUserId?: string,
client: ProvisionClient = prisma
) => {
if (!Number.isInteger(personId) || personId <= 0) return null
const person = await client.person.findFirst({
where: {
id: personId,
status: 'active',
source_userid: expectedDingTalkUserId?.trim() || { not: null }
},
select: {
id: true,
real_name: true,
nickname: true,
source_userid: true
}
})
const dingtalkUserId = person?.source_userid?.trim() || ''
if (!person || !dingtalkUserId) return null
if (expectedDingTalkUserId && dingtalkUserId !== expectedDingTalkUserId.trim()) return null
const existing = await client.user.findUnique({
where: { person_id: person.id },
select: { id: true, status: true, role: true }
})
if (existing) return isEligibleExistingUser(existing) ? existing : null
try {
const created = await client.user.create({
data: {
username: buildDingTalkUsername(dingtalkUserId),
password: await getDisabledLocalPasswordHash(),
name: (person.nickname?.trim() || person.real_name.trim()).slice(0, 50),
role: 'user',
status: 'active',
person_id: person.id
},
select: { id: true, status: true, role: true }
})
return created
} catch (error: any) {
if (error?.code !== 'P2002') throw error
// 两次钉钉登录或任务派发同时首次触达同一员工时,只允许一个账号落库。
const raced = await client.user.findUnique({
where: { person_id: person.id },
select: { id: true, status: true, role: true }
})
return isEligibleExistingUser(raced) ? raced : null
}
}
export const provisionDingTalkLoginUser = async (
dingtalkUserId: string,
client: ProvisionClient = prisma
) => {
const normalizedUserId = String(dingtalkUserId || '').trim()
if (!normalizedUserId) return null
const person = await client.person.findUnique({
where: { source_userid: normalizedUserId },
select: { id: true, status: true }
})
if (!person || person.status !== 'active') return null
return ensureOrdinaryUserForActivePerson(person.id, normalizedUserId, client)
}

View File

@ -11,12 +11,27 @@ import {
import { buildInventoryScopeItemDraft } from '../utils/inventoryTaskDraft'
import { getInventoryTaskDeleteBlockedReason } from '../utils/inventoryTaskLifecycle'
import { PUBLIC_ASSET_PERSON_SOURCE_USERID } from '../utils/systemReservedPerson'
import {
createBusinessNotification,
findActiveDingTalkUsersByIds,
formatBeijingDateTime,
getCurrentDingTalkRecipientRollout
} from './businessNotificationService'
import { ensureOrdinaryUserForActivePerson } from './dingtalkUserProvisionService'
type ScopeEntity = {
id: number
name: string
}
type ScopePerson = {
id: number
name: string
real_name: string
department_name: string | null
dingtalk_ready: boolean
}
type InventoryScopeOptions = NormalizeInventoryScopeInputParams | NormalizedInventoryScopeInput
type FrozenParticipantDraft = {
@ -32,7 +47,8 @@ const sanitizeInventoryParticipant = <T extends { person_source_userid?: string
const { person_source_userid: sourceUserId, ...safeParticipant } = participant
return {
...safeParticipant,
is_public_asset_person: sourceUserId === PUBLIC_ASSET_PERSON_SOURCE_USERID
is_public_asset_person: sourceUserId === PUBLIC_ASSET_PERSON_SOURCE_USERID,
dingtalk_ready: Boolean(sourceUserId?.trim() && sourceUserId !== PUBLIC_ASSET_PERSON_SOURCE_USERID)
}
}
@ -59,6 +75,7 @@ type InventoryScopeBuildResult = {
scopeDepartments: ScopeEntity[]
scopeAssetTypes: ScopeEntity[]
scopeStorageLocations: ScopeEntity[]
scopePeople: ScopePerson[]
participants: FrozenParticipantDraft[]
items: FrozenItemDraft[]
}
@ -114,12 +131,43 @@ const buildScopeWhere = ({
return where
}
const expandDepartmentScopeIds = async (selectedIds: number[]) => {
if (selectedIds.length === 0) return []
const departments = await prisma.department.findMany({
where: {
AND: [buildBusinessDepartmentWhere(), { status: 'active' }]
},
select: { id: true, parent_id: true }
})
const childrenByParent = new Map<number, number[]>()
for (const department of departments) {
if (!department.parent_id) continue
const children = childrenByParent.get(department.parent_id) || []
children.push(department.id)
childrenByParent.set(department.parent_id, children)
}
const expanded = new Set(selectedIds)
const pending = [...selectedIds]
while (pending.length > 0) {
const current = pending.shift() as number
for (const childId of childrenByParent.get(current) || []) {
if (expanded.has(childId)) continue
expanded.add(childId)
pending.push(childId)
}
}
return [...expanded]
}
const getScopeMeta = async ({
departmentIds,
assetTypeIds,
storageLocationIds
storageLocationIds,
personIds
}: NormalizedInventoryScopeInput) => {
const [departments, assetTypes, storageLocations] = await Promise.all([
const [departments, assetTypes, storageLocations, people] = await Promise.all([
departmentIds.length > 0
? prisma.department.findMany({
where: {
@ -158,6 +206,27 @@ const getScopeMeta = async ({
orderBy: { name: 'asc' },
select: { id: true, name: true }
})
: Promise.resolve([]),
personIds.length > 0
? prisma.person.findMany({
where: {
id: { in: personIds },
status: 'active',
OR: [
{ source_userid: null },
{ source_userid: { not: PUBLIC_ASSET_PERSON_SOURCE_USERID } }
]
},
orderBy: [{ nickname: 'asc' }, { real_name: 'asc' }],
select: {
id: true,
real_name: true,
nickname: true,
source_userid: true,
department: true,
mainDepartment: { select: { name: true } }
}
})
: Promise.resolve([])
])
@ -173,10 +242,23 @@ const getScopeMeta = async ({
throw new Error('所选地区不存在或已停用')
}
if (personIds.length > 0 && people.length !== personIds.length) {
throw new Error('所选盘点人员不存在或已离职/停用')
}
return {
scopeDepartments: departments,
scopeAssetTypes: assetTypes,
scopeStorageLocations: storageLocations
scopeStorageLocations: storageLocations,
scopePeople: people.map((person) => ({
id: person.id,
name: person.nickname?.trim() || person.real_name,
real_name: person.real_name,
department_name: person.mainDepartment?.name || person.department || null,
dingtalk_ready: Boolean(
person.source_userid?.trim() && person.source_userid !== PUBLIC_ASSET_PERSON_SOURCE_USERID
)
}))
}
}
@ -312,8 +394,12 @@ export const buildInventoryScope = async (
): Promise<InventoryScopeBuildResult> => {
const normalizedScope = normalizeInventoryScopeInput(options)
const meta = await getScopeMeta(normalizedScope)
const expandedDepartmentIds = await expandDepartmentScopeIds(normalizedScope.departmentIds)
const equipments = await prisma.equipment.findMany({
where: buildScopeWhere(normalizedScope),
where: buildScopeWhere({
...normalizedScope,
departmentIds: expandedDepartmentIds
}),
orderBy: [
{ storage_location_id: 'asc' },
{ department_id: 'asc' },
@ -366,6 +452,13 @@ export const buildInventoryScope = async (
activeBorrowRecord
})
if (
normalizedScope.personIds.length > 0 &&
(!itemDraft.participant_person_id || !normalizedScope.personIds.includes(itemDraft.participant_person_id))
) {
continue
}
if (activeBorrowRecord?.person && itemDraft.participant_person_id) {
participantMap.set(itemDraft.participant_person_id, {
person_id: activeBorrowRecord.person.id,
@ -387,6 +480,7 @@ export const buildInventoryScope = async (
scopeDepartments: meta.scopeDepartments,
scopeAssetTypes: meta.scopeAssetTypes,
scopeStorageLocations: meta.scopeStorageLocations,
scopePeople: meta.scopePeople,
participants: Array.from(participantMap.values()).sort((left, right) =>
left.person_nickname.localeCompare(right.person_nickname, 'zh-CN')
),
@ -404,6 +498,7 @@ export const previewInventoryTask = async (options: InventoryScopeOptions) => {
scope_departments: scope.scopeDepartments,
scope_asset_types: scope.scopeAssetTypes,
scope_storage_locations: scope.scopeStorageLocations,
scope_people: scope.scopePeople,
participants_count: scope.participants.length,
items_count: scope.items.length,
unassigned_count: unassignedCount,
@ -420,6 +515,7 @@ const buildTaskScopeSnapshot = (scope: InventoryScopeBuildResult) => ({
scope_departments: scope.scopeDepartments,
scope_asset_types: scope.scopeAssetTypes,
scope_storage_locations: scope.scopeStorageLocations,
scope_people: scope.scopePeople,
participants_count: scope.participants.length,
items_count: scope.items.length,
unassigned_count: scope.items.filter((item) => item.is_unassigned).length,
@ -535,11 +631,16 @@ const parseScopeSnapshot = (task: any) => {
? snapshot.scope_storage_locations
: []
const scopePeople = Array.isArray(snapshot?.scope_people)
? snapshot.scope_people
: []
return {
scope_snapshot: snapshot,
scope_departments: scopeDepartments,
scope_asset_types: scopeAssetTypes,
scope_storage_locations: scopeStorageLocations
scope_storage_locations: scopeStorageLocations,
scope_people: scopePeople
}
}
@ -551,6 +652,7 @@ const serializeTaskListItem = (task: any) => {
title: task.title,
description: task.description,
status: task.status,
identity_mode: task.identity_mode,
public_token: task.public_token,
scope_department_id: task.scope_department_id,
scope_department_name: task.scope_department_name,
@ -563,6 +665,7 @@ const serializeTaskListItem = (task: any) => {
unassigned_count: task.unassigned_count,
created_by: task.created_by,
created_by_name: task.creator?.name || task.creator?.username || null,
deadline_at: task.deadline_at,
closed_at: task.closed_at,
created_at: task.created_at,
updated_at: task.updated_at,
@ -574,12 +677,15 @@ export const createInventoryTask = async (
input: {
title: string
description?: string | null
department_id?: number | null
asset_type_id?: number | null
storage_location_id?: number | null
department_ids?: number[] | null
asset_type_ids?: number[] | null
storage_location_ids?: number[] | null
deadline_at?: Date | string | null
department_id?: number | null
asset_type_id?: number | null
storage_location_id?: number | null
person_id?: number | null
department_ids?: number[] | null
asset_type_ids?: number[] | null
storage_location_ids?: number[] | null
person_ids?: number[] | null
},
operatorUserId: number
) => {
@ -588,13 +694,23 @@ export const createInventoryTask = async (
throw new Error('盘点任务标题不能为空')
}
const deadlineAt = input.deadline_at ? new Date(input.deadline_at) : null
if (deadlineAt && Number.isNaN(deadlineAt.getTime())) {
throw new Error('盘点截止时间格式无效')
}
if (deadlineAt && deadlineAt.getTime() <= Date.now()) {
throw new Error('盘点截止时间必须晚于当前时间')
}
const scope = await buildInventoryScope({
department_id: input.department_id || null,
asset_type_id: input.asset_type_id || null,
storage_location_id: input.storage_location_id || null,
person_id: input.person_id || null,
department_ids: input.department_ids || [],
asset_type_ids: input.asset_type_ids || [],
storage_location_ids: input.storage_location_ids || []
storage_location_ids: input.storage_location_ids || [],
person_ids: input.person_ids || []
})
const task = await prisma.$transaction(async (tx) => {
@ -603,13 +719,15 @@ export const createInventoryTask = async (
title,
description: input.description?.trim() || null,
status: INVENTORY_TASK_STATUSES.active,
identity_mode: 'dingtalk',
public_token: createPublicToken(),
scope_department_id: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0].id : null,
scope_department_name: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0].name : null,
scope_asset_type_id: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0].id : null,
scope_asset_type_name: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0].name : null,
scope_snapshot_json: JSON.stringify(buildTaskScopeSnapshot(scope)),
created_by: operatorUserId
created_by: operatorUserId,
deadline_at: deadlineAt
}
})
@ -627,7 +745,11 @@ export const createInventoryTask = async (
where: { task_id: createdTask.id },
select: {
id: true,
person_id: true
person_id: true,
person_name: true,
person_nickname: true,
person_source_userid: true,
department_name: true
}
})
const participantIdByPersonId = new Map(
@ -678,6 +800,94 @@ export const createInventoryTask = async (
}
})
let missingDingTalkRecipients = 0
const notificationRollout = getCurrentDingTalkRecipientRollout()
for (const participant of savedParticipants) {
const dingtalkUserId = participant.person_source_userid?.trim() || ''
if (dingtalkUserId === PUBLIC_ASSET_PERSON_SOURCE_USERID) {
continue
}
if (!participant.person_id || !dingtalkUserId) {
missingDingTalkRecipients += 1
continue
}
const user = await ensureOrdinaryUserForActivePerson(
participant.person_id,
dingtalkUserId,
tx
)
if (!user) {
missingDingTalkRecipients += 1
continue
}
const dingTalkRecipients = await findActiveDingTalkUsersByIds(tx, [user.id], notificationRollout)
if (dingTalkRecipients.length === 0) {
// 灰度模式名单外人员属于主动抑制,不制造历史积压;全量模式下由下方汇总提醒管理员。
if (notificationRollout.fullRollout) {
missingDingTalkRecipients += 1
}
continue
}
const participantItemCount = scope.items.filter(
(item) => item.participant_person_id === participant.person_id
).length
await createBusinessNotification(tx, {
eventType: 'inventory_task_assigned',
businessType: 'inventory',
businessId: createdTask.id,
title: `盘点任务:${title}`,
content: `${participant.person_nickname}有 ${participantItemCount} 件资产需要盘点。`,
level: 'info',
actionUrl: `/inventory/task/${createdTask.public_token}`,
metadata: {
participant_id: participant.id,
participant_person_id: participant.person_id,
items_count: participantItemCount,
deadline_at: deadlineAt?.toISOString() || null
},
stationRecipients: [],
dingTalkRecipients,
dingTalk: {
title: '资产盘点待完成',
content: [
`盘点任务:${title}`,
`责任人:${participant.person_nickname}|${participant.department_name || '未归属部门'}`,
`待盘资产:${participantItemCount} 件`,
`截止时间:${deadlineAt ? formatBeijingDateTime(deadlineAt) : '未设置'}`,
'操作要求:打开任务后使用手机摄像头扫描资产二维码;未找到或不在本人处的资产请直接上报异常。'
].join('\n'),
actionTitle: '打开并开始盘点',
messageVersion: 'v1'
},
rollout: notificationRollout
})
}
if (missingDingTalkRecipients > 0) {
await createBusinessNotification(tx, {
eventType: 'inventory_task_recipient_missing',
businessType: 'inventory',
businessId: createdTask.id,
title: `盘点任务有 ${missingDingTalkRecipients} 人无法接收钉钉通知`,
content: `盘点任务“${title}”已创建,但有 ${missingDingTalkRecipients} 名参与人缺少有效钉钉身份或系统账号不可用。`,
level: 'error',
actionUrl: `/inventory/${createdTask.id}`,
metadata: { missing_recipient_count: missingDingTalkRecipients },
stationRecipients: [],
dingTalkRecipients: [],
dingTalk: {
title: '盘点任务收件人异常',
content: '请管理员检查参与人的花名册状态和钉钉身份。',
actionTitle: '查看盘点任务',
messageVersion: 'v1'
},
rollout: notificationRollout
})
}
return tx.inventoryTask.findUnique({
where: { id: createdTask.id },
include: {
@ -873,6 +1083,7 @@ export const getInventoryTaskPublicInfo = async (publicToken: string) => {
title: true,
description: true,
status: true,
identity_mode: true,
scope_department_name: true,
scope_asset_type_name: true,
participants_count: true,
@ -881,6 +1092,7 @@ export const getInventoryTaskPublicInfo = async (publicToken: string) => {
exception_count: true,
unassigned_count: true,
scope_snapshot_json: true,
deadline_at: true,
created_at: true,
closed_at: true
}
@ -903,7 +1115,8 @@ export const searchTaskParticipants = async (publicToken: string, keyword: strin
},
select: {
id: true,
status: true
status: true,
identity_mode: true
}
})
@ -911,6 +1124,10 @@ export const searchTaskParticipants = async (publicToken: string, keyword: strin
throw new Error('盘点任务不存在')
}
if (task.identity_mode === 'dingtalk') {
throw new Error('该盘点任务必须使用钉钉登录后进入')
}
const normalizedKeyword = keyword.trim()
return prisma.inventoryTaskParticipant.findMany({
where: {
@ -996,6 +1213,51 @@ export const confirmInventoryParticipant = async (publicToken: string, participa
}
}
export const confirmLegacyInventoryParticipant = async (
publicToken: string,
participantId: number
) => {
const task = await prisma.inventoryTask.findUnique({
where: { public_token: publicToken },
select: { id: true, identity_mode: true }
})
if (!task) throw new Error('盘点任务不存在')
if (task.identity_mode === 'dingtalk') {
throw new Error('该盘点任务必须使用钉钉登录后进入')
}
return confirmInventoryParticipant(publicToken, participantId)
}
export const confirmInventoryParticipantByPerson = async (
publicToken: string,
personId: number
) => {
if (!Number.isInteger(personId) || personId <= 0) {
throw new Error('当前系统账号未绑定员工档案')
}
const task = await prisma.inventoryTask.findUnique({
where: { public_token: publicToken },
select: { id: true }
})
if (!task) {
throw new Error('盘点任务不存在')
}
const participant = await prisma.inventoryTaskParticipant.findFirst({
where: {
task_id: task.id,
person_id: personId
},
select: { id: true }
})
if (!participant) {
throw new Error('当前登录人员不在本次盘点范围内')
}
return confirmInventoryParticipant(publicToken, participant.id)
}
export const getParticipantTaskContext = async (taskId: number, participantId: number) => {
const [task, participant] = await Promise.all([
prisma.inventoryTask.findUnique({

View File

@ -0,0 +1,20 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import test from 'node:test'
const migrationPath = path.resolve(
__dirname,
'../../../prisma/migrations/20260717_inventory_dingtalk_dispatch/migration.sql'
)
test('盘点钉钉派发迁移只增量增加截止时间和查询索引', () => {
const sql = readFileSync(migrationPath, 'utf8')
assert.match(sql, /ALTER TABLE `inventory_task`/i)
assert.match(sql, /ADD COLUMN `deadline_at` DATETIME\(3\) NULL/i)
assert.match(sql, /ADD COLUMN `identity_mode` VARCHAR\(20\) NOT NULL DEFAULT 'legacy'/i)
assert.match(sql, /MODIFY COLUMN `identity_mode` VARCHAR\(20\) NOT NULL DEFAULT 'dingtalk'/i)
assert.match(sql, /CREATE INDEX `inventory_task_status_deadline_at_idx`/i)
assert.doesNotMatch(sql, /DROP\s+(TABLE|COLUMN)/i)
assert.doesNotMatch(sql, /DELETE\s+FROM/i)
})

View File

@ -15,7 +15,8 @@ test('盘点范围会把旧单选字段归一成数组', () => {
{
departmentIds: [3],
assetTypeIds: [9],
storageLocationIds: []
storageLocationIds: [],
personIds: []
}
)
})
@ -31,7 +32,23 @@ test('盘点范围会合并新旧字段、去重并过滤非法值', () => {
{
departmentIds: [3, 8],
assetTypeIds: [5, 12],
storageLocationIds: [2, 7]
storageLocationIds: [2, 7],
personIds: []
}
)
})
test('盘点范围会归一指定人员并过滤重复和非法编号', () => {
assert.deepEqual(
normalizeInventoryScopeInput({
person_id: 6,
person_ids: [6, '9', 0, 'bad', 9]
}),
{
departmentIds: [],
assetTypeIds: [],
storageLocationIds: [],
personIds: [6, 9]
}
)
})

View File

@ -21,12 +21,13 @@ test('行政可以登记和审批但不能借还、调拨或挂载', () => {
assert.ok(!permissions.includes(PERMISSIONS.ASSETS_MOUNT))
})
test('IT可以流转和发起报废但不能审批或管理用户', () => {
test('IT可以流转、发起报废和盘点,但不能审批或管理用户', () => {
const permissions = getPermissionsForRole('it')
assert.ok(permissions.includes(PERMISSIONS.ASSETS_BORROW))
assert.ok(permissions.includes(PERMISSIONS.ASSETS_TRANSFER))
assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS))
assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPLY))
assert.ok(permissions.includes(PERMISSIONS.INVENTORY_MANAGE))
assert.ok(!permissions.includes(PERMISSIONS.DISPOSALS_APPROVE))
assert.ok(!permissions.includes(PERMISSIONS.USERS_MANAGE))
})

View File

@ -2,6 +2,7 @@ const allowedRedirectPrefixes = [
'/workflows/disposals',
'/maintenance',
'/my-assets',
'/inventory/task',
'/equipment',
'/notifications',
'/dashboard'

View File

@ -8,12 +8,16 @@ export interface NormalizeInventoryScopeInputParams {
storage_location_id?: unknown
storage_location_ids?: unknown
storageLocationIds?: unknown
person_id?: unknown
person_ids?: unknown
personIds?: unknown
}
export interface NormalizedInventoryScopeInput {
departmentIds: number[]
assetTypeIds: number[]
storageLocationIds: number[]
personIds: number[]
}
const toNormalizedScopeIds = (values: unknown[]) =>
@ -51,7 +55,8 @@ export const normalizeInventoryScopeInput = (
storageLocationIds: normalizeScopeIdArray(
input.storageLocationIds ?? input.storage_location_ids,
input.storage_location_id
)
),
personIds: normalizeScopeIdArray(input.personIds ?? input.person_ids, input.person_id)
})
export const resolveUniqueActiveBorrowRecord = <T extends { person?: { status?: string | null } | null }>(

View File

@ -51,6 +51,7 @@ const ROLE_PERMISSIONS: Record<UserRole, readonly Permission[]> = {
PERMISSIONS.ASSETS_MOUNT,
PERMISSIONS.ASSETS_PRINT_LABELS,
PERMISSIONS.DISPOSALS_APPLY,
PERMISSIONS.INVENTORY_MANAGE,
PERMISSIONS.NOTIFICATIONS_VIEW,
PERMISSIONS.REPORTS_VIEW
],

View File

@ -5,6 +5,7 @@ export interface DingTalkPublicConfig {
configured: boolean
mode: 'notification_center'
loginEntryUrl?: string
rolloutMode?: 'disabled' | 'test' | 'full'
}
export interface DingTalkDeliveryStatus {

View File

@ -15,11 +15,14 @@ export interface InventoryTaskPreviewPayload {
asset_type_ids?: number[]
storage_location_id?: number | null
storage_location_ids?: number[]
person_id?: number | null
person_ids?: number[]
}
export interface CreateInventoryTaskPayload extends InventoryTaskPreviewPayload {
title: string
description?: string
deadline_at?: string
}
export const previewInventoryTask = async (data: InventoryTaskPreviewPayload) => {

View File

@ -74,6 +74,19 @@ export const confirmPublicInventoryParticipant = async (token: string, participa
return response.data
}
export const confirmCurrentInventoryParticipant = async (token: string) => {
const response = await publicInventoryApi.post<
ApiResponse<{
task_id: number
task_title: string
participant_id: number
participant_name: string
participant_nickname: string
}>
>(`/tasks/${token}/participants/confirm-me`)
return response.data
}
export const getPublicInventoryMe = async () => {
const response = await publicInventoryApi.get<ApiResponse<PublicInventorySessionData>>('/me')
return response.data

View File

@ -256,12 +256,21 @@ export interface InventoryScopeEntity {
name: string
}
export interface InventoryScopePerson {
id: number
name: string
real_name: string
department_name?: string | null
dingtalk_ready: boolean
}
export interface InventoryTaskScopeSnapshot {
scope_department: InventoryScopeEntity | null
scope_asset_type: InventoryScopeEntity | null
scope_departments: InventoryScopeEntity[]
scope_asset_types: InventoryScopeEntity[]
scope_storage_locations: InventoryScopeEntity[]
scope_people: InventoryScopePerson[]
participants_count: number
items_count: number
unassigned_count: number
@ -285,6 +294,7 @@ export interface InventoryPreviewParticipantSample {
person_name: string
person_nickname: string
person_source_userid: string | null
dingtalk_ready?: boolean
department_id: number | null
department_name: string | null
}
@ -314,6 +324,7 @@ export interface InventoryTaskPreview {
scope_departments: InventoryScopeEntity[]
scope_asset_types: InventoryScopeEntity[]
scope_storage_locations: InventoryScopeEntity[]
scope_people: InventoryScopePerson[]
participants_count: number
items_count: number
unassigned_count: number
@ -328,6 +339,7 @@ export interface InventoryTaskListItem {
title: string
description?: string | null
status: InventoryTaskStatus | string
identity_mode?: 'legacy' | 'dingtalk' | string
public_token: string
scope_department_id?: number | null
scope_department_name?: string | null
@ -340,6 +352,7 @@ export interface InventoryTaskListItem {
unassigned_count: number
created_by: number
created_by_name?: string | null
deadline_at?: string | null
closed_at?: string | null
created_at: string
updated_at: string
@ -347,6 +360,7 @@ export interface InventoryTaskListItem {
scope_departments?: InventoryScopeEntity[]
scope_asset_types?: InventoryScopeEntity[]
scope_storage_locations?: InventoryScopeEntity[]
scope_people?: InventoryScopePerson[]
}
export interface InventoryTaskParticipant {
@ -356,6 +370,7 @@ export interface InventoryTaskParticipant {
person_name: string
person_nickname: string
person_source_userid?: string | null
dingtalk_ready?: boolean
department_id: number | null
department_name: string | null
status: InventoryParticipantStatus | string
@ -436,6 +451,7 @@ export interface PublicInventoryTaskInfo {
title: string
description?: string | null
status: InventoryTaskStatus | string
identity_mode?: 'legacy' | 'dingtalk' | string
scope_department_name?: string | null
scope_asset_type_name?: string | null
participants_count: number
@ -443,12 +459,14 @@ export interface PublicInventoryTaskInfo {
completed_count: number
exception_count: number
unassigned_count: number
deadline_at?: string | null
created_at: string
closed_at?: string | null
scope_snapshot?: InventoryTaskScopeSnapshot | null
scope_departments?: InventoryScopeEntity[]
scope_asset_types?: InventoryScopeEntity[]
scope_storage_locations?: InventoryScopeEntity[]
scope_people?: InventoryScopePerson[]
}
export interface PublicInventoryParticipantOption {
@ -465,6 +483,7 @@ export interface PublicInventorySessionTask {
id: number
title: string
status: InventoryTaskStatus | string
identity_mode?: 'legacy' | 'dingtalk' | string
scope_department_name?: string | null
scope_asset_type_name?: string | null
scope_snapshot?: InventoryTaskScopeSnapshot | null
@ -474,6 +493,7 @@ export interface PublicInventorySessionTask {
completed_count: number
exception_count: number
items_count: number
deadline_at?: string | null
closed_at?: string | null
}

View File

@ -49,6 +49,26 @@
>
{{ loading ? '登录中...' : '进入系统' }}
</button>
<template v-if="dingtalkAvailable">
<div class="flex items-center gap-3 py-1" aria-hidden="true">
<span class="h-px flex-1 bg-border"></span>
<span class="text-xs text-text-tertiary">或</span>
<span class="h-px flex-1 bg-border"></span>
</div>
<button
type="button"
class="btn-secondary w-full justify-center"
:disabled="loading"
@click="handleDingTalkLogin"
>
使用钉钉登录
</button>
<p class="text-center text-xs leading-5 text-text-tertiary">
在职员工无需单独申请普通账号,管理员、行政和 IT 仍按系统角色授权。
</p>
</template>
</form>
<div v-else class="space-y-4">
@ -83,6 +103,7 @@ import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import keycloak from '@/auth/keycloak'
import api from '@/api'
import { getDingTalkPublicConfig } from '@/api/dingtalk'
import { useSiteStore } from '@/stores/site'
import { resolveAuthMode } from '@/utils/authMode'
@ -92,6 +113,7 @@ const loading = ref(false)
const username = ref('')
const password = ref('')
const errorText = ref('')
const dingtalkAvailable = ref(false)
const siteStore = useSiteStore()
const authMode = resolveAuthMode((import.meta.env as any).VITE_AUTH_MODE)
@ -137,7 +159,22 @@ const handleLocalLogin = async () => {
}
}
onMounted(() => {
const handleDingTalkLogin = async () => {
await router.push({
path: '/dingtalk-entry',
query: { redirect: '/dashboard' }
})
}
onMounted(async () => {
siteStore.fetchSiteConfig()
if (!isLocalAuth.value) return
try {
const response = await getDingTalkPublicConfig('/dashboard')
dingtalkAvailable.value = Boolean(response.data?.configured && response.data?.loginEntryUrl)
} catch {
dingtalkAvailable.value = false
}
})
</script>

View File

@ -17,7 +17,7 @@ import { useRoute, useRouter } from 'vue-router'
import { getDingTalkPublicConfig } from '@/api/dingtalk'
import { useAuthStore } from '@/stores/auth'
const SAFE_PATH_PREFIXES = ['/dashboard', '/equipment', '/workflows/disposals', '/maintenance', '/my-assets', '/notifications']
const SAFE_PATH_PREFIXES = ['/dashboard', '/equipment', '/workflows/disposals', '/maintenance', '/my-assets', '/notifications', '/inventory/task']
const route = useRoute()
const router = useRouter()

View File

@ -18,6 +18,7 @@ const ACTIONS_BY_ROLE: Record<UserRole, DashboardRoleAction[]> = {
{ id: 'borrow', label: '资产借出', description: '扫码选择使用者并完成出库', to: '/borrow/scan' },
{ id: 'return', label: '资产归还', description: '扫码归还资产及随借配件', to: '/return/scan' },
{ id: 'transfer', label: '调拨与闲置', description: '在资产管理中选择资产后调拨', to: '/equipment' },
{ id: 'inventory', label: '发起盘点', description: '按人员或组织派发手机盘点任务', to: '/inventory' },
{ id: 'maintenance', label: '维修管理', description: '登记、完成或取消维修', to: '/maintenance' },
{ id: 'disposal-apply', label: '发起报废', description: '在资产详情中选择整机及配件', to: '/equipment' }
],

View File

@ -59,6 +59,11 @@
placeholder="可选,给盘点负责人留一句简短说明"
></textarea>
</div>
<div class="field-stack">
<label class="field-label">截止时间</label>
<input v-model="form.deadline_at" type="datetime-local" class="input" />
<p class="text-xs leading-5 text-text-tertiary">通知中会明确显示,便于员工及时完成。</p>
</div>
</div>
</section>
@ -70,12 +75,70 @@
</div>
</div>
<section class="panel space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-sm font-semibold text-text-primary">指定盘点人员(可选)</p>
<p class="mt-1 text-xs leading-5 text-text-secondary">
留空时覆盖当前资产范围内的全部责任人;指定后只纳入这些人员当前持有的资产。
</p>
</div>
<span class="badge badge-neutral">已选 {{ form.person_ids.length }} 人</span>
</div>
<div v-if="selectedPeople.length" class="flex flex-wrap gap-2">
<button
v-for="person in selectedPeople"
:key="person.id"
type="button"
class="badge badge-neutral"
:aria-label="`移除 ${person.nickname}`"
@click="togglePerson(person)"
>
{{ person.nickname }} · {{ person.real_name }} ×
</button>
</div>
<div class="field-stack">
<label class="field-label">搜索花名、姓名或工号</label>
<input v-model.trim="personKeyword" type="text" class="input" placeholder="输入关键词选择人员" />
</div>
<div v-if="personLoading" class="empty-state">正在搜索人员...</div>
<div v-else class="grid max-h-64 gap-2 overflow-auto sm:grid-cols-2 xl:grid-cols-3">
<button
v-for="person in personOptions"
:key="person.id"
type="button"
class="touch-option text-left"
:class="{ 'is-active': form.person_ids.includes(person.id) }"
@click="togglePerson(person)"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p>
<p class="mt-1 truncate text-xs text-text-secondary">
{{ person.main_department?.name || person.department || '未归属部门' }}
<span v-if="person.job_number"> · {{ person.job_number }}</span>
</p>
</div>
<span class="badge" :class="person.dingtalk_ready ? 'badge-success' : 'badge-warning'">
{{ person.dingtalk_ready ? '钉钉可达' : '无法通知' }}
</span>
</div>
</button>
<p v-if="personOptions.length === 0" class="py-6 text-center text-sm text-text-tertiary sm:col-span-2 xl:col-span-3">
没有匹配的在职人员。
</p>
</div>
</section>
<div class="grid gap-6 xl:grid-cols-3">
<section class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-semibold text-text-primary">业务部门</p>
<p class="mt-1 text-xs text-text-tertiary">{{ selectedDepartmentLabel }}</p>
<p class="mt-1 text-xs text-text-tertiary">{{ selectedDepartmentLabel }},自动包含下级部门</p>
</div>
<button
type="button"
@ -251,7 +314,7 @@
<div class="panel">
<p class="mobile-card__label">无人归属资产</p>
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.unassigned_count) }}</p>
<p class="mt-1 text-xs text-text-secondary">这部分会单列给管理员处理,其他资产管理员也可直接代扫</p>
<p class="mt-1 text-xs text-text-secondary">这部分会单列给管理人员处理,管理人员也可直接代扫</p>
</div>
</div>
@ -360,12 +423,13 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getAssetTypeOptions } from '@/api/assetType'
import { getDepartmentOptions } from '@/api/department'
import { createInventoryTask, previewInventoryTask } from '@/api/inventory'
import { getStorageLocationOptions } from '@/api/storageLocation'
import { getPersonOptions, type PersonOption } from '@/api/person'
import type { AssetType, Department, InventoryTaskPreview, StorageLocation } from '@/types'
import { formatNumber } from '@/utils/format'
import { getInventoryPublicAssetLabel, isInventoryPublicAssetPerson } from './inventoryPublicAsset'
@ -376,17 +440,31 @@ const router = useRouter()
const departments = ref<Department[]>([])
const assetTypes = ref<AssetType[]>([])
const storageLocations = ref<StorageLocation[]>([])
const personOptions = ref<PersonOption[]>([])
const selectedPeople = ref<PersonOption[]>([])
const personKeyword = ref('')
const personLoading = ref(false)
const preview = ref<InventoryTaskPreview | null>(null)
const previewLoading = ref(false)
const creating = ref(false)
const previewDirty = ref(true)
const toLocalDateTimeInput = (date: Date) => {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const defaultDeadline = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
defaultDeadline.setSeconds(0, 0)
const form = reactive({
title: '',
description: '',
deadline_at: toLocalDateTimeInput(defaultDeadline),
department_ids: [] as number[],
asset_type_ids: [] as number[],
storage_location_ids: [] as number[]
storage_location_ids: [] as number[],
person_ids: [] as number[]
})
const selectedDepartments = computed(() =>
@ -399,13 +477,14 @@ const selectedStorageLocations = computed(() =>
storageLocations.value.filter((location) => form.storage_location_ids.includes(location.id))
)
const currentScopeLabel = computed(() =>
buildInventoryScopeLabel({
const currentScopeLabel = computed(() => {
const scope = buildInventoryScopeLabel({
scope_departments: selectedDepartments.value,
scope_asset_types: selectedAssetTypes.value,
scope_storage_locations: selectedStorageLocations.value
})
)
return form.person_ids.length > 0 ? `${scope} · 指定 ${form.person_ids.length} 人` : scope
})
const selectedDepartmentLabel = computed(() =>
form.department_ids.length > 0 ? `已选 ${form.department_ids.length} 个业务部门` : '留空表示全部业务部门'
)
@ -420,7 +499,8 @@ const canCreate = computed(() => form.title.length > 0 && !creating.value)
const buildPreviewPayload = () => ({
department_ids: [...form.department_ids],
asset_type_ids: [...form.asset_type_ids],
storage_location_ids: [...form.storage_location_ids]
storage_location_ids: [...form.storage_location_ids],
person_ids: [...form.person_ids]
})
const toggleSelection = (
@ -439,6 +519,38 @@ const clearSelection = (key: 'department_ids' | 'asset_type_ids' | 'storage_loca
form[key] = [] as never
}
const togglePerson = (person: PersonOption) => {
if (form.person_ids.includes(person.id)) {
form.person_ids = form.person_ids.filter((id) => id !== person.id)
selectedPeople.value = selectedPeople.value.filter((item) => item.id !== person.id)
return
}
form.person_ids = [...form.person_ids, person.id].sort((left, right) => left - right)
selectedPeople.value = [...selectedPeople.value, person]
.sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'))
}
let personSearchTimer: number | null = null
const searchPeople = async () => {
personLoading.value = true
try {
const response = await getPersonOptions({
page: 1,
pageSize: 30,
keyword: personKeyword.value,
status: 'active'
})
personOptions.value = response.data?.list || []
} catch (error: any) {
personOptions.value = []
alert(error?.message || '搜索盘点人员失败')
} finally {
personLoading.value = false
}
}
const loadOptions = async () => {
try {
const [departmentResponse, assetTypeResponse, storageLocationResponse] = await Promise.all([
@ -500,6 +612,7 @@ const handleSubmit = async () => {
const response = await createInventoryTask({
title: form.title.trim(),
description: form.description.trim() || undefined,
deadline_at: form.deadline_at ? new Date(form.deadline_at).toISOString() : undefined,
...buildPreviewPayload()
})
alert('盘点任务已创建')
@ -512,14 +625,26 @@ const handleSubmit = async () => {
}
watch(
() => [form.department_ids.join(','), form.asset_type_ids.join(','), form.storage_location_ids.join(',')],
() => [form.department_ids.join(','), form.asset_type_ids.join(','), form.storage_location_ids.join(','), form.person_ids.join(',')],
() => {
previewDirty.value = true
}
)
watch(
() => personKeyword.value,
() => {
if (personSearchTimer) window.clearTimeout(personSearchTimer)
personSearchTimer = window.setTimeout(searchPeople, 250)
}
)
onMounted(async () => {
await loadOptions()
await Promise.all([loadOptions(), searchPeople()])
handlePreview()
})
onUnmounted(() => {
if (personSearchTimer) window.clearTimeout(personSearchTimer)
})
</script>

View File

@ -40,7 +40,7 @@
<p class="page-meta__label">任务状态</p>
<p class="page-meta__value">{{ getTaskStatusText(task.status) }}</p>
<p class="page-meta__subvalue">
{{ task.closed_at ? `关闭于 ${formatDateTime(task.closed_at)}` : `创建于 ${formatDateTime(task.created_at)}` }}
{{ task.closed_at ? `关闭于 ${formatDateTime(task.closed_at)}` : task.deadline_at ? `截止 ${formatDateTime(task.deadline_at)}` : `创建于 ${formatDateTime(task.created_at)}` }}
</p>
</div>
<div class="page-meta">
@ -56,7 +56,7 @@
<div class="page-meta">
<p class="page-meta__label">无人归属资产</p>
<p class="page-meta__value">{{ formatNumber(unassignedItems.length) }}</p>
<p class="page-meta__subvalue">这部分会单列处理,其他责任人资产管理员也可直接代扫</p>
<p class="page-meta__subvalue">这部分会单列处理,管理人员也可直接代扫</p>
</div>
</div>
</section>
@ -78,6 +78,10 @@
<p class="field-label">创建人</p>
<p class="text-sm text-text-primary">{{ task.created_by_name || `用户 ${task.created_by}` }}</p>
</div>
<div class="field-stack">
<p class="field-label">截止时间</p>
<p class="text-sm text-text-primary">{{ task.deadline_at ? formatDateTime(task.deadline_at) : '未设置' }}</p>
</div>
<div class="field-stack">
<p class="field-label">手机盘点地址</p>
<div class="flex flex-col gap-2 sm:flex-row">
@ -143,8 +147,8 @@
<section v-if="task.status === 'active'" class="card">
<div class="list-header">
<div>
<h3 class="section-title">管理员代扫</h3>
<p class="mt-1 text-sm text-text-secondary">管理员可直接替任意责任人完成扫码盘点,也能顺手处理无人归属资产。</p>
<h3 class="section-title">管理人员代扫</h3>
<p class="mt-1 text-sm text-text-secondary">管理员、行政或 IT 可替责任人完成扫码盘点,也能处理无人归属资产。</p>
</div>
<span class="badge badge-warning">全任务通用</span>
</div>
@ -526,7 +530,7 @@
<CameraScannerModal
v-model="showAdminScanner"
title="管理员代扫"
title="管理人员代扫"
description="扫码后将直接替责任人完成本次盘点。"
manual-placeholder="请输入资产编码"
@detected="handleAdminScannerDetected"
@ -716,7 +720,7 @@ const resolveActionSummary = (action: InventoryTaskAction) => {
task_closed: '关闭了盘点任务',
participant_confirmed: '盘点人完成身份确认',
item_checked: '确认了一件资产',
item_checked_by_admin: '管理员代扫了一件资产',
item_checked_by_admin: '管理人员代扫了一件资产',
item_exception_reported: '上报了一件异常资产',
item_reassigned: '重新分配了一件无人归属资产'
}
@ -735,8 +739,8 @@ const resolveActionDetail = (action: InventoryTaskAction) => {
return payload?.equipment_code ? `资产编码:${payload.equipment_code}` : '资产已被确认。'
case 'item_checked_by_admin':
return payload?.equipment_code
? `管理员代扫资产编码:${payload.equipment_code}${payload?.is_unassigned ? '(原为无人归属)' : ''}`
: '管理员已代为完成扫码盘点。'
? `管理人员代扫资产编码:${payload.equipment_code}${payload?.is_unassigned ? '(原为无人归属)' : ''}`
: '管理人员已代为完成扫码盘点。'
case 'item_exception_reported':
return payload
? `${getExceptionText(payload.exception_type)}${payload.exception_note ? `,备注:${payload.exception_note}` : ''}`
@ -859,7 +863,7 @@ const runAdminScan = async (equipmentCode: string) => {
showAdminScanner.value = false
await loadTask()
} catch (error: any) {
alert(error?.message || '管理员扫码盘点失败')
alert(error?.message || '管理人员扫码盘点失败')
} finally {
adminScanning.value = false
}

View File

@ -85,6 +85,10 @@
<p class="mobile-card__label">创建时间</p>
<p class="mobile-card__value">{{ formatDateTime(task.created_at) }}</p>
</div>
<div>
<p class="mobile-card__label">截止时间</p>
<p class="mobile-card__value">{{ task.deadline_at ? formatDateTime(task.deadline_at) : '未设置' }}</p>
</div>
</div>
<div class="mt-4 grid gap-2 sm:grid-cols-2">
@ -164,6 +168,7 @@
<td>
<p class="text-sm text-text-primary">{{ task.created_by_name || `用户 ${task.created_by}` }}</p>
<p class="mt-1 text-xs text-text-secondary">{{ formatDateTime(task.created_at) }}</p>
<p v-if="task.deadline_at" class="mt-1 text-xs text-status-warning">截止 {{ formatDateTime(task.deadline_at) }}</p>
<p v-if="task.closed_at" class="mt-1 text-xs text-text-tertiary">结束于 {{ formatDateTime(task.closed_at) }}</p>
</td>
<td>

View File

@ -8,6 +8,7 @@
<h2 class="page-title">{{ displayedTask?.title || '手机盘点' }}</h2>
<p class="mt-2 text-xs text-text-secondary">
{{ displayedTask?.description || '确认身份后盘点资产' }}
<span v-if="displayedTask?.deadline_at"> · 截止 {{ formatDateTime(displayedTask.deadline_at) }}</span>
</p>
</div>
@ -48,13 +49,34 @@
<template v-else-if="task">
<section v-if="!session" class="card">
<div class="list-header">
<h3 class="section-title">确认身份</h3>
<h3 class="section-title">{{ usesBoundIdentity ? '钉钉身份确认' : '确认身份' }}</h3>
<span class="badge" :class="isTaskClosed ? 'badge-neutral' : 'badge-success'">
{{ isTaskClosed ? '任务已关闭,仅可查看结果' : '任务进行中' }}
</span>
</div>
<div class="space-y-3">
<div v-if="usesBoundIdentity" class="panel">
<p class="text-sm font-medium text-text-primary">
{{ automaticIdentityError || '正在按当前钉钉账号匹配本人盘点清单...' }}
</p>
<p class="mt-2 text-xs leading-5 text-text-secondary">
系统只允许进入本人任务,不能切换成其他员工。
</p>
</div>
<div v-else-if="requiresDingTalkIdentity" class="panel space-y-4">
<div>
<p class="text-sm font-medium text-text-primary">请使用本人钉钉身份进入</p>
<p class="mt-2 text-xs leading-5 text-text-secondary">
新盘点任务不再允许通过共享链接手工选择姓名,避免误选或代替他人提交。
</p>
</div>
<button type="button" class="btn-primary w-full" @click="startDingTalkIdentityLogin">
使用钉钉登录并进入本人清单
</button>
</div>
<div v-else class="space-y-3">
<div class="field-stack">
<label class="field-label">搜索姓名</label>
<input
@ -135,7 +157,7 @@
>
{{ showManualEntry ? '收起' : '手动输入' }}
</button>
<button type="button" class="btn-secondary w-full text-sm sm:w-auto" @click="switchIdentity">
<button v-if="!usesBoundIdentity && !requiresDingTalkIdentity" type="button" class="btn-secondary w-full text-sm sm:w-auto" @click="switchIdentity">
切换
</button>
</div>
@ -417,6 +439,7 @@ import { useRoute } from 'vue-router'
import CameraScannerModal from '@/components/CameraScannerModal.vue'
import {
confirmPublicInventoryParticipant,
confirmCurrentInventoryParticipant,
getPublicInventoryItems,
getPublicInventoryMe,
getPublicInventoryTask,
@ -424,6 +447,7 @@ import {
submitPublicInventoryException,
submitPublicInventoryScan
} from '@/api/publicInventory'
import { useAuthStore } from '@/stores/auth'
import type {
InventoryExceptionType,
InventoryTaskItem,
@ -439,6 +463,7 @@ type ItemFilter = 'all' | 'pending' | 'checked' | 'exception'
type FeedbackType = 'success' | 'error' | 'info'
const route = useRoute()
const authStore = useAuthStore()
const task = ref<PublicInventoryTaskInfo | null>(null)
const session = ref<PublicInventorySessionData | null>(null)
@ -454,6 +479,7 @@ const lastHandledItem = ref<InventoryTaskItem | null>(null)
const loadingTask = ref(false)
const searchingParticipants = ref(false)
const confirmingParticipant = ref(false)
const automaticIdentityError = ref('')
const exceptionSubmitting = ref(false)
const feedback = ref<{ type: FeedbackType; text: string } | null>(null)
const participantKeyword = ref('')
@ -477,6 +503,8 @@ const exceptionForm = reactive<{
let participantSearchTimer: number | null = null
const taskToken = computed(() => String(route.params.token || ''))
const usesBoundIdentity = computed(() => Boolean(authStore.isAuthenticated && authStore.user?.person_id))
const requiresDingTalkIdentity = computed(() => task.value?.identity_mode === 'dingtalk')
const displayedTask = computed(() => {
if (!task.value) return null
if (!session.value || session.value.task.id !== task.value.id) return task.value
@ -629,12 +657,24 @@ const bootstrap = async () => {
participants.value = []
selectedParticipantId.value = null
lastHandledItem.value = null
automaticIdentityError.value = ''
try {
await loadTaskInfo()
const hasSession = await loadSessionAndItems()
if (!hasSession) {
await searchParticipantsNow('')
if (usesBoundIdentity.value) {
try {
await confirmCurrentInventoryParticipant(taskToken.value)
const confirmed = await loadSessionAndItems()
if (!confirmed) throw new Error('钉钉身份已确认,但盘点会话建立失败')
setFeedback('success', '已按当前钉钉身份进入本人盘点清单')
} catch (error: any) {
automaticIdentityError.value = error?.message || '当前钉钉账号不在本次盘点范围内'
}
} else if (!requiresDingTalkIdentity.value) {
await searchParticipantsNow('')
}
}
} catch (error: any) {
setFeedback('error', error?.message || '获取盘点任务失败')
@ -643,6 +683,11 @@ const bootstrap = async () => {
}
}
const startDingTalkIdentityLogin = () => {
const redirect = `/inventory/task/${encodeURIComponent(taskToken.value)}`
window.location.assign(`/dingtalk-entry?redirect=${encodeURIComponent(redirect)}`)
}
const confirmIdentity = async () => {
if (!selectedParticipantId.value) {
setFeedback('error', '请先选择你的身份')

View File

@ -1,9 +1,10 @@
import type { InventoryScopeEntity } from '@/types'
import type { InventoryScopeEntity, InventoryScopePerson } from '@/types'
type InventoryScopeLike = {
scope_departments?: InventoryScopeEntity[] | null
scope_asset_types?: InventoryScopeEntity[] | null
scope_storage_locations?: InventoryScopeEntity[] | null
scope_people?: InventoryScopePerson[] | null
scope_department_name?: string | null
scope_asset_type_name?: string | null
}
@ -53,6 +54,10 @@ export const buildInventoryScopeLabel = (scope: InventoryScopeLike) => {
formatScopeSegment(
'地区',
normalizeScopeEntities(scope.scope_storage_locations)
),
formatScopeSegment(
'人员',
normalizeScopeEntities(scope.scope_people)
)
].filter(Boolean)