Commit 7ebb14a3 authored by 李文光's avatar 李文光

feat(offer): 优化 Offer 流程页布局配色并补齐邮件草稿展示

- 工作流步骤条加连接线/完成勾,后续步骤弱化
- 把当前唯一「下一步」提为步骤面板主 CTA,右侧按 推进 / 风险终止 分组
- 补齐「邮件草稿」预览(此前只生成不展示)+复制/重新生成
- 头部加语义色状态 chip、审批驳回/终止风险横幅
- 审批材料缺失项改为「待补充」标签+缺失汇总
- Offer 明细表单改为两列网格(原 .two 未定义实际单列)并加响应式折叠
- 抽出 offerStatusMeta 统一 Offer 列表与流程页状态色
parent 3fdbebc5
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import { offerWorkflow, offerBlockers } from '@/utils/offer' import { offerWorkflow, offerBlockers, offerStatusMeta, OFFER_TERMINAL_STATUSES } from '@/utils/offer'
import { workflowStageClass } from '@/utils/task' import { workflowStageClass } from '@/utils/task'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
...@@ -41,6 +41,18 @@ const gates = computed(() => { ...@@ -41,6 +41,18 @@ const gates = computed(() => {
const materials = computed(() => offer.value?.approvalMaterials || null) const materials = computed(() => offer.value?.approvalMaterials || null)
// 状态展示:头部 chip + 语义色(tone → 全局变量)
const statusMeta = computed(() => offerStatusMeta(status.value))
const TONE_VAR = {
blue: 'var(--blue)',
violet: 'var(--violet)',
cyan: 'var(--cyan)',
green: 'var(--green)',
amber: 'var(--yellow)',
rose: 'var(--rose)',
}
const accentVar = computed(() => TONE_VAR[statusMeta.value.tone] || 'var(--blue)')
const back = () => { const back = () => {
store.focusedTarget = null store.focusedTarget = null
} }
...@@ -192,18 +204,148 @@ const copyMaterials = async () => { ...@@ -192,18 +204,148 @@ const copyMaterials = async () => {
showToast('复制失败', 'error') showToast('复制失败', 'error')
} }
} }
const copyMail = async () => {
const text = offer.value?.mailDraft || ''
if (!text) return
try {
await navigator.clipboard.writeText(text)
showToast('邮件草稿已复制')
} catch {
showToast('复制失败', 'error')
}
}
// 当前唯一的「下一步」主操作:放进工作流面板,让页面只有一处突出主 CTA,
// 右侧「流程操作」只保留其余推进/风险按钮,避免一列按钮没有主次。
const hero = computed(() => {
const s = status.value
const g = gates.value
if (['待 HR 确认', '审批驳回'].includes(s)) {
const blocked = blockers.value.length > 0
return {
key: 'generateMail',
label: '生成邮件草稿',
type: 'primary',
plain: false,
disabled: blocked,
reason: blocked ? blockedReason.value : g.generateMail.reason,
run: generateMail,
}
}
if (s === '审批中') {
return {
key: 'approve',
label: '审批通过',
type: 'primary',
plain: false,
disabled: !g.approve.enabled,
reason: g.approve.reason,
run: approve,
}
}
if (s === '审批通过') {
return {
key: 'send',
label: '发送 Offer 给候选人',
type: 'primary',
plain: false,
disabled: !g.send.enabled,
reason: g.send.reason,
run: sendOffer,
}
}
if (s === '待入职') {
return {
key: 'onboard',
label: '确认已入职',
type: 'primary',
plain: false,
disabled: !g.onboard.enabled,
reason: g.onboard.reason,
run: confirmOnboarded,
}
}
if (OFFER_TERMINAL_STATUSES.includes(s)) {
return {
key: 'reopen',
label: '重开 Offer',
type: 'primary',
plain: true,
disabled: !g.reopen.enabled,
reason: g.reopen.reason,
run: reopenOffer,
}
}
return null
})
function pushOp(list, key, label, type, plain, disabled, reason, run, group) {
list.push({ key, label, type, plain, disabled, reason, run, group })
}
const operationList = computed(() => {
const g = gates.value
const all = []
pushOp(
all,
'generateMail',
'生成邮件草稿',
'primary',
true,
!g.generateMail.enabled,
g.generateMail.reason,
generateMail,
'main'
)
pushOp(all, 'approve', '审批通过', 'primary', false, !g.approve.enabled, g.approve.reason, approve, 'main')
pushOp(all, 'send', '发送 Offer 给候选人', 'primary', false, !g.send.enabled, g.send.reason, sendOffer, 'main')
pushOp(all, 'onboard', '确认已入职', 'primary', false, !g.onboard.enabled, g.onboard.reason, confirmOnboarded, 'main')
pushOp(all, 'reject', '审批驳回', 'warning', false, !g.reject.enabled, g.reject.reason, rejectOffer, 'risk')
pushOp(all, 'void', '撤回 / 作废', 'danger', true, !g.void.enabled, g.void.reason, voidOffer, 'risk')
pushOp(all, 'decline', '候选人拒绝', 'danger', true, !g.decline.enabled, g.decline.reason, markDeclined, 'risk')
pushOp(all, 'reopen', '重开 Offer', 'primary', true, !g.reopen.enabled, g.reopen.reason, reopenOffer, 'risk')
const heroKey = hero.value?.key
return all.filter((a) => a.key !== heroKey)
})
const mainOps = computed(() => operationList.value.filter((o) => o.group === 'main'))
const riskOps = computed(() => operationList.value.filter((o) => o.group === 'risk'))
</script> </script>
<template> <template>
<div v-if="offer" class="offer-flow-page"> <div v-if="offer" class="offer-flow-page" :style="{ '--offer-accent': accentVar }">
<div class="subpage-head"> <div class="subpage-head">
<el-button @click="back">返回录用入职列表</el-button> <el-button @click="back">返回录用入职列表</el-button>
<div> <div class="subpage-title">
<h3>{{ candidate?.name || '候选人' }} · Offer 流程</h3> <div class="title-row">
<p>{{ candidate?.jobTitle || '待匹配岗位' }} · {{ offer.status || '待 HR 确认' }}</p> <h3>{{ candidate?.name || '候选人' }} · Offer 流程</h3>
<el-tag :type="statusMeta.tag" size="small" effect="light" class="status-chip">{{
offer.status || '待 HR 确认'
}}</el-tag>
</div>
<p>
{{ candidate?.jobTitle || '待匹配岗位' }} · {{ offer.contractType || '劳动合同' }}
<template v-if="offer.risk && offer.risk !== '正常'">
· <span class="risk-inline">{{ offer.risk }}</span></template
>
</p>
</div> </div>
</div> </div>
<div
v-if="flow.terminal || flow.rejected"
class="status-banner"
:class="{ rejected: flow.rejected, terminal: flow.terminal }"
>
<template v-if="flow.rejected">
审批已驳回<span v-if="offer.approvalNote">{{ offer.approvalNote }}</span
>,请在下方补全审批材料后重新生成邮件草稿。
</template>
<template v-else>
{{ offer.status === '已拒绝' ? '候选人已拒绝该 Offer' : 'Offer 已撤回 / 作废' }},可对同一候选人重开。
</template>
</div>
<section class="card workflow-panel"> <section class="card workflow-panel">
<div class="workflow-steps"> <div class="workflow-steps">
<div <div
...@@ -212,18 +354,34 @@ const copyMaterials = async () => { ...@@ -212,18 +354,34 @@ const copyMaterials = async () => {
class="workflow-step" class="workflow-step"
:class="workflowStageClass(index, flow.currentIndex)" :class="workflowStageClass(index, flow.currentIndex)"
> >
<span>{{ index + 1 }}</span> <span class="step-dot">{{ index < flow.currentIndex ? '✓' : index + 1 }}</span>
<b>{{ step }}</b> <b>{{ step }}</b>
</div> </div>
</div> </div>
<div class="workflow-next"> <div class="workflow-next">
<div> <div class="next-copy">
<el-tag v-if="flow.terminal" type="danger" size="small" class="flow-tag">流程已终止</el-tag> <div class="next-eyebrow">{{ flow.terminal ? '流程状态' : '下一步' }}</div>
<el-tag v-else-if="flow.rejected" type="warning" size="small" class="flow-tag">审批已驳回</el-tag>
<strong>{{ flow.next.title }}</strong> <strong>{{ flow.next.title }}</strong>
<p>{{ flow.next.desc }}</p> <p>{{ flow.next.desc }}</p>
</div> </div>
<span>{{ flow.next.action }}</span> <div class="next-cta">
<template v-if="hero">
<el-button
:type="hero.type"
:plain="hero.plain"
:disabled="hero.disabled"
:title="hero.reason || hero.label"
@click="hero.run"
>
{{ hero.label }}
</el-button>
<span v-if="hero.disabled" class="cta-hint">{{ hero.reason }}</span>
</template>
<template v-else>
<el-tag type="success" effect="plain" size="large">流程完成</el-tag>
</template>
</div>
</div> </div>
</section> </section>
...@@ -233,13 +391,21 @@ const copyMaterials = async () => { ...@@ -233,13 +391,21 @@ const copyMaterials = async () => {
<div class="card-title">Offer 明细</div> <div class="card-title">Offer 明细</div>
<div class="card-note">补齐薪资、试用期、五险一金和入职时间</div> <div class="card-note">补齐薪资、试用期、五险一金和入职时间</div>
</div> </div>
<el-form label-position="top" class="form two"> <el-form label-position="top" class="form offer-form">
<el-form-item label="薪资"> <el-form-item label="薪资" class="span-2">
<el-input v-model="form.salary" placeholder="如 45k/月 + 年终" /> <el-input v-model="form.salary" placeholder="如 45k/月 + 年终" />
<div class="field-help">留空则自动汇总下方「试用 / 转正 / 试用期」三项</div>
</el-form-item> </el-form-item>
<el-form-item label="入职日期"> <el-form-item label="入职日期">
<el-date-picker v-model="form.startDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" /> <el-date-picker v-model="form.startDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="合同类型">
<el-select v-model="form.contractType">
<el-option label="劳动合同" value="劳动合同" />
<el-option label="劳务合同" value="劳务合同" />
<el-option label="实习协议" value="实习协议" />
</el-select>
</el-form-item>
<el-form-item label="试用期薪资"> <el-form-item label="试用期薪资">
<el-input v-model="form.probationSalary" /> <el-input v-model="form.probationSalary" />
</el-form-item> </el-form-item>
...@@ -261,20 +427,20 @@ const copyMaterials = async () => { ...@@ -261,20 +427,20 @@ const copyMaterials = async () => {
<el-form-item label="工作地点"> <el-form-item label="工作地点">
<el-input v-model="form.workLocation" /> <el-input v-model="form.workLocation" />
</el-form-item> </el-form-item>
<el-form-item label="合同类型">
<el-select v-model="form.contractType">
<el-option label="劳动合同" value="劳动合同" />
<el-option label="劳务合同" value="劳务合同" />
<el-option label="实习协议" value="实习协议" />
</el-select>
</el-form-item>
<el-form-item label="风险标记"> <el-form-item label="风险标记">
<el-input v-model="form.risk" placeholder="如 审批超时 / 离职证明缺失" /> <el-input v-model="form.risk" placeholder="如 审批超时 / 离职证明缺失" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<div class="actions"> <div class="form-footer">
<el-button type="primary" @click="saveOffer">保存 Offer</el-button> <el-button type="primary" @click="saveOffer">保存 Offer</el-button>
<el-button @click="generateMaterials">生成审批材料</el-button> <el-button @click="generateMaterials">生成审批材料</el-button>
<span v-if="materials" class="form-hint" :class="{ warn: materials.blockers && materials.blockers.length }">
{{
materials.blockers && materials.blockers.length
? `还有 ${materials.blockers.length} 项待补充`
: '审批材料已齐备'
}}
</span>
</div> </div>
</section> </section>
...@@ -292,7 +458,11 @@ const copyMaterials = async () => { ...@@ -292,7 +458,11 @@ const copyMaterials = async () => {
:class="{ missing: item.missing }" :class="{ missing: item.missing }"
> >
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
<strong>{{ item.value }}</strong> <strong v-if="!item.missing">{{ item.value }}</strong>
<el-tag v-else type="warning" effect="light" size="small">待补充</el-tag>
</div>
<div v-if="materials.blockers && materials.blockers.length" class="blockers-summary">
缺失:{{ materials.blockers.join('、') }}
</div> </div>
</div> </div>
<div v-else class="muted">尚未生成审批材料。</div> <div v-else class="muted">尚未生成审批材料。</div>
...@@ -301,74 +471,49 @@ const copyMaterials = async () => { ...@@ -301,74 +471,49 @@ const copyMaterials = async () => {
</div> </div>
</section> </section>
<section class="card"> <section v-if="offer.mailDraft" class="card mail-card">
<div class="card-head">
<div class="card-title">邮件草稿</div>
<div class="card-note">发送前可预览 / 复制</div>
</div>
<pre class="mail-preview">{{ offer.mailDraft }}</pre>
<div class="actions">
<el-button @click="copyMail">复制邮件</el-button>
<el-button v-if="gates.generateMail.enabled" @click="generateMail">重新生成</el-button>
</div>
</section>
<section class="card op-panel">
<div class="card-head"> <div class="card-head">
<div class="card-title">流程操作</div> <div class="card-title">流程操作</div>
</div> </div>
<div class="actions column"> <div class="op-group">
<el-button <div class="op-group-title"><span class="op-mark main">推进</span></div>
type="primary"
:disabled="!gates.generateMail.enabled"
:title="gates.generateMail.reason || '补齐材料后生成审批邮件'"
@click="generateMail"
>
生成邮件草稿
</el-button>
<el-button
type="primary"
:disabled="!gates.approve.enabled"
:title="gates.approve.reason || '审批通过后进入发放'"
@click="approve"
>
审批通过
</el-button>
<el-button
:disabled="!gates.send.enabled"
:title="gates.send.reason || '发送 Offer 给候选人'"
@click="sendOffer"
>
发送 Offer 给候选人
</el-button>
<el-button <el-button
:disabled="!gates.onboard.enabled" v-for="a in mainOps"
:title="gates.onboard.reason || '确认候选人已入职'" :key="a.key"
@click="confirmOnboarded" :type="a.type"
:plain="a.plain"
:disabled="a.disabled"
:title="a.reason || a.label"
@click="a.run"
> >
确认已入职 {{ a.label }}
</el-button> </el-button>
</div>
<el-divider /> <div class="op-group risk">
<div class="op-group-title"><span class="op-mark risk">风险 / 终止</span></div>
<el-input v-model="noteInput" class="note-input" placeholder="驳回 / 作废理由(写入审批意见)" clearable /> <el-input v-model="noteInput" class="note-input" placeholder="驳回 / 作废理由(写入审批意见)" clearable />
<el-button <el-button
type="warning" v-for="a in riskOps"
:disabled="!gates.reject.enabled" :key="a.key"
:title="gates.reject.reason || '审批不通过,退回 HR 补材料'" :type="a.type"
@click="rejectOffer" :plain="a.plain"
:disabled="a.disabled"
:title="a.reason || a.label"
@click="a.run"
> >
审批驳回 {{ a.label }}
</el-button>
<el-button
:disabled="!gates.void.enabled"
:title="gates.void.reason || '发放前撤回 / 作废本 Offer'"
@click="voidOffer"
>
撤回 / 作废
</el-button>
<el-button
:disabled="!gates.decline.enabled"
:title="gates.decline.reason || '候选人拒绝该 Offer,关闭本流程'"
@click="markDeclined"
>
候选人拒绝
</el-button>
<el-button
type="primary"
plain
:disabled="!gates.reopen.enabled"
:title="gates.reopen.reason || '对同一候选人重开一份 Offer'"
@click="reopenOffer"
>
重开 Offer
</el-button> </el-button>
</div> </div>
</section> </section>
...@@ -379,64 +524,152 @@ const copyMaterials = async () => { ...@@ -379,64 +524,152 @@ const copyMaterials = async () => {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.offer-flow-page {
--offer-accent: var(--blue);
}
.subpage-head { .subpage-head {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 14px; gap: 14px;
margin-bottom: 16px; margin-bottom: 16px;
h3 { h3 {
margin: 0; margin: 0;
} }
p { p {
margin: 4px 0 0; margin: 4px 0 0;
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 13px;
} }
.subpage-title {
min-width: 0;
}
.title-row {
display: flex;
align-items: center;
gap: 10px;
}
.status-chip {
flex-shrink: 0;
}
.risk-inline {
color: var(--rose);
font-weight: 500;
}
}
.status-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 16px;
border-radius: 8px;
font-size: 13px;
border: 1px solid;
&.rejected {
background: color-mix(in srgb, var(--yellow) 12%, #fff);
border-color: color-mix(in srgb, var(--yellow) 40%, var(--line));
color: color-mix(in srgb, #8a5a00 85%, var(--ink));
}
&.terminal {
background: color-mix(in srgb, var(--rose) 10%, #fff);
border-color: color-mix(in srgb, var(--rose) 35%, var(--line));
color: color-mix(in srgb, var(--rose) 92%, var(--ink));
}
} }
.workflow-panel { .workflow-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 20px;
margin-bottom: 16px; margin-bottom: 16px;
border-top: 3px solid var(--offer-accent);
} }
.workflow-steps { .workflow-steps {
display: flex; display: flex;
gap: 8px; align-items: flex-start;
padding-top: 2px;
.workflow-step { .workflow-step {
flex: 1; flex: 1;
display: flex; position: relative;
align-items: center; text-align: center;
gap: 8px;
padding: 10px 12px; &::before {
background: var(--bg); content: '';
border-radius: 8px; position: absolute;
font-size: 13px; top: 13px;
left: -50%;
width: 100%;
height: 2px;
background: var(--line);
}
span { &:first-child::before {
width: 20px; display: none;
height: 20px; }
.step-dot {
position: relative;
z-index: 1;
margin: 0 auto;
width: 26px;
height: 26px;
border-radius: 50%; border-radius: 50%;
background: var(--muted); background: var(--muted);
color: #fff; color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 11px; font-size: 12px;
font-weight: 600;
}
b {
display: block;
margin-top: 8px;
font-size: 13px;
font-weight: 500;
color: var(--muted);
} }
&.done { &.done {
span { &::before {
background: var(--green); background: var(--green);
} }
.step-dot {
background: var(--green);
}
b {
color: var(--ink);
}
} }
&.active { &.active {
border: 1px solid var(--blue); &::before {
span { background: var(--offer-accent);
background: var(--blue); }
.step-dot {
background: var(--offer-accent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--offer-accent) 18%, transparent);
}
b {
color: var(--ink);
font-weight: 600;
} }
} }
} }
...@@ -446,20 +679,48 @@ const copyMaterials = async () => { ...@@ -446,20 +679,48 @@ const copyMaterials = async () => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 16px; gap: 20px;
padding-top: 16px;
border-top: 1px dashed var(--line);
.next-copy {
min-width: 0;
strong { .next-eyebrow {
font-size: 16px; font-size: 11px;
letter-spacing: 1px;
color: var(--muted);
margin-bottom: 4px;
}
strong {
font-size: 16px;
}
p {
margin: 4px 0 0;
color: var(--muted);
font-size: 13px;
}
} }
p {
margin: 4px 0 0; .next-cta {
color: var(--muted); flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
.cta-hint {
font-size: 12px;
color: var(--muted);
}
} }
} }
.offer-grid { .offer-grid {
display: grid; display: grid;
grid-template-columns: 1fr 340px; grid-template-columns: minmax(0, 1fr) 340px;
gap: 16px; gap: 16px;
} }
...@@ -469,6 +730,44 @@ const copyMaterials = async () => { ...@@ -469,6 +730,44 @@ const copyMaterials = async () => {
gap: 16px; gap: 16px;
} }
.offer-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 16px;
.el-form-item {
margin-bottom: 16px;
}
.span-2 {
grid-column: 1 / -1;
}
.field-help {
font-size: 12px;
color: var(--muted);
line-height: 1.4;
margin-top: 4px;
}
}
.form-footer {
display: flex;
align-items: center;
gap: 10px;
margin-top: 4px;
.form-hint {
font-size: 12px;
color: var(--green);
margin-left: 4px;
&.warn {
color: var(--yellow);
}
}
}
.materials-list { .materials-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
...@@ -479,39 +778,127 @@ const copyMaterials = async () => { ...@@ -479,39 +778,127 @@ const copyMaterials = async () => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 10px;
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
&.missing strong {
color: var(--rose);
}
span { span {
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 13px;
} }
strong { strong {
font-size: 14px; font-size: 14px;
} }
} }
.blockers-summary {
padding: 8px 10px;
margin-top: 4px;
border-radius: 6px;
background: color-mix(in srgb, var(--yellow) 10%, #fff);
color: color-mix(in srgb, #8a5a00 80%, var(--ink));
font-size: 12px;
}
} }
.actions { .actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
margin-top: 12px; margin-top: 12px;
}
.mail-card {
.mail-preview {
margin: 0;
padding: 12px 14px;
border-radius: 8px;
background: var(--bg);
border: 1px solid var(--line);
font-family: inherit;
font-size: 13px;
line-height: 1.6;
color: var(--ink);
white-space: pre-wrap;
word-break: break-word;
max-height: 260px;
overflow: auto;
}
}
&.column { .op-panel {
display: flex;
flex-direction: column;
gap: 14px;
.op-group {
display: flex;
flex-direction: column; flex-direction: column;
gap: 10px;
.op-group-title {
font-size: 11px;
letter-spacing: 1px;
color: var(--muted);
.op-mark {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
&::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
border: 1px solid var(--line);
}
&.main::before {
background: var(--blue);
border-color: var(--blue);
}
&.risk::before {
background: var(--rose);
border-color: var(--rose);
}
}
}
.note-input {
width: 100%;
}
} }
} }
.note-input { @media (max-width: 1080px) {
margin-top: 4px; .offer-grid {
grid-template-columns: 1fr;
}
.workflow-next {
flex-direction: column;
align-items: stretch;
.next-cta {
align-items: flex-start;
}
}
} }
.flow-tag { @media (max-width: 640px) {
margin-right: 8px; .offer-form {
vertical-align: middle; grid-template-columns: 1fr;
}
.workflow-steps {
.workflow-step {
b {
font-size: 12px;
}
}
}
} }
</style> </style>
...@@ -2,21 +2,12 @@ ...@@ -2,21 +2,12 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import { offerMetricsView, offerSalarySummary } from '@/utils/offer' import { offerMetricsView, offerSalarySummary, offerStatusMeta } from '@/utils/offer'
const store = useRecruitmentStore() const store = useRecruitmentStore()
const metrics = computed(() => offerMetricsView(store.offers, store.candidates)) const metrics = computed(() => offerMetricsView(store.offers, store.candidates))
const statusTag = (offer) => {
const status = offer.status || '待 HR 确认'
if (['已入职'].includes(status)) return { text: status, type: 'success' }
if (['审批通过', '待入职'].includes(status)) return { text: status, type: 'primary' }
if (['审批中', '审批驳回'].includes(status)) return { text: status, type: 'warning' }
if (['已拒绝', '已作废'].includes(status)) return { text: status, type: 'danger' }
return { text: status, type: 'info' }
}
const openOffer = (offer) => { const openOffer = (offer) => {
store.selectedOfferId = offer.id store.selectedOfferId = offer.id
store.focusedTarget = { type: 'offer', id: offer.id } store.focusedTarget = { type: 'offer', id: offer.id }
...@@ -71,7 +62,7 @@ const candidateFor = (offer) => store.candidates.find((c) => c.id === offer.cand ...@@ -71,7 +62,7 @@ const candidateFor = (offer) => store.candidates.find((c) => c.id === offer.cand
</el-table-column> </el-table-column>
<el-table-column label="状态" width="130"> <el-table-column label="状态" width="130">
<template #default="{ row }"> <template #default="{ row }">
<el-tag size="small" :type="statusTag(row).type">{{ statusTag(row).text }}</el-tag> <el-tag size="small" :type="offerStatusMeta(row.status).tag">{{ row.status || '待 HR 确认' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="风险" width="110"> <el-table-column label="风险" width="110">
......
...@@ -21,6 +21,23 @@ export const OFFER_REJECTED_STATUS = '审批驳回' ...@@ -21,6 +21,23 @@ export const OFFER_REJECTED_STATUS = '审批驳回'
// 进行中(active)状态:任一候选人至多一条处于这些状态的 Offer。 // 进行中(active)状态:任一候选人至多一条处于这些状态的 Offer。
export const OFFER_ACTIVE_STATUSES = ['待 HR 确认', '审批中', '审批通过', '待入职'] export const OFFER_ACTIVE_STATUSES = ['待 HR 确认', '审批中', '审批通过', '待入职']
// Offer 状态 → 展示元数据(列表 el-tag 类型 + 流程页语义色)的唯一数据源。
// tone 供页面映射到语义色变量,避免列表与流程页各写一套 status→颜色。
export const OFFER_STATUS_META = {
'待 HR 确认': { tag: 'info', tone: 'blue' },
审批中: { tag: 'warning', tone: 'violet' },
审批通过: { tag: 'primary', tone: 'cyan' },
待入职: { tag: 'success', tone: 'green' },
已入职: { tag: 'success', tone: 'green' },
审批驳回: { tag: 'warning', tone: 'amber' },
已拒绝: { tag: 'danger', tone: 'rose' },
已作废: { tag: 'danger', tone: 'rose' },
}
export function offerStatusMeta(status = '') {
return OFFER_STATUS_META[status] || { tag: 'info', tone: 'blue' }
}
export function offerWorkflow(offer = {}) { export function offerWorkflow(offer = {}) {
const steps = OFFER_STATUS_STEPS const steps = OFFER_STATUS_STEPS
const status = offer.status || '待 HR 确认' const status = offer.status || '待 HR 确认'
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment