feat(duty): add web-admin calendar page and public H5 grab UI
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
36fc7f286c
commit
35e09fa09b
9 files changed
+3152
No files matched your search
@@ -0,0 +1,71 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export type ShiftType = 'morning' | 'evening' | 'duty'
|
||||
export type LinkStatus = 'open' | 'closed'
|
||||
|
||||
export interface DutyAssignmentItem {
|
||||
id?: string
|
||||
source: 'assign' | 'grab'
|
||||
userId?: string | null
|
||||
personName: string
|
||||
}
|
||||
|
||||
export interface DutySlotItem {
|
||||
id?: string
|
||||
shiftType: ShiftType
|
||||
shiftLabel?: string
|
||||
capacity: number
|
||||
taken?: number
|
||||
assignments: DutyAssignmentItem[]
|
||||
}
|
||||
|
||||
export interface DutyDayItem {
|
||||
id?: string | null
|
||||
dutyDate: string
|
||||
linkStatus: LinkStatus
|
||||
accessToken?: string | null
|
||||
publicUrl?: string | null
|
||||
slots: DutySlotItem[]
|
||||
}
|
||||
|
||||
export function fetchDutyMonth(year: number, month: number) {
|
||||
return request.get<DutyDayItem[]>({
|
||||
url: '/web/admin/duty',
|
||||
params: { year, month }
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchDutyDay(dutyDate: string) {
|
||||
return request.get<DutyDayItem>({
|
||||
url: '/web/admin/duty/day',
|
||||
params: { dutyDate }
|
||||
})
|
||||
}
|
||||
|
||||
export function saveDutyDay(data: {
|
||||
dutyDate: string
|
||||
slots: Array<{
|
||||
shiftType: ShiftType
|
||||
capacity: number
|
||||
assignments: DutyAssignmentItem[]
|
||||
}>
|
||||
linkStatus?: LinkStatus
|
||||
}) {
|
||||
return request.post<DutyDayItem>({
|
||||
url: '/web/admin/duty/day',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function setDutyLinkStatus(dayId: string, linkStatus: LinkStatus) {
|
||||
return request.post<DutyDayItem>({
|
||||
url: `/web/admin/duty/day/${dayId}/link`,
|
||||
data: { linkStatus }
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDutyAssignment(id: string) {
|
||||
return request.delete({
|
||||
url: `/web/admin/duty/assignments/${id}`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { publicPlGet, publicPlPost } from '@/utils/http/public-pl'
|
||||
import type { ShiftType } from '@/api/duty'
|
||||
|
||||
export interface PublicDutySlot {
|
||||
id?: string
|
||||
shiftType: ShiftType
|
||||
shiftLabel?: string
|
||||
capacity: number
|
||||
taken: number
|
||||
assignments: Array<{ id?: string; source: string; personName: string }>
|
||||
}
|
||||
|
||||
export interface PublicDutyPage {
|
||||
dutyDate: string
|
||||
linkStatus: 'open' | 'closed'
|
||||
closed?: boolean
|
||||
slots: PublicDutySlot[]
|
||||
}
|
||||
|
||||
export interface PublicDutyGrabResult {
|
||||
dutyDate: string
|
||||
shiftType: ShiftType
|
||||
shiftLabel: string
|
||||
personName: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export const publicDutyApi = {
|
||||
getPage: (token: string) => publicPlGet<PublicDutyPage>(`/public/duty/${token}`),
|
||||
grab: (token: string, data: { shiftType: string; personName: string }) =>
|
||||
publicPlPost<PublicDutyGrabResult>(`/public/duty/${token}/grab`, data)
|
||||
}
|
||||
@@ -87,6 +87,12 @@ export const staticRoutes: AppRouteRecordRaw[] = [
|
||||
component: () => import('@views/public/db/inventory/index.vue'),
|
||||
meta: { title: '出入库单', isHideTab: true, isPublicMobile: true }
|
||||
},
|
||||
{
|
||||
path: '/public/duty/:token',
|
||||
name: 'PublicDutyGrab',
|
||||
component: () => import('@views/public/duty/index.vue'),
|
||||
meta: { title: '值班抢班', isHideTab: true, isPublicMobile: true }
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'Exception404CatchAll',
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
<template>
|
||||
<div class="page-container duty-schedule-page">
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">值班排班</h2>
|
||||
<div class="month-nav">
|
||||
<el-button class="btn-ghost" @click="shiftMonth(-1)">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</el-button>
|
||||
<span class="month-label">{{ year }}年 {{ month }}月</span>
|
||||
<el-button class="btn-ghost" @click="shiftMonth(1)">
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" @click="goToday">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
今天
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span><i class="dot am" />早班</span>
|
||||
<span><i class="dot pm" />晚班</span>
|
||||
<span><i class="dot duty" />值班</span>
|
||||
<span class="muted">点击日期配置 / 复制链接</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="cal-wrap">
|
||||
<table class="duty-cal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="wknd">日</th>
|
||||
<th>一</th>
|
||||
<th>二</th>
|
||||
<th>三</th>
|
||||
<th>四</th>
|
||||
<th>五</th>
|
||||
<th class="wknd">六</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(week, wi) in weeks" :key="wi">
|
||||
<td
|
||||
v-for="(cell, di) in week"
|
||||
:key="di"
|
||||
:class="{
|
||||
muted: !cell.inMonth,
|
||||
today: cell.isToday,
|
||||
'has-day': cell.inMonth
|
||||
}"
|
||||
@click="cell.inMonth && openDay(cell.dateStr)"
|
||||
>
|
||||
<div class="day-num">{{ cell.day }}</div>
|
||||
<template v-if="cell.inMonth && cell.summary">
|
||||
<span
|
||||
v-for="(chip, ci) in cell.chips"
|
||||
:key="ci"
|
||||
class="chip"
|
||||
:class="chip.cls"
|
||||
>
|
||||
{{ chip.text }}
|
||||
</span>
|
||||
<span v-if="!cell.summary.slots?.length" class="chip empty">未排班 · 点击配置</span>
|
||||
</template>
|
||||
<span v-else-if="cell.inMonth" class="chip empty">未排班 · 点击配置</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
class="dialog-form-layout duty-day-dialog"
|
||||
:title="`配置 · ${editingDate}`"
|
||||
width="640px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form label-position="top" class="duty-day-form">
|
||||
<p class="form-tip">
|
||||
为当天配置班次名额,保存后生成公开链接。可直接从系统用户安排,也可留给员工抢班。
|
||||
</p>
|
||||
|
||||
<div v-for="slot in formSlots" :key="slot.shiftType" class="slot-card">
|
||||
<div class="slot-hd">
|
||||
<el-checkbox v-model="slot.enabled">{{ shiftLabel(slot.shiftType) }}</el-checkbox>
|
||||
<div class="cap-field" v-if="slot.enabled">
|
||||
<span>名额</span>
|
||||
<el-input-number v-model="slot.capacity" :min="1" :max="99" controls-position="right" />
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="slot.enabled">
|
||||
<el-form-item label="直接安排(系统用户)">
|
||||
<el-select
|
||||
v-model="slot.pendingUserId"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择员工后添加"
|
||||
style="width: 100%"
|
||||
@change="(v) => addAssign(slot, String(v || ''))"
|
||||
>
|
||||
<el-option
|
||||
v-for="u in userOptions"
|
||||
:key="u.value"
|
||||
:label="u.label"
|
||||
:value="u.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="person-tags">
|
||||
<el-tag
|
||||
v-for="(a, ai) in slot.assignments"
|
||||
:key="a.id || `${a.source}-${a.personName}-${ai}`"
|
||||
:type="a.source === 'grab' ? 'warning' : 'success'"
|
||||
closable
|
||||
size="small"
|
||||
@close="removeAssign(slot, ai)"
|
||||
>
|
||||
{{ a.personName }}({{ a.source === 'grab' ? '抢班' : '指定' }})
|
||||
</el-tag>
|
||||
<span v-if="!slot.assignments.length" class="empty-tip">暂无报名</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="dayDetail?.publicUrl" class="link-box">
|
||||
<span>公开链接</span>
|
||||
<code>{{ dayDetail.publicUrl }}</code>
|
||||
<el-button class="btn-main" type="primary" size="small" @click="copyLink">
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
复制
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="dayDetail.id"
|
||||
class="btn-ghost"
|
||||
size="small"
|
||||
@click="toggleLink"
|
||||
>
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
{{ dayDetail.linkStatus === 'closed' ? '打开链接' : '关闭链接' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="saving" @click="handleSave">
|
||||
<el-icon><Check /></el-icon>
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
Check,
|
||||
Close,
|
||||
CopyDocument,
|
||||
SwitchButton
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { fetchUserOptions } from '@/api/admin'
|
||||
import {
|
||||
deleteDutyAssignment,
|
||||
fetchDutyDay,
|
||||
fetchDutyMonth,
|
||||
saveDutyDay,
|
||||
setDutyLinkStatus,
|
||||
type DutyDayItem,
|
||||
type DutySlotItem,
|
||||
type ShiftType
|
||||
} from '@/api/duty'
|
||||
|
||||
defineOptions({ name: 'DutySchedule' })
|
||||
|
||||
const SHIFT_TYPES: ShiftType[] = ['morning', 'evening', 'duty']
|
||||
const SHIFT_LABELS: Record<ShiftType, string> = {
|
||||
morning: '早班',
|
||||
evening: '晚班',
|
||||
duty: '值班'
|
||||
}
|
||||
const SHIFT_CLS: Record<ShiftType, string> = {
|
||||
morning: 'am',
|
||||
evening: 'pm',
|
||||
duty: 'duty'
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const year = ref(now.getFullYear())
|
||||
const month = ref(now.getMonth() + 1)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const monthDays = ref<DutyDayItem[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingDate = ref('')
|
||||
const dayDetail = ref<DutyDayItem | null>(null)
|
||||
const userOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
type FormSlot = {
|
||||
shiftType: ShiftType
|
||||
enabled: boolean
|
||||
capacity: number
|
||||
pendingUserId: string
|
||||
assignments: DutySlotItem['assignments']
|
||||
}
|
||||
|
||||
const formSlots = reactive<FormSlot[]>(
|
||||
SHIFT_TYPES.map((t) => ({
|
||||
shiftType: t,
|
||||
enabled: false,
|
||||
capacity: 1,
|
||||
pendingUserId: '',
|
||||
assignments: []
|
||||
}))
|
||||
)
|
||||
|
||||
function shiftLabel(t: ShiftType) {
|
||||
return SHIFT_LABELS[t]
|
||||
}
|
||||
|
||||
function pad(n: number) {
|
||||
return n < 10 ? `0${n}` : String(n)
|
||||
}
|
||||
|
||||
function toDateStr(y: number, m: number, d: number) {
|
||||
return `${y}-${pad(m)}-${pad(d)}`
|
||||
}
|
||||
|
||||
const dayMap = computed(() => {
|
||||
const map = new Map<string, DutyDayItem>()
|
||||
for (const d of monthDays.value) {
|
||||
if (d.dutyDate) map.set(d.dutyDate, d)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const weeks = computed(() => {
|
||||
const y = year.value
|
||||
const m = month.value
|
||||
const first = new Date(y, m - 1, 1)
|
||||
const startWeekday = first.getDay()
|
||||
const daysInMonth = new Date(y, m, 0).getDate()
|
||||
const todayStr = toDateStr(now.getFullYear(), now.getMonth() + 1, now.getDate())
|
||||
const cells: Array<{
|
||||
day: number
|
||||
inMonth: boolean
|
||||
dateStr: string
|
||||
isToday: boolean
|
||||
summary?: DutyDayItem
|
||||
chips: Array<{ text: string; cls: string }>
|
||||
}> = []
|
||||
|
||||
const prevDays = new Date(y, m - 1, 0).getDate()
|
||||
for (let i = 0; i < startWeekday; i++) {
|
||||
const d = prevDays - startWeekday + i + 1
|
||||
cells.push({ day: d, inMonth: false, dateStr: '', isToday: false, chips: [] })
|
||||
}
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = toDateStr(y, m, d)
|
||||
const summary = dayMap.value.get(dateStr)
|
||||
const chips: Array<{ text: string; cls: string }> = []
|
||||
if (summary?.slots?.length) {
|
||||
for (const s of summary.slots) {
|
||||
const names = (s.assignments || []).map((a) => a.personName).filter(Boolean)
|
||||
const taken = s.taken ?? names.length
|
||||
const text = names.length
|
||||
? `${s.shiftLabel || SHIFT_LABELS[s.shiftType]} ${names.slice(0, 2).join('、')}${names.length > 2 ? '…' : ''}`
|
||||
: `${s.shiftLabel || SHIFT_LABELS[s.shiftType]} ${taken}/${s.capacity}`
|
||||
chips.push({ text, cls: SHIFT_CLS[s.shiftType] || 'am' })
|
||||
}
|
||||
}
|
||||
cells.push({
|
||||
day: d,
|
||||
inMonth: true,
|
||||
dateStr,
|
||||
isToday: dateStr === todayStr,
|
||||
summary,
|
||||
chips
|
||||
})
|
||||
}
|
||||
while (cells.length % 7 !== 0) {
|
||||
cells.push({
|
||||
day: cells.length % 7,
|
||||
inMonth: false,
|
||||
dateStr: '',
|
||||
isToday: false,
|
||||
chips: []
|
||||
})
|
||||
}
|
||||
const rows: (typeof cells)[] = []
|
||||
for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7))
|
||||
return rows
|
||||
})
|
||||
|
||||
async function loadMonth() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchDutyMonth(year.value, month.value)
|
||||
monthDays.value = Array.isArray(res) ? res : []
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载月历失败')
|
||||
monthDays.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function shiftMonth(delta: number) {
|
||||
let y = year.value
|
||||
let m = month.value + delta
|
||||
if (m < 1) {
|
||||
m = 12
|
||||
y -= 1
|
||||
} else if (m > 12) {
|
||||
m = 1
|
||||
y += 1
|
||||
}
|
||||
year.value = y
|
||||
month.value = m
|
||||
void loadMonth()
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
year.value = now.getFullYear()
|
||||
month.value = now.getMonth() + 1
|
||||
void loadMonth()
|
||||
}
|
||||
|
||||
function resetFormSlots(detail: DutyDayItem | null) {
|
||||
for (const fs of formSlots) {
|
||||
const found = detail?.slots?.find((s) => s.shiftType === fs.shiftType)
|
||||
fs.enabled = !!found
|
||||
fs.capacity = found?.capacity || 1
|
||||
fs.pendingUserId = ''
|
||||
fs.assignments = found ? [...(found.assignments || [])] : []
|
||||
}
|
||||
if (!detail?.slots?.length) {
|
||||
// 默认启用早班晚班各 1
|
||||
formSlots[0].enabled = true
|
||||
formSlots[0].capacity = 1
|
||||
formSlots[1].enabled = true
|
||||
formSlots[1].capacity = 1
|
||||
}
|
||||
}
|
||||
|
||||
async function openDay(dateStr: string) {
|
||||
editingDate.value = dateStr
|
||||
dialogVisible.value = true
|
||||
try {
|
||||
const detail = (await fetchDutyDay(dateStr)) as DutyDayItem
|
||||
dayDetail.value = detail
|
||||
resetFormSlots(detail)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载失败')
|
||||
dayDetail.value = { dutyDate: dateStr, linkStatus: 'open', slots: [] }
|
||||
resetFormSlots(null)
|
||||
}
|
||||
}
|
||||
|
||||
function addAssign(slot: FormSlot, userId: string) {
|
||||
if (!userId) return
|
||||
const u = userOptions.value.find((x) => x.value === userId)
|
||||
if (!u) return
|
||||
if (slot.assignments.some((a) => a.userId === userId || a.personName === u.label)) {
|
||||
ElMessage.warning('该员工已在名单中')
|
||||
slot.pendingUserId = ''
|
||||
return
|
||||
}
|
||||
if (slot.assignments.length >= slot.capacity) {
|
||||
ElMessage.warning('已达名额上限')
|
||||
slot.pendingUserId = ''
|
||||
return
|
||||
}
|
||||
slot.assignments.push({
|
||||
source: 'assign',
|
||||
userId,
|
||||
personName: u.label
|
||||
})
|
||||
slot.pendingUserId = ''
|
||||
}
|
||||
|
||||
async function removeAssign(slot: FormSlot, index: number) {
|
||||
const a = slot.assignments[index]
|
||||
if (a?.id) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认删除该报名?', '提示', { type: 'warning' })
|
||||
await deleteDutyAssignment(a.id)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
slot.assignments.splice(index, 1)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const slots = formSlots
|
||||
.filter((s) => s.enabled)
|
||||
.map((s) => ({
|
||||
shiftType: s.shiftType,
|
||||
capacity: s.capacity,
|
||||
assignments: s.assignments
|
||||
.filter((a) => a.source === 'assign')
|
||||
.map((a) => ({
|
||||
source: 'assign' as const,
|
||||
userId: a.userId || undefined,
|
||||
personName: a.personName
|
||||
}))
|
||||
}))
|
||||
if (!slots.length) {
|
||||
ElMessage.warning('请至少启用一个班次')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const detail = (await saveDutyDay({
|
||||
dutyDate: editingDate.value,
|
||||
slots,
|
||||
linkStatus: dayDetail.value?.linkStatus || 'open'
|
||||
})) as DutyDayItem
|
||||
dayDetail.value = detail
|
||||
ElMessage.success('保存成功')
|
||||
await loadMonth()
|
||||
resetFormSlots(detail)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
const url = dayDetail.value?.publicUrl
|
||||
if (!url) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
ElMessage.success('已复制链接')
|
||||
} catch {
|
||||
ElMessage.info(url)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLink() {
|
||||
if (!dayDetail.value?.id) return
|
||||
const next = dayDetail.value.linkStatus === 'closed' ? 'open' : 'closed'
|
||||
try {
|
||||
dayDetail.value = (await setDutyLinkStatus(dayDetail.value.id, next)) as DutyDayItem
|
||||
ElMessage.success(next === 'closed' ? '已关闭链接' : '已打开链接')
|
||||
await loadMonth()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const opts = await fetchUserOptions()
|
||||
userOptions.value = (Array.isArray(opts) ? opts : []) as Array<{
|
||||
label: string
|
||||
value: string
|
||||
}>
|
||||
} catch {
|
||||
userOptions.value = []
|
||||
}
|
||||
await loadMonth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.duty-schedule-page {
|
||||
.panel-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.month-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.month-label {
|
||||
min-width: 110px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
.legend .muted {
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dot.am {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.dot.pm {
|
||||
background: #3b82f6;
|
||||
}
|
||||
.dot.duty {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.duty-cal {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border: 1px solid var(--mint-border, #d9e8e2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.duty-cal th {
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--mint-border, #d9e8e2);
|
||||
}
|
||||
.duty-cal th.wknd {
|
||||
color: #dc2626;
|
||||
}
|
||||
.duty-cal td {
|
||||
height: 108px;
|
||||
padding: 6px;
|
||||
vertical-align: top;
|
||||
border-right: 1px solid var(--mint-border, #d9e8e2);
|
||||
border-bottom: 1px solid var(--mint-border, #d9e8e2);
|
||||
background: #fff;
|
||||
cursor: default;
|
||||
}
|
||||
.duty-cal tr td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.duty-cal tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.duty-cal td.has-day {
|
||||
cursor: pointer;
|
||||
}
|
||||
.duty-cal td.has-day:hover {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
.duty-cal td.muted {
|
||||
background: #fafafa;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.day-num {
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.duty-cal td.today .day-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #fff;
|
||||
background: var(--el-color-primary, #10b981);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.chip {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
padding: 2px 6px;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.chip.am {
|
||||
color: #b45309;
|
||||
background: #fffbeb;
|
||||
}
|
||||
.chip.pm {
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
}
|
||||
.chip.duty {
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
}
|
||||
.chip.empty {
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
background: #f1f5f9;
|
||||
border: 1px dashed #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
.duty-day-form {
|
||||
.form-tip {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
.slot-card {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border: 1px solid var(--mint-border, #d9e8e2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.slot-hd {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cap-field {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
.person-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.empty-tip {
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
}
|
||||
.link-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
background: var(--el-color-primary-light-9, #ecfdf5);
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.link-box code {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
word-break: break-all;
|
||||
color: var(--el-color-primary-dark-2, #059669);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<div class="duty-h5">
|
||||
<div v-if="loading" class="state">加载中…</div>
|
||||
<div v-else-if="errorType === 'invalid'" class="state">
|
||||
<div class="icon">?</div>
|
||||
<h2>链接无效或已失效</h2>
|
||||
<p>请确认是否复制完整,或向管理员重新索取当日值班链接。</p>
|
||||
</div>
|
||||
<div v-else-if="errorType === 'closed' || page?.linkStatus === 'closed' || page?.closed" class="state">
|
||||
<div class="icon off">⊘</div>
|
||||
<h2>报名已关闭</h2>
|
||||
<p>管理员已关闭本链接,暂时无法继续抢班。</p>
|
||||
<div v-if="page?.dutyDate" class="card">
|
||||
<div class="kv"><span>日期</span><span>{{ page.dutyDate }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="success" class="page">
|
||||
<header class="header">
|
||||
<div class="eyebrow">山峡化工 · 值班抢班</div>
|
||||
<h1>报名成功</h1>
|
||||
<p class="sub">请按时到岗。如需调整请联系管理员。</p>
|
||||
</header>
|
||||
<div class="body state-ok">
|
||||
<div class="icon ok">✓</div>
|
||||
<h2>已抢到{{ success.shiftLabel }}</h2>
|
||||
<p>{{ success.dutyDate }}</p>
|
||||
<div class="card">
|
||||
<div class="kv"><span>日期</span><span>{{ success.dutyDate }}</span></div>
|
||||
<div class="kv"><span>班次</span><span>{{ success.shiftLabel }}</span></div>
|
||||
<div class="kv"><span>姓名</span><span>{{ success.personName }}</span></div>
|
||||
</div>
|
||||
<button type="button" class="btn secondary" @click="backToList">返回查看当日班次</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="page" class="page">
|
||||
<header class="header">
|
||||
<div class="eyebrow">山峡化工 · 值班抢班</div>
|
||||
<h1>{{ formatTitle(page.dutyDate) }}</h1>
|
||||
<p class="sub">无需登录,填写姓名后选择班次提交。名额满了就不能再抢。</p>
|
||||
<div class="meta">
|
||||
<span class="pill">{{ weekdayLabel(page.dutyDate) }}</span>
|
||||
<span class="pill">链接有效</span>
|
||||
<span class="pill">剩余 {{ remainTotal }} 个名额</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="body">
|
||||
<div class="tip">提示:同名可能重复,请填写真实姓名;提交后如需改动请联系管理员。</div>
|
||||
<section v-for="slot in page.slots" :key="slot.shiftType" class="slot">
|
||||
<div class="slot-hd">
|
||||
<div class="title">
|
||||
<i class="dot" :class="slot.shiftType" />
|
||||
{{ slot.shiftLabel || slot.shiftType }}
|
||||
</div>
|
||||
<span class="badge" :class="isFull(slot) ? 'full' : 'open'">
|
||||
{{ isFull(slot) ? '已满' : '可抢' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="cap">
|
||||
<span>名额</span>
|
||||
<strong>{{ slot.taken }} / {{ slot.capacity }}</span>
|
||||
</div>
|
||||
<div class="bar"><i :style="{ width: barWidth(slot) }" :class="slot.shiftType" /></div>
|
||||
<div class="people">
|
||||
<span v-for="(a, i) in slot.assignments" :key="i" class="person">{{ a.personName }}</span>
|
||||
<span v-if="!slot.assignments?.length" class="person empty">暂无人报名</span>
|
||||
<span v-else-if="slot.capacity - slot.taken > 0" class="person empty"
|
||||
>空缺 {{ slot.capacity - slot.taken }}</span
|
||||
>
|
||||
</div>
|
||||
<template v-if="!isFull(slot)">
|
||||
<label class="field">
|
||||
<span>您的姓名</span>
|
||||
<input v-model="names[slot.shiftType]" maxlength="20" placeholder="请输入真实姓名" />
|
||||
</label>
|
||||
<button type="button" class="btn" :disabled="submitting" @click="grab(slot)">
|
||||
提交抢班
|
||||
</button>
|
||||
</template>
|
||||
<button v-else type="button" class="btn" disabled>名额已满</button>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="note">提交即表示确认当日可到岗值班<br />链接由管理员分享 · 请勿转发无关人员</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
publicDutyApi,
|
||||
type PublicDutyGrabResult,
|
||||
type PublicDutyPage,
|
||||
type PublicDutySlot
|
||||
} from '@/api/public-duty'
|
||||
|
||||
defineOptions({ name: 'PublicDutyGrab' })
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const errorType = ref<'invalid' | 'closed' | ''>('')
|
||||
const page = ref<PublicDutyPage | null>(null)
|
||||
const success = ref<PublicDutyGrabResult | null>(null)
|
||||
const names = reactive<Record<string, string>>({})
|
||||
|
||||
const token = computed(() => String(route.params.token || ''))
|
||||
|
||||
const remainTotal = computed(() => {
|
||||
if (!page.value?.slots) return 0
|
||||
return page.value.slots.reduce((sum, s) => sum + Math.max(0, s.capacity - s.taken), 0)
|
||||
})
|
||||
|
||||
function isFull(slot: PublicDutySlot) {
|
||||
return (slot.taken || 0) >= (slot.capacity || 0)
|
||||
}
|
||||
|
||||
function barWidth(slot: PublicDutySlot) {
|
||||
if (!slot.capacity) return '0%'
|
||||
return `${Math.min(100, Math.round((slot.taken / slot.capacity) * 100))}%`
|
||||
}
|
||||
|
||||
function formatTitle(dutyDate: string) {
|
||||
const d = dutyDate?.split('-')
|
||||
if (!d || d.length < 3) return '值班'
|
||||
return `${Number(d[1])}月${Number(d[2])}日 值班`
|
||||
}
|
||||
|
||||
function weekdayLabel(dutyDate: string) {
|
||||
const map = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
const dt = new Date(dutyDate.replace(/-/g, '/'))
|
||||
if (Number.isNaN(dt.getTime())) return ''
|
||||
return map[dt.getDay()]
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorType.value = ''
|
||||
success.value = null
|
||||
try {
|
||||
const data = await publicDutyApi.getPage(token.value)
|
||||
page.value = data
|
||||
if (data.linkStatus === 'closed' || data.closed) {
|
||||
errorType.value = 'closed'
|
||||
}
|
||||
for (const s of data.slots || []) {
|
||||
if (names[s.shiftType] === undefined) names[s.shiftType] = ''
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message || e || '')
|
||||
if (msg.includes('关闭')) errorType.value = 'closed'
|
||||
else errorType.value = 'invalid'
|
||||
page.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function grab(slot: PublicDutySlot) {
|
||||
const name = (names[slot.shiftType] || '').trim()
|
||||
if (!name) {
|
||||
window.alert('请先填写姓名')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await publicDutyApi.grab(token.value, {
|
||||
shiftType: slot.shiftType,
|
||||
personName: name
|
||||
})
|
||||
success.value = res
|
||||
names[slot.shiftType] = ''
|
||||
} catch (e: any) {
|
||||
window.alert(e?.message || '提交失败')
|
||||
await load()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
success.value = null
|
||||
void load()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.duty-h5 {
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
|
||||
color: #1f2a26;
|
||||
background: #f5f8f7;
|
||||
}
|
||||
.page {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
padding: 28px 18px 22px;
|
||||
color: #fff;
|
||||
background: linear-gradient(160deg, #0a9f6d 0%, #087f57 100%);
|
||||
border-radius: 0 0 22px 22px;
|
||||
}
|
||||
.eyebrow {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0.88;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sub {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.pill {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.body {
|
||||
padding: 14px 14px 28px;
|
||||
}
|
||||
.tip {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #8a6418;
|
||||
background: #fff7e8;
|
||||
border: 1px solid #f0dfb5;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.slot {
|
||||
margin-bottom: 12px;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #dcebe4;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 1px 2px rgba(31, 42, 38, 0.04);
|
||||
}
|
||||
.slot-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot.morning {
|
||||
background: #cf8a19;
|
||||
}
|
||||
.dot.evening {
|
||||
background: #3b82f6;
|
||||
}
|
||||
.dot.duty {
|
||||
background: #d14343;
|
||||
}
|
||||
.badge {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.badge.open {
|
||||
color: #087f57;
|
||||
background: #e9f9f2;
|
||||
}
|
||||
.badge.full {
|
||||
color: #6b7b74;
|
||||
background: #eef2f0;
|
||||
}
|
||||
.cap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #6b7b74;
|
||||
}
|
||||
.cap strong {
|
||||
font-size: 15px;
|
||||
color: #1f2a26;
|
||||
}
|
||||
.bar {
|
||||
height: 7px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
background: #e8f0ec;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #0a9f6d;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.bar > i.morning {
|
||||
background: #cf8a19;
|
||||
}
|
||||
.bar > i.evening {
|
||||
background: #3b82f6;
|
||||
}
|
||||
.bar > i.duty {
|
||||
background: #d14343;
|
||||
}
|
||||
.people {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.person {
|
||||
padding: 4px 9px;
|
||||
font-size: 12px;
|
||||
color: #6b7b74;
|
||||
background: #f3f7f5;
|
||||
border: 1px solid #dcebe4;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.person.empty {
|
||||
color: #8a9a93;
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
}
|
||||
.field {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.field span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: #6b7b74;
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
font-size: 15px;
|
||||
background: #fff;
|
||||
border: 1px solid #dcebe4;
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
margin-top: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
background: #0a9f6d;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
background: #b7c5bf;
|
||||
}
|
||||
.btn.secondary {
|
||||
max-width: 280px;
|
||||
color: #087f57;
|
||||
background: #fff;
|
||||
border: 1px solid #0a9f6d;
|
||||
}
|
||||
.note {
|
||||
padding: 4px 16px 24px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #8a9a93;
|
||||
text-align: center;
|
||||
}
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 70vh;
|
||||
padding: 40px 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.state-ok {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 34px;
|
||||
color: #0a9f6d;
|
||||
background: #e9f9f2;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.icon.off {
|
||||
color: #6b7b74;
|
||||
background: #eef2f0;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin-top: 22px;
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1px solid #dcebe4;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.kv span:first-child {
|
||||
color: #6b7b74;
|
||||
}
|
||||
.kv span:last-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user