1102 lines
39 KiB
Vue
1102 lines
39 KiB
Vue
<script setup>
|
||
/**
|
||
* 开发项目管理(迭代-2026-09-30,含四期交付流程)。
|
||
*
|
||
* 流程:建单(选维护模板 + 付款分期模板 → 自动生成分期)→ 生成绑定小程序码(用户扫码绑定)
|
||
* → 阶段 / 小节点 / 交付物维护(功能确认上传功能图等)→ 分期到账确认
|
||
* → 用户确认验收进入维护期(起算日可调)→ 到期前 N 天自动提醒(站内信 + 订阅消息,隔 3 天一发 / 最后 3 天天天发)。
|
||
*/
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { message } from 'ant-design-vue'
|
||
import { useUserStore } from '@/stores/user'
|
||
import { hasPermission, PERM } from '@/utils/permissions'
|
||
import { yuanFromCents } from '@/api/request/software'
|
||
import { adminUploadApi } from '@/api/request/attachment'
|
||
import {
|
||
PAY_TRIGGER_OPTIONS, PROJECT_STATUS_OPTIONS, maintainPlanDeleteApi, maintainPlanListApi, maintainPlanSaveApi,
|
||
maintainRemindGetApi, maintainRemindSaveApi, payPlanDeleteApi, payPlanListApi, payPlanSaveApi,
|
||
payTriggerLabel, projectDeleteApi, projectDetailApi, projectFileAddApi, projectFileDeleteApi,
|
||
projectListApi, projectMaintainAdjustApi, projectMaintainRenewApi, projectNodeDeleteApi,
|
||
projectNodeSaveApi, projectPayMarkApi, projectPayUnmarkApi, projectQrcodeApi, projectSaveApi,
|
||
projectStageDeleteApi, projectStageSaveApi, stageStatusLabel, STAGE_STATUS_OPTIONS,
|
||
} from '@/api/request/project'
|
||
import PageShell from '@/components/PageShell.vue'
|
||
import GlassCard from '@/components/GlassCard.vue'
|
||
import StateBlock from '@/components/StateBlock.vue'
|
||
import AppModal, { appConfirm } from '@/components/AppModal.vue'
|
||
|
||
const userStore = useUserStore()
|
||
const canWrite = computed(() => hasPermission(userStore.user?.role_value, PERM.GOODS_WRITE))
|
||
|
||
// ===== 列表 =====
|
||
const loading = ref(false)
|
||
const loadError = ref('')
|
||
const list = ref([])
|
||
const total = ref(0)
|
||
const query = reactive({ page: 1, pageSize: 20, keyword: '', status: undefined })
|
||
|
||
const columns = [
|
||
{ title: '项目', key: 'title' },
|
||
{ title: '客户', key: 'user', width: 130 },
|
||
{ title: '金额', key: 'price', width: 110 },
|
||
{ title: '阶段', key: 'stage', width: 90 },
|
||
{ title: '付款', key: 'pay', width: 110 },
|
||
{ title: '状态', key: 'status', width: 90 },
|
||
{ title: '操作', key: 'action', width: 230, align: 'right' },
|
||
]
|
||
|
||
/** 待缴费提醒横幅:已验收且距缴费到期 ≤ remindDays(含逾期) */
|
||
const remindDays = ref(15)
|
||
const dueProjects = computed(() =>
|
||
list.value.filter((p) => p.maintain_days_left >= 0 && p.maintain_days_left <= remindDays.value),
|
||
)
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
loadError.value = ''
|
||
try {
|
||
const res = await projectListApi({
|
||
page: query.page,
|
||
pageSize: query.pageSize,
|
||
keyword: query.keyword || undefined,
|
||
status: query.status ?? undefined,
|
||
})
|
||
if (res) {
|
||
list.value = res.list || []
|
||
total.value = res.total || 0
|
||
}
|
||
} catch {
|
||
loadError.value = '加载失败,请稍后重试'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadRemindDays() {
|
||
try {
|
||
const res = await maintainRemindGetApi()
|
||
if (res) remindDays.value = res.remind_days || 15
|
||
} catch {
|
||
/* 静默,用默认 15 */
|
||
}
|
||
}
|
||
onMounted(() => {
|
||
load()
|
||
loadRemindDays()
|
||
})
|
||
|
||
function onTableChange(pag) {
|
||
query.page = pag.current
|
||
query.pageSize = pag.pageSize
|
||
load()
|
||
}
|
||
|
||
function priceText(record) {
|
||
if (!record.price_cents) return '面议'
|
||
return `¥${yuanFromCents(record.price_cents)}${record.price_note || ''}`
|
||
}
|
||
|
||
// ===== 模板(维护 / 付款分期)=====
|
||
const maintainPlans = ref([])
|
||
const payPlans = ref([])
|
||
|
||
async function loadPlans() {
|
||
try {
|
||
const [mp, pp] = await Promise.all([maintainPlanListApi(), payPlanListApi()])
|
||
maintainPlans.value = (mp?.list || []).filter((x) => x.is_enabled === 1)
|
||
payPlans.value = (pp?.list || []).filter((x) => x.is_enabled === 1)
|
||
} catch {
|
||
/* 拦截器已提示 */
|
||
}
|
||
}
|
||
|
||
function maintainPlanSummary(plan) {
|
||
if (!plan) return ''
|
||
if (plan.source_only === 1) return '仅交付源码,无售后'
|
||
const free = plan.free_years > 0 ? `交付后 ${plan.free_years} 年免费维护` : '无免费维护'
|
||
const fee = plan.yearly_fee_cents > 0 ? `,后续 ¥${yuanFromCents(plan.yearly_fee_cents)}/年` : ''
|
||
return free + fee
|
||
}
|
||
|
||
// ===== 新建 / 编辑 =====
|
||
const modalOpen = ref(false)
|
||
const saving = ref(false)
|
||
const form = reactive({
|
||
id: 0, title: '', description: '', priceYuan: null, priceNote: '', dueNote: '',
|
||
status: 1, userId: null, maintainPlanId: null, payPlanId: null,
|
||
})
|
||
|
||
function openCreate() {
|
||
Object.assign(form, {
|
||
id: 0, title: '', description: '', priceYuan: null, priceNote: '', dueNote: '',
|
||
status: 1, userId: null, maintainPlanId: null, payPlanId: null,
|
||
})
|
||
loadPlans()
|
||
modalOpen.value = true
|
||
}
|
||
|
||
function openEdit(record) {
|
||
Object.assign(form, {
|
||
id: record.id,
|
||
title: record.title,
|
||
description: record.description,
|
||
priceYuan: record.price_cents ? Number((record.price_cents / 100).toFixed(2)) : null,
|
||
priceNote: record.price_note,
|
||
dueNote: record.due_note,
|
||
status: record.status,
|
||
userId: record.user_id || null,
|
||
maintainPlanId: record.maintain_plan_id || null,
|
||
payPlanId: record.pay_plan_id || null,
|
||
})
|
||
loadPlans()
|
||
modalOpen.value = true
|
||
}
|
||
|
||
async function save() {
|
||
if (!form.title?.trim()) {
|
||
message.warning('请填写项目名称')
|
||
return
|
||
}
|
||
if (!form.id && (!form.maintainPlanId || !form.payPlanId)) {
|
||
message.warning('请选择维护模板与付款分期模板')
|
||
return
|
||
}
|
||
saving.value = true
|
||
try {
|
||
const res = await projectSaveApi({
|
||
id: form.id,
|
||
title: form.title.trim(),
|
||
description: form.description?.trim() || '',
|
||
priceCents: form.priceYuan != null ? Math.round(form.priceYuan * 100) : 0,
|
||
priceNote: form.priceNote?.trim() || '',
|
||
dueNote: form.dueNote?.trim() || '',
|
||
status: form.status,
|
||
userId: form.userId || 0,
|
||
maintainPlanId: form.maintainPlanId || 0,
|
||
payPlanId: form.payPlanId || 0,
|
||
})
|
||
message.success('已保存')
|
||
modalOpen.value = false
|
||
load()
|
||
if (!form.id && res?.id) {
|
||
openQrcode({ id: res.id, title: form.title, bind_token: res.bind_token, code_url: '' })
|
||
}
|
||
} catch {
|
||
/* 拦截器已提示 */
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
function confirmDelete(record) {
|
||
appConfirm({
|
||
title: `确认删除项目「${record.title}」?`,
|
||
content: '阶段、小节点、交付物与分期计划将一并删除;用户端立即不可见。',
|
||
okType: 'danger',
|
||
okText: '删除',
|
||
async onOk() {
|
||
await projectDeleteApi({ id: record.id })
|
||
message.success('已删除')
|
||
load()
|
||
},
|
||
})
|
||
}
|
||
|
||
// ===== 绑定码 =====
|
||
const qrOpen = ref(false)
|
||
const qrLoading = ref(false)
|
||
const qrProject = ref(null)
|
||
const qrUrl = ref('')
|
||
|
||
async function openQrcode(record) {
|
||
qrProject.value = record
|
||
qrUrl.value = record.code_url || ''
|
||
qrOpen.value = true
|
||
if (!qrUrl.value) await genQrcode()
|
||
}
|
||
|
||
async function genQrcode() {
|
||
if (!qrProject.value) return
|
||
qrLoading.value = true
|
||
try {
|
||
const res = await projectQrcodeApi({ id: qrProject.value.id })
|
||
qrUrl.value = res?.code_url || ''
|
||
load()
|
||
} catch {
|
||
/* 拦截器已提示(不可用时改用令牌绑定) */
|
||
} finally {
|
||
qrLoading.value = false
|
||
}
|
||
}
|
||
|
||
function copyToken() {
|
||
const token = qrProject.value?.bind_token || ''
|
||
if (!token) return
|
||
if (navigator.clipboard?.writeText) {
|
||
navigator.clipboard.writeText(token).then(
|
||
() => message.success('绑定码已复制'),
|
||
() => message.warning('复制失败,请手动复制'),
|
||
)
|
||
} else {
|
||
message.info(token)
|
||
}
|
||
}
|
||
|
||
// ===== 项目详情(阶段 / 节点 / 交付物 / 分期 / 维护期)=====
|
||
const detailOpen = ref(false)
|
||
const detailLoading = ref(false)
|
||
const detail = ref(null)
|
||
const savingFlag = ref(false)
|
||
|
||
async function openDetail(record) {
|
||
detailOpen.value = true
|
||
detailLoading.value = true
|
||
detail.value = null
|
||
try {
|
||
detail.value = await projectDetailApi(record.id)
|
||
} catch {
|
||
/* 拦截器已提示 */
|
||
} finally {
|
||
detailLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function reloadDetail() {
|
||
if (!detail.value) return
|
||
detail.value = await projectDetailApi(detail.value.id).catch(() => null)
|
||
}
|
||
|
||
// —— 阶段(沿用二期交互)——
|
||
function addStage() {
|
||
if (!detail.value) return
|
||
appInput({
|
||
title: '新增阶段',
|
||
placeholder: '阶段名称,如:功能确认 / 原型设计 / UI 设计 / 开发 / 测试',
|
||
async onOk(title) {
|
||
await projectStageSaveApi({
|
||
id: 0, projectId: detail.value.id, title, status: 0,
|
||
sort: (detail.value.stages?.length || 0) + 1, isEnabled: 1,
|
||
})
|
||
message.success('阶段已添加')
|
||
await reloadDetail()
|
||
},
|
||
})
|
||
}
|
||
|
||
/** 按标准交付流程一键填充阶段(已有阶段时跳过) */
|
||
async function fillStandardStages() {
|
||
if (!detail.value) return
|
||
if (detail.value.stages?.length) {
|
||
message.warning('已有阶段,跳过填充(可手动补)')
|
||
return
|
||
}
|
||
const titles = ['需求与功能确认', '原型设计', 'UI 设计', '开发', '测试', '支付尾款', '上线']
|
||
savingFlag.value = true
|
||
try {
|
||
for (let i = 0; i < titles.length; i++) {
|
||
await projectStageSaveApi({
|
||
id: 0, projectId: detail.value.id, title: titles[i], status: 0, sort: i + 1, isEnabled: 1,
|
||
})
|
||
}
|
||
message.success('已按标准流程填充 7 个阶段')
|
||
await reloadDetail()
|
||
} catch {
|
||
/* 拦截器已提示 */
|
||
} finally {
|
||
savingFlag.value = false
|
||
}
|
||
}
|
||
|
||
async function onStageStatusChange(stage, status) {
|
||
savingFlag.value = true
|
||
try {
|
||
await projectStageSaveApi({
|
||
id: stage.id, projectId: detail.value.id, title: stage.title,
|
||
status, sort: stage.sort, isEnabled: stage.is_enabled,
|
||
})
|
||
message.success(`已更新为「${stageStatusLabel(status)}」,已通知用户`)
|
||
await reloadDetail()
|
||
} catch {
|
||
/* 拦截器已提示 */
|
||
} finally {
|
||
savingFlag.value = false
|
||
}
|
||
}
|
||
|
||
function confirmStageDelete(stage) {
|
||
appConfirm({
|
||
title: `删除阶段「${stage.title}」?`,
|
||
content: '该阶段下的小节点与交付物将一并删除。',
|
||
okType: 'danger',
|
||
okText: '删除',
|
||
async onOk() {
|
||
await projectStageDeleteApi({ id: stage.id })
|
||
message.success('已删除')
|
||
await reloadDetail()
|
||
},
|
||
})
|
||
}
|
||
|
||
function addNode(stage) {
|
||
appInput({
|
||
title: `「${stage.title}」新增小节点`,
|
||
placeholder: '节点内容,如:首页原型确认',
|
||
async onOk(title) {
|
||
await projectNodeSaveApi({
|
||
id: 0, stageId: stage.id, title, isDone: 0,
|
||
sort: (stage.nodes?.length || 0) + 1,
|
||
})
|
||
message.success('节点已添加')
|
||
await reloadDetail()
|
||
},
|
||
})
|
||
}
|
||
|
||
async function onNodeToggle(stage, node, checked) {
|
||
savingFlag.value = true
|
||
try {
|
||
await projectNodeSaveApi({
|
||
id: node.id, stageId: stage.id, title: node.title, isDone: checked ? 1 : 0, sort: node.sort,
|
||
})
|
||
await reloadDetail()
|
||
} catch {
|
||
/* 拦截器已提示 */
|
||
} finally {
|
||
savingFlag.value = false
|
||
}
|
||
}
|
||
|
||
function confirmNodeDelete(stage, node) {
|
||
appConfirm({
|
||
title: `删除节点「${node.title}」?`,
|
||
content: '',
|
||
okType: 'danger',
|
||
okText: '删除',
|
||
async onOk() {
|
||
await projectNodeDeleteApi({ id: node.id })
|
||
await reloadDetail()
|
||
},
|
||
})
|
||
}
|
||
|
||
// —— 交付物上传 ——
|
||
function beforeFileUpload(file) {
|
||
const ok = ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
|
||
if (!ok) message.warning('仅支持 jpg / png / webp 格式')
|
||
else if (file.size > 10 * 1024 * 1024) message.warning('图片不能超过 10MB')
|
||
return ok && file.size <= 10 * 1024 * 1024
|
||
}
|
||
|
||
async function onStageFileUpload(stage, { file, onSuccess, onError }) {
|
||
try {
|
||
const res = await adminUploadApi(file, 'cover')
|
||
if (res) {
|
||
await projectFileAddApi({
|
||
projectId: detail.value.id, stageId: stage.id,
|
||
fileName: file.name, filePath: res.path,
|
||
})
|
||
message.success('交付物已上传,已通知用户')
|
||
await reloadDetail()
|
||
}
|
||
onSuccess?.(res)
|
||
} catch (e) {
|
||
onError?.(e)
|
||
}
|
||
}
|
||
|
||
async function confirmFileDelete(stage, file) {
|
||
appConfirm({
|
||
title: `删除交付物「${file.file_name}」?`,
|
||
content: '',
|
||
okType: 'danger',
|
||
okText: '删除',
|
||
async onOk() {
|
||
await projectFileDeleteApi({ id: file.id })
|
||
await reloadDetail()
|
||
},
|
||
})
|
||
}
|
||
|
||
// —— 分期到账 ——
|
||
function markPayPaid(pay) {
|
||
appInput({
|
||
title: `第 ${pay.seq} 期收款确认`,
|
||
placeholder: '收款备注(如:微信收款 / 对公转账),可留空',
|
||
onOk: async (note) => {
|
||
await projectPayMarkApi({ id: pay.id, paidNote: note })
|
||
message.success('已确认到账,已通知用户')
|
||
await reloadDetail()
|
||
load()
|
||
},
|
||
})
|
||
}
|
||
|
||
function unmarkPayPaid(pay) {
|
||
appConfirm({
|
||
title: `撤销第 ${pay.seq} 期到账?`,
|
||
content: '分期回到「待支付」状态。',
|
||
okType: 'danger',
|
||
okText: '撤销',
|
||
async onOk() {
|
||
await projectPayUnmarkApi({ id: pay.id })
|
||
message.success('已撤销')
|
||
await reloadDetail()
|
||
load()
|
||
},
|
||
})
|
||
}
|
||
|
||
// —— 维护期 ——
|
||
function renewMaintain() {
|
||
appConfirm({
|
||
title: '确认已收到下一年度维护费?',
|
||
content: '维护有效期将顺延一年,并通知用户新的到期日。',
|
||
okText: '确认续收',
|
||
async onOk() {
|
||
const res = await projectMaintainRenewApi({ id: detail.value.id })
|
||
message.success(`已续收,有效期至 ${res?.paid_until || ''}`)
|
||
await reloadDetail()
|
||
},
|
||
})
|
||
}
|
||
|
||
function adjustMaintainStart() {
|
||
appInput({
|
||
title: '调整维护期起算日',
|
||
placeholder: '格式 YYYY-MM-DD,如 2026-10-15',
|
||
onOk: async (v) => {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) {
|
||
message.warning('日期格式应为 YYYY-MM-DD')
|
||
return
|
||
}
|
||
const res = await projectMaintainAdjustApi({ id: detail.value.id, startAt: v })
|
||
message.success(`已调整,维护费已缴至 ${res?.paid_until || '—'}`)
|
||
await reloadDetail()
|
||
},
|
||
})
|
||
}
|
||
|
||
// ===== 模板设置(维护模板 / 付款分期模板 / 提醒设置)=====
|
||
const settingsOpen = ref(false)
|
||
const mpList = ref([])
|
||
const ppList = ref([])
|
||
const remindForm = reactive({ remindDays: 15, adminNotifyUserId: null })
|
||
|
||
async function openSettings() {
|
||
settingsOpen.value = true
|
||
await reloadSettings()
|
||
}
|
||
|
||
async function reloadSettings() {
|
||
const [mp, pp, rd] = await Promise.all([maintainPlanListApi(), payPlanListApi(), maintainRemindGetApi()])
|
||
mpList.value = mp?.list || []
|
||
ppList.value = pp?.list || []
|
||
if (rd) {
|
||
remindForm.remindDays = rd.remind_days || 15
|
||
remindForm.adminNotifyUserId = rd.admin_notify_user_id || null
|
||
}
|
||
}
|
||
|
||
async function saveRemind() {
|
||
await maintainRemindSaveApi({
|
||
remindDays: remindForm.remindDays,
|
||
adminNotifyUserId: remindForm.adminNotifyUserId || 0,
|
||
})
|
||
message.success('提醒设置已保存')
|
||
remindDays.value = remindForm.remindDays
|
||
}
|
||
|
||
function editMaintainPlan(plan) {
|
||
appInput({
|
||
title: plan?.id ? `编辑维护模板:${plan.name}` : '新增维护模板',
|
||
placeholder: '格式:名称 | 免费年数 | 年费元 | 仅源码(0/1),如:一年免费维护 | 1 | 500 | 0',
|
||
onOk: async (v) => {
|
||
const parts = v.split('|').map((x) => x.trim())
|
||
if (parts.length < 4 || isNaN(Number(parts[1])) || isNaN(Number(parts[2]))) {
|
||
message.warning('格式不正确,请按提示填写')
|
||
return
|
||
}
|
||
await maintainPlanSaveApi({
|
||
id: plan?.id || 0,
|
||
name: parts[0],
|
||
freeYears: Number(parts[1]),
|
||
yearlyFeeCents: Math.round(Number(parts[2]) * 100),
|
||
sourceOnly: parts[3] === '1' ? 1 : 0,
|
||
sort: plan?.sort || (mpList.value.length + 1) * 10,
|
||
isEnabled: plan?.is_enabled ?? 1,
|
||
remark: plan?.remark || '',
|
||
})
|
||
message.success('已保存')
|
||
await reloadSettings()
|
||
loadPlans()
|
||
},
|
||
})
|
||
}
|
||
|
||
function confirmMaintainPlanDelete(plan) {
|
||
appConfirm({
|
||
title: `删除维护模板「${plan.name}」?`,
|
||
content: '已选用该模板的项目不受影响。',
|
||
okType: 'danger',
|
||
okText: '删除',
|
||
async onOk() {
|
||
await maintainPlanDeleteApi({ id: plan.id })
|
||
message.success('已删除')
|
||
await reloadSettings()
|
||
loadPlans()
|
||
},
|
||
})
|
||
}
|
||
|
||
function editPayPlan(plan) {
|
||
appInput({
|
||
title: plan?.id ? `编辑分期模板:${plan.name}` : '新增分期模板',
|
||
placeholder: '格式:名称 | JSON,如:两期 | [{"seq":1,"percent":50,"trigger":"start"},{"seq":2,"percent":50,"trigger":"online_before"}]',
|
||
onOk: async (v) => {
|
||
const idx = v.indexOf('|')
|
||
if (idx < 0) {
|
||
message.warning('格式不正确:名称 | JSON')
|
||
return
|
||
}
|
||
const name = v.slice(0, idx).trim()
|
||
const schedule = v.slice(idx + 1).trim()
|
||
await payPlanSaveApi({
|
||
id: plan?.id || 0,
|
||
name,
|
||
schedule,
|
||
sort: plan?.sort || (ppList.value.length + 1) * 10,
|
||
isEnabled: plan?.is_enabled ?? 1,
|
||
remark: plan?.remark || '',
|
||
})
|
||
message.success('已保存')
|
||
await reloadSettings()
|
||
loadPlans()
|
||
},
|
||
})
|
||
}
|
||
|
||
function confirmPayPlanDelete(plan) {
|
||
appConfirm({
|
||
title: `删除分期模板「${plan.name}」?`,
|
||
content: '已选用该模板的项目不受影响。',
|
||
okType: 'danger',
|
||
okText: '删除',
|
||
async onOk() {
|
||
await payPlanDeleteApi({ id: plan.id })
|
||
message.success('已删除')
|
||
await reloadSettings()
|
||
loadPlans()
|
||
},
|
||
})
|
||
}
|
||
|
||
// ===== 简易输入弹窗 =====
|
||
const inputOpen = ref(false)
|
||
const inputTitle = ref('')
|
||
const inputPlaceholder = ref('')
|
||
const inputValue = ref('')
|
||
const inputOk = ref(null)
|
||
const inputSaving = ref(false)
|
||
|
||
function appInput({ title, placeholder, onOk }) {
|
||
inputTitle.value = title
|
||
inputPlaceholder.value = placeholder || ''
|
||
inputValue.value = ''
|
||
inputOk.value = onOk
|
||
inputOpen.value = true
|
||
}
|
||
|
||
async function onInputOk() {
|
||
const v = inputValue.value?.trim()
|
||
if (!v) {
|
||
message.warning('请填写内容')
|
||
return
|
||
}
|
||
inputSaving.value = true
|
||
try {
|
||
await inputOk.value?.(v)
|
||
inputOpen.value = false
|
||
} catch {
|
||
/* 拦截器已提示 */
|
||
} finally {
|
||
inputSaving.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<PageShell title="开发项目" desc="建单(选模板 → 自动生成分期)→ 绑定码 → 阶段/节点/交付物 → 分期到账 → 验收进维护期,全程站内信同步用户">
|
||
<template #actions>
|
||
<a-button @click="openSettings">模板与提醒设置</a-button>
|
||
<a-button v-if="canWrite" type="primary" @click="openCreate">新建项目</a-button>
|
||
</template>
|
||
|
||
<!-- 待缴费提醒横幅 -->
|
||
<a-alert
|
||
v-if="dueProjects.length" type="warning" show-icon style="margin-bottom: 12px"
|
||
:message="`维护费缴费提醒:${dueProjects.length} 个项目已进入提醒窗口`"
|
||
>
|
||
<template #description>
|
||
<div v-for="p in dueProjects" :key="p.id" class="due-row">
|
||
<span>{{ p.title }}</span>
|
||
<a-tag :color="p.maintain_days_left < 0 ? 'error' : 'warning'" style="margin-left: 8px">
|
||
{{ p.maintain_days_left < 0 ? `已逾期 ${-p.maintain_days_left} 天` : `剩余 ${p.maintain_days_left} 天` }}
|
||
</a-tag>
|
||
<span class="due-row__until">已缴至 {{ p.maintain_paid_until }}</span>
|
||
</div>
|
||
</template>
|
||
</a-alert>
|
||
|
||
<GlassCard>
|
||
<StateBlock :loading="loading" :error="loadError" :empty="!list.length" empty-text="还没有开发项目,点右上角「新建项目」创建" @retry="load">
|
||
<a-table
|
||
:columns="columns" :data-source="list" row-key="id"
|
||
:pagination="{ current: query.page, pageSize: query.pageSize, total, showSizeChanger: false, showTotal: (t) => `共 ${t} 个项目` }"
|
||
@change="onTableChange"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'title'">
|
||
<div class="title-cell">
|
||
<div class="title-cell__name">{{ record.title }}</div>
|
||
<div class="title-cell__sub">{{ record.description || '—' }}</div>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'user'">
|
||
<span v-if="record.user_id">{{ record.user_nickname || `用户 ${record.user_id}` }}</span>
|
||
<a-tag v-else color="warning">待扫码绑定</a-tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'price'">
|
||
<span class="price">{{ priceText(record) }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'stage'">
|
||
<span class="stage-progress">{{ record.stage_done }}/{{ record.stage_total }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'pay'">
|
||
<span v-if="record.pay_plan_id" class="stage-progress">{{ record.pay_plan_name || '自定义' }}</span>
|
||
<span v-else class="stage-progress">—</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'status'">
|
||
<a-tag :color="(PROJECT_STATUS_OPTIONS.find((s) => s.value === record.status) || {}).color">
|
||
{{ record.status_text }}
|
||
</a-tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'action'">
|
||
<a-space :size="0" wrap>
|
||
<a-button v-if="canWrite" type="link" size="small" @click="openQrcode(record)">绑定码</a-button>
|
||
<a-button v-if="canWrite" type="link" size="small" @click="openDetail(record)">管理</a-button>
|
||
<a-button v-if="canWrite" type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||
<a-button v-if="canWrite" type="link" size="small" danger @click="confirmDelete(record)">删除</a-button>
|
||
</a-space>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</StateBlock>
|
||
</GlassCard>
|
||
|
||
<!-- 新建 / 编辑 -->
|
||
<AppModal
|
||
v-model:open="modalOpen" :title="form.id ? '编辑项目' : '新建开发项目'" :width="760"
|
||
:confirm-loading="saving" ok-text="保存" :mask-closable="false" @ok="save"
|
||
>
|
||
<a-form layout="vertical">
|
||
<a-form-item label="项目名称" required>
|
||
<a-input v-model:value="form.title" :maxlength="128" placeholder="如:门店预约小程序" />
|
||
</a-form-item>
|
||
<a-form-item label="需求描述">
|
||
<a-textarea v-model:value="form.description" :maxlength="1000" show-count :rows="3" placeholder="给用户看的项目简介与范围" />
|
||
</a-form-item>
|
||
<a-row :gutter="16">
|
||
<a-col :span="8">
|
||
<a-form-item label="项目金额(元,留空 = 面议)">
|
||
<a-input-number v-model:value="form.priceYuan" :min="0" :precision="2" style="width: 100%" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="8">
|
||
<a-form-item label="金额备注">
|
||
<a-input v-model:value="form.priceNote" :maxlength="64" placeholder="如:首期" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="8">
|
||
<a-form-item label="交付周期">
|
||
<a-input v-model:value="form.dueNote" :maxlength="64" placeholder="如:4 周" />
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
<a-row :gutter="16">
|
||
<a-col :span="12">
|
||
<a-form-item label="维护模板" required>
|
||
<a-select v-model:value="form.maintainPlanId" placeholder="选择维护模板" allow-clear>
|
||
<a-select-option v-for="p in maintainPlans" :key="p.id" :value="p.id">
|
||
{{ p.name }}({{ maintainPlanSummary(p) }})
|
||
</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="12">
|
||
<a-form-item label="付款分期模板" required>
|
||
<a-select v-model:value="form.payPlanId" placeholder="选择分期方案" allow-clear>
|
||
<a-select-option v-for="p in payPlans" :key="p.id" :value="p.id">
|
||
{{ p.name }}({{ p.remark }})
|
||
</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
<a-row :gutter="16">
|
||
<a-col :span="12">
|
||
<a-form-item label="状态">
|
||
<a-radio-group v-model:value="form.status">
|
||
<a-radio v-for="s in PROJECT_STATUS_OPTIONS" :key="s.value" :value="s.value">{{ s.label }}</a-radio>
|
||
</a-radio-group>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="12">
|
||
<a-form-item label="绑定用户 ID(选填,留空 = 待扫码绑定)">
|
||
<a-input-number v-model:value="form.userId" :min="0" style="width: 100%" placeholder="后台用户管理里的用户 ID" />
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
<a-alert
|
||
v-if="!form.id"
|
||
message="保存后自动生成分期计划(按所选模板比例拆分金额),并弹出绑定小程序码"
|
||
type="info" show-icon
|
||
/>
|
||
<a-alert
|
||
v-else-if="form.payPlanId"
|
||
message="更换付款模板需要该下没有已到账分期;仅改金额会自动重算未到账期数"
|
||
type="info" show-icon
|
||
/>
|
||
</a-form>
|
||
</AppModal>
|
||
|
||
<!-- 绑定码 -->
|
||
<AppModal v-model:open="qrOpen" title="项目绑定码" :width="480" :footer="null">
|
||
<div class="qr-wrap">
|
||
<a-spin :spinning="qrLoading">
|
||
<img v-if="qrUrl" :src="qrUrl" alt="项目绑定小程序码" class="qr-img">
|
||
<div v-else class="qr-placeholder">生成中…</div>
|
||
</a-spin>
|
||
<div v-if="qrProject" class="qr-token">
|
||
扫码绑定 / 手动绑定码:
|
||
<a-typography-text copyable :content="qrProject.bind_token" class="qr-token__code">{{ qrProject.bind_token }}</a-typography-text>
|
||
</div>
|
||
<p class="qr-tip">
|
||
用户微信扫码后进入绑定页即完成绑定;若小程序码不可用(页面未发布等),把绑定码发给用户,在绑定页手动输入。
|
||
</p>
|
||
<a-space>
|
||
<a-button :loading="qrLoading" @click="genQrcode">重新生成</a-button>
|
||
<a-button v-if="qrUrl" type="primary" :href="qrUrl" download="project-bind-qrcode.png" target="_blank">下载</a-button>
|
||
</a-space>
|
||
</div>
|
||
</AppModal>
|
||
|
||
<!-- 项目管理:阶段 / 节点 / 交付物 / 分期 / 维护期 -->
|
||
<AppModal
|
||
v-model:open="detailOpen" :title="detail ? `项目管理:${detail.title}` : '项目管理'" :width="1040"
|
||
:footer="null" :mask-closable="false"
|
||
>
|
||
<a-spin :spinning="detailLoading || savingFlag">
|
||
<template v-if="detail">
|
||
<!-- 分期付款 -->
|
||
<div class="sec-block">
|
||
<div class="sec-block__head">
|
||
<span class="sec-block__title">分期付款({{ detail.pay_plan_name || '未选模板' }})</span>
|
||
</div>
|
||
<div v-if="detail.pays?.length" class="pay-list">
|
||
<div v-for="pay in detail.pays" :key="pay.id" class="pay-row">
|
||
<span class="pay-row__seq">第 {{ pay.seq }} 期</span>
|
||
<span class="pay-row__trigger">{{ pay.trigger_text }}</span>
|
||
<span class="pay-row__amount">¥{{ yuanFromCents(pay.amount_cents) }}({{ pay.percent }}%)</span>
|
||
<a-tag v-if="pay.status === 1" color="success" style="margin: 0">已到账{{ pay.paid_note ? ` · ${pay.paid_note}` : '' }}</a-tag>
|
||
<a-tag v-else color="warning" style="margin: 0">待支付</a-tag>
|
||
<a-button v-if="pay.status !== 1" size="small" type="primary" ghost @click="markPayPaid(pay)">确认到账</a-button>
|
||
<a-button v-else size="small" type="text" danger @click="unmarkPayPaid(pay)">撤销</a-button>
|
||
</div>
|
||
</div>
|
||
<div v-else class="empty-stage">未选择付款模板,无分期计划(编辑项目可选择)。</div>
|
||
</div>
|
||
|
||
<!-- 维护期 -->
|
||
<div class="sec-block">
|
||
<div class="sec-block__head">
|
||
<span class="sec-block__title">维护期({{ detail.maintain_plan_name || '未选模板' }})</span>
|
||
<a-space>
|
||
<a-button size="small" @click="adjustMaintainStart">调整起算日</a-button>
|
||
<a-button v-if="detail.accepted === 1" size="small" type="primary" ghost @click="renewMaintain">续收一年</a-button>
|
||
</a-space>
|
||
</div>
|
||
<div class="maintain-info">
|
||
<template v-if="detail.accepted === 1">
|
||
<span>已验收 {{ detail.accepted_at }}</span>
|
||
<span>起算 {{ detail.maintain_start_at || '—' }}</span>
|
||
<span>已缴至 <b>{{ detail.maintain_paid_until || '—' }}</b></span>
|
||
<a-tag v-if="detail.maintain_days_left >= 0 && detail.maintain_days_left <= remindDays" color="warning">
|
||
{{ detail.maintain_days_left < 0 ? `已逾期 ${-detail.maintain_days_left} 天` : `剩余 ${detail.maintain_days_left} 天` }}
|
||
</a-tag>
|
||
</template>
|
||
<span v-else>待用户在项目详情页点击「确认验收」后开始计算维护期</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 阶段 / 节点 / 交付物 -->
|
||
<div class="sec-block">
|
||
<div class="sec-block__head">
|
||
<span class="sec-block__title">阶段进度 {{ detail.stage_done }}/{{ detail.stage_total }}</span>
|
||
<a-space>
|
||
<a-button v-if="!detail.stages?.length" size="small" @click="fillStandardStages">按标准流程填充</a-button>
|
||
<a-button size="small" type="primary" @click="addStage">+ 新增阶段</a-button>
|
||
</a-space>
|
||
</div>
|
||
|
||
<div v-if="!detail.stages?.length" class="empty-stage">
|
||
还没有阶段。可一键填充标准流程:需求与功能确认 → 原型设计 → UI 设计 → 开发 → 测试 → 支付尾款 → 上线。
|
||
</div>
|
||
|
||
<div v-for="stage in detail.stages" :key="stage.id" class="stage-card">
|
||
<div class="stage-head">
|
||
<span class="stage-title">{{ stage.title }}</span>
|
||
<a-select
|
||
:value="stage.status" size="small" style="width: 104px"
|
||
@change="(v) => onStageStatusChange(stage, v)"
|
||
>
|
||
<a-select-option v-for="s in STAGE_STATUS_OPTIONS" :key="s.value" :value="s.value">{{ s.label }}</a-select-option>
|
||
</a-select>
|
||
<a-button size="small" type="link" @click="addNode(stage)">+ 节点</a-button>
|
||
<a-button size="small" type="link" danger @click="confirmStageDelete(stage)">删除阶段</a-button>
|
||
</div>
|
||
|
||
<div v-if="stage.nodes?.length" class="node-list">
|
||
<div v-for="node in stage.nodes" :key="node.id" class="node-row">
|
||
<a-checkbox
|
||
:checked="node.is_done === 1"
|
||
@change="(e) => onNodeToggle(stage, node, e.target.checked)"
|
||
>
|
||
<span :class="{ 'node-done': node.is_done === 1 }">{{ node.title }}</span>
|
||
</a-checkbox>
|
||
<a-button type="text" size="small" danger @click="confirmNodeDelete(stage, node)">删除</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="file-row">
|
||
<div v-for="f in stage.files" :key="f.id" class="file-thumb">
|
||
<img :src="f.file_url" :alt="f.file_name" preview-disabled>
|
||
<span class="file-thumb__name" :title="f.file_name">{{ f.file_name }}</span>
|
||
<a-button type="text" size="small" danger class="file-thumb__del" @click="confirmFileDelete(stage, f)">删</a-button>
|
||
</div>
|
||
<a-upload
|
||
accept=".jpg,.jpeg,.png,.webp" :show-upload-list="false"
|
||
:custom-request="(opt) => onStageFileUpload(stage, opt)" :before-upload="beforeFileUpload"
|
||
>
|
||
<a-button size="small">+ 上传交付物</a-button>
|
||
</a-upload>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<p class="detail-tip">
|
||
阶段状态变更、上传交付物、分期到账、验收、续费均会自动发站内信给绑定用户;「需求与功能确认」阶段可上传多个版本的功能图给用户过目。
|
||
</p>
|
||
</template>
|
||
</a-spin>
|
||
</AppModal>
|
||
|
||
<!-- 模板与提醒设置 -->
|
||
<AppModal v-model:open="settingsOpen" title="模板与提醒设置" :width="880" :footer="null" :mask-closable="false">
|
||
<a-tabs>
|
||
<a-tab-pane key="maintain" tab="维护模板">
|
||
<div class="tpl-toolbar">
|
||
<a-button size="small" type="primary" @click="editMaintainPlan(null)">+ 新增维护模板</a-button>
|
||
</div>
|
||
<div v-for="p in mpList" :key="p.id" class="tpl-row">
|
||
<span class="tpl-row__name">{{ p.name }}</span>
|
||
<span class="tpl-row__desc">{{ maintainPlanSummary(p) }}</span>
|
||
<a-tag v-if="p.is_enabled !== 1" color="default">停用</a-tag>
|
||
<a-button size="small" type="link" @click="editMaintainPlan(p)">编辑</a-button>
|
||
<a-button size="small" type="link" danger @click="confirmMaintainPlanDelete(p)">删除</a-button>
|
||
</div>
|
||
</a-tab-pane>
|
||
<a-tab-pane key="pay" tab="付款分期模板">
|
||
<div class="tpl-toolbar">
|
||
<a-button size="small" type="primary" @click="editPayPlan(null)">+ 新增分期模板</a-button>
|
||
</div>
|
||
<div v-for="p in ppList" :key="p.id" class="tpl-row">
|
||
<span class="tpl-row__name">{{ p.name }}</span>
|
||
<span class="tpl-row__desc">{{ p.remark }}</span>
|
||
<a-tag v-if="p.is_enabled !== 1" color="default">停用</a-tag>
|
||
<a-button size="small" type="link" @click="editPayPlan(p)">编辑</a-button>
|
||
<a-button size="small" type="link" danger @click="confirmPayPlanDelete(p)">删除</a-button>
|
||
</div>
|
||
<p class="detail-tip">trigger 取值:{{ PAY_TRIGGER_OPTIONS.map((t) => `${t.value}=${t.label}`).join(',') }};比例合计必须 100%。</p>
|
||
</a-tab-pane>
|
||
<a-tab-pane key="remind" tab="缴费提醒">
|
||
<a-form layout="vertical" style="max-width: 420px">
|
||
<a-form-item label="到期前提醒天数(默认 15)" required>
|
||
<a-input-number v-model:value="remindForm.remindDays" :min="1" :max="90" style="width: 100%" />
|
||
</a-form-item>
|
||
<a-form-item label="超管通知用户 ID(选填)">
|
||
<a-input-number v-model:value="remindForm.adminNotifyUserId" :min="0" style="width: 100%" placeholder="超管绑定的小程序用户 id,站内信 + 订阅消息同步提醒" />
|
||
</a-form-item>
|
||
<a-button type="primary" @click="saveRemind">保存提醒设置</a-button>
|
||
</a-form>
|
||
<p class="detail-tip">提醒节奏:进入提醒窗口后每 3 天提醒一次,最后 3 天(含逾期)每天提醒;用户与超管都会收到站内信 + 微信订阅消息(需用户授权过订阅)。</p>
|
||
</a-tab-pane>
|
||
</a-tabs>
|
||
</AppModal>
|
||
|
||
<!-- 通用输入弹窗 -->
|
||
<AppModal
|
||
v-model:open="inputOpen" :title="inputTitle" :width="560"
|
||
:confirm-loading="inputSaving" ok-text="确定" :mask-closable="false" @ok="onInputOk"
|
||
>
|
||
<a-input v-model:value="inputValue" :placeholder="inputPlaceholder" :maxlength="600" @press-enter="onInputOk" />
|
||
</AppModal>
|
||
</PageShell>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.due-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.due-row__until {
|
||
color: var(--text-3);
|
||
font-size: var(--font-xs);
|
||
}
|
||
.title-cell { min-width: 0; }
|
||
.title-cell__name { color: var(--text-1); font-weight: 600; }
|
||
.title-cell__sub {
|
||
margin-top: 2px;
|
||
color: var(--text-3);
|
||
font-size: var(--font-xs);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 280px;
|
||
}
|
||
.price { color: var(--color-primary); font-weight: 600; }
|
||
.stage-progress { color: var(--text-2); }
|
||
|
||
.qr-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 14px;
|
||
padding: 8px 0 4px;
|
||
}
|
||
.qr-img {
|
||
width: 240px;
|
||
height: 240px;
|
||
border-radius: var(--radius-md);
|
||
border: 1px solid var(--border-1);
|
||
}
|
||
.qr-placeholder {
|
||
width: 240px;
|
||
height: 240px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--text-3);
|
||
border: 1px dashed var(--border-1);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.qr-token { color: var(--text-2); font-size: var(--font-sm); }
|
||
.qr-token__code { font-weight: 700; letter-spacing: 0.12em; }
|
||
.qr-tip { color: var(--text-3); font-size: var(--font-xs); text-align: center; max-width: 380px; margin: 0; }
|
||
|
||
.sec-block { margin-bottom: 18px; }
|
||
.sec-block__head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
}
|
||
.sec-block__title { font-weight: 700; color: var(--text-1); }
|
||
.pay-list { display: flex; flex-direction: column; gap: 8px; }
|
||
.pay-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 14px;
|
||
border: 1px solid var(--border-1);
|
||
border-radius: var(--radius-md);
|
||
background: var(--bg-surface);
|
||
}
|
||
.pay-row__seq { font-weight: 700; color: var(--text-1); white-space: nowrap; }
|
||
.pay-row__trigger { color: var(--text-2); font-size: var(--font-sm); flex: 1; }
|
||
.pay-row__amount { color: var(--color-primary); font-weight: 600; white-space: nowrap; }
|
||
.maintain-info {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 14px;
|
||
color: var(--text-2);
|
||
font-size: var(--font-sm);
|
||
padding: 10px 14px;
|
||
border: 1px solid var(--border-1);
|
||
border-radius: var(--radius-md);
|
||
background: var(--bg-surface);
|
||
}
|
||
.empty-stage {
|
||
padding: 20px;
|
||
color: var(--text-3);
|
||
border: 1px dashed var(--border-1);
|
||
border-radius: var(--radius-md);
|
||
text-align: center;
|
||
}
|
||
.stage-card {
|
||
border: 1px solid var(--border-1);
|
||
border-radius: var(--radius-md);
|
||
padding: 14px 16px;
|
||
margin-bottom: 12px;
|
||
background: var(--bg-surface);
|
||
}
|
||
.stage-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.stage-title { flex: 1; font-weight: 700; color: var(--text-1); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.node-list { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
|
||
.node-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||
.node-done { color: var(--text-3); text-decoration: line-through; }
|
||
.file-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; align-items: flex-start; }
|
||
.file-thumb {
|
||
width: 108px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.file-thumb img {
|
||
width: 108px;
|
||
height: 72px;
|
||
object-fit: cover;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--border-1);
|
||
}
|
||
.file-thumb__name {
|
||
max-width: 108px;
|
||
color: var(--text-3);
|
||
font-size: var(--font-xs);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.file-thumb__del { height: 20px; padding: 0 4px; font-size: var(--font-xs); }
|
||
.tpl-toolbar { margin-bottom: 10px; }
|
||
.tpl-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 14px;
|
||
border: 1px solid var(--border-1);
|
||
border-radius: var(--radius-md);
|
||
margin-bottom: 8px;
|
||
background: var(--bg-surface);
|
||
}
|
||
.tpl-row__name { font-weight: 700; color: var(--text-1); white-space: nowrap; }
|
||
.tpl-row__desc { flex: 1; color: var(--text-3); font-size: var(--font-sm); }
|
||
.detail-tip { margin: 8px 0 0; color: var(--text-3); font-size: var(--font-xs); }
|
||
</style>
|