Files
qitongxue-admin/src/views/service/ProjectsManage.vue
2026-09-30 17:00:29 +08:00

1102 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>