修复个人中心问题

This commit is contained in:
2026-09-29 18:25:09 +08:00
parent 0dc5c2f075
commit ca4220f92c
44 changed files with 1793 additions and 310 deletions
+19 -3
View File
@@ -33,6 +33,14 @@ export function convertClueToBusiness(id, data) {
return request({ url: `/backend/crm/clue/${id}/convert`, method: "post", data });
}
/**
* 线索状态流转:1跟进中 / 3已废弃。
* 「已转化(2)」不走本接口,须用 convertClueToBusiness 完成(会落地客户并锁定线索)。
*/
export function changeClueStatus(id, status) {
return request({ url: `/backend/crm/clue/${id}/status`, method: "post", data: { status } });
}
/* --------------------------------- 商机 --------------------------------- */
export function getBusinessList(params) {
@@ -60,6 +68,11 @@ export function convertBusinessToProject(id, data) {
return request({ url: `/backend/crm/business/${id}/convert`, method: "post", data });
}
/** 商机阶段流转:1初步接洽 2需求确认 3方案报价 4商务谈判 5赢单 6输单 */
export function changeBusinessStage(id, stage) {
return request({ url: `/backend/crm/business/${id}/stage`, method: "post", data: { stage } });
}
/* --------------------------------- 项目 --------------------------------- */
export function getProjectList(params) {
@@ -82,9 +95,12 @@ export function deleteProject(id) {
return request({ url: `/backend/crm/project/${id}`, method: "delete" });
}
/** 项目状态流转:仅更新状态(1未开始 / 2进行中 / 3已完成 / 4已暂停 / 5异常) */
export function changeProjectStatus(id, status) {
return request({ url: `/backend/crm/project/${id}/status`, method: "post", data: { status } });
/**
* 项目状态流转:仅更新状态(1未开始 / 2进行中 / 3已完成 / 4已暂停 / 5异常)。
* status=5(异常)时必须传 reason,后端会校验并写入操作日志。
*/
export function changeProjectStatus(id, status, reason = "") {
return request({ url: `/backend/crm/project/${id}/status`, method: "post", data: { status, reason } });
}
/**
+12
View File
@@ -63,6 +63,18 @@ export function bindEmail(userId, email) {
return editUser(userId, { email });
}
// 上传并设置当前登录用户头像(一步到位:上传文件 + 写入认证中心头像)
// formData 需包含 file 字段;返回 data.url 为头像地址
export function uploadAvatar(formData) {
return request({
url: '/backend/uploadavatar',
method: 'post',
data: formData,
// 上传不设超时;不要手写 Content-Type,需浏览器生成 multipart boundary
timeout: 0,
});
}
// 删除用户
export function deleteUser(userId) {
return request({
+31 -2
View File
@@ -106,6 +106,28 @@ async function loadTenants() {
}
}
/**
* 企业切换后,上一家的菜单 / 标签页 / 企业信息缓存都必须失效。
* 菜单虽然是按 tid 建 key,但旧 key 仍会留在本地,顺手清掉可避免脏数据累积。
*/
function clearTenantScopedCache() {
try {
const removeKeys: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith("menu_cache_") || key === "tabs_list" || key === "active_tab")) {
removeKeys.push(key);
}
}
removeKeys.forEach((key) => localStorage.removeItem(key));
sessionStorage.removeItem("tabs_list");
localStorage.removeItem("tenant");
sessionStorage.removeItem("tenant");
} catch (e) {
console.warn("清理企业级缓存失败:", e);
}
}
async function handleSwitch(tid: number | string) {
if (tid === RELOAD_COMMAND) {
await loadTenants();
@@ -115,9 +137,16 @@ async function handleSwitch(tid: number | string) {
const target = tenantOptions.value.find((item) => String(item.tid) === String(tid));
try {
await switchTenant(tid);
clearTenantScopedCache();
ElMessage.success(`已切换到「${target?.tenant_name || "新企业"}」`);
// 菜单、按钮权限、模块套餐均按企业隔离,整页重载确保全部按新企业重新初始化
setTimeout(() => window.location.reload(), 300);
// 切企业后必须离开当前页:当前页可能属于上一家才开通的模块,
// 停留在原地会让新企业未开通的功能继续可用。回到首页(门户)锁死入口,
// 菜单/权限/套餐再按新企业整页初始化。
// 用 replaceState 改 hash(不触发路由跳转),避免用旧企业状态多渲染一次
setTimeout(() => {
window.history.replaceState({}, document.title, `${window.location.pathname}#/home`);
window.location.reload();
}, 300);
} catch (e: any) {
ElMessage.error(e?.message || "切换企业失败");
}
+5 -2
View File
@@ -16,13 +16,16 @@ export const useMenuStore = defineStore('menu', () => {
// 正在加载的 Promise(用于避免重复请求)
let loadingPromise = null;
// 菜单缓存 key(基于用户类型和角色ID)
// 菜单缓存 key(基于用户类型、当前企业与角色ID)
// 必须带 tid:菜单是按企业下发的,key 里没有企业会让「切换企业」后
// 直接复用上一家的菜单,导致新企业未开通的模块仍能进入。
const getCacheKey = () => {
try {
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
const loginType = userInfo.type || 'user';
const roleId = userInfo.group_id || 0;
return `menu_cache_${loginType}_${roleId}`;
const tid = userInfo.tid || 0;
return `menu_cache_${loginType}_${tid}_${roleId}`;
} catch (e) {
return 'menu_cache_default';
}
+11 -11
View File
@@ -51,27 +51,27 @@ service.interceptors.response.use(
const bizMsg = error.response.data?.msg || error.response.data?.message;
switch (error.response.status) {
case 401: {
// 每个请求最多用新令牌重放一次,避免「401 → 刷新 → 仍 401」反复刷新
if (error.config?._tokenRetried) {
return Promise.reject(new Error(bizMsg || 'token无效,请重新登录'));
}
// 并发场景下,本请求可能带的是「旧令牌」,而别的请求已经把令牌换成新的
// 阶段一:并发场景下,本请求可能带的是「旧令牌」,而别的请求已经把令牌换成新的
// (登录回跳、另一请求的静默续期都会改 localStorage)。
// 此时直接用最新令牌重放即可,既省掉一次刷新,也不会误报登录失效。
// 此时直接用最新令牌重放,免得白跑一次续期。
const latestToken = localStorage.getItem('token') || '';
const sentToken = String(error.config?.headers?.Authorization || '').replace(/^Bearer\s+/i, '');
if (latestToken && latestToken !== sentToken) {
if (!error.config?._replayedWithLatest && latestToken && latestToken !== sentToken) {
console.warn('[auth] 请求携带的令牌已过期,改用最新令牌重放:', error.config?.url);
error.config.headers['Authorization'] = `Bearer ${latestToken}`;
error.config._tokenRetried = true;
error.config._replayedWithLatest = true;
return service.request(error.config);
}
// 统一认证:先用 refresh_token 静默换取新令牌并重放原请求,
// 刷新失败(令牌过期/被吊销/旧本地登录遗留 token)才真正登出。
// 阶段二:仍 401 → 用 refresh_token 静默续期后重放一次。
// 注意「重放」与「续期」是两个独立阶段:前者不消耗 refresh_token,
// 只挡一次;否则令牌真的过期时就永远等不到续期,只会一路报「无效的token」。
if (error.config?._refreshed) {
return Promise.reject(new Error(bizMsg || 'token无效,请重新登录'));
}
try {
const newToken = await refreshAccessToken();
error.config.headers['Authorization'] = `Bearer ${newToken}`;
error.config._tokenRetried = true;
error.config._refreshed = true;
return service.request(error.config);
} catch (e) {
// 续期失败:清空登录态并跳认证中心重新登录(只跳一次,避免死循环)
@@ -7,6 +7,40 @@
@update:model-value="handleClose"
@opened="loadAll"
>
<!-- 标题 + 右上角阶段流转(与合同 / 项目详情一致) -->
<template #header>
<div class="drawer-header">
<span class="drawer-title">{{ business?.business_name || "商机详情" }}</span>
<div v-if="business" class="drawer-actions">
<el-dropdown
v-if="stageOptionsExcept(business.stage).length"
trigger="click"
@command="(cmd) => handleChangeStage(cmd)"
>
<el-button
link
type="warning"
size="small"
:disabled="Number(business.locked) === 1 || Number(business.status) === 2"
>
阶段流转<el-icon style="margin-left: 2px"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="i in stageOptionsExcept(business.stage)"
:key="i.value"
:command="i.value"
>
{{ i.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<div v-if="business" class="biz-detail">
<el-tabs v-model="activeTab" tab-position="left" class="detail-tabs">
<!-- 基本信息 -->
@@ -62,6 +96,10 @@
<script setup>
import { ref, reactive, watch } from "vue";
import { ArrowDown } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { changeBusinessStage } from "@/api/crmPipeline";
import { BUSINESS_STAGE_OPTIONS, businessStageText } from "../../dict";
import DetailBasic from "./detail_basic.vue";
import DetailContact from "./detail_contact.vue";
import DetailFollow from "./detail_follow.vue";
@@ -101,6 +139,24 @@ function handleClose() {
emit("update:visible", false);
}
/** 可流转的阶段:排除当前阶段 */
function stageOptionsExcept(cur) {
return BUSINESS_STAGE_OPTIONS.filter((i) => String(i.value) !== String(cur));
}
/** 阶段流转:仅更新阶段字段,成功后同步本地展示并通知列表刷新 */
async function handleChangeStage(stage) {
if (!props.business?.id) return;
try {
await changeBusinessStage(props.business.id, stage);
ElMessage.success(`阶段已更新为「${businessStageText(stage)}」`);
props.business.stage = stage;
emit("refresh");
} catch (e) {
ElMessage.error(e.message || "阶段更新失败");
}
}
/** 抽屉打开后刷新各 Tab 数据 */
function loadAll() {
if (!props.business?.id) return;
@@ -118,6 +174,38 @@ defineExpose({ loadAll });
overflow-x: hidden;
}
/* 抽屉头部:左侧标题(超长省略)+ 右上角阶段流转 */
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
.drawer-title {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.drawer-actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
// 间距统一交给 gap,抵消 el-button 相邻默认 margin
.el-button + .el-button {
margin-left: 0;
}
}
}
.biz-detail {
.detail-tabs {
// 横向 flex:导航固定宽 + 内容区占满剩余宽度
@@ -12,6 +12,32 @@
<div class="drawer-header">
<span class="drawer-title">{{ clue?.clue_name || "线索详情" }}</span>
<div v-if="clue" class="drawer-actions">
<!-- 状态流转:与合同 / 项目详情一致,右上角下拉直接切换线索状态 -->
<el-dropdown
v-if="statusOptionsExcept(clue.status).length"
trigger="click"
@command="(cmd) => handleChangeStatus(cmd)"
>
<el-button
link
type="warning"
size="small"
:disabled="Number(clue.locked) === 1 || Number(clue.status) === 2"
>
状态流转<el-icon style="margin-left: 2px"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="i in statusOptionsExcept(clue.status)"
:key="i.value"
:command="i.value"
>
{{ i.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button size="small" :icon="EditPen" :disabled="Number(clue.locked) === 1" @click="emit('edit', clue)">
编辑
</el-button>
@@ -87,7 +113,10 @@
<script setup>
import { ref, reactive, watch } from "vue";
import { Delete, EditPen } from "@element-plus/icons-vue";
import { Delete, EditPen, ArrowDown } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { changeClueStatus } from "@/api/crmPipeline";
import { CLUE_STATUS_OPTIONS, clueStatusText } from "../../dict";
import DetailBasic from "./detail_basic.vue";
import DetailContact from "./detail_contact.vue";
import DetailFollow from "./detail_follow.vue";
@@ -127,6 +156,29 @@ function handleClose() {
emit("update:visible", false);
}
/**
* 可流转的状态:排除当前状态与「已转化」。
* 已转化必须走「转化商机」流程(会落地客户并锁定线索),不能只改状态。
*/
function statusOptionsExcept(cur) {
return CLUE_STATUS_OPTIONS.filter(
(i) => String(i.value) !== String(cur) && String(i.value) !== "2"
);
}
/** 状态流转:仅更新状态字段,成功后同步本地展示并通知列表刷新 */
async function handleChangeStatus(status) {
if (!props.clue?.id) return;
try {
await changeClueStatus(props.clue.id, Number(status));
ElMessage.success(`状态已更新为「${clueStatusText(status)}」`);
props.clue.status = Number(status);
emit("refresh");
} catch (e) {
ElMessage.error(e.message || "状态更新失败");
}
}
/** 抽屉打开后刷新各 Tab 数据 */
function loadAll() {
if (!props.clue?.id) return;
@@ -164,7 +216,15 @@ defineExpose({ loadAll });
}
.drawer-actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
// 间距统一交给 gap,抵消 el-button 相邻默认 margin,避免间距翻倍
.el-button + .el-button {
margin-left: 0;
}
}
}
@@ -16,8 +16,31 @@
</el-col>
<el-col :span="12">
<el-form-item label="客户名称" prop="customer_name">
<div class="name-row">
<el-input v-model="form.customer_name" placeholder="请输入客户名称" maxlength="100" />
<div class="picker-with-add">
<!-- 客户下拉直接检索「客户管理」,选中后带出行业 / 地址 / 对接人。
线索表只存客户名称(无 customer_id),因此以名称作为选中值 -->
<el-select
v-model="form.customer_name"
filterable
remote
clearable
:remote-method="searchCustomers"
:loading="customerLoading"
placeholder="输入客户名称搜索"
style="flex: 1"
@change="onCustomerPick"
>
<el-option
v-for="c in customerOptions"
:key="c.id"
:label="c.customer_name"
:value="c.customer_name"
/>
</el-select>
<!-- 客户库里没有时可直接新建,创建成功后自动选中并带出资料 -->
<el-tooltip content="没有找到?点击新建客户" placement="top">
<el-button :icon="Plus" @click="openCustomerCreate" />
</el-tooltip>
<a
class="aiqicha-link"
:href="aiqichaUrl"
@@ -89,7 +112,36 @@
</el-col>
<el-col :span="12">
<el-form-item label="对接人" prop="contact_person">
<el-input v-model="form.contact_person" placeholder="请输入对接人" maxlength="50" />
<div class="picker-with-add">
<!-- 对接人取自所选客户的联系人库,选中后自动补全职位 / 手机 / 微信 / QQ;
allow-create 保留直接输入能力,历史手填的数据也能回显 -->
<el-select
v-model="form.contact_person"
filterable
allow-create
clearable
:loading="contactLoading"
:disabled="!form.customer_name"
:placeholder="form.customer_name ? '选择客户联系人' : '请先选择客户'"
style="flex: 1"
@change="onContactPick"
>
<el-option
v-for="c in contactOptions"
:key="c.id"
:label="c.contact_name"
:value="c.contact_name"
>
<span>{{ c.contact_name }}</span>
<span class="opt-meta">
{{ [c.position || c.department, c.mobile || c.phone].filter(Boolean).join(" · ") }}
</span>
</el-option>
</el-select>
<el-tooltip content="该客户下没有联系人?点击新建" placement="top">
<el-button :icon="Plus" :disabled="!currentCustomerId" @click="openContactCreate" />
</el-tooltip>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
@@ -119,6 +171,47 @@
</el-col>
</el-row>
</el-form>
<!-- 快速新建客户:创建成功后自动选中,并按填写内容带出对接人与电话 -->
<QuickCreateParty
v-model:visible="customerCreateVisible"
:ref-type="1"
:preset-name="form.customer_name"
associate-tip="线索客户"
@created="onCustomerCreated"
/>
<!-- 快速新建联系人:写入所选客户的联系人库,创建成功后自动选中并带出联系方式 -->
<el-dialog
v-model="contactCreateVisible"
title="新建联系人"
width="520px"
append-to-body
:close-on-click-modal="false"
>
<el-form ref="contactFormRef" :model="contactForm" :rules="contactRules" label-width="90px">
<el-form-item label="姓名" prop="contact_name">
<el-input v-model="contactForm.contact_name" placeholder="请输入联系人姓名" maxlength="50" />
</el-form-item>
<el-form-item label="职位">
<el-input v-model="contactForm.position" placeholder="如:采购经理、技术总监" maxlength="50" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="contactForm.mobile" placeholder="请输入手机号" maxlength="20" />
</el-form-item>
<el-form-item label="微信">
<el-input v-model="contactForm.wechat" placeholder="请输入微信号" maxlength="64" />
</el-form-item>
<el-form-item label="QQ">
<el-input v-model="contactForm.qq" placeholder="请输入QQ号" maxlength="20" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="contactCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="contactSubmitting" @click="submitContact">保存</el-button>
</template>
</el-dialog>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
@@ -129,9 +222,13 @@
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { createClue, updateClue } from "@/api/crmPipeline";
import { getCrmCustomerList } from "@/api/crm";
import { listContacts, createContact } from "@/api/contact";
import { getAllUsers } from "@/api/user";
import { useAuthStore } from "@/stores/auth";
import QuickCreateParty from "../../contract/components/QuickCreateParty.vue";
import {
CLUE_SOURCE_OPTIONS,
CLUE_STATUS_OPTIONS,
@@ -186,7 +283,7 @@ const rules = {
watch(
() => props.visible,
(val) => {
async (val) => {
if (val) {
if (props.editData) {
isEdit.value = true;
@@ -203,10 +300,190 @@ watch(
form.owner_user_name = authStore.user?.name || "";
}
loadUsers();
// 客户 / 联系人候选按当前值预加载,保证编辑回显与「客户 → 联系人」联动
await searchCustomers(form.customer_name || "");
await resolveCurrentCustomer();
loadContacts();
}
}
);
/* ------------------------- 客户(取自客户管理) ------------------------- */
const customerOptions = ref([]);
const customerLoading = ref(false);
const customerCreateVisible = ref(false);
// 当前客户在客户库中的 ID:线索表只存客户名称,此值仅用于拉取该客户的联系人
const currentCustomerId = ref(0);
/** 客户候选:远程搜索客户管理(列表接口已按 tenant + 非公海过滤) */
async function searchCustomers(keyword) {
customerLoading.value = true;
try {
const res = await getCrmCustomerList({ page: 1, pageSize: 20, keyword: keyword || "" });
const data = res?.data || {};
customerOptions.value = (data.list || []).map((c) => ({
id: c.id,
customer_name: c.customer_name,
industry: c.industry || "",
address: c.address || "",
contact_person: c.contact_person || "",
contact_phone: c.contact_phone || "",
}));
// 历史手填 / 未建档的客户名:补一条临时项,保证下拉能回显当前值
const name = String(form.customer_name || "").trim();
if (name && !customerOptions.value.some((c) => c.customer_name === name)) {
customerOptions.value.unshift({ id: 0, customer_name: name });
}
} catch (e) {
customerOptions.value = [];
} finally {
customerLoading.value = false;
}
}
/**
* 选中客户:带出客户档案里的行业 / 地址(这两项跟随客户走,直接覆盖),
* 对接人只在为空时带出,避免覆盖已选的联系人;随后加载该客户联系人候选。
*/
function onCustomerPick(name) {
const hit = customerOptions.value.find((c) => c.customer_name === name);
currentCustomerId.value = hit?.id ? Number(hit.id) : 0;
if (hit) {
form.industry = hit.industry || "";
form.address = hit.address || "";
if (!form.contact_person && hit.contact_person) form.contact_person = hit.contact_person;
if (!form.contact_phone && hit.contact_phone) form.contact_phone = hit.contact_phone;
}
loadContacts();
}
/** 编辑回显时按名称定位当前客户,便于加载其联系人(名称可能不在默认搜索结果里) */
async function resolveCurrentCustomer() {
const name = String(form.customer_name || "").trim();
currentCustomerId.value = 0;
if (!name) return;
const local = customerOptions.value.find((c) => c.customer_name === name && Number(c.id) > 0);
if (local) {
currentCustomerId.value = Number(local.id);
return;
}
try {
const res = await getCrmCustomerList({ page: 1, pageSize: 20, keyword: name });
const exact = (res?.data?.list || []).find((c) => c.customer_name === name);
if (exact) {
currentCustomerId.value = Number(exact.id);
if (!customerOptions.value.some((c) => Number(c.id) === Number(exact.id))) {
customerOptions.value.unshift({ ...exact });
}
}
} catch (e) {
/* 定位失败不影响编辑,仅表示暂时无法加载该客户的联系人 */
}
}
function openCustomerCreate() {
customerCreateVisible.value = true;
}
/** 快速新建客户成功:刷新候选、自动选中,并带出对接人与联系电话 */
async function onCustomerCreated(row) {
if (!row?.id) return;
const name = row.customer_name || row.label || "";
await searchCustomers("");
if (name && !customerOptions.value.some((c) => c.customer_name === name)) {
customerOptions.value.unshift({ id: row.id, customer_name: name });
}
form.customer_name = name;
currentCustomerId.value = Number(row.id);
if (row.contact_person) form.contact_person = row.contact_person;
if (row.contact_phone) form.contact_phone = row.contact_phone;
loadContacts();
}
/* --------------------- 联系人(取自客户的联系人库) --------------------- */
const contactOptions = ref([]);
const contactLoading = ref(false);
const contactCreateVisible = ref(false);
const contactSubmitting = ref(false);
const contactFormRef = ref();
const contactForm = reactive({ contact_name: "", position: "", mobile: "", wechat: "", qq: "" });
const contactRules = {
contact_name: [{ required: true, message: "请输入联系人姓名", trigger: "blur" }],
};
/** 加载当前客户下的联系人(related_type 1=客户 / 2=供应商) */
async function loadContacts() {
const id = Number(currentCustomerId.value) || 0;
if (!id) {
contactOptions.value = [];
return;
}
contactLoading.value = true;
try {
const res = await listContacts({ related_type: 1, related_id: id, page: 1, pageSize: 100 });
contactOptions.value = res?.data?.list || [];
} catch (e) {
contactOptions.value = [];
} finally {
contactLoading.value = false;
}
}
/** 选中联系人:带出职位 / 手机 / 微信 / QQ */
function onContactPick(name) {
const hit = contactOptions.value.find((c) => c.contact_name === name);
if (!hit) return;
form.contact_position = hit.position || hit.department || "";
form.contact_phone = hit.mobile || hit.phone || "";
form.contact_wechat = hit.wechat || "";
form.contact_qq = hit.qq || "";
}
function openContactCreate() {
if (!currentCustomerId.value) {
ElMessage.warning("请先选择客户,再为其新增联系人");
return;
}
Object.assign(contactForm, { contact_name: "", position: "", mobile: "", wechat: "", qq: "" });
contactCreateVisible.value = true;
}
/** 保存联系人:写入所选客户的联系人库,并自动选中带回主表单 */
async function submitContact() {
if (!contactFormRef.value) return;
try {
await contactFormRef.value.validate();
} catch (e) {
return;
}
contactSubmitting.value = true;
try {
await createContact({
related_type: 1,
related_id: Number(currentCustomerId.value),
contact_name: contactForm.contact_name.trim(),
position: contactForm.position.trim(),
mobile: contactForm.mobile.trim(),
wechat: contactForm.wechat.trim(),
qq: contactForm.qq.trim(),
});
await loadContacts();
form.contact_person = contactForm.contact_name.trim();
form.contact_position = contactForm.position.trim();
form.contact_phone = contactForm.mobile.trim();
form.contact_wechat = contactForm.wechat.trim();
form.contact_qq = contactForm.qq.trim();
contactCreateVisible.value = false;
ElMessage.success("联系人已创建并选中");
} catch (e) {
ElMessage.error(e.message || "联系人创建失败");
} finally {
contactSubmitting.value = false;
}
}
async function loadUsers() {
if (userOptions.value.length) return;
try {
@@ -260,14 +537,15 @@ async function handleSubmit() {
</script>
<style lang="less" scoped>
.name-row {
/* 下拉选择 + 右侧新建按钮(客户 / 联系人共用,与项目编辑保持一致) */
.picker-with-add {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
.el-input {
flex: 1;
.el-button {
flex-shrink: 0;
}
.aiqicha-link {
@@ -282,4 +560,11 @@ async function handleSubmit() {
}
}
}
/* 联系人下拉项右侧的次要信息(职位 · 电话) */
.opt-meta {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -81,6 +81,9 @@
<el-table-column label="下次联系时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
</el-table-column>
<el-table-column label="创建时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<!-- 编辑 / 删除已收口到「线索详情」内,列表只保留转化 -->
@@ -453,7 +453,7 @@ function onProductPick(row, val) {
row.dev_tree = Array.isArray(hit.dev_tree)
? JSON.parse(JSON.stringify(hit.dev_tree))
: safeParseTree(hit.dev_tree);
// 带出开发总成本作为成本单价;销售单价默认与之相同,可在清单中自行改写
// 成本单价取模块报价的「开发总成本」(叶子人天 × 统一人天单价)
const total =
Number(hit.dev_total_price) > 0
? round2(hit.dev_total_price)
@@ -461,7 +461,9 @@ function onProductPick(row, val) {
row.dev_total_price = total;
row.dev_auto_total = total;
row.cost_price = total;
row.price = total;
// 销售单价:产品管理里预制过销售价就直接带出,只有未预制(0/空)时才回落到开发总成本
const presetPrice = Number(hit.price) || 0;
row.price = presetPrice > 0 ? round2(presetPrice) : total;
// 此处是直接赋值 line_type,不走下拉的 change,需自行兜底类别/单位
if (!row.category) row.category = "软件开发";
if (!row.unit) row.unit = "项";
@@ -186,19 +186,29 @@
</el-select>
</el-form-item>
<el-form-item label="进度节点">
<!-- 按现有进度选择性付款:不指定时按整单登记 -->
<!-- 按现有进度选择性付款:不指定时按整单登记。
已收齐的节点不再出现,避免对同一进度重复登记回款 -->
<el-select
v-model="receiveForm.item_seq"
clearable
placeholder="不指定(按整单登记)"
:disabled="!selectableItems.length"
:placeholder="selectableItems.length ? '不指定(按整单登记)' : '该计划所有节点均已回款完毕'"
style="width: 100%"
>
<el-option
v-for="it in planItems(receiveForm.payback_id)"
v-for="it in selectableItems"
:key="it.seq"
:label="`${it.name || '节点' + it.seq}(¥${formatMoney(it.amount)})`"
:label="it.name || `节点${it.seq}`"
:value="it.seq"
/>
>
<span>{{ it.name || `节点${it.seq}` }}</span>
<span class="opt-meta">
¥{{ formatMoney(it.amount) }}
<template v-if="planItemReceived(receiveForm.payback_id, it) > 0">
· 已收 ¥{{ formatMoney(planItemReceived(receiveForm.payback_id, it)) }}
</template>
</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="本次回款" required>
@@ -377,6 +387,15 @@ function planItems(paybackId) {
return Array.isArray(p?.items) ? p.items : [];
}
/** 指定计划下某节点的已回款(按标记到该节点的记录汇总,兼容历史 received_amount) */
function planItemReceived(paybackId, item) {
const p = planOf(paybackId);
const fromRecords = (Array.isArray(p?.children) ? p.children : [])
.filter((r) => Number(r.item_seq) === Number(item.seq))
.reduce((s, r) => s + (Number(r.amount) || 0), 0);
return Math.max(round2(fromRecords), Number(item.received_amount) || 0);
}
/** 节点已回款:按「标记到该节点的回款记录」汇总,兼容历史上按明细直接登记(received_amount)的值 */
function itemReceivedOf(row) {
const plan = row.__plan;
@@ -512,6 +531,17 @@ const receiveForm = reactive({
remark: "",
});
/**
* 可选进度节点:排除已收齐(已回款完毕)的节点。
* 已收齐的节点不允许再登记,避免对同一进度重复回款。
*/
const selectableItems = computed(() =>
planItems(receiveForm.payback_id).filter((it) => {
const total = Number(it.amount) || 0;
return !(total > 0 && planItemReceived(receiveForm.payback_id, it) >= total);
})
);
/**
* 新建回款:以回款计划为先导条件(无计划时不允许登记)。
* 计划与进度节点(按进度选择性付款)都在弹窗内选择。
@@ -688,6 +718,13 @@ defineExpose({ reload });
</script>
<style lang="less" scoped>
/* 下拉项右侧的次要信息(计划金额 · 已收) */
.opt-meta {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.section-head {
display: flex;
justify-content: space-between;
@@ -245,8 +245,11 @@ const filters = reactive({
});
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
/** 统计周期:all(全部)/ month / quarter / year / custom(自定义选区间),默认全部 */
const statsPeriod = ref("all");
/**
* 统计周期:all(全部)/ month / quarter / year / custom(自定义选区间)。
* 默认「本年」——列表与统计都按签订日期落在本年内归类,打开即看当年合同。
*/
const statsPeriod = ref("year");
const statsRange = ref([]);
/** 列表页统计:后端按周期汇总(排除已作废),周期参数透传给 Stats */
@@ -254,7 +257,7 @@ const stats = ref({ total: 0, total_amount: 0, total_cost: 0, total_profit: 0 })
/** 卡片加载态:切换周期时给出可见的刷新反馈 */
const statsLoading = ref(false);
/** 当前统计范围文案:与后端口径一致(合同有效期与所选周期重叠),便于对照卡片数字 */
/** 当前统计范围文案:与后端口径一致(按「签订日期」落在所选周期内),便于对照卡片数字 */
const statsScopeText = computed(() => {
const pad = (n) => String(n).padStart(2, "0");
const fmt = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
@@ -275,13 +278,13 @@ const statsScopeText = computed(() => {
? `${statsRange.value[0]} ~ ${statsRange.value[1]}`
: "自定义区间";
default:
return "全部时间(不限有效期)";
return "全部时间(不限签订日期)";
}
});
/**
* 周期参数:统计与列表共用,保证两者口径一致。
* 「全部」不传周期参数——列表不按有效期过滤,避免有效期不在所选周期内的合同(如历史合同)被隐藏。
* 「全部」不传周期参数——列表不按签订日期过滤,避免签订日期不在所选周期内的合同被隐藏。
*/
function periodParams() {
if (statsPeriod.value === "all") return {};
@@ -207,7 +207,7 @@
</el-form-item>
<div class="final-step-tip">
<el-icon><InfoFilled /></el-icon>
<span>确认所有信息无误后,点击「保存」完成创建。</span>
<span>确认信息无误后点「保存并完成」提交;中途可随时点「保存」暂存已填内容。</span>
</div>
</div>
</el-form>
@@ -222,20 +222,15 @@
</div>
<div class="footer-right">
<el-button @click="handleClose">取消</el-button>
<el-button
v-if="!isEdit || form.is_draft === 1"
:loading="submitting"
@click="handleSaveDraft"
>
保存草稿
</el-button>
<!-- 每一步都可保存:只校验当前步骤,保存后停留在本步,可继续往下填 -->
<el-button :loading="submitting" @click="handleSaveStep">保存</el-button>
<el-button
v-if="activeStep === 3"
type="primary"
:loading="submitting"
@click="handleSave(false)"
>
保存
保存并完成
</el-button>
</div>
</div>
@@ -438,8 +433,68 @@ async function handleSave(isDraft) {
}
}
function handleSaveDraft() {
handleSave(true);
/**
* 分步保存:每一步都可以落库,不必走完最后一步。
*
* 与「保存并完成」的区别:
* - 只校验**当前步骤**的规则(不被后面步骤的必填项拦住);
* - 保存后不关闭弹窗,可继续往下填;
* - 新建时若还没填联系电话,自动按草稿保存(后端正式保存要求联系电话非空)。
*/
async function handleSaveStep() {
if (!formRef.value) return;
if (!form.customer_name || form.customer_name.trim() === "") {
// 客户名称是后端建档的硬性前提,缺了就先回到第 1 步
ElMessage.warning("请先填写客户名称");
activeStep.value = 0;
return;
}
// 只校验当前步骤,避免被后续步骤的必填项拦住
const fields = Object.keys(stepRules[activeStep.value] || {});
if (fields.length > 0) {
try {
await formRef.value.validateField(fields);
} catch (e) {
ElMessage.warning("请先完善当前步骤的必填项");
return;
}
}
// 新建 + 未填联系电话:按草稿保存,否则后端会以「正式客户」校验并拒绝
const hasPhone = String(form.contact_phone || "").trim() !== "";
const isDraftValue = !internalId.value && !hasPhone ? 1 : Number(form.is_draft) || 0;
submitting.value = true;
try {
const payload = { ...form, is_draft: isDraftValue };
if (internalId.value) {
const res = await updateCrmCustomer(internalId.value, payload);
if (res && res.code !== 200) {
ElMessage.error(res.msg || "保存失败");
return;
}
} else {
const res = await createCrmCustomer(payload);
if (res && res.code === 200 && res.data && res.data.id) {
// 建档成功:记住 id,后续保存走更新
internalId.value = res.data.id;
isEdit.value = true;
} else if (res && res.code !== 200) {
ElMessage.error(res.msg || "保存失败");
return;
}
}
form.is_draft = isDraftValue;
ElMessage.success(isDraftValue === 1 ? "已保存为草稿" : "已保存");
// 通知父组件刷新列表;弹窗保持打开,便于继续填写下一步
emit("success");
} catch (e) {
ElMessage.error(e.message || "保存失败");
} finally {
submitting.value = false;
}
}
</script>
@@ -101,29 +101,6 @@
</el-col>
</el-row>
<!-- 流水回执:银行流水 / 转账凭证,非必填 -->
<el-divider content-position="left">
流水回执
<span class="tip">银行流水 / 转账凭证等,选填</span>
</el-divider>
<div class="receipt-block">
<el-upload
:show-file-list="false"
:http-request="handleReceiptUpload"
:disabled="uploading"
accept=".jpg,.jpeg,.png,.pdf,.doc,.docx,.xls,.xlsx"
>
<el-button :icon="Upload" :loading="uploading" size="small">上传流水回执</el-button>
</el-upload>
<div v-if="form.receipt_url" class="receipt-file">
<el-link type="primary" :href="form.receipt_url" target="_blank" :underline="false">
<el-icon><Document /></el-icon>
<span class="receipt-name">{{ form.receipt_name || "流水回执" }}</span>
</el-link>
<el-button link type="danger" size="small" :icon="Delete" @click="clearReceipt">移除</el-button>
</div>
</div>
<!-- 计划明细:月度 / 季度 / 年度 = 期数 + 每期金额 -->
<template v-if="isFixedCycle">
<el-divider content-position="left">
@@ -280,9 +257,8 @@
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { ElMessage } from "element-plus";
import { Plus, QuestionFilled, Upload, Delete, Document } from "@element-plus/icons-vue";
import { Plus, QuestionFilled } from "@element-plus/icons-vue";
import { createPayback, updatePayback } from "@/api/crmPayback";
import { uploadFile } from "@/api/file";
import { getContractList } from "@/api/crmContract";
import { getAllUsers } from "@/api/user";
import { useAuthStore } from "@/stores/auth";
@@ -328,8 +304,6 @@ const defaultForm = () => ({
owner_user_id: authStore.user?.id ? String(authStore.user.id) : "",
owner_user_name: authStore.user?.name || "",
remark: "",
receipt_url: "",
receipt_name: "",
periodCount: 1,
periodAmount: 0,
periodDates: [],
@@ -424,35 +398,6 @@ const removeRow = (key, index) => {
form[key].splice(index, 1);
};
/* ---------------------- 流水回执上传(非必填) ---------------------- */
const uploading = ref(false);
/** 上传流水回执:复用通用文件上传接口,返回地址存入表单(选填) */
async function handleReceiptUpload(options) {
const file = options.file;
uploading.value = true;
try {
const fd = new FormData();
fd.append("file", file);
const res = await uploadFile(fd);
const data = res?.data || {};
form.receipt_url = data.url || "";
form.receipt_name = data.name || file.name;
ElMessage.success("上传成功");
} catch (e) {
ElMessage.error(e.message || "上传失败");
} finally {
uploading.value = false;
}
}
/** 移除已上传的流水回执 */
function clearReceipt() {
form.receipt_url = "";
form.receipt_name = "";
}
/* ---------------------- 进度金额自动计算 ---------------------- */
/** 金额保留两位小数 */
@@ -647,8 +592,6 @@ async function handleSubmit() {
owner_user_id: form.owner_user_id,
owner_user_name: owner?.name || form.owner_user_name || "",
remark: form.remark,
receipt_url: form.receipt_url,
receipt_name: form.receipt_name,
items,
};
@@ -688,28 +631,6 @@ function handleClosed() {
margin-left: 8px;
}
/* 流水回执上传 */
.receipt-block {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
.receipt-file {
display: flex;
align-items: center;
gap: 8px;
.receipt-name {
margin-left: 4px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.total-amount {
font-size: 16px;
font-weight: 700;
@@ -163,18 +163,28 @@
<el-dialog v-model="recordVisible" title="新建回款记录" width="460px" append-to-body>
<el-form :model="recordForm" label-width="90px">
<el-form-item v-if="planItems.length" label="进度节点">
<!-- 只列未收齐的节点:已回款完毕(已收齐)的节点不可再选,避免重复登记 -->
<el-select
v-model="recordForm.item_seq"
clearable
placeholder="不指定(按整单登记)"
:disabled="!selectableItems.length"
:placeholder="selectableItems.length ? '不指定(按整单登记)' : '所有进度节点均已回款完毕'"
style="width: 100%"
>
<el-option
v-for="it in planItems"
v-for="it in selectableItems"
:key="it.seq"
:label="`${it.name || '节点' + it.seq}(¥${formatMoney(it.amount)})`"
:label="it.name || `节点${it.seq}`"
:value="it.seq"
/>
>
<span>{{ it.name || `节点${it.seq}` }}</span>
<span class="opt-meta">
¥{{ formatMoney(it.amount) }}
<template v-if="itemReceivedOf(it) > 0">
· 已收 ¥{{ formatMoney(itemReceivedOf(it)) }}
</template>
</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="本次回款" required>
@@ -317,6 +327,12 @@ function itemStatusTagOf(item) {
return { 0: "info", 1: "warning", 2: "success" }[itemStatusOf(item)] || "info";
}
/**
* 可选进度节点:排除已收齐(已回款完毕)的节点。
* 已收齐的节点不允许再登记回款,否则会把同一进度重复计入。
*/
const selectableItems = computed(() => planItems.value.filter((it) => itemStatusOf(it) !== 2));
async function loadDetail() {
// 先用列表行数据回显,再拉取详情补充完整信息
detail.value = props.payback || {};
@@ -506,6 +522,13 @@ async function handleDelete() {
</script>
<style lang="less" scoped>
/* 下拉项右侧的次要信息(计划金额 · 已收) */
.opt-meta {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.payback-detail {
.detail-head {
display: flex;
@@ -83,14 +83,17 @@
<!-- <div class="dev-tip">叶子模块只填「人天」,人天单价统一;父级自动汇总金额</div> -->
</el-form-item>
</el-col>
<el-col :span="8" v-if="form.line_type === 'product'">
<!-- 销售 / 成本单价:成品与软件开发行都要填。
软件开发行的对外金额取这里的「销售单价」,成本单价默认取模块报价总额
(人天 × 统一单价),两者差额即毛利——所以不能按行类型隐藏。 -->
<el-col :span="8">
<el-form-item label="销售单价" prop="price">
<el-input-number v-model="form.price" :min="0" :precision="2" :controls="false" style="width: 100%">
<template #prefix>¥</template>
</el-input-number>
</el-form-item>
</el-col>
<el-col :span="8" v-if="form.line_type === 'product'">
<el-col :span="8">
<el-form-item label="成本单价" prop="cost_price">
<el-input-number v-model="form.cost_price" :min="0" :precision="2" :controls="false" style="width: 100%">
<template #prefix>¥</template>
@@ -181,12 +184,28 @@ const rules = {
product_name: [{ required: true, message: "请输入产品名称", trigger: "blur" }],
};
/**
* 软件开发行:把「模块报价总额」(叶子人天 × 统一人天单价)带出为
* 销售单价 / 成本单价的默认值。
*
* 只填充空值(0),已填写的不会被覆盖——这两个字段都允许手工改写:
* 对外金额取销售单价,成本单价默认取模块报价总额,差额即毛利。
*/
function applyDevPriceDefaults() {
if (form.line_type !== "dev") return;
const total = treeSellTotal(form.dev_tree, form.dev_unit_price);
if (total <= 0) return;
if (!(Number(form.price) > 0)) form.price = total;
if (!(Number(form.cost_price) > 0)) form.cost_price = total;
}
/** 切换行类型:开发行确保有模块树容器与研发方式;切回成品时清空开发字段,避免残留脏数据 */
function onLineTypeChange() {
if (form.line_type === "dev") {
if (!Array.isArray(form.dev_tree)) form.dev_tree = [];
if (form.dev_unit_price == null) form.dev_unit_price = 0;
if (!form.dev_mode) form.dev_mode = "self";
applyDevPriceDefaults();
} else {
form.dev_tree = [];
form.dev_unit_price = 0;
@@ -227,6 +246,8 @@ watch(
internalId.value = null;
Object.assign(form, defaultForm());
}
// 软件开发行:模块报价总额带出销售/成本单价(仅填空值,不覆盖已填写的)
applyDevPriceDefaults();
fetchCategories();
fetchUnits();
}
@@ -273,7 +294,8 @@ function handleOpened() {
}
async function handleSubmit() {
if (!formRef.value) return;
// 软件开发行提交前兜底:模块报价有值但销售/成本单价为空时,会被原样存成 0
applyDevPriceDefaults();
try {
await formRef.value.validate();
} catch (e) {
@@ -3,7 +3,7 @@
:model-value="visible"
:title="project?.project_name || '项目详情'"
direction="rtl"
size="900px"
size="1200px"
@update:model-value="handleClose"
@opened="loadAll"
>
@@ -168,11 +168,32 @@ function statusOptionsExcept(cur) {
return PROJECT_STATUS_OPTIONS.filter((i) => String(i.value) !== String(cur));
}
/** 项目状态流转:成功后更新本地展示并通知父组件刷新列表 */
/**
* 项目状态流转:成功后更新本地展示并通知父组件刷新列表。
* 置为「异常」时先询问原因,原因会写入操作日志,便于追溯是什么导致的。
*/
async function handleChangeStatus(status) {
if (!props.project?.id) return;
let reason = "";
if (Number(status) === 5) {
try {
const { value } = await ElMessageBox.prompt(
"请填写项目异常原因,该原因会记入操作日志",
"异常原因",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
inputPlaceholder: "如:客户预算取消 / 供应商延期交付",
inputValidator: (v) => (String(v || "").trim() ? true : "异常原因不能为空"),
}
);
reason = String(value || "").trim();
} catch (e) {
return; // 用户取消:不流转状态
}
}
try {
await changeProjectStatus(props.project.id, Number(status));
await changeProjectStatus(props.project.id, Number(status), reason);
ElMessage.success(`状态已更新为「${projectStatusText(status)}」`);
props.project.status = Number(status);
emit("refresh");
@@ -253,11 +274,13 @@ defineExpose({ loadAll });
width: 100%;
min-width: 0;
// 关键:内容区占满「容器剩余宽度」并允许收缩,内部表格才能随容器自适应
// 关键:内容区占满「容器剩余宽度」并允许收缩,内部表格才能随容器自适应。
// 注意不能用 overflow: hidden——它会把附件/日志等较高的内容直接截断,
// 让内容自然撑高,滚动交给 drawer 自身(el-drawer__body)处理。
:deep(.el-tabs__content) {
flex: 1;
min-width: 0;
overflow: hidden;
overflow: visible;
padding-left: 4px;
}
@@ -44,6 +44,16 @@
</el-select>
</el-form-item>
</el-col>
<!-- 状态选「异常」时必须写明原因,原因会写入操作日志便于追溯 -->
<el-col v-if="Number(form.status) === 5" :span="12">
<el-form-item label="异常原因" prop="reason">
<el-input
v-model="form.reason"
placeholder="如:客户预算取消 / 供应商延期交付"
maxlength="100"
/>
</el-form-item>
</el-col>
<el-col v-if="isBackToBack" :span="12">
<el-form-item label="项目毛利">
<span class="profit-value">¥{{ formatMoney(profitAmount) }}</span>
@@ -299,6 +309,8 @@ const defaultForm = () => ({
downstream_contact: "",
downstream_phone: "",
status: 1,
// 异常原因:状态选「异常」时必填,会写入操作日志
reason: "",
start_date: "",
end_date: "",
address: "",
@@ -402,8 +414,21 @@ async function loadProjectProducts(projectId) {
}
}
/** 异常原因校验:状态改为「异常」时必填(原本已是异常且未改状态的除外,项目表未存历史原因) */
const validateReason = (rule, value, callback) => {
const isAbnormal = Number(form.status) === 5;
const keepOriginal =
isEdit.value && Number(props.editData?.status) === 5 && Number(form.status) === 5;
if (isAbnormal && !keepOriginal && !String(value || "").trim()) {
callback(new Error("请填写项目异常原因"));
} else {
callback();
}
};
const rules = {
project_name: [{ required: true, message: "请输入项目名称", trigger: "blur" }],
reason: [{ validator: validateReason, trigger: "blur" }],
};
watch(
@@ -695,6 +720,8 @@ async function handleSubmit() {
downstream_amount: hasCounterparty.value ? Number(form.downstream_amount) || 0 : 0,
amount: upstream, // 兼容旧字段
status: Number(form.status) || 1,
// 异常原因:后端仅在状态变为「异常」时校验并写入日志,其他状态忽略
reason: form.reason,
start_date: form.start_date,
end_date: form.end_date,
upstream_contact: form.upstream_contact,
+29 -1
View File
@@ -28,6 +28,12 @@
<el-option v-for="i in PROJECT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<!-- 年度筛选:按项目开始时间(关联合同汇总)所在年份过滤,选项由后端按现有项目汇总 -->
<el-form-item label="年度">
<el-select v-model="filters.year" clearable placeholder="全部" style="width: 120px">
<el-option v-for="y in yearOptions" :key="y" :label="`${y} 年`" :value="String(y)" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
@@ -42,6 +48,15 @@
<el-tag :type="projectStatusTag(row.status)" size="small">{{ projectStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<!-- 年度:按项目开始时间(关联合同汇总的起始日)取年份 -->
<el-table-column label="年度" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="projectYear(row)" type="primary" size="small" effect="plain">
{{ projectYear(row) }} 年
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="项目名称" min-width="180" show-overflow-tooltip >
<template #default="{ row }">
<span class="name-link" @click="openDetail(row)">{{ row.project_name }}</span>
@@ -158,13 +173,23 @@ const contactBookVisible = ref(false);
const contactBookCompanyId = ref(null);
const contactBookCompanyName = ref("");
const filters = reactive({ keyword: "", status: "" });
const filters = reactive({ keyword: "", status: "", year: "" });
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
/** 年度筛选项:由后端按现有项目的开始时间汇总返回(倒序),避免前端写死年份范围 */
const yearOptions = ref([]);
onMounted(() => {
fetchList();
});
/** 项目年度:按项目开始时间(关联合同汇总的起始日,回退旧的手工列 start_date)取年份 */
function projectYear(row) {
const d = row?.contract_start_date || row?.start_date;
if (!d) return "";
const y = String(d).slice(0, 4);
return /^\d{4}$/.test(y) ? Number(y) : "";
}
async function fetchList() {
loading.value = true;
try {
@@ -175,6 +200,8 @@ async function fetchList() {
});
tableData.value = res?.data?.list || [];
pagination.total = res?.data?.total || 0;
// 年度选项始终取全量汇总结果(不受当前年度筛选影响,切换年份时下拉不丢项)
if (Array.isArray(res?.data?.years)) yearOptions.value = res.data.years;
} catch (e) {
tableData.value = [];
pagination.total = 0;
@@ -192,6 +219,7 @@ function handleSearch() {
function resetFilters() {
filters.keyword = "";
filters.status = "";
filters.year = "";
handleSearch();
}
+62 -2
View File
@@ -1,5 +1,13 @@
<template>
<div class="user-profile">
<!-- 更换头像:隐藏的文件选择框,由 ProfileCard 的「更换头像」按钮触发 -->
<input
ref="avatarInputRef"
type="file"
accept="image/*"
class="avatar-file-input"
@change="onAvatarPicked"
/>
<el-row :gutter="20">
<el-col :xs="24" :sm="24" :md="8" :lg="8" :xl="8">
<ProfileCard
@@ -61,7 +69,7 @@
<script setup>
import { ref, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { getUserInfo } from "@/api/user";
import { getUserInfo, uploadAvatar } from "@/api/user";
import { useAuthStore } from "@/stores/auth";
// 该页面展示精确到秒的时间:YYYY-MM-DD HH:mm:ss
import { formatDateTime as formatDate } from "@/utils/datetime";
@@ -119,10 +127,57 @@ const handleChangePassword = () => {
passwordDialogVisible.value = true;
};
// ---------------- 更换头像 ----------------
const avatarInputRef = ref(null);
const avatarUploading = ref(false);
const AVATAR_MAX_MB = 2;
const AVATAR_ACCEPT = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp"];
const handleAvatarChange = () => {
ElMessage.info("头像上传功能开发中");
if (avatarUploading.value) return;
avatarInputRef.value?.click();
};
async function onAvatarPicked(event) {
const input = event?.target;
const file = input?.files?.[0];
// 清空 value,允许连续两次选择同一张图
if (input) input.value = "";
if (!file) return;
if (!AVATAR_ACCEPT.includes(file.type)) {
ElMessage.error("头像仅支持 jpg / png / gif / webp 格式");
return;
}
if (file.size > AVATAR_MAX_MB * 1024 * 1024) {
ElMessage.error(`头像大小不能超过 ${AVATAR_MAX_MB}MB`);
return;
}
const formData = new FormData();
formData.append("file", file);
avatarUploading.value = true;
try {
const res = await uploadAvatar(formData);
const url = res?.data?.url;
if (res?.code !== 200 || !url) {
ElMessage.error(res?.msg || "头像上传失败");
return;
}
// 本页立即生效
if (userInfo.value) userInfo.value.avatar = url;
// 顶部栏 / 首页头像读的是 store 与本地缓存,同步更新
authStore.updateUserInfo({ ...authStore.user, avatar: url });
ElMessage.success("头像已更新");
} catch (e) {
console.error("头像上传失败:", e);
ElMessage.error(e?.message || "头像上传失败");
} finally {
avatarUploading.value = false;
}
}
const handleLogout = () => {
ElMessageBox.confirm("确定要退出登录吗?", "提示", {
confirmButtonText: "确定",
@@ -191,5 +246,10 @@ onMounted(() => {
<style scoped lang="less">
.user-profile {
padding: 0;
// 更换头像用的隐藏文件选择框
.avatar-file-input {
display: none;
}
}
</style>