修复个人中心问题
This commit is contained in:
@@ -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 } });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 || "切换企业失败");
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
await changeProjectStatus(props.project.id, Number(status));
|
||||
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), 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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -45,6 +45,21 @@ auth_enforce = warn
|
||||
# 额外免鉴权路径前缀(在内置白名单之外追加),多个用英文逗号分隔
|
||||
auth_whitelist =
|
||||
|
||||
# ==================== 模块套餐鉴权 ====================
|
||||
# 未开通模块的接口直接拒绝:off=不启用 / warn=观察模式只记日志 / on=拦截返回403
|
||||
# 上线步骤同 auth_enforce:先 warn 跑一段,看日志确认映射无遗漏,再改 on
|
||||
plan_enforce = warn
|
||||
# 接口前缀 → 模块编码(yz_system_modules.code),多个用英文逗号分隔,格式 前缀:模块编码
|
||||
# 按「最长前缀」匹配;未出现在这里的接口不做模块校验(默认放行),可逐模块灰度开启
|
||||
# 说明:
|
||||
# /backend/crm/* 含招投标等全部 CRM 子功能
|
||||
# /backend/erp/* 含客户/供应商/账套/通讯录/组织架构(erp 端)
|
||||
# /backend/oa/* 含人事档案/薪酬/日程/通知/文档/组织架构(oa 端)
|
||||
# 如需覆盖网站管理(文章/产品/解决方案/Banner/友链/单页/模板/域名)与 AI,
|
||||
# 按各自模块编码追加前缀即可,例如:
|
||||
# ,/backend/articles:cms,/backend/productsList:cms,/backend/ai:ai,/backend/mcp:ai
|
||||
plan_module_paths = /backend/crm:crm,/backend/erp:erp,/backend/oa:oa
|
||||
|
||||
# JWT 签发者(统一认证中心地址)
|
||||
jwt_issuer = https://api.yunzer.cn/auth
|
||||
# HS256 主密钥:留空则沿用内置默认密钥(兼容历史 token)
|
||||
|
||||
@@ -103,9 +103,11 @@ func (c *AppAuthController) GetCurrentUser() {
|
||||
return
|
||||
}
|
||||
|
||||
// 令牌 user_id 是认证中心 identity_id,需换算成本租户的老 uid 再查老表
|
||||
uid := services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
|
||||
var tenantUser models.SystemTenantUser
|
||||
err = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", claims.UserID).
|
||||
Filter("uid", uid).
|
||||
Filter("tid", claims.TenantId).
|
||||
One(&tenantUser)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,12 +2,14 @@ package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/pkg/passwordutil"
|
||||
"server/services"
|
||||
|
||||
@@ -28,6 +30,7 @@ type backendUserInfoDTO struct {
|
||||
Name *string `json:"name"`
|
||||
Phone *string `json:"phone"`
|
||||
Email *string `json:"email"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Sex uint8 `json:"sex"`
|
||||
Birth *string `json:"birth"`
|
||||
IsDefault int8 `json:"is_default"`
|
||||
@@ -94,6 +97,12 @@ func toBackendUserInfoDTO(u models.SystemTenantUser) backendUserInfoDTO {
|
||||
tenantCode = tenant.TenantCode
|
||||
}
|
||||
|
||||
// 头像取自认证中心身份(业务用户表 yz_system_tenant_user 不存头像)
|
||||
var avatar *string
|
||||
if a := services.GetIdentityAvatar(u.Uid); a != "" {
|
||||
avatar = &a
|
||||
}
|
||||
|
||||
return backendUserInfoDTO{
|
||||
ID: u.ID,
|
||||
Tid: u.Tid,
|
||||
@@ -103,6 +112,7 @@ func toBackendUserInfoDTO(u models.SystemTenantUser) backendUserInfoDTO {
|
||||
Name: u.Name,
|
||||
Phone: u.Phone,
|
||||
Email: u.Email,
|
||||
Avatar: avatar,
|
||||
Sex: u.Sex,
|
||||
Birth: formatBackendBirth(u.Birth),
|
||||
IsDefault: u.IsDefault,
|
||||
@@ -115,21 +125,43 @@ func toBackendUserInfoDTO(u models.SystemTenantUser) backendUserInfoDTO {
|
||||
}
|
||||
}
|
||||
|
||||
// getJWTUidTid 取当前登录者标识:
|
||||
// - uid:令牌里的 user_id,即认证中心 identity_id
|
||||
// - tid:令牌里的 tenant_id
|
||||
//
|
||||
// 键名历史上有两套写法(uid/tid 与 userId/tenantId),这里都兼容;
|
||||
// 中间件在 warn/off 模式下可能没有注入,故再兜底自行解析 Authorization 头,
|
||||
// 否则会静默拿到 0(曾导致按 identity_id 直查老表、显示到别人的资料)。
|
||||
func (c *BackendAdminUserController) getJWTUidTid() (uint64, uint64) {
|
||||
var uid uint64
|
||||
var tid uint64
|
||||
|
||||
data := c.Ctx.Input.Data()
|
||||
|
||||
if jwtUid := data["uid"]; jwtUid != nil {
|
||||
if v, ok := jwtUid.(uint64); ok {
|
||||
for _, key := range []string{"uid", "userId"} {
|
||||
if v, ok := data[key].(uint64); ok && v > 0 {
|
||||
uid = v
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"tid", "tenantId"} {
|
||||
if v, ok := data[key].(uint64); ok && v > 0 {
|
||||
tid = v
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if jwtTid := data["tid"]; jwtTid != nil {
|
||||
if v, ok := jwtTid.(uint64); ok {
|
||||
tid = v
|
||||
if uid == 0 || tid == 0 {
|
||||
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||
if parts := strings.SplitN(auth, " ", 2); len(parts) == 2 && parts[0] == "Bearer" {
|
||||
if claims, err := jwtutil.ParseToken(parts[1]); err == nil {
|
||||
if uid == 0 {
|
||||
uid = uint64(claims.UserID)
|
||||
}
|
||||
if tid == 0 {
|
||||
tid = uint64(claims.TenantId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,33 +181,44 @@ func (c *BackendAdminUserController) parseTenantUserPayload() (backendTenantUser
|
||||
return p, true
|
||||
}
|
||||
|
||||
// findBackendTenantUser 按「用户在认证中心的 identity_id」或「记录主键 id」查租户用户。
|
||||
//
|
||||
// 注意查询顺序:令牌/前端传来的用户标识是认证中心 identity_id,而
|
||||
// yz_system_tenant_user.uid 仍是老 uid(老表不在 uid 全量迁移范围内),
|
||||
// 因此必须先换算再按 uid 查;先按主键 id 查会把 identity_id 命中的是
|
||||
// 主键恰好相同的**别人**的记录(个人中心显示他人资料就是这么来的)。
|
||||
func findBackendTenantUser(idOrUid uint64, jwtTid uint64) (*models.SystemTenantUser, error) {
|
||||
var row models.SystemTenantUser
|
||||
if idOrUid == 0 {
|
||||
return nil, errors.New("用户ID不能为空")
|
||||
}
|
||||
|
||||
base := func() orm.QuerySeter {
|
||||
qs := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("delete_time__isnull", true)
|
||||
|
||||
if jwtTid > 0 {
|
||||
qs = qs.Filter("tid", jwtTid)
|
||||
}
|
||||
return qs
|
||||
}
|
||||
|
||||
err := qs.Filter("id", idOrUid).One(&row)
|
||||
if err == nil {
|
||||
// 1) 当作 identity_id:换算成本租户老 uid 后匹配
|
||||
var row models.SystemTenantUser
|
||||
if uid := services.ResolveTenantUID(jwtTid, idOrUid); uid > 0 {
|
||||
if err := base().Filter("uid", uid).One(&row); err == nil {
|
||||
return &row, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 当作记录主键 id(用户管理页传的就是 row.id)
|
||||
if err := base().Filter("id", idOrUid).One(&row); err == nil {
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
qs = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("delete_time__isnull", true)
|
||||
|
||||
if jwtTid > 0 {
|
||||
qs = qs.Filter("tid", jwtTid)
|
||||
}
|
||||
|
||||
err = qs.Filter("uid", idOrUid).One(&row)
|
||||
// 3) 兜底:本来就是老 uid 的场景
|
||||
err := base().Filter("uid", idOrUid).One(&row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
@@ -355,7 +398,15 @@ func (c *BackendAdminUserController) AddUser() {
|
||||
p.Password = &hashed
|
||||
|
||||
if p.Uid == 0 {
|
||||
uid, err := generateTenantUID(p.Tid)
|
||||
// 统一走 uid:优先用认证中心 identity_id,与已全量迁移的业务表口径一致
|
||||
uid, err := ensureTenantUIDForNewUser(
|
||||
p.Tid,
|
||||
strOrEmpty(p.Account),
|
||||
strOrEmpty(p.Name),
|
||||
strOrEmpty(p.Phone),
|
||||
strOrEmpty(p.Email),
|
||||
strOrEmpty(p.Password),
|
||||
)
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "生成用户ID失败"}
|
||||
_ = c.ServeJSON()
|
||||
|
||||
@@ -149,9 +149,12 @@ func (c *BackendAuthController) GetCurrentUser() {
|
||||
return
|
||||
}
|
||||
|
||||
// 令牌里的 user_id 是认证中心 identity_id,而老表 uid 不在 uid 迁移范围内,
|
||||
// 必须先换算成本租户的老 uid,否则会查不到(甚至按主键误命中他人记录)
|
||||
uid := services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
|
||||
var tenantUser models.SystemTenantUser
|
||||
err = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", claims.UserID).
|
||||
Filter("uid", uid).
|
||||
Filter("tid", claims.TenantId).
|
||||
One(&tenantUser)
|
||||
if err != nil {
|
||||
@@ -179,17 +182,24 @@ func (c *BackendAuthController) GetCurrentUser() {
|
||||
tenantName = strings.TrimSpace(tenant.TenantName)
|
||||
}
|
||||
|
||||
// 头像:统一取认证中心身份头像(个人中心上传后写在这里,userinfo 同源)
|
||||
avatar := services.GetIdentityAvatar(tenantUser.Uid)
|
||||
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"id": tenantUser.Uid,
|
||||
// id 统一为认证中心 identity_id,与 /auth/userinfo 保持一致,
|
||||
// 避免同一字段在不同来源(登录回跳 vs 本接口)含义不同
|
||||
"id": claims.UserID,
|
||||
// uid 为老表 yz_system_tenant_user.uid,仅少数按老 uid 存取的场景需要
|
||||
"uid": tenantUser.Uid,
|
||||
"account": account,
|
||||
"name": name,
|
||||
"tid": tenantUser.Tid,
|
||||
"tenant_name": tenantName,
|
||||
"rid": 0,
|
||||
"avatar": "",
|
||||
"avatar": avatar,
|
||||
"role_name": "",
|
||||
},
|
||||
})
|
||||
@@ -220,9 +230,11 @@ func (c *BackendAuthController) GetCurrentUserProfile() {
|
||||
return
|
||||
}
|
||||
|
||||
// 同上:令牌 user_id 是 identity_id,需换算成本租户的老 uid
|
||||
uid := services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
|
||||
var tenantUser models.SystemTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", claims.UserID).
|
||||
Filter("uid", uid).
|
||||
Filter("tid", claims.TenantId).
|
||||
One(&tenantUser); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": "用户不存在"})
|
||||
|
||||
@@ -298,6 +298,93 @@ func (c *BackendCrmBusinessController) Update() {
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID})
|
||||
}
|
||||
|
||||
// ChangeStage POST /backend/crm/business/:id/stage
|
||||
// 商机阶段流转:1初步接洽 2需求确认 3方案报价 4商务谈判 5赢单 6输单。
|
||||
// 仅更新阶段字段,不触碰其他业务字段;已转化并锁定的商机不允许再流转。
|
||||
func (c *BackendCrmBusinessController) ChangeStage() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p struct {
|
||||
Stage string `json:"stage"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
stage := strings.TrimSpace(p.Stage)
|
||||
if !validBusinessStage(stage) {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的商机阶段")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var biz models.TenantCrmBusiness
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&biz); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据")
|
||||
return
|
||||
}
|
||||
if biz.Locked == 1 || biz.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机已转化并锁定,不能流转阶段")
|
||||
return
|
||||
}
|
||||
// 阶段未变化:直接幂等返回,不写日志
|
||||
if strings.TrimSpace(biz.Stage) == stage {
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID, "stage": biz.Stage})
|
||||
return
|
||||
}
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Update(map[string]interface{}{"stage": stage, "update_time": time.Now()}); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "阶段更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(tenantID, 2, biz.ID, "status",
|
||||
"商机阶段:"+businessStageName(biz.Stage)+" → "+businessStageName(stage), claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID, "stage": stage})
|
||||
}
|
||||
|
||||
// businessStageName 商机阶段文案(用于操作日志):1初步接洽 … 5赢单 / 6输单
|
||||
func businessStageName(s string) string {
|
||||
switch strings.TrimSpace(s) {
|
||||
case "2":
|
||||
return "需求确认"
|
||||
case "3":
|
||||
return "方案报价"
|
||||
case "4":
|
||||
return "商务谈判"
|
||||
case "5":
|
||||
return "赢单"
|
||||
case "6":
|
||||
return "输单"
|
||||
default:
|
||||
return "初步接洽"
|
||||
}
|
||||
}
|
||||
|
||||
// validBusinessStage 商机阶段取值校验(1~6)
|
||||
func validBusinessStage(s string) bool {
|
||||
switch s {
|
||||
case "1", "2", "3", "4", "5", "6":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Delete DELETE /backend/crm/business/:id
|
||||
func (c *BackendCrmBusinessController) Delete() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
|
||||
@@ -92,9 +92,9 @@ func (c *BackendCrmClueController) List() {
|
||||
total, _ := qs.Count()
|
||||
var list []models.TenantCrmClue
|
||||
if total > 0 {
|
||||
// 列表排列:跟进中(1) → 已转化(2) → 已废弃(3),同状态下按新→旧。
|
||||
// 状态值本身即为期望的展示顺序,按 status 升序即可(排序在数据库侧完成,翻页后整体顺序一致)。
|
||||
_, _ = qs.OrderBy("status", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
// 列表排列:按 id 倒序,最新创建的线索排在最前。
|
||||
// 排序在数据库侧完成,翻页后整体顺序保持一致。
|
||||
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||
@@ -293,6 +293,66 @@ func (c *BackendCrmClueController) Update() {
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": clue.ID})
|
||||
}
|
||||
|
||||
// ChangeStatus POST /backend/crm/clue/:id/status
|
||||
// 线索状态流转:1=跟进中 / 3=已废弃。
|
||||
// 「已转化(2)」不开放本接口——必须通过 Convert 完成(会落地正式客户并锁定线索),
|
||||
// 否则会出现「状态已转化但客户未落地」的脏数据。
|
||||
func (c *BackendCrmClueController) ChangeStatus() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p struct {
|
||||
Status int8 `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if p.Status != 1 && p.Status != 3 {
|
||||
pipelineErr(&c.Controller, 400, 400, "仅支持流转为「跟进中 / 已废弃」,转为已转化请使用「转化商机」")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var clue models.TenantCrmClue
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmClue)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&clue); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "线索未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据")
|
||||
return
|
||||
}
|
||||
if clue.Locked == 1 || clue.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能流转状态")
|
||||
return
|
||||
}
|
||||
// 状态未变化:直接幂等返回,不写日志
|
||||
if clue.Status == p.Status {
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": clue.ID, "status": clue.Status})
|
||||
return
|
||||
}
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantCrmClue)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Update(map[string]interface{}{"status": p.Status, "update_time": time.Now()}); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "状态更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(tenantID, 1, clue.ID, "status",
|
||||
fmt.Sprintf("线索状态:%s → %s", clueStatusName(clue.Status), clueStatusName(p.Status)), claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": clue.ID, "status": p.Status})
|
||||
}
|
||||
|
||||
// clueStatusName 线索状态文案(用于操作日志):1跟进中 / 2已转化 / 3已废弃
|
||||
func clueStatusName(s int8) string {
|
||||
switch s {
|
||||
|
||||
@@ -121,8 +121,9 @@ func (c *BackendCrmContractController) List() {
|
||||
cond = cond.And("project_id", pid)
|
||||
}
|
||||
}
|
||||
// 周期筛选:与顶部统计同口径(合同有效期 [生效日期, 结束日期] 与所选周期重叠),
|
||||
// period=month/quarter/year 取当期区间,period=custom 用 start_date / end_date
|
||||
// 周期筛选:按「签订日期」归类(与顶部统计同口径)——签订日期落在所选周期内即命中。
|
||||
// period=month/quarter/year 取当期区间,period=custom 用 start_date / end_date;
|
||||
// 签订日期为空的合同(草稿 / 尚未填写)不受周期限制,避免默认「本年」时被隐藏。
|
||||
period := strings.TrimSpace(c.GetString("period"))
|
||||
rangeStart := strings.TrimSpace(c.GetString("start_date"))
|
||||
rangeEnd := strings.TrimSpace(c.GetString("end_date"))
|
||||
@@ -130,68 +131,22 @@ func (c *BackendCrmContractController) List() {
|
||||
rangeStart, rangeEnd = periodRange(period)
|
||||
}
|
||||
if rangeStart != "" {
|
||||
cond = cond.AndCond(orm.NewCondition().Or("expire_date__isnull", true).Or("expire_date__gte", rangeStart))
|
||||
cond = cond.AndCond(orm.NewCondition().Or("sign_date__isnull", true).Or("sign_date__gte", rangeStart))
|
||||
}
|
||||
if rangeEnd != "" {
|
||||
cond = cond.AndCond(orm.NewCondition().Or("effective_date__isnull", true).Or("effective_date__lte", rangeEnd))
|
||||
cond = cond.AndCond(orm.NewCondition().Or("sign_date__isnull", true).Or("sign_date__lte", rangeEnd))
|
||||
}
|
||||
qs := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(cond)
|
||||
|
||||
total, _ := qs.Count()
|
||||
var list []models.TenantCrmContract
|
||||
if total > 0 {
|
||||
offset := (page - 1) * pageSize
|
||||
if status != "" {
|
||||
// 已按状态筛选:结果只含单一状态,直接按新→旧分页
|
||||
if _, err := qs.OrderBy("-id").Offset(offset).Limit(pageSize).All(&list); err != nil {
|
||||
// 列表排列:按「签订日期」倒序,最新签约的合同排最前。
|
||||
// 同日签约多份时按 id 倒序,保证翻页顺序稳定;签订日期为空的合同(草稿 / 尚未签约)沉底。
|
||||
if _, err := qs.OrderBy("-sign_date", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 列表排列:按状态分组展示 —— 草稿(1) → 异常(5) → 履约中(4) → 已完成(2) → 已作废(3),
|
||||
// 其余状态(NULL / 历史异常值)沉底;组内仍按新→旧。
|
||||
// 先统计各组数量,把当前页窗口换算成各组区间,再分别取数按序拼接,保证翻页后整体顺序一致。
|
||||
groupConds := []*orm.Condition{
|
||||
orm.NewCondition().And("status", 1),
|
||||
orm.NewCondition().And("status", 5),
|
||||
orm.NewCondition().And("status", 4),
|
||||
orm.NewCondition().And("status", 2),
|
||||
orm.NewCondition().And("status", 3),
|
||||
orm.NewCondition().AndNot("status__in", []int8{1, 5, 4, 2, 3}).Or("status__isnull", true),
|
||||
}
|
||||
end := offset + pageSize
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
cursor := 0
|
||||
for _, gc := range groupConds {
|
||||
if cursor >= end {
|
||||
break
|
||||
}
|
||||
// 注意:beego 的 AndCond 会修改接收者,这里用新条件对象包装,避免循环内条件累积
|
||||
groupCond := orm.NewCondition().AndCond(cond).AndCond(gc)
|
||||
cnt, _ := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(groupCond).Count()
|
||||
groupTotal := int(cnt)
|
||||
if groupTotal == 0 {
|
||||
continue
|
||||
}
|
||||
groupStart := cursor
|
||||
groupEnd := cursor + groupTotal
|
||||
cursor = groupEnd
|
||||
// 当前页与本组区间的交集
|
||||
from, to := max(offset, groupStart), min(end, groupEnd)
|
||||
if to <= from {
|
||||
continue
|
||||
}
|
||||
var part []models.TenantCrmContract
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(groupCond).
|
||||
OrderBy("-id").Offset(from - groupStart).Limit(to - from).All(&part); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
list = append(list, part...)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 回款逾期日期(取最早,未收齐且已过计划回款日期):合同「逾期」标签与结束日期口径一并参考
|
||||
if len(list) > 0 {
|
||||
@@ -216,7 +171,7 @@ func (c *BackendCrmContractController) List() {
|
||||
// Stats GET /backend/crm/contract/stats
|
||||
// 全租户合同统计(排除已作废),供列表页顶部汇总卡片使用。
|
||||
// 支持按周期筛选:period=month/quarter/year(当期)或 custom(start_date~end_date),
|
||||
// 按合同有效期 [生效日期, 结束日期] 与所选周期重叠统计。
|
||||
// 按合同「签订日期」落在所选周期内统计(与列表口径一致)。
|
||||
// 「合同总利润」仅统计非甲方(收款方)合同:我方为甲方的合同是采购付款,只有支出、不存在利润。
|
||||
func (c *BackendCrmContractController) Stats() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
@@ -236,14 +191,14 @@ func (c *BackendCrmContractController) Stats() {
|
||||
// 我方不参与的合同(our_role=0,如上中游项目的链条合同)不属于我方经营合同,不计入经营统计
|
||||
where := "tenant_id = ? AND delete_time IS NULL AND status <> 3 AND our_role <> 0"
|
||||
args := []interface{}{tenantID}
|
||||
// 按合同有效期 [生效日期, 结束日期] 与所选周期 [start, end] 重叠来统计:
|
||||
// 合同生效日 <= 周期末 且 合同结束日 >= 周期初;日期为空视为不限制对应边界。
|
||||
// 按「签订日期」落在所选周期 [start, end] 内统计(与列表口径一致):
|
||||
// 签订日期为空的合同不受周期限制,视为不限制对应边界。
|
||||
if start != "" {
|
||||
where += " AND (expire_date IS NULL OR expire_date >= ?)"
|
||||
where += " AND (sign_date IS NULL OR sign_date >= ?)"
|
||||
args = append(args, start)
|
||||
}
|
||||
if end != "" {
|
||||
where += " AND (effective_date IS NULL OR effective_date <= ?)"
|
||||
where += " AND (sign_date IS NULL OR sign_date <= ?)"
|
||||
args = append(args, end)
|
||||
}
|
||||
// 「合同总利润」只汇总非甲方(收款方)合同:我方为甲方时是采购付款方,合同金额即支出,不存在利润
|
||||
@@ -268,7 +223,7 @@ func (c *BackendCrmContractController) Stats() {
|
||||
}
|
||||
|
||||
// periodRange 根据 period 返回当期区间 [start, end](YYYY-MM-DD):month/quarter/year。
|
||||
// 统计与列表均以该区间与合同有效期比较(有效期与周期重叠即命中)。
|
||||
// 统计与列表均以该区间与合同「签订日期」比较(签订日期落在区间内即命中)。
|
||||
func periodRange(period string) (string, string) {
|
||||
now := time.Now()
|
||||
loc := now.Location()
|
||||
|
||||
@@ -343,8 +343,12 @@ func (c *BackendCrmPaybackController) Update() {
|
||||
row.ContractAmount = round2(p.ContractAmount)
|
||||
}
|
||||
row.Remark = p.Remark
|
||||
// 计划级回执已下线(流水回执属于具体回款记录):仅在显式传入非空值时更新,
|
||||
// 避免前端不再提交该字段时把历史数据清空。
|
||||
if strings.TrimSpace(p.ReceiptURL) != "" {
|
||||
row.ReceiptURL = strings.TrimSpace(p.ReceiptURL)
|
||||
row.ReceiptName = strings.TrimSpace(p.ReceiptName)
|
||||
}
|
||||
if p.Status != 0 {
|
||||
row.Status = pickInt8(p.Status, 1, 2)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
@@ -45,8 +46,9 @@ func isTenantAdmin(userID int, tenantID int) bool {
|
||||
return false
|
||||
}
|
||||
var tu models.SystemTenantUser
|
||||
// 令牌 user_id 是 identity_id,查老表前先换算成本租户的老 uid
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", userID).
|
||||
Filter("uid", services.ResolveTenantUID(uint64(tenantID), uint64(userID))).
|
||||
Filter("tid", tenantID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&tu); err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -63,6 +64,8 @@ type projectPayload struct {
|
||||
// Amount 为旧「项目金额」,未传上游金额时回退使用(兼容未升级的调用方)
|
||||
Amount float64 `json:"amount"`
|
||||
Status int8 `json:"status"`
|
||||
// Reason 异常原因:状态改为「异常(5)」时必填,会写入操作日志便于追溯
|
||||
Reason string `json:"reason"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
// 上下游对接人:仅姓名 + 电话;Contact* 为旧字段,未传新字段时回退使用
|
||||
@@ -222,6 +225,8 @@ func (c *BackendCrmProjectController) List() {
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
status := strings.TrimSpace(c.GetString("status"))
|
||||
ownerID := strings.TrimSpace(c.GetString("owner_user_id"))
|
||||
// 年度筛选:按项目开始时间所在年份过滤(该时间由关联合同运行时汇总,见下方内存过滤)
|
||||
year := strings.TrimSpace(c.GetString("year"))
|
||||
|
||||
tenantID := pipelineTenantID(claims)
|
||||
|
||||
@@ -251,65 +256,79 @@ func (c *BackendCrmProjectController) List() {
|
||||
return models.Orm.QueryTable(new(models.TenantCrmProject)).SetCond(cond)
|
||||
}
|
||||
total, _ := projectQuery(buildCond()).Count()
|
||||
// 年度筛选项:按全部候选项目的开始时间汇总(不受当次年度筛选影响,保证下拉里始终能看到所有年份)
|
||||
years := []int{}
|
||||
var list []models.TenantCrmProject
|
||||
if total > 0 {
|
||||
offset := (page - 1) * pageSize
|
||||
if status != "" {
|
||||
// 已按状态筛选:结果只含单一状态,直接按新→旧分页
|
||||
if _, err := projectQuery(buildCond()).OrderBy("-id").Offset(offset).Limit(pageSize).All(&list); err != nil {
|
||||
// 排序与年度筛选都依赖「项目开始时间」:取该项目所有关联合同中最早的生效日期
|
||||
// (无生效日期取签订日期),未关联合同时回退旧的手工列 start_date。
|
||||
// 该值是运行时汇总的只读字段(不落库),无法交给 SQL,因此先取全量候选、汇总后在内存里筛选与排序。
|
||||
if _, err := projectQuery(buildCond()).All(&list); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 列表排列:按状态分组展示 —— 未开始(1) → 异常(5) → 进行中(2) → 已暂停(4) → 已完成(3),
|
||||
// 其余状态(NULL / 历史异常值)沉底;组内仍按新→旧。
|
||||
// 先统计各组数量,把当前页窗口换算成各组区间,再分别取数按序拼接,保证翻页后整体顺序一致。
|
||||
groupConds := []*orm.Condition{
|
||||
orm.NewCondition().And("status", 1), // 未开始
|
||||
orm.NewCondition().And("status", 5), // 异常
|
||||
orm.NewCondition().And("status", 2), // 进行中
|
||||
orm.NewCondition().And("status", 4), // 已暂停
|
||||
orm.NewCondition().And("status", 3), // 已完成
|
||||
orm.NewCondition().AndNot("status__in", []int8{1, 2, 3, 4, 5}).Or("status__isnull", true),
|
||||
attachProjectContractPeriod(tenantID, list)
|
||||
startOf := func(p *models.TenantCrmProject) time.Time {
|
||||
if p.ContractStartDate != nil {
|
||||
return *p.ContractStartDate
|
||||
}
|
||||
if p.StartDate != nil {
|
||||
return *p.StartDate
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
yearSet := make(map[int]struct{})
|
||||
for i := range list {
|
||||
if d := startOf(&list[i]); !d.IsZero() {
|
||||
yearSet[d.Year()] = struct{}{}
|
||||
}
|
||||
}
|
||||
years = make([]int, 0, len(yearSet))
|
||||
for y := range yearSet {
|
||||
years = append(years, y)
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(years)))
|
||||
|
||||
// 年度筛选:仅保留开始时间落在该年度的项目(无开始时间的项目不参与年度筛选)
|
||||
if year != "" {
|
||||
if want, err := strconv.Atoi(year); err == nil && want > 0 {
|
||||
kept := make([]models.TenantCrmProject, 0, len(list))
|
||||
for i := range list {
|
||||
if d := startOf(&list[i]); !d.IsZero() && d.Year() == want {
|
||||
kept = append(kept, list[i])
|
||||
}
|
||||
}
|
||||
list = kept
|
||||
total = int64(len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// 排列:按项目开始时间倒序,最新开始的项目排最前;
|
||||
// 无开始时间的项目沉底,时间相同按 id 倒序,保证翻页顺序稳定。
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
ti, tj := startOf(&list[i]), startOf(&list[j])
|
||||
if ti.IsZero() != tj.IsZero() {
|
||||
return !ti.IsZero()
|
||||
}
|
||||
if !ti.Equal(tj) {
|
||||
return ti.After(tj)
|
||||
}
|
||||
return list[i].ID > list[j].ID
|
||||
})
|
||||
offset := (page - 1) * pageSize
|
||||
if offset > len(list) {
|
||||
offset = len(list)
|
||||
}
|
||||
end := offset + pageSize
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
cursor := 0
|
||||
for _, gc := range groupConds {
|
||||
if cursor >= end {
|
||||
break
|
||||
}
|
||||
// 注意:beego 的 AndCond 会修改接收者,这里用新条件对象包装,避免循环内条件累积
|
||||
groupCond := orm.NewCondition().AndCond(buildCond()).AndCond(gc)
|
||||
cnt, _ := projectQuery(groupCond).Count()
|
||||
groupTotal := int(cnt)
|
||||
if groupTotal == 0 {
|
||||
continue
|
||||
}
|
||||
groupStart := cursor
|
||||
groupEnd := cursor + groupTotal
|
||||
cursor = groupEnd
|
||||
// 当前页与本组区间的交集
|
||||
from, to := max(offset, groupStart), min(end, groupEnd)
|
||||
if to <= from {
|
||||
continue
|
||||
}
|
||||
var part []models.TenantCrmProject
|
||||
if _, err := projectQuery(groupCond).OrderBy("-id").Offset(from - groupStart).Limit(to - from).All(&part); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
list = append(list, part...)
|
||||
}
|
||||
if end > len(list) {
|
||||
end = len(list)
|
||||
}
|
||||
list = list[offset:end]
|
||||
}
|
||||
attachBusinessName(tenantID, list)
|
||||
// 项目起止时间由关联合同汇总(不再依赖手工维护的开始 / 结束日期)
|
||||
attachProjectContractPeriod(tenantID, list)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||
"list": list, "total": total, "years": years,
|
||||
"page": page, "pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -529,6 +548,8 @@ func (c *BackendCrmProjectController) Update() {
|
||||
proj.DownstreamPhone = downPhone
|
||||
proj.ContactPerson = upContact
|
||||
proj.ContactPhone = upPhone
|
||||
// 状态变更(编辑表单亦可改状态):先记住原状态,供操作日志记录流转
|
||||
prevStatus := proj.Status
|
||||
if p.Status != 0 {
|
||||
proj.Status = p.Status
|
||||
}
|
||||
@@ -539,6 +560,14 @@ func (c *BackendCrmProjectController) Update() {
|
||||
proj.Products = p.Products
|
||||
proj.UpdateTime = time.Now()
|
||||
|
||||
// 状态改为「异常」时必须写明原因(与状态流转同一口径),否则日志无法追溯异常成因
|
||||
statusChanged := p.Status != 0 && p.Status != prevStatus
|
||||
reason := strings.TrimSpace(p.Reason)
|
||||
if p.Status == 5 && prevStatus != 5 && reason == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "请填写项目异常原因")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := models.Orm.Update(&proj); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
|
||||
return
|
||||
@@ -554,6 +583,14 @@ func (c *BackendCrmProjectController) Update() {
|
||||
if proj.DocCategoryID > 0 {
|
||||
_ = services.RenameCrmProjectDocCategory(claims.TenantId, proj.DocCategoryID, proj.ProjectName)
|
||||
}
|
||||
// 编辑表单里改了状态同样要留痕(含异常原因)
|
||||
if statusChanged {
|
||||
logText := "项目状态:" + projectStatusName(prevStatus) + " → " + projectStatusName(proj.Status)
|
||||
if proj.Status == 5 && reason != "" {
|
||||
logText += ";异常原因:" + reason
|
||||
}
|
||||
crmWriteLog(tenantID, 3, proj.ID, "status", logText, claims)
|
||||
}
|
||||
crmWriteLog(tenantID, 3, proj.ID, "update", "更新项目:"+proj.ProjectName, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID})
|
||||
}
|
||||
@@ -731,6 +768,7 @@ func (c *BackendCrmProjectController) ChangeStatus() {
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p struct {
|
||||
Status int8 `json:"status"`
|
||||
Reason string `json:"reason"` // 异常原因:status=5 时必填
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
@@ -740,6 +778,12 @@ func (c *BackendCrmProjectController) ChangeStatus() {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的状态值")
|
||||
return
|
||||
}
|
||||
// 置为「异常」时必须写明原因,日志里要能看出是什么导致的
|
||||
reason := strings.TrimSpace(p.Reason)
|
||||
if p.Status == 5 && reason == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "请填写项目异常原因")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var proj models.TenantCrmProject
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||
@@ -763,8 +807,11 @@ func (c *BackendCrmProjectController) ChangeStatus() {
|
||||
pipelineErr(&c.Controller, 500, 500, "状态更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(tenantID, 3, proj.ID, "status",
|
||||
"项目状态流转:"+projectStatusName(proj.Status)+" → "+projectStatusName(p.Status), claims)
|
||||
logText := "项目状态流转:" + projectStatusName(proj.Status) + " → " + projectStatusName(p.Status)
|
||||
if p.Status == 5 {
|
||||
logText += ";异常原因:" + reason
|
||||
}
|
||||
crmWriteLog(tenantID, 3, proj.ID, "status", logText, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID, "status": p.Status})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"server/services"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/beego/beego/v2/core/logs"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
@@ -908,16 +909,139 @@ func (c *BackendFileController) BatchDeleteFilesPermanently() {
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// UploadAvatar POST /backend/uploadavatar(占位)
|
||||
func (c *BackendFileController) UploadAvatar() {
|
||||
c.Data["json"] = map[string]interface{}{"code": 501, "msg": "上传头像暂未实现"}
|
||||
_ = c.ServeJSON()
|
||||
// avatarUploadMaxMB 头像大小上限:头像场景 2MB 足够
|
||||
const avatarUploadMaxMB = 2
|
||||
const avatarUploadMaxBytes = avatarUploadMaxMB * 1024 * 1024
|
||||
|
||||
// avatarAllowedExts 头像允许的图片格式
|
||||
var avatarAllowedExts = map[string]bool{
|
||||
"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true, "bmp": true,
|
||||
}
|
||||
|
||||
// UpdateAvatar POST /backend/uploadavatar/:id(占位)
|
||||
// UploadAvatar POST /backend/uploadavatar
|
||||
// 上传并设置「当前登录用户」头像:
|
||||
// 复用文件存储链路(Stage → 查重 → Commit → 落文件表),
|
||||
// 并把最终 URL 写入 yz_auth_identity.avatar 供认证中心 userinfo 下发。
|
||||
func (c *BackendFileController) UploadAvatar() {
|
||||
claims, err := c.backendClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
if err := c.Ctx.Request.ParseMultipartForm(avatarUploadMaxBytes); err != nil {
|
||||
c.jsonErr(400, 400, "解析上传失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fh, header, err := c.GetFile("file")
|
||||
if err != nil || fh == nil {
|
||||
c.jsonErr(400, 400, "请选择要上传的头像")
|
||||
return
|
||||
}
|
||||
defer fh.Close()
|
||||
|
||||
if header != nil && header.Size > avatarUploadMaxBytes {
|
||||
c.jsonErr(400, 400, fmt.Sprintf("头像大小不能超过%dMB", avatarUploadMaxMB))
|
||||
return
|
||||
}
|
||||
ext := strings.ToLower(services.FileExt(header.Filename))
|
||||
if !avatarAllowedExts[ext] {
|
||||
c.jsonErr(400, 400, "头像仅支持 jpg / png / gif / webp 格式")
|
||||
return
|
||||
}
|
||||
|
||||
storageService, err := services.GetStorageService()
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "获取存储服务失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
staged, err := storageService.Stage(fh, header)
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "读取上传文件失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
// 去重命中或出错时清理临时文件,避免产生垃圾
|
||||
if !committed && staged != nil {
|
||||
_ = storageService.Discard(staged)
|
||||
}
|
||||
}()
|
||||
|
||||
tid := c.effectiveTid(claims)
|
||||
// 头像固定归入「我的个人文件」目录:既不会混进租户共享文件列表,
|
||||
// 又能让本人在文件管理里找到自己上传过的头像。
|
||||
tuid := uint64(claims.UserID)
|
||||
upCtx := services.UploadContext{Source: services.SourceBackend, Tid: tid, Tuid: tuid}
|
||||
|
||||
// 同一张图重复上传:直接复用已有文件,不再占一份空间
|
||||
if exist, e := services.FindDuplicate(services.SourceBackend, upCtx.Scope(), tid, tuid, staged.MD5); e == nil && exist != nil {
|
||||
c.saveAvatarAndReply(claims.UserID, exist.Src, exist.Name)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := storageService.Commit(staged, upCtx)
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "上传头像失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
committed = true
|
||||
|
||||
// 文件台账落库失败不阻断头像设置(头像可用优先)
|
||||
if _, e := services.CreateFileRecord(services.FileUploadParams{
|
||||
Source: services.SourceBackend,
|
||||
Scope: upCtx.Scope(),
|
||||
Tid: tid,
|
||||
Tuid: tuid,
|
||||
Uid: uint64(claims.UserID),
|
||||
Name: header.Filename,
|
||||
Ext: ext,
|
||||
Size: uint64(result.Size),
|
||||
Src: result.URL,
|
||||
ObjectKey: result.Key,
|
||||
Storage: storageService.Type(),
|
||||
MD5: result.MD5,
|
||||
}); e != nil {
|
||||
logs.Warn("[avatar] 头像文件落库失败: %v", e)
|
||||
}
|
||||
|
||||
c.saveAvatarAndReply(claims.UserID, result.URL, header.Filename)
|
||||
}
|
||||
|
||||
// saveAvatarAndReply 写入身份头像并返回结果
|
||||
func (c *BackendFileController) saveAvatarAndReply(identityID int, url, name string) {
|
||||
if err := services.UpdateIdentityAvatar(uint64(identityID), url); err != nil {
|
||||
c.jsonErr(500, 500, "保存头像失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.jsonOK(map[string]interface{}{"url": url, "name": name})
|
||||
}
|
||||
|
||||
// UpdateAvatar POST /backend/uploadavatar/:id
|
||||
// 兼容旧调用:把指定文件的地址设为当前登录用户头像(不重新上传文件)。
|
||||
func (c *BackendFileController) UpdateAvatar() {
|
||||
c.Data["json"] = map[string]interface{}{"code": 501, "msg": "更新头像暂未实现"}
|
||||
_ = c.ServeJSON()
|
||||
claims, err := c.backendClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
idStr := c.Ctx.Input.Param(":id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.jsonErr(400, 400, "无效的文件ID")
|
||||
return
|
||||
}
|
||||
var f models.SystemFile
|
||||
if err := models.Orm.QueryTable(new(models.SystemFile)).
|
||||
Filter("id", id).
|
||||
Filter("tid", c.effectiveTid(claims)).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&f); err != nil {
|
||||
c.jsonErr(404, 404, "文件不存在")
|
||||
return
|
||||
}
|
||||
c.saveAvatarAndReply(claims.UserID, f.Src, f.Name)
|
||||
}
|
||||
|
||||
// BatchMoveFiles POST /backend/batchMoveFiles
|
||||
|
||||
@@ -447,6 +447,9 @@ func (c *PlatformProductController) CloseProductOrder() {
|
||||
c.jsonErr(500, 500, "关闭失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
if order.Tid > 0 {
|
||||
services.InvalidateTenantModuleCodes(order.Tid)
|
||||
}
|
||||
c.jsonOK(nil, "功能已关闭")
|
||||
}
|
||||
|
||||
@@ -482,5 +485,9 @@ func activateProductOrder(order *models.PlatformProductOrder, operator string) e
|
||||
fields = append(fields, "ExpireTime")
|
||||
}
|
||||
_, err := models.Orm.Update(order, fields...)
|
||||
if err == nil && order.Tid > 0 {
|
||||
// 开通后立即刷新模块权益缓存,避免「已购买却仍被拦」的窗口期
|
||||
services.InvalidateTenantModuleCodes(order.Tid)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -233,7 +233,14 @@ func (c *PlatformTenantUserController) CreateTenantUser() {
|
||||
}
|
||||
p.Password = &hashed
|
||||
if p.Uid == 0 {
|
||||
uid, err := generateTenantUID(p.Tid)
|
||||
uid, err := ensureTenantUIDForNewUser(
|
||||
p.Tid,
|
||||
strOrEmpty(p.Account),
|
||||
strOrEmpty(p.Name),
|
||||
strOrEmpty(p.Phone),
|
||||
strOrEmpty(p.Email),
|
||||
strOrEmpty(p.Password),
|
||||
)
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "生成租户用户ID失败"}
|
||||
_ = c.ServeJSON()
|
||||
@@ -417,6 +424,41 @@ func (c *PlatformTenantUserController) parsePayload() (tenantUserPayload, bool)
|
||||
return p, true
|
||||
}
|
||||
|
||||
// ensureTenantUIDForNewUser 新增租户用户时确定 uid。
|
||||
//
|
||||
// 统一走 uid:优先取认证中心 identity_id —— 业务表(文件/客户/合同/日程…)
|
||||
// 已在 uid 全量迁移中统一成 identity_id,老表 uid 与它一致,业务侧就无需任何换算。
|
||||
// 认证中心不可用时退回随机 uid(generateTenantUID),保证新增流程不中断。
|
||||
func ensureTenantUIDForNewUser(tid uint64, account, name, phone, email, passwordHash string) (uint64, error) {
|
||||
if tid == 0 {
|
||||
return 0, errors.New("tid 不能为空")
|
||||
}
|
||||
id, err := authsvc.SyncTenantUserAndGetIdentity(authsvc.SyncTenantUserInput{
|
||||
Tid: tid,
|
||||
Account: strings.TrimSpace(account),
|
||||
Name: strings.TrimSpace(name),
|
||||
Phone: strings.TrimSpace(phone),
|
||||
Email: strings.TrimSpace(email),
|
||||
PasswordHash: strings.TrimSpace(passwordHash),
|
||||
Status: 1,
|
||||
})
|
||||
if err == nil && id > 0 {
|
||||
return id, nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[auth] 预建身份失败,退回随机 uid: %v", err)
|
||||
}
|
||||
return generateTenantUID(tid)
|
||||
}
|
||||
|
||||
// strOrEmpty 取指针字符串的值,nil 时返回空串
|
||||
func strOrEmpty(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func generateTenantUID(tid uint64) (uint64, error) {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
for i := 0; i < 8; i++ {
|
||||
|
||||
@@ -183,6 +183,11 @@ func JWTAuthMiddleware() beego.FilterFunc {
|
||||
ctx.Input.SetData("userId", claims.UserID)
|
||||
ctx.Input.SetData("username", claims.Username)
|
||||
ctx.Input.SetData("tenantId", claims.TenantId)
|
||||
// 兼容历史键名:部分控制器(menu / admin_user)读的是 uid / tid,
|
||||
// 且按 uint64 断言,这里补写一份,避免它们静默拿到 0。
|
||||
// 值语义与令牌一致:uid 为认证中心 identity_id。
|
||||
ctx.Input.SetData("uid", uint64(claims.UserID))
|
||||
ctx.Input.SetData("tid", uint64(claims.TenantId))
|
||||
|
||||
// 从token中获取用户类型(如果token中没有,则默认为"user")
|
||||
userType := claims.UserType
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
"github.com/beego/beego/v2/server/web/context"
|
||||
)
|
||||
|
||||
// 模块套餐鉴权(PlanAuth)
|
||||
//
|
||||
// 把「接口前缀」映射到「功能模块编码」(yz_system_modules.code),
|
||||
// 当前租户生效套餐(含已开通的单品订单)未包含该模块时拒绝访问。
|
||||
//
|
||||
// 与 JWT 鉴权一致支持三档模式(app.conf: plan_enforce):
|
||||
// - off :完全不启用
|
||||
// - warn:观察模式,只记录「本会被拦截」的请求(默认,便于上线前核对映射)
|
||||
// - on :真正拦截,未开通返回 403
|
||||
//
|
||||
// 映射表(app.conf: plan_module_paths)示例:
|
||||
// plan_module_paths = /backend/crm:crm,/backend/erp:erp,/backend/oa:oa
|
||||
// 按最长前缀匹配;未出现在映射表中的接口不做模块校验(默认放行),
|
||||
// 因此可以按模块逐个灰度开启,不会误伤用户/角色/文件/日志等通用接口。
|
||||
|
||||
const planMappingSeparator = ":"
|
||||
|
||||
var (
|
||||
errPlanNoToken = errors.New("未提供认证信息")
|
||||
errPlanBadToken = errors.New("认证信息格式错误")
|
||||
|
||||
planOnce sync.Once
|
||||
planMode = AuthModeWarn
|
||||
planRoutes []planRoute
|
||||
)
|
||||
|
||||
// planRoute 接口前缀 → 模块编码
|
||||
type planRoute struct {
|
||||
prefix string
|
||||
module string
|
||||
}
|
||||
|
||||
func planInit() {
|
||||
planOnce.Do(func() {
|
||||
if v, err := beego.AppConfig.String("plan_enforce"); err == nil {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case AuthModeOff, AuthModeWarn, AuthModeOn:
|
||||
planMode = strings.ToLower(strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
|
||||
raw, _ := beego.AppConfig.String("plan_module_paths")
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(item, planMappingSeparator, 2)
|
||||
if len(parts) != 2 {
|
||||
log.Printf("[plan] 忽略无效映射(应为 前缀:模块编码): %s", item)
|
||||
continue
|
||||
}
|
||||
prefix := strings.TrimSpace(parts[0])
|
||||
module := services.NormalizeModuleCode(parts[1])
|
||||
if prefix == "" || module == "" {
|
||||
log.Printf("[plan] 忽略无效映射(前缀或模块编码为空): %s", item)
|
||||
continue
|
||||
}
|
||||
planRoutes = append(planRoutes, planRoute{prefix: prefix, module: module})
|
||||
}
|
||||
|
||||
// 最长前缀优先:/backend/crm/bidding 必须先于 /backend/crm 命中
|
||||
sort.Slice(planRoutes, func(i, j int) bool {
|
||||
return len(planRoutes[i].prefix) > len(planRoutes[j].prefix)
|
||||
})
|
||||
log.Printf("[plan] 模块套餐鉴权模式=%s,接口映射=%d 条", planMode, len(planRoutes))
|
||||
})
|
||||
}
|
||||
|
||||
// planModuleOf 返回路径归属的模块编码;未配置归属时返回空字符串
|
||||
func planModuleOf(path string) string {
|
||||
for _, r := range planRoutes {
|
||||
if strings.HasPrefix(path, r.prefix) {
|
||||
return r.module
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PlanAuthMiddleware 模块套餐鉴权中间件
|
||||
func PlanAuthMiddleware() beego.FilterFunc {
|
||||
return func(ctx *context.Context) {
|
||||
planInit()
|
||||
if planMode == AuthModeOff || len(planRoutes) == 0 {
|
||||
return
|
||||
}
|
||||
if ctx.Input.Method() == http.MethodOptions {
|
||||
return
|
||||
}
|
||||
|
||||
path := cleanPath(ctx.Request.RequestURI)
|
||||
if !isProtected(path) || isWhitelisted(path) {
|
||||
return
|
||||
}
|
||||
|
||||
module := planModuleOf(path)
|
||||
if module == "" {
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := planClaims(ctx)
|
||||
if err != nil {
|
||||
// 未登录 / 令牌无效:交给 JWTAuthMiddleware 与各控制器按原逻辑处理
|
||||
return
|
||||
}
|
||||
if claims.TenantId <= 0 {
|
||||
return
|
||||
}
|
||||
if services.IsModuleEnabled(uint64(claims.TenantId), module) {
|
||||
return
|
||||
}
|
||||
|
||||
reason := "模块未开通: " + module
|
||||
if planMode == AuthModeWarn {
|
||||
warnLog(path, reason)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Output.SetStatus(http.StatusForbidden)
|
||||
_ = ctx.Output.JSON(map[string]interface{}{
|
||||
"code": 403,
|
||||
"msg": "该功能未开通,请先购买对应套餐",
|
||||
}, false, false)
|
||||
}
|
||||
}
|
||||
|
||||
// planClaims 解析 Authorization 头中的 JWT(仅用于本中间件的模块归属判断)
|
||||
func planClaims(ctx *context.Context) (*jwtutil.Claims, error) {
|
||||
authHeader := ctx.Request.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return nil, errPlanNoToken
|
||||
}
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return nil, errPlanBadToken
|
||||
}
|
||||
return jwtutil.ParseToken(parts[1])
|
||||
}
|
||||
@@ -422,12 +422,16 @@ func registerOrganizationRoutes(module string) {
|
||||
beego.Router("/backend/crm/clue", &controllers.BackendCrmClueController{}, "post:Create")
|
||||
beego.Router("/backend/crm/clue/:id", &controllers.BackendCrmClueController{}, "get:Detail;put:Update;delete:Delete")
|
||||
beego.Router("/backend/crm/clue/:id/convert", &controllers.BackendCrmClueController{}, "post:Convert")
|
||||
// 线索状态流转:1跟进中 / 3已废弃(「已转化」须走「转化商机」流程)
|
||||
beego.Router("/backend/crm/clue/:id/status", &controllers.BackendCrmClueController{}, "post:ChangeStatus")
|
||||
|
||||
// CRM商机管理(商机 -> 项目转化)
|
||||
beego.Router("/backend/crm/business/list", &controllers.BackendCrmBusinessController{}, "get:List")
|
||||
beego.Router("/backend/crm/business", &controllers.BackendCrmBusinessController{}, "post:Create")
|
||||
beego.Router("/backend/crm/business/:id", &controllers.BackendCrmBusinessController{}, "get:Detail;put:Update;delete:Delete")
|
||||
beego.Router("/backend/crm/business/:id/convert", &controllers.BackendCrmBusinessController{}, "post:Convert")
|
||||
// 商机阶段流转:1初步接洽 2需求确认 3方案报价 4商务谈判 5赢单 6输单
|
||||
beego.Router("/backend/crm/business/:id/stage", &controllers.BackendCrmBusinessController{}, "post:ChangeStage")
|
||||
|
||||
// CRM项目管理
|
||||
beego.Router("/backend/crm/project/list", &controllers.BackendCrmProjectController{}, "get:List")
|
||||
|
||||
@@ -40,6 +40,10 @@ func init() {
|
||||
// 放在操作日志之前注册,保证操作日志能取到 userId。
|
||||
beego.InsertFilter("*", beego.BeforeRouter, middleware.JWTAuthMiddleware())
|
||||
|
||||
// 模块套餐鉴权:租户当前套餐(含单品订单)未开通的模块,其接口直接拒绝。
|
||||
// 映射与开关见 app.conf 的 plan_module_paths / plan_enforce(默认 warn 只记日志)。
|
||||
beego.InsertFilter("*", beego.BeforeRouter, middleware.PlanAuthMiddleware())
|
||||
|
||||
// 全局操作日志:请求开始采集并异步写入
|
||||
beego.InsertFilter("*", beego.BeforeRouter, middleware.BeginOperationLog)
|
||||
|
||||
|
||||
@@ -32,11 +32,24 @@ type SyncTenantUserInput struct {
|
||||
// 注意:密码属于身份层(一人一份),因此在任一企业修改密码,
|
||||
// 该用户在其他企业的登录密码会同步变化——这是统一认证的预期行为。
|
||||
func SyncTenantUser(in SyncTenantUserInput) error {
|
||||
identity, err := findOrCreateIdentity(in)
|
||||
if err != nil {
|
||||
_, err := SyncTenantUserAndGetIdentity(in)
|
||||
return err
|
||||
}
|
||||
return upsertTenantBinding(identity, in)
|
||||
|
||||
// SyncTenantUserAndGetIdentity 同 SyncTenantUser,但额外返回认证中心身份ID。
|
||||
//
|
||||
// 用途:新增租户用户时先把身份建出来,再用 identity_id 作为
|
||||
// yz_system_tenant_user.uid——业务表(文件/客户/合同…)已在 uid 全量迁移中
|
||||
// 统一成 identity_id,老表 uid 只有与它一致,业务侧才不需要任何换算。
|
||||
func SyncTenantUserAndGetIdentity(in SyncTenantUserInput) (uint64, error) {
|
||||
identity, err := findOrCreateIdentity(in)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := upsertTenantBinding(identity, in); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return identity.ID, nil
|
||||
}
|
||||
|
||||
// RemoveTenantUser 删除该用户在指定企业的绑定(保留身份本身,
|
||||
|
||||
@@ -61,9 +61,10 @@ func NewOaDocActor(tid int, uid uint64) OaDocActor {
|
||||
actor := OaDocActor{Tid: tid, UID: uid, OrgIDs: []uint64{}}
|
||||
|
||||
var user models.SystemTenantUser
|
||||
// uid 来自令牌(identity_id),查老表前先换算成老 uid
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("uid", uid).
|
||||
Filter("uid", ResolveTenantUID(uint64(tid), uid)).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&user); err != nil {
|
||||
// 查不到归属信息时退化为"普通用户",仅能看公开文档
|
||||
@@ -1472,7 +1473,7 @@ func oaDocShareTargetName(tid int, shareType int8, targetID uint64) string {
|
||||
}
|
||||
var u models.SystemTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tid).Filter("uid", targetID).Filter("delete_time__isnull", true).One(&u); err == nil {
|
||||
Filter("tid", tid).Filter("uid", ResolveTenantUID(uint64(tid), targetID)).Filter("delete_time__isnull", true).One(&u); err == nil {
|
||||
if u.Name != nil {
|
||||
return *u.Name
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@ func OperationLogUser(tid *uint64, userID uint64) (account, name string) {
|
||||
|
||||
if tid != nil && *tid != 0 {
|
||||
var user models.SystemTenantUser
|
||||
// userID 是令牌里的 identity_id,查老表前先换算成老 uid
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", *tid).
|
||||
Filter("uid", userID).
|
||||
Filter("uid", ResolveTenantUID(*tid, userID)).
|
||||
One(&user); err == nil {
|
||||
if user.Account != nil {
|
||||
account = *user.Account
|
||||
|
||||
@@ -153,6 +153,59 @@ func paybackPlanOverdueFor(plan models.TenantCrmPayback, today string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// crmProjectStatusText 项目状态文案(与控制器层 projectStatusName 保持一致,用于操作日志)。
|
||||
func crmProjectStatusText(s int8) string {
|
||||
switch s {
|
||||
case 1:
|
||||
return "未开始"
|
||||
case 2:
|
||||
return "进行中"
|
||||
case 3:
|
||||
return "已完成"
|
||||
case 4:
|
||||
return "已暂停"
|
||||
case 5:
|
||||
return "异常"
|
||||
}
|
||||
return "未知"
|
||||
}
|
||||
|
||||
// crmContractStatusText 合同状态文案(与控制器层 contractStatusName 保持一致,用于操作日志)。
|
||||
func crmContractStatusText(s int8) string {
|
||||
switch s {
|
||||
case 1:
|
||||
return "草稿"
|
||||
case 2:
|
||||
return "已完成"
|
||||
case 3:
|
||||
return "已作废"
|
||||
case 4:
|
||||
return "履约中"
|
||||
case 5:
|
||||
return "异常"
|
||||
}
|
||||
return "未知"
|
||||
}
|
||||
|
||||
// writeOverdueStatusLog 逾期联动自动改状态时写入 CRM 操作日志。
|
||||
// 自动置「异常」与人工流转同一口径:日志里必须写明原因,操作者记为「系统自动」,便于事后追溯。
|
||||
// relatedType=3 与控制器层一致(项目 / 合同的操作日志同属该分类)。
|
||||
func writeOverdueStatusLog(tenantID string, relatedID uint64, content string, now time.Time) {
|
||||
if models.Orm == nil || relatedID == 0 {
|
||||
return
|
||||
}
|
||||
log := models.TenantCrmOperateLog{
|
||||
TenantID: tenantID,
|
||||
RelatedType: 3,
|
||||
RelatedID: relatedID,
|
||||
Action: "status",
|
||||
Content: content,
|
||||
OperatorName: "系统自动",
|
||||
CreateTime: now,
|
||||
}
|
||||
_, _ = models.Orm.Insert(&log)
|
||||
}
|
||||
|
||||
// applyContractOverdue 合同置「异常」(5),并联动项目置「异常」(5)。
|
||||
// 返回 (合同是否更新, 项目是否更新)。
|
||||
func applyContractOverdue(tenantID string, contractID uint64, now time.Time) (int, int) {
|
||||
@@ -174,6 +227,9 @@ func applyContractOverdue(tenantID string, contractID uint64, now time.Time) (in
|
||||
return 0, 0
|
||||
}
|
||||
contractUpdated = 1
|
||||
// 自动改异常同样写明原因,否则日志里只看到"变异常"却不知为何
|
||||
writeOverdueStatusLog(tenantID, contractID,
|
||||
"合同状态自动流转:"+crmContractStatusText(contract.Status)+" → 异常;异常原因:回款计划逾期未收齐", now)
|
||||
}
|
||||
// 合同关联项目联动置异常
|
||||
projectUpdated := 0
|
||||
@@ -186,6 +242,9 @@ func applyContractOverdue(tenantID string, contractID uint64, now time.Time) (in
|
||||
Filter("id", proj.ID).Filter("tenant_id", tenantID).
|
||||
Update(map[string]interface{}{"status": int8(5), "update_time": now}); err == nil {
|
||||
projectUpdated = 1
|
||||
writeOverdueStatusLog(tenantID, proj.ID,
|
||||
"项目状态自动流转:"+crmProjectStatusText(proj.Status)+" → 异常;异常原因:关联合同「"+
|
||||
contract.ContractName+"」回款计划逾期未收齐", now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
@@ -151,6 +152,75 @@ func NormalizeModuleCode(code string) string {
|
||||
return strings.ToLower(strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 模块权益鉴权
|
||||
|
||||
// moduleCodesCacheTTL 模块权益缓存时长。
|
||||
// 模块鉴权中间件会在每个业务请求上判断开通状态,直查数据库代价太高;
|
||||
// 60 秒延迟对「购买后立即可用」没有实际影响(前端刷新即可)。
|
||||
const moduleCodesCacheTTL = 60 * time.Second
|
||||
|
||||
type tenantModuleCodesItem struct {
|
||||
codes map[string]bool
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
tenantModuleCodesMu sync.RWMutex
|
||||
tenantModuleCodesCache = map[uint64]tenantModuleCodesItem{}
|
||||
)
|
||||
|
||||
// GetTenantModuleCodesCached 带进程内缓存的模块权益查询(供请求级鉴权使用)
|
||||
func GetTenantModuleCodesCached(tid uint64) map[string]bool {
|
||||
now := time.Now()
|
||||
tenantModuleCodesMu.RLock()
|
||||
item, ok := tenantModuleCodesCache[tid]
|
||||
tenantModuleCodesMu.RUnlock()
|
||||
if ok && now.Before(item.expireAt) {
|
||||
return item.codes
|
||||
}
|
||||
|
||||
codes := GetTenantModuleCodes(tid)
|
||||
|
||||
tenantModuleCodesMu.Lock()
|
||||
// 顺带清理过期项,避免长期运行后内存堆积
|
||||
if len(tenantModuleCodesCache) > 5000 {
|
||||
for k, v := range tenantModuleCodesCache {
|
||||
if now.After(v.expireAt) {
|
||||
delete(tenantModuleCodesCache, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
tenantModuleCodesCache[tid] = tenantModuleCodesItem{codes: codes, expireAt: now.Add(moduleCodesCacheTTL)}
|
||||
tenantModuleCodesMu.Unlock()
|
||||
return codes
|
||||
}
|
||||
|
||||
// InvalidateTenantModuleCodes 清除某租户的模块权益缓存(套餐 / 订单变更后调用)
|
||||
func InvalidateTenantModuleCodes(tid uint64) {
|
||||
if tid == 0 {
|
||||
return
|
||||
}
|
||||
tenantModuleCodesMu.Lock()
|
||||
delete(tenantModuleCodesCache, tid)
|
||||
tenantModuleCodesMu.Unlock()
|
||||
}
|
||||
|
||||
// IsModuleEnabled 判断租户是否已开通指定功能模块。
|
||||
//
|
||||
// 口径与 /backend/modules/getTenantList 保持一致:租户没有任何模块权益时视为「全开」,
|
||||
// 避免历史租户或未配置套餐的环境被一刀切拦截。
|
||||
func IsModuleEnabled(tid uint64, moduleCode string) bool {
|
||||
code := NormalizeModuleCode(moduleCode)
|
||||
if code == "" || tid == 0 {
|
||||
return true
|
||||
}
|
||||
codes := GetTenantModuleCodesCached(tid)
|
||||
if len(codes) == 0 {
|
||||
return true
|
||||
}
|
||||
return codes[code]
|
||||
}
|
||||
|
||||
// DayStart 取时间所在自然日的 0 点(有效期按「日」计算,不精确到秒)
|
||||
func DayStart(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
@@ -277,9 +347,10 @@ func CheckTenantUserQuota(tid, uid uint64) (*TenantQuotaCheck, error) {
|
||||
result.Quota = quota
|
||||
|
||||
if uid > 0 {
|
||||
// uid 可能来自令牌(identity_id),查老表前先换算
|
||||
exist, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("uid", uid).
|
||||
Filter("uid", ResolveTenantUID(tid, uid)).
|
||||
Filter("delete_time__isnull", true).
|
||||
Count()
|
||||
if err == nil && exist > 0 {
|
||||
@@ -442,6 +513,8 @@ func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) {
|
||||
Update(update); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 套餐变更后清掉模块权益缓存,避免「刚换套餐仍被拦/仍在放行」的延迟
|
||||
InvalidateTenantModuleCodes(tid)
|
||||
return newQuota, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,26 @@ import (
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
authsvc "server/services/auth"
|
||||
)
|
||||
|
||||
// ResolveTenantUID 把「令牌中的 user_id(认证中心 identity_id)」换算成
|
||||
// 老表 yz_system_tenant_user.uid。
|
||||
//
|
||||
// 背景:uid 全量迁移脚本(scripts/uidmigrate)刻意跳过 yz_system_tenant_user
|
||||
// (见其 skipTables),业务表列迁成 identity_id,而老表 uid 仍是老值。
|
||||
// 因此凡是用 identity_id 去查老表的场景都必须先经过这里换算;
|
||||
// 两者已经对齐的环境(或传入的本来就是老 uid)原值返回。
|
||||
func ResolveTenantUID(tid, userID uint64) uint64 {
|
||||
if tid == 0 || userID == 0 {
|
||||
return 0
|
||||
}
|
||||
if legacy := authsvc.FindLegacyUID(tid, userID); legacy > 0 {
|
||||
return legacy
|
||||
}
|
||||
return userID
|
||||
}
|
||||
|
||||
// BindTenantUser 绑定用户到租户(若已存在则更新状态/默认值)
|
||||
func BindTenantUser(tid, uid uint64, account, name, phone, email *string, sex *uint8, birth *string, password *string, isDefault, status int8, remark *string) (uint64, error) {
|
||||
var existed models.SystemTenantUser
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// 用户头像统一存在认证中心身份表 yz_auth_identity.avatar:
|
||||
// /auth/userinfo 会返回该字段,前端登录回跳、顶部栏、个人中心都从这里取,
|
||||
// 业务侧(yz_system_tenant_user)不再另存一份,避免两处不一致。
|
||||
|
||||
// UpdateIdentityAvatar 更新身份头像
|
||||
func UpdateIdentityAvatar(identityID uint64, avatarURL string) error {
|
||||
if identityID == 0 {
|
||||
return errors.New("身份ID不能为空")
|
||||
}
|
||||
url := strings.TrimSpace(avatarURL)
|
||||
if url == "" {
|
||||
return errors.New("头像地址不能为空")
|
||||
}
|
||||
_, err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||
Filter("id", identityID).
|
||||
Update(map[string]interface{}{
|
||||
"avatar": url,
|
||||
"update_time": time.Now(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// GetIdentityAvatar 读取身份头像;未设置或身份不存在时返回空字符串
|
||||
func GetIdentityAvatar(identityID uint64) string {
|
||||
if identityID == 0 {
|
||||
return ""
|
||||
}
|
||||
var identity models.AuthIdentity
|
||||
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||
Filter("id", identityID).
|
||||
One(&identity); err != nil || identity.Avatar == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*identity.Avatar)
|
||||
}
|
||||
Reference in New Issue
Block a user