style: 精简盘点任务创建页面

This commit is contained in:
WuYongJie 2026-07-17 16:15:11 +08:00
parent 68ba8d74e4
commit 6e1441f7a4

View File

@ -1,16 +1,8 @@
<template>
<div class="space-y-6">
<section class="page-header">
<div>
<p class="page-kicker">Inventory Task</p>
<h2 class="page-title">新建盘点任务</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-text-secondary">
先确定由谁盘点,再核对覆盖范围。创建后,可触达的人员会收到钉钉通知。
</p>
</div>
<div class="flex justify-start lg:justify-end">
<router-link to="/inventory" class="btn-secondary text-sm">返回任务列表</router-link>
</div>
<div class="space-y-4">
<section class="page-header !flex !flex-row !items-center !justify-between !py-4">
<h2 class="page-title !mt-0">新建盘点任务</h2>
<router-link to="/inventory" class="btn-secondary text-sm">返回</router-link>
</section>
<div
@ -21,112 +13,88 @@
{{ optionsError }}
</div>
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start">
<main class="space-y-6">
<section class="card space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<div class="flex items-center gap-2">
<span class="badge badge-neutral">步骤 1</span>
<h3 class="section-title">填写任务信息</h3>
</div>
<p class="mt-2 text-sm text-text-secondary">标题和截止时间会直接显示在员工收到的通知里。</p>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px] xl:items-start">
<main class="card space-y-5">
<div class="grid gap-4 md:grid-cols-2">
<div class="field-stack">
<label class="field-label" for="inventory-title">任务标题 *</label>
<input
id="inventory-title"
v-model.trim="form.title"
type="text"
class="input"
maxlength="80"
placeholder="例如:7 月办公设备盘点"
/>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="field-stack">
<label class="field-label" for="inventory-title">任务标题 *</label>
<input
id="inventory-title"
v-model.trim="form.title"
type="text"
class="input"
maxlength="80"
placeholder="例如:2026 年 7 月办公设备盘点"
/>
</div>
<div class="field-stack">
<label class="field-label" for="inventory-deadline">截止时间</label>
<input id="inventory-deadline" v-model="form.deadline_at" type="datetime-local" class="input" />
<p v-if="deadlineError" class="text-xs text-status-error">{{ deadlineError }}</p>
</div>
<div class="field-stack md:col-span-2">
<label class="field-label" for="inventory-description">给员工的说明(可选)</label>
<textarea
id="inventory-description"
v-model.trim="form.description"
class="input"
rows="2"
maxlength="300"
placeholder="例如:请在截止时间前扫描本人当前持有的全部设备"
></textarea>
</div>
<div class="field-stack">
<label class="field-label" for="inventory-deadline">截止时间</label>
<input id="inventory-deadline" v-model="form.deadline_at" type="datetime-local" class="input" />
<p v-if="deadlineError" class="text-xs text-status-error">{{ deadlineError }}</p>
</div>
</section>
</div>
<section class="card space-y-4">
<div>
<div class="flex items-center gap-2">
<span class="badge badge-neutral">步骤 2</span>
<h3 class="section-title">选择盘点对象</h3>
</div>
<p class="mt-2 text-sm text-text-secondary">三种方式只选一种,避免范围互相叠加后难以理解。</p>
<details class="group border-t border-border pt-4">
<summary class="cursor-pointer list-none text-sm text-text-secondary hover:text-text-primary">
添加说明
</summary>
<textarea
id="inventory-description"
v-model.trim="form.description"
class="input mt-3"
rows="2"
maxlength="300"
aria-label="给员工的说明"
placeholder="给员工的补充说明(可选)"
></textarea>
</details>
<section class="space-y-4 border-t border-border pt-5">
<div class="flex items-center justify-between gap-3">
<h3 class="section-title">盘点对象</h3>
<span v-if="hasCompletePrimaryScope" class="text-xs text-text-tertiary">{{ currentScopeLabel }}</span>
</div>
<div class="grid gap-3 md:grid-cols-3">
<div class="grid grid-cols-3 gap-1 rounded-lg border border-border bg-bg-elevated p-1">
<button
type="button"
class="touch-option !p-4"
:class="{ 'is-active': scopeMode === 'people' }"
class="flex min-h-10 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition"
:class="scopeMode === 'people' ? 'bg-accent-primary text-white' : 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'"
:aria-pressed="scopeMode === 'people'"
@click="selectScopeMode('people')"
>
<UsersIcon class="h-5 w-5 text-accent-primary" aria-hidden="true" />
<p class="mt-3 text-sm font-semibold text-text-primary">按人员盘点</p>
<p class="mt-1 text-xs leading-5 text-text-secondary">适合新入职、离职或少量指定员工。</p>
<UsersIcon class="h-4 w-4" aria-hidden="true" />
<span>人员</span>
</button>
<button
type="button"
class="touch-option !p-4"
:class="{ 'is-active': scopeMode === 'departments' }"
class="flex min-h-10 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition"
:class="scopeMode === 'departments' ? 'bg-accent-primary text-white' : 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'"
:aria-pressed="scopeMode === 'departments'"
@click="selectScopeMode('departments')"
>
<BuildingOffice2Icon class="h-5 w-5 text-accent-primary" aria-hidden="true" />
<p class="mt-3 text-sm font-semibold text-text-primary">按部门盘点</p>
<p class="mt-1 text-xs leading-5 text-text-secondary">适合部门或组织范围盘点,自动包含下级部门。</p>
<BuildingOffice2Icon class="h-4 w-4" aria-hidden="true" />
<span>部门</span>
</button>
<button
type="button"
class="touch-option !p-4"
:class="{ 'is-active': scopeMode === 'all' }"
class="flex min-h-10 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition"
:class="scopeMode === 'all' ? 'bg-accent-primary text-white' : 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'"
:aria-pressed="scopeMode === 'all'"
@click="selectScopeMode('all')"
>
<GlobeAsiaAustraliaIcon class="h-5 w-5 text-accent-primary" aria-hidden="true" />
<p class="mt-3 text-sm font-semibold text-text-primary">全公司盘点</p>
<p class="mt-1 text-xs leading-5 text-text-secondary">仅用于确实需要覆盖全部资产的正式盘点。</p>
<GlobeAsiaAustraliaIcon class="h-4 w-4" aria-hidden="true" />
<span>全公司</span>
</button>
</div>
<div v-if="!scopeMode" class="empty-state !py-8">
请选择一种盘点方式。系统不会再默认创建全公司范围的任务。
</div>
<section v-else-if="scopeMode === 'people'" 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>
<section v-if="scopeMode === 'people'" class="space-y-3 rounded-lg border border-border bg-bg-elevated p-3">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-text-primary">选择人员</p>
<span class="text-xs text-text-tertiary">{{ form.person_ids.length }} 人</span>
</div>
<div v-if="selectedPeople.length" class="flex flex-wrap gap-2">
@ -143,8 +111,7 @@
</button>
</div>
<div class="field-stack">
<label class="field-label" for="inventory-person-search">搜索花名、姓名或工号</label>
<div>
<div class="relative">
<MagnifyingGlassIcon
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-text-tertiary"
@ -156,17 +123,14 @@
type="search"
class="input !pl-10"
autocomplete="off"
placeholder="输入关键词后再显示人员"
placeholder="搜索花名、姓名或工号"
/>
</div>
</div>
<p v-if="personError" class="text-sm text-status-error" role="alert">{{ personError }}</p>
<div v-if="personLoading" class="empty-state !py-6">正在搜索人员...</div>
<div v-else-if="!personKeyword.trim()" class="empty-state !py-6">
输入关键词开始搜索,不再一次展示整份花名册。
</div>
<div v-else-if="personOptions.length" class="grid max-h-72 gap-2 overflow-auto sm:grid-cols-2">
<p v-if="personLoading" class="text-xs text-text-tertiary">正在搜索...</p>
<div v-else-if="personKeyword.trim() && personOptions.length" class="grid max-h-72 gap-2 overflow-auto sm:grid-cols-2">
<button
v-for="person in personOptions"
:key="person.id"
@ -193,23 +157,20 @@
</div>
</button>
</div>
<div v-else class="empty-state !py-6">没有匹配的在职人员。</div>
<p v-else-if="personKeyword.trim()" class="text-xs text-text-tertiary">没有匹配人员</p>
<div
v-if="selectedPeopleNotReadyCount > 0"
class="rounded-lg border border-status-warning/30 bg-status-warning/10 px-3 py-2 text-xs leading-5 text-status-warning"
>
已选人员中有 {{ selectedPeopleNotReadyCount }} 人暂时无法接收钉钉通知,任务仍会生成,但需要线下提醒。
{{ selectedPeopleNotReadyCount }} 人无法接收钉钉通知
</div>
</section>
<section v-else-if="scopeMode === 'departments'" 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.department_ids.length }} 个</span>
<section v-else-if="scopeMode === 'departments'" class="space-y-3 rounded-lg border border-border bg-bg-elevated p-3">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-text-primary">选择部门</p>
<span class="text-xs text-text-tertiary">{{ form.department_ids.length }} 个</span>
</div>
<div v-if="selectedDepartments.length" class="flex flex-wrap gap-2">
@ -226,8 +187,7 @@
</button>
</div>
<div class="field-stack">
<label class="field-label" for="inventory-department-search">搜索部门</label>
<div>
<div class="relative">
<MagnifyingGlassIcon
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-text-tertiary"
@ -268,34 +228,16 @@
</div>
</button>
</div>
<div v-else class="empty-state !py-6">没有匹配的业务部门。</div>
<p v-else class="text-xs text-text-tertiary">没有匹配部门</p>
</section>
<section
v-else
class="panel !border-status-warning/30 !bg-status-warning/10"
>
<div class="flex items-start gap-3">
<ExclamationTriangleIcon class="mt-0.5 h-5 w-5 shrink-0 text-status-warning" aria-hidden="true" />
<div>
<p class="text-sm font-semibold text-text-primary">这是影响范围最大的方式</p>
<p class="mt-1 text-xs leading-5 text-text-secondary">
系统会覆盖全部符合条件的资产。创建前必须先生成预览,并再次确认参与人数和资产数量。
</p>
</div>
</div>
</section>
<details v-if="scopeMode" class="panel group">
<details v-if="scopeMode" class="group rounded-lg border border-border bg-bg-elevated p-3">
<summary class="flex cursor-pointer list-none items-center justify-between gap-3">
<div class="flex items-center gap-3">
<AdjustmentsHorizontalIcon class="h-5 w-5 text-text-secondary" aria-hidden="true" />
<div>
<p class="text-sm font-semibold text-text-primary">进一步限定资产范围(可选)</p>
<p class="mt-1 text-xs text-text-secondary">只在需要限定资产类型或所在地区时展开。</p>
</div>
<AdjustmentsHorizontalIcon class="h-4 w-4 text-text-secondary" aria-hidden="true" />
<p class="text-sm font-medium text-text-primary">资产筛选</p>
</div>
<span class="badge badge-neutral">已选 {{ extraFilterCount }} 项</span>
<span v-if="extraFilterCount" class="badge badge-neutral">{{ extraFilterCount }}</span>
</summary>
<div class="mt-4 grid gap-6 border-t border-border pt-4 lg:grid-cols-2">
@ -380,35 +322,22 @@
</main>
<aside class="card space-y-4 xl:sticky xl:top-4">
<div>
<div class="flex items-center gap-2">
<span class="badge badge-neutral">步骤 3</span>
<h3 class="section-title">创建前确认</h3>
</div>
<p class="mt-2 text-sm leading-6 text-text-secondary">先看清会影响多少人和资产,再发送通知。</p>
</div>
<h3 class="section-title">任务概览</h3>
<div class="panel space-y-3">
<div>
<p class="mobile-card__label">盘点对象</p>
<p class="mt-1 text-sm font-medium leading-6 text-text-primary">{{ currentScopeLabel }}</p>
<dl class="divide-y divide-border rounded-lg border border-border bg-bg-elevated px-3">
<div class="flex items-start justify-between gap-4 py-3 text-sm">
<dt class="shrink-0 text-text-tertiary">范围</dt>
<dd class="text-right font-medium text-text-primary">{{ currentScopeLabel }}</dd>
</div>
<div class="border-t border-border pt-3">
<p class="mobile-card__label">截止时间</p>
<p class="mt-1 text-sm text-text-primary">{{ formattedDeadline }}</p>
<div class="flex items-start justify-between gap-4 py-3 text-sm">
<dt class="shrink-0 text-text-tertiary">截止</dt>
<dd class="text-right text-text-primary">{{ formattedDeadline }}</dd>
</div>
</div>
</dl>
<div v-if="!hasCompletePrimaryScope" class="empty-state !py-6">
{{ scopeRequirementText }}
</div>
<div v-else-if="previewLoading" class="empty-state !py-6">正在计算盘点范围...</div>
<div v-else-if="previewDirty" class="rounded-lg border border-status-warning/30 bg-status-warning/10 px-3 py-3">
<p class="text-sm font-medium text-status-warning">范围还没有确认</p>
<p class="mt-1 text-xs leading-5 text-text-secondary">请先生成预览;每次修改范围后都要重新确认。</p>
</div>
<p v-if="previewLoading" class="text-xs text-text-tertiary">正在计算...</p>
<template v-else-if="preview">
<template v-if="preview && !previewDirty">
<div class="grid grid-cols-3 gap-2">
<div class="panel !p-3">
<p class="mobile-card__label">参与人</p>
@ -428,7 +357,7 @@
v-if="preview.unassigned_count > 0"
class="rounded-lg border border-status-warning/30 bg-status-warning/10 px-3 py-2 text-xs leading-5 text-status-warning"
>
有 {{ formatNumber(preview.unassigned_count) }} 件资产没有明确责任人,创建后需由管理人员处理。
{{ formatNumber(preview.unassigned_count) }} 件资产暂无责任人
</div>
<details class="rounded-lg border border-border bg-bg-elevated px-3 py-3">
@ -498,12 +427,8 @@
:disabled="!canOpenConfirm"
@click="openConfirm"
>
创建并发送通知
创建任务
</button>
<div v-if="createBlockReason" class="text-xs leading-5 text-text-tertiary">
{{ createBlockReason }}
</div>
</aside>
</div>
@ -514,13 +439,7 @@
aria-modal="true"
aria-labelledby="inventory-confirm-title"
>
<div>
<p class="page-kicker">Final Check</p>
<h3 id="inventory-confirm-title" class="mt-1 text-xl font-semibold text-text-primary">确认创建盘点任务?</h3>
<p class="mt-2 text-sm leading-6 text-text-secondary">
任务创建后,可触达人员会收到钉钉通知并通过手机完成扫码盘点。
</p>
</div>
<h3 id="inventory-confirm-title" class="text-xl font-semibold text-text-primary">确认创建</h3>
<div v-if="preview" class="grid grid-cols-3 gap-3">
<div class="panel !p-3">
@ -556,20 +475,20 @@
v-if="isLargeScope"
class="rounded-lg border border-status-warning/30 bg-status-warning/10 px-3 py-3 text-sm leading-6 text-status-warning"
>
这是大范围任务。请再次确认人数和资产数量,避免误发全员通知。
将通知 {{ formatNumber(preview?.participants_count || 0) }} 人,请确认范围。
</div>
<div
v-if="preview?.items_count === 0"
class="rounded-lg border border-status-error/30 bg-status-error/10 px-3 py-3 text-sm leading-6 text-status-error"
>
当前范围没有匹配到资产,建议返回调整范围后再创建。
当前范围没有资产
</div>
<p v-if="submitError" class="text-sm text-status-error" role="alert">{{ submitError }}</p>
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button type="button" class="btn-secondary" :disabled="creating" @click="closeConfirm">返回调整</button>
<button type="button" class="btn-secondary" :disabled="creating" @click="closeConfirm">取消</button>
<button type="button" class="btn-primary" :disabled="creating" @click="confirmCreate">
{{ creating ? '正在创建...' : '确认创建并发送' }}
{{ creating ? '正在创建...' : '确认创建' }}
</button>
</div>
</section>
@ -584,7 +503,6 @@ import {
AdjustmentsHorizontalIcon,
BuildingOffice2Icon,
CheckCircleIcon,
ExclamationTriangleIcon,
GlobeAsiaAustraliaIcon,
MagnifyingGlassIcon,
UsersIcon
@ -691,13 +609,13 @@ const scopeRequirementText = computed(() => {
})
const currentScopeLabel = computed(() => {
let primaryLabel = '尚未选择盘点对象'
let primaryLabel = '未选择'
if (scopeMode.value === 'people') {
primaryLabel = form.person_ids.length > 0 ? `指定 ${form.person_ids.length} 名员工` : '按人员盘点(尚未选人)'
primaryLabel = form.person_ids.length > 0 ? `${form.person_ids.length} 人` : '未选择人员'
} else if (scopeMode.value === 'departments') {
primaryLabel = form.department_ids.length > 0
? `${form.department_ids.length} 个部门(含下级)`
: '按部门盘点(尚未选部门)'
: '未选择部门'
} else if (scopeMode.value === 'all') {
primaryLabel = '全公司资产'
}
@ -752,15 +670,8 @@ const canOpenConfirm = computed(() =>
&& !creating.value
)
const previewActionLabel = computed(() => {
if (!preview.value) return '预览盘点范围'
return previewDirty.value ? '更新范围预览' : '重新计算预览'
})
const createBlockReason = computed(() => {
if (!form.title.trim()) return '填写任务标题后才能创建。'
if (deadlineError.value) return deadlineError.value
if (!hasCompletePrimaryScope.value) return scopeRequirementText.value
if (!preview.value || previewDirty.value) return '先生成并确认最新范围预览。'
return ''
if (!preview.value || previewDirty.value) return '预览'
return '刷新预览'
})
const isLargeScope = computed(() =>
scopeMode.value === 'all'