weizong song 6 months ago
parent 8fc7bc98fd
commit e1ce620541

@ -6,4 +6,4 @@ VUE_APP_BASE_API=''
VUE_APP_UPLOAD_API='/api/upload-file'
VUE_APP_PREVIEW=//view.langye.net/preview/onlinePreview
VUE_APP_MODULE_NAME=oa
VUE_APP_OUTPUT_DIR='/Users/mac/Documents/朗业/2025/c-常州环境监测中心内控/cz_hjjc/public/oa'
VUE_APP_OUTPUT_DIR='../backend/public/oa/'

4
.gitignore vendored

@ -14,3 +14,7 @@ tests/**/coverage/
*.ntvs*
*.njsproj
*.sln
.env.development
.env.production
.env.staging

@ -0,0 +1,113 @@
#!/bin/bash
# OA模块重新构建和部署脚本
# 用于解决资源文件404错误
set -e
echo "=========================================="
echo "OA模块重新构建和部署脚本"
echo "=========================================="
# 获取项目根目录
PROJECT_ROOT="/Users/weizongsong/www/cz_hjjc"
OA_DIR="$PROJECT_ROOT/cz-hjjc-oa"
OUTPUT_DIR="$PROJECT_ROOT/backend/public/oa"
# 进入OA项目目录
cd "$OA_DIR"
echo ""
echo "步骤1: 清理旧的构建文件..."
if [ -d "dist" ]; then
rm -rf dist
echo "✓ 已清理 dist 目录"
else
echo "✓ dist 目录不存在,跳过清理"
fi
echo ""
echo "步骤2: 设置环境变量并构建..."
export VUE_APP_MODULE_NAME=oa
export VUE_APP_BASE_API=/api
echo "环境变量:"
echo " VUE_APP_MODULE_NAME=$VUE_APP_MODULE_NAME"
echo " VUE_APP_BASE_API=$VUE_APP_BASE_API"
echo ""
echo "开始构建(这可能需要几分钟)..."
npm run build:prod
if [ ! -d "dist" ]; then
echo "❌ 构建失败dist 目录不存在"
exit 1
fi
echo "✓ 构建完成"
echo ""
echo "步骤3: 检查构建输出..."
echo "CSS文件:"
ls -1 dist/static/css/chunk-libs.* 2>/dev/null || echo " ⚠️ 未找到 chunk-libs.css"
echo "JS文件:"
ls -1 dist/static/js/chunk-libs.* 2>/dev/null || echo " ⚠️ 未找到 chunk-libs.js"
ls -1 dist/static/js/app.* 2>/dev/null || echo " ⚠️ 未找到 app.js"
echo ""
echo "步骤4: 备份旧文件(如果存在)..."
if [ -d "$OUTPUT_DIR" ]; then
BACKUP_DIR="${OUTPUT_DIR}.backup.$(date +%Y%m%d_%H%M%S)"
echo " 备份到: $BACKUP_DIR"
cp -r "$OUTPUT_DIR" "$BACKUP_DIR"
echo "✓ 备份完成"
else
echo "✓ 输出目录不存在,无需备份"
fi
echo ""
echo "步骤5: 部署到服务器..."
mkdir -p "$OUTPUT_DIR"
cp -r dist/* "$OUTPUT_DIR"
echo "✓ 部署完成到: $OUTPUT_DIR"
echo ""
echo "步骤6: 验证部署..."
if [ -f "$OUTPUT_DIR/index.html" ]; then
echo "✓ index.html 存在"
# 检查HTML中引用的文件
echo ""
echo "HTML中引用的资源文件:"
grep -oE 'chunk-libs\.[^"]*' "$OUTPUT_DIR/index.html" | head -2 | while read file; do
if [ -f "$OUTPUT_DIR/static/css/$file" ] || [ -f "$OUTPUT_DIR/static/js/$file" ]; then
echo "$file 存在"
else
echo "$file 不存在"
fi
done
else
echo "❌ index.html 不存在"
exit 1
fi
echo ""
echo "=========================================="
echo "✅ 构建和部署完成!"
echo "=========================================="
echo ""
echo "下一步操作:"
echo "1. 清除浏览器缓存Ctrl+Shift+Delete 或 Cmd+Shift+Delete"
echo "2. 强制刷新页面Ctrl+F5 或 Cmd+Shift+R"
echo "3. 访问: http://192.167.20.118:8080/oa/"
echo ""
echo "如果问题仍然存在,请检查:"
echo "- Web服务器Nginx/Apache的缓存配置"
echo "- 服务器上的文件权限"
echo "- 服务器日志中的错误信息"
echo ""

@ -320,3 +320,22 @@ export function createContract(data, isLoading = false) {
isLoading
})
}
// 获取可关联的流程列表
export function getRelationOptions(params, isLoading = false) {
return request({
method: 'get',
url: '/api/oa/flow/relation-options',
params,
isLoading
})
}
// 获取流程的关联列表
export function getRelationList(flowId, isLoading = false) {
return request({
method: 'get',
url: `/api/oa/flow/relation-list/${flowId}`,
isLoading
})
}

@ -7,12 +7,19 @@
<span v-if="hasValue && displayText" class="budget-source-label" :title="displayText">
{{ displayText }}
</span>
<span
v-else-if="hasValue && budgetDataId"
class="budget-source-label budget-source-label--muted"
:title="`预算ID: ${budgetDataId}`"
>
预算ID: {{ budgetDataId }}
</span>
<span
v-else-if="hasValue"
class="budget-source-label budget-source-label--muted"
:title="normalizedValue"
>
ID: {{ normalizedValue }}
{{ normalizedValue }}
</span>
<el-button type="primary" size="small" @click="openDialog"></el-button>
@ -21,7 +28,7 @@
<el-dialog
title="选择预算来源"
:visible.sync="dialogVisible"
width="720px"
:width="pendingAuthorizations.length > 1 ? '1000px' : '720px'"
:close-on-click-modal="false"
append-to-body
@open="onDialogOpen"
@ -45,7 +52,9 @@
</el-select>
</div>
<div v-loading="treeLoading" style="max-height: 420px; overflow: auto">
<div style="display: flex; gap: 16px">
<!-- 左侧预算树 -->
<div v-loading="treeLoading" style="flex: 1; max-height: 420px; overflow: auto; min-width: 0">
<div v-if="departmentTree.length" class="tree-block">
<div class="tree-title">部门预算</div>
<el-tree
@ -57,7 +66,30 @@
:expand-on-click-node="false"
:current-node-key="pendingId"
@node-click="onNodeClick"
/>
>
<span class="custom-tree-node" slot-scope="{ node, data }">
<span class="node-label">{{ node.label }}</span>
<span v-if="data.is_leaf" class="node-usage-info">
<span class="usage-item">
<span class="usage-label">预算:</span>
<span class="usage-value">{{ formatAmount(data.budget_amount) }}</span>
</span>
<span class="usage-item">
<span class="usage-label">已用:</span>
<span class="usage-value usage-value--used">{{ formatAmount(data.used_amount || 0) }}</span>
</span>
<span class="usage-item">
<span class="usage-label">执行率:</span>
<span
class="usage-value usage-value--rate"
:class="getExecutionRateClass(data.execution_rate || 0)"
>
{{ formatExecutionRate(data.execution_rate || 0) }}
</span>
</span>
</span>
</span>
</el-tree>
</div>
<div v-if="projectTree.length" class="tree-block" style="margin-top: 16px">
@ -71,24 +103,137 @@
:expand-on-click-node="false"
:current-node-key="pendingId"
@node-click="onNodeClick"
/>
>
<span class="custom-tree-node" slot-scope="{ node, data }">
<span class="node-label">{{ node.label }}</span>
<span v-if="data.is_leaf" class="node-usage-info">
<span class="usage-item">
<span class="usage-label">预算:</span>
<span class="usage-value">{{ formatAmount(data.budget_amount) }}</span>
</span>
<span class="usage-item">
<span class="usage-label">已用:</span>
<span class="usage-value usage-value--used">{{ formatAmount(data.used_amount || 0) }}</span>
</span>
<span class="usage-item">
<span class="usage-label">执行率:</span>
<span
class="usage-value usage-value--rate"
:class="getExecutionRateClass(data.execution_rate || 0)"
>
{{ formatExecutionRate(data.execution_rate || 0) }}
</span>
</span>
</span>
</span>
</el-tree>
</div>
<div v-if="specialFundTree.length" class="tree-block" style="margin-top: 16px">
<div class="tree-title">专项资金</div>
<el-tree
:data="specialFundTree"
node-key="id"
:props="treeProps"
highlight-current
default-expand-all
:expand-on-click-node="false"
:current-node-key="pendingId"
@node-click="onNodeClick"
>
<span class="custom-tree-node" slot-scope="{ node, data }">
<span class="node-label">{{ node.label }}</span>
<span v-if="data.is_leaf" class="node-usage-info">
<span class="usage-item">
<span class="usage-label">预算:</span>
<span class="usage-value">{{ formatAmount(data.budget_amount) }}</span>
</span>
<span class="usage-item">
<span class="usage-label">已用:</span>
<span class="usage-value usage-value--used">{{ formatAmount(data.used_amount || 0) }}</span>
</span>
<span class="usage-item">
<span class="usage-label">执行率:</span>
<span
class="usage-value usage-value--rate"
:class="getExecutionRateClass(data.execution_rate || 0)"
>
{{ formatExecutionRate(data.execution_rate || 0) }}
</span>
</span>
</span>
</span>
</el-tree>
</div>
<div
v-if="!treeLoading && departmentTree.length === 0 && projectTree.length === 0"
v-if="!treeLoading && departmentTree.length === 0 && projectTree.length === 0 && specialFundTree.length === 0"
style="text-align: center; color: #909399; padding: 32px 0"
>
暂无数据
</div>
</div>
<!-- 右侧部门分配界面仅多部门时显示 -->
<div v-if="pendingAuthorizations.length > 1" style="flex: 0 0 400px; border-left: 1px solid #e4e7ed; padding-left: 16px">
<div style="margin-bottom: 12px; font-weight: 500; color: #303133">
部门金额分配
</div>
<div style="margin-bottom: 12px; color: #606266; font-size: 12px">
该预算来源授权给多个部门请将支付总额分配到各个部门分配金额可以为0
</div>
<el-table :data="pendingAllocations" border size="small" style="width: 100%">
<el-table-column prop="department_name" label="部门" width="180"></el-table-column>
<el-table-column label="分配金额" width="180">
<template slot-scope="scope">
<el-input-number
v-model="scope.row.allocated_amount"
:min="0"
:precision="2"
:controls="false"
style="width: 100%"
size="small"
@change="onAllocationChange"
></el-input-number>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 12px; padding: 12px; background-color: #f5f7fa; border-radius: 4px">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px">
<span style="font-size: 12px; color: #606266">已分配总额</span>
<span :style="{ color: allocationTotal === totalAmount ? '#67c23a' : '#f56c6c', fontWeight: 'bold', fontSize: '14px' }">
{{ formatAmount(allocationTotal) }}
</span>
</div>
<div v-if="totalAmount > 0" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px">
<span style="font-size: 12px; color: #606266">支付总额</span>
<span style="font-weight: 500; font-size: 14px">{{ formatAmount(totalAmount) }}</span>
</div>
<div v-if="totalAmount > 0" style="display: flex; justify-content: space-between; align-items: center">
<span style="font-size: 12px; color: #606266">剩余金额</span>
<span :style="{ color: remainingAmount >= 0 ? '#67c23a' : '#f56c6c', fontWeight: 'bold', fontSize: '14px' }">
{{ formatAmount(remainingAmount) }}
</span>
</div>
<div v-if="totalAmount === 0" style="margin-top: 8px; color: #909399; font-size: 11px; line-height: 1.5">
提示当前支付总额为0您可以在财务确认时填写实际金额
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :disabled="!pendingId" @click="confirmPick">
<el-button
type="primary"
:disabled="!pendingId || (pendingAuthorizations.length > 1 && totalAmount > 0 && allocationTotal !== totalAmount)"
@click="confirmPick"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
@ -100,7 +245,7 @@ export default {
name: "BudgetSourcePickerField",
props: {
fieldName: { type: String, required: true },
value: { type: [String, Number], default: "" },
value: { type: [String, Number, Object, Array], default: "" },
display: { type: String, default: "" },
},
data() {
@ -112,19 +257,123 @@ export default {
yearId: null,
treeData: [],
pendingId: null,
//
pendingAuthorizations: [],
pendingAllocations: [],
totalAmount: 0,
};
},
computed: {
// JSONvalue
parsedValue() {
const value = this.value;
if (!value || value === null || value === undefined) {
return { budget_data_id: null, allocations: null };
}
// /
if (typeof value === "object" && !Array.isArray(value)) {
return {
budget_data_id: value?.budget_data_id ?? null,
allocations: value?.allocations ?? null,
};
}
// / allocations
if (Array.isArray(value)) {
return { budget_data_id: null, allocations: value };
}
//
// 1) "123"
// 2) '{"budget_data_id":"2","allocations":[...]}'
// 3) '"{\"budget_data_id\":\"2\",\"allocations\":[...]}"'
if (typeof value === "string") {
const raw = value.trim();
if (!raw) return { budget_data_id: null, allocations: null };
const parseMaybeJson = (s) => {
try {
return { ok: true, data: JSON.parse(s) };
} catch (e) {
return { ok: false, data: null };
}
};
const p1 = parseMaybeJson(raw);
if (p1.ok) {
//
if (p1.data && typeof p1.data === "object" && !Array.isArray(p1.data)) {
return {
budget_data_id: p1.data?.budget_data_id ?? null,
allocations: p1.data?.allocations ?? null,
};
}
// allocations budget_data_id
if (Array.isArray(p1.data)) {
return { budget_data_id: null, allocations: p1.data };
}
//
if (typeof p1.data === "string") {
const inner = p1.data.trim();
// inner '{...}' '2'
const p2 = parseMaybeJson(inner);
if (p2.ok && p2.data && typeof p2.data === "object" && !Array.isArray(p2.data)) {
return {
budget_data_id: p2.data?.budget_data_id ?? null,
allocations: p2.data?.allocations ?? null,
};
}
if (p2.ok && Array.isArray(p2.data)) {
return { budget_data_id: null, allocations: p2.data };
}
// inner
return {
budget_data_id: isNaN(inner) ? null : String(inner),
allocations: null,
};
}
}
// JSON ID
return { budget_data_id: isNaN(raw) ? null : String(raw), allocations: null };
}
return { budget_data_id: null, allocations: null };
},
budgetDataId() {
return this.parsedValue.budget_data_id;
},
allocations() {
return this.parsedValue.allocations;
},
normalizedValue() {
return this.value === null || this.value === undefined ? "" : String(this.value);
// JSON/
if (!this.budgetDataId) return "";
return JSON.stringify({
budget_data_id: this.budgetDataId,
allocations: this.allocations,
});
},
hasValue() {
return this.normalizedValue !== "";
return this.budgetDataId !== null && this.budgetDataId !== "";
},
displayText() {
// `{}_display`
// 退 id 1
return this.hasValue ? (this.display || "") : "";
// 使 `{}_display`
if (this.display && this.display.trim()) {
return this.display;
}
// displayJSON
if (this.hasValue && this.budgetDataId) {
// valueJSONdisplaybudget_data_idfallback
// JSONID
return `预算ID: ${this.budgetDataId}`;
}
// valueJSON
// JSON
return "";
},
treeProps() {
return {
@ -134,11 +383,31 @@ export default {
};
},
departmentTree() {
return (this.treeData || []).filter((n) => n?.budget_type !== "project");
return (this.treeData || []).filter((n) => n?.budget_type === "department");
},
projectTree() {
return (this.treeData || []).filter((n) => n?.budget_type === "project");
},
specialFundTree() {
return (this.treeData || []).filter((n) => n?.budget_type === "special_fund");
},
budgetTypeMap() {
return {
department: "部门预算",
project: "自有账户",
special_fund: "专项资金",
};
},
//
allocationTotal() {
return (this.pendingAllocations || []).reduce((sum, item) => {
return sum + (parseFloat(item.allocated_amount) || 0);
}, 0);
},
//
remainingAmount() {
return this.totalAmount - this.allocationTotal;
},
},
methods: {
openDialog() {
@ -181,11 +450,11 @@ export default {
}
},
async tryResolveYearIdByValue() {
if (!this.hasValue) return null;
if (!this.hasValue || !this.budgetDataId) return null;
try {
const detail = await request({
method: "get",
url: `/api/budget/budget-data/${this.normalizedValue}`,
url: `/api/budget/budget-data/${this.budgetDataId}`,
isLoading: false,
});
const yearId = detail?.year_id ?? null;
@ -194,6 +463,18 @@ export default {
return null;
}
},
async getDepartmentAuthorizations(budgetDataId) {
try {
const res = await request({
method: "get",
url: `/api/budget/budget-data/${budgetDataId}/department-authorizations`,
isLoading: false,
});
return Array.isArray(res) ? res : [];
} catch (e) {
return [];
}
},
async loadTree(yearId) {
if (!yearId) return;
this.treeLoading = true;
@ -209,9 +490,42 @@ export default {
this.treeData = [];
await this.loadTree(val);
},
onNodeClick(node) {
async onNodeClick(node) {
if (!node || !node.is_leaf) return;
this.pendingId = node.id;
//
const authorizations = await this.getDepartmentAuthorizations(node.id);
if (authorizations.length > 1) {
//
this.pendingAuthorizations = authorizations;
//
const existingAllocations = this.allocations || [];
if (existingAllocations.length > 0) {
// 0
this.pendingAllocations = authorizations.map(auth => {
const existing = existingAllocations.find(
a => String(a.department_id) === String(auth.department_id)
);
return {
department_id: auth.department_id,
department_name: auth.department?.name || '',
allocated_amount: existing ? (parseFloat(existing.allocated_amount) || 0) : 0
};
});
} else {
//
this.pendingAllocations = authorizations.map(auth => ({
department_id: auth.department_id,
department_name: auth.department?.name || '',
allocated_amount: 0
}));
}
} else {
//
this.pendingAuthorizations = [];
this.pendingAllocations = [];
}
},
findPathNames(tree, id, path = []) {
if (!Array.isArray(tree)) return null;
@ -231,28 +545,87 @@ export default {
);
return opt?.label || (this.yearId ? `${this.yearId}` : "");
},
confirmPick() {
async confirmPick() {
if (!this.pendingId) return;
// totalAmount > 0
if (this.pendingAuthorizations.length > 1 && this.totalAmount > 0) {
if (this.allocationTotal !== this.totalAmount) {
this.$message.warning(`分配总额必须等于支付总额 ${this.formatAmount(this.totalAmount)}`);
return;
}
}
//
const authorizations = await this.getDepartmentAuthorizations(this.pendingId);
let allocations = [];
if (authorizations.length === 1) {
//
allocations = [{
department_id: authorizations[0].department_id,
department_name: authorizations[0].department?.name || '',
allocated_amount: 0 // financialConfirm
}];
} else if (authorizations.length > 1) {
// 使
allocations = this.pendingAllocations || [];
}
this.doConfirmPick(allocations);
},
doConfirmPick(allocations) {
const nodes = this.findPathNames(this.treeData, this.pendingId) || [];
const names = nodes.map((n) => n?.name).filter(Boolean);
const leaf = nodes[nodes.length - 1];
const typeText =
leaf?.budget_type === "project"
? "自有账户"
: leaf?.budget_type === "department"
? "部门预算"
: leaf?.budget_type
? String(leaf.budget_type)
: "";
const typeText = this.budgetTypeMap[leaf?.budget_type] || leaf?.budget_type || "";
const yearLabel = this.getYearLabel();
const display = [yearLabel, typeText, ...names].filter(Boolean).join(" / ");
// JSON json json_encode
const payload = {
budget_data_id: String(this.pendingId),
allocations: allocations.length > 0 ? allocations : null,
};
// /
this.$emit("input", String(this.pendingId));
this.$emit("input", payload);
this.$emit("update:display", display);
this.dialogVisible = false;
this.pendingId = null;
//
this.pendingAuthorizations = [];
this.pendingAllocations = [];
},
onAllocationChange() {
// disabled
},
onDialogClose() {
this.pendingId = null;
//
this.pendingAuthorizations = [];
this.pendingAllocations = [];
},
formatAmount(amount) {
if (amount === null || amount === undefined) return "0.00";
const num = parseFloat(amount);
if (isNaN(num)) return "0.00";
return num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
},
formatExecutionRate(rate) {
if (rate === null || rate === undefined) return "0.00%";
const num = parseFloat(rate);
if (isNaN(num)) return "0.00%";
return num.toFixed(2) + "%";
},
getExecutionRateClass(rate) {
const num = parseFloat(rate);
if (isNaN(num)) return "";
if (num >= 100) return "rate-high";
if (num >= 80) return "rate-medium";
if (num >= 50) return "rate-normal";
return "rate-low";
},
clearValue() {
this.$emit("input", "");
@ -292,6 +665,75 @@ export default {
padding-bottom: 8px;
border-bottom: 1px solid #e4e7ed;
}
.custom-tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
padding-right: 8px;
}
.node-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-usage-info {
display: flex;
align-items: center;
gap: 12px;
margin-left: 12px;
flex-shrink: 0;
font-size: 12px;
}
.usage-item {
display: flex;
align-items: center;
gap: 4px;
}
.usage-label {
color: #909399;
font-size: 12px;
}
.usage-value {
color: #606266;
font-weight: 500;
font-size: 12px;
}
.usage-value--used {
color: #409eff;
}
.usage-value--rate {
min-width: 50px;
text-align: right;
}
.usage-value--rate.rate-high {
color: #f56c6c;
font-weight: 600;
}
.usage-value--rate.rate-medium {
color: #e6a23c;
font-weight: 500;
}
.usage-value--rate.rate-normal {
color: #409eff;
}
.usage-value--rate.rate-low {
color: #909399;
}
</style>

@ -22,7 +22,7 @@
<el-dialog
title="合同签订"
:visible.sync="dialogVisible"
width="60%"
width="75%"
:close-on-click-modal="false"
append-to-body
@open="onDialogOpen"
@ -32,67 +32,209 @@
:model="form"
:rules="rules"
ref="formRef"
label-width="120px"
label-width="140px"
v-loading="loading"
>
<el-form-item label="合同编号" prop="contract_no">
<el-input v-model="form.contract_no" />
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="合同编号" prop="contract_no">
<el-input v-model="form.contract_no" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合同名称">
<el-input v-model="form.title" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="合同主要内容">
<el-input v-model="form.main_content" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="标题">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="甲方">
<el-input v-model="form.party_a" />
</el-form-item>
<el-form-item label="乙方">
<el-input v-model="form.party_b" />
</el-form-item>
<el-form-item label="总额">
<el-input-number v-model="form.amount_total" :min="0" :step="1000" style="width: 100%" />
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="甲方">
<el-input v-model="form.party_a" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="乙方">
<el-input v-model="form.party_b" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="合同金额(元)">
<el-input-number
v-model="form.amount_total"
:min="0"
:precision="2"
:controls="form.amount_type !== 'open'"
:readonly="form.amount_type === 'open'"
@keydown="handleAmountInputKeydown"
@focus="handleAmountInputFocus"
style="width: 100%"
:class="{ 'readonly-amount': form.amount_type === 'open' }"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目预算金额(元)">
<el-input-number v-model="form.budget_amount" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="金额类型">
<el-select v-model="form.amount_type" placeholder="请选择金额类型" style="width: 100%">
<el-option label="闭口合同(金额确定)" value="fixed" />
<el-option label="框架协议/开口合同(金额不确定)" value="open" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12" v-if="form.amount_type === 'open'">
<el-form-item label="金额说明">
<el-input v-model="form.amount_description" type="textarea" :rows="2" placeholder="请说明开口合同的金额情况" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合同类型">
<el-select v-model="form.contract_type" placeholder="请选择合同类型" style="width: 100%">
<el-option label="收入类" value="1" />
<el-option label="支出类" value="2" />
<el-option label="技术协议" value="3" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="签订日期">
<el-date-picker
v-model="form.sign_date"
type="date"
value-format="yyyy-MM-dd"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="申请日期">
<el-date-picker
v-model="form.apply_date"
type="date"
value-format="yyyy-MM-dd"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="合同履行期">
<el-input v-model="form.perform_period" placeholder="例如2025-01-01 至 2025-12-31" />
</el-form-item>
<el-form-item label="金额类型">
<el-select v-model="form.amount_type" placeholder="请选择金额类型" style="width: 100%">
<el-option label="闭口合同(金额确定)" value="fixed" />
<el-option label="框架协议/开口合同(金额不确定)" value="open" />
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="付款方式">
<el-input v-model="form.pay_method" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目经费来源">
<el-input v-model="form.fund_source" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="招标代理机构">
<el-input v-model="form.tender_agent" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="采购方式">
<el-select v-model="form.purchase_method" placeholder="请选择采购方式" style="width: 100%">
<el-option label="公开招标" value="公开招标" />
<el-option label="邀请招标" value="邀请招标" />
<el-option label="竞争性谈判" value="竞争性谈判" />
<el-option label="单一来源采购" value="单一来源采购" />
<el-option label="询价" value="询价" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="合同履行情况">
<el-input v-model="form.perform_status" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否验收">
<el-switch v-model="form.is_accepted" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="采购类别">
<el-select v-model="form.purchase_category" placeholder="请选择采购类别" style="width: 100%">
<el-option label="仪器" value="仪器" />
<el-option label="外包" value="外包" />
<el-option label="服务" value="服务" />
<el-option label="一般采购" value="一般采购" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="草稿" value="draft" />
<el-option label="生效" value="approved" />
<el-option label="终止" value="terminated" />
<el-form-item label="科室">
<el-select v-model="form.owner_department_ids_array" multiple placeholder="请选择科室" filterable clearable style="width: 100%">
<el-option v-for="dept in departmentList" :key="dept.id" :label="dept.name" :value="dept.id.toString()" />
</el-select>
</el-form-item>
<el-form-item label="签订日期">
<el-date-picker
v-model="form.sign_date"
type="date"
value-format="yyyy-MM-dd"
style="width: 100%"
/>
<el-form-item label="合同签订/变更经办人">
<el-select v-model="form.handler_admin_ids_array" multiple placeholder="请选择经办人" filterable clearable style="width: 100%">
<el-option v-for="user in userList" :key="user.id" :label="user.name" :value="user.id.toString()" />
</el-select>
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker
v-model="form.start_date"
type="date"
value-format="yyyy-MM-dd"
style="width: 100%"
/>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="申请科室经办人">
<el-select v-model="form.apply_handler_id" placeholder="请选择" filterable clearable style="width: 100%">
<el-option v-for="user in userList" :key="user.id" :label="user.name" :value="user.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="采购科室经办人">
<el-select v-model="form.purchase_handler_id" placeholder="请选择" filterable clearable style="width: 100%">
<el-option v-for="user in userList" :key="user.id" :label="user.name" :value="user.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker
v-model="form.end_date"
type="date"
value-format="yyyy-MM-dd"
style="width: 100%"
/>
<el-form-item label="附件">
<el-upload
:action="uploadAction"
:headers="uploadHeaders"
:before-upload="beforeUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:on-remove="handleUploadRemove"
:file-list="attachmentFileList"
:limit="1"
>
<el-button type="primary">上传文件</el-button>
<template #tip>
<div class="el-upload__tip">只能上传 JPG/PNG/PDF/DOC/DOCX/XLS/XLSX 格式的文件且不超过 10MB</div>
</template>
</el-upload>
</el-form-item>
<el-divider content-position="left">付款计划</el-divider>
<div class="payplan-toolbar">
<el-button type="primary" size="small" @click="addPayPlan"></el-button>
</div>
<el-table :data="form.pay_plans" border size="small" class="mb-16">
<el-table :data="form.pay_plans" border size="small" class="mb-16 payplan-table" style="width: 100%">
<el-table-column prop="phase_no" label="期次" width="100">
<template #default="{ row }">
<el-input-number v-model="row.phase_no" :min="1" size="small" />
@ -139,12 +281,14 @@
<script>
import request from "@/utils/request";
import { Message } from 'element-ui';
import { getToken } from '@/utils/auth';
import {
getFlowDetailForContract,
getContractSettings,
getContractByFlowId,
createContract,
} from "@/api/flow";
import { userListNoAuth, departmentListNoAuth } from "@/api/common";
export default {
name: "ContractSignField",
@ -160,18 +304,41 @@ export default {
loading: false,
saving: false,
formRef: null,
userList: [],
departmentList: [],
attachmentFileList: [],
form: {
contract_no: "",
title: "",
main_content: "",
party_a: "",
party_b: "",
amount_total: 0,
amount_total: null,
amount_type: "fixed",
status: "draft",
amount_description: "",
budget_amount: 0,
contract_type: "",
sign_date: "",
start_date: "",
end_date: "",
apply_date: "",
perform_period: "",
pay_method: "",
fund_source: "",
tender_agent: "",
purchase_method: "",
perform_status: "",
is_accepted: false,
purchase_category: "",
handler_admin_ids: "",
handler_admin_ids_array: [],
apply_handler_id: null,
purchase_handler_id: null,
owner_department_id: null,
owner_department_ids: "",
owner_department_ids_array: [],
remark: "",
attachment_id: null,
pay_plans: [],
savedAmountTotal: null,
},
rules: {
contract_no: [{ required: true, message: "请填写合同编号", trigger: "blur" }],
@ -188,20 +355,138 @@ export default {
displayText() {
return this.hasValue ? this.display || "" : "";
},
uploadAction() {
// 使API使
const uploadAPI = process.env.VUE_APP_UPLOAD_API;
if (uploadAPI) {
return uploadAPI;
}
// 使 baseURL + /upload-file
const baseURL = process.env.VUE_APP_BASE_API || '';
// baseURL /api /api/
const apiPath = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL;
return `${apiPath}/upload-file`;
},
uploadHeaders() {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
},
},
watch: {
'form.amount_type'(newVal, oldVal) {
if (newVal === 'open') {
if (oldVal === 'fixed' && this.form.amount_total) {
this.form.savedAmountTotal = this.form.amount_total;
}
this.form.amount_total = null;
} else if (newVal === 'fixed') {
if (this.form.savedAmountTotal !== null) {
this.form.amount_total = this.form.savedAmountTotal;
this.form.savedAmountTotal = null;
}
}
}
},
methods: {
openDialog() {
this.dialogVisible = true;
},
handleAmountInputKeydown(event) {
if (this.form.amount_type === 'open') {
event.preventDefault();
event.stopPropagation();
return false;
}
},
handleAmountInputFocus(event) {
if (this.form.amount_type === 'open') {
event.target.blur();
}
},
beforeUpload(file) {
const isValidType = ['image/jpeg', 'image/png', 'image/jpg', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'].includes(file.type);
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isValidType) {
Message.error('只能上传 JPG/PNG/PDF/DOC/DOCX/XLS/XLSX 格式的文件!');
return false;
}
if (!isLt10M) {
Message.error('文件大小不能超过 10MB!');
return false;
}
return true;
},
handleUploadSuccess(response) {
if (response.code === 0) {
const fileData = response.data;
this.form.attachment_id = fileData.id;
this.attachmentFileList = [{
name: fileData.original_name || fileData.name || '文件',
url: fileData.url || (fileData.folder && fileData.name ? `${fileData.folder}/${fileData.name}` : ''),
uid: fileData.id,
id: fileData.id
}];
Message.success('上传成功');
} else {
Message.error('上传失败:' + (response.msg || '未知错误'));
}
},
handleUploadError(error) {
console.error('上传错误:', error);
Message.error('上传失败,请重试');
},
handleUploadRemove() {
this.form.attachment_id = null;
this.attachmentFileList = [];
Message.success('已移除文件');
},
async loadUserList() {
try {
// 使 common.js
// res.data response data
const response = await userListNoAuth({ rows: 1000 });
// simpleUser : { data: [...], current_page: 1, ... }
if (response && response.data && Array.isArray(response.data)) {
this.userList = response.data;
} else if (Array.isArray(response)) {
//
this.userList = response;
} else {
this.userList = [];
}
} catch (e) {
console.error('获取用户列表失败', e);
Message.error('获取用户列表失败:' + (e.message || '未知错误'));
this.userList = [];
}
},
async loadDepartmentList() {
try {
// 使 common.js
// res.data response data
const response = await departmentListNoAuth({ rows: 1000 });
// simpleDepartment
if (Array.isArray(response)) {
this.departmentList = response;
} else {
this.departmentList = [];
}
} catch (e) {
console.error('获取部门列表失败', e);
Message.error('获取部门列表失败:' + (e.message || '未知错误'));
this.departmentList = [];
}
},
async onDialogOpen() {
this.resetForm();
if (this.flowId) {
// flow_id
const contractExists = await this.loadExistingContract();
if (!contractExists) {
//
this.loadFlowDataAndPrefill();
}
await this.loadUserList();
await this.loadDepartmentList();
// IDflowId
const contractExists = await this.loadExistingContract();
// flowId
if (!contractExists && this.flowId) {
this.loadFlowDataAndPrefill();
}
},
onDialogClose() {
@ -211,42 +496,127 @@ export default {
this.form = {
contract_no: "",
title: "",
main_content: "",
party_a: "",
party_b: "",
amount_total: 0,
amount_total: null,
amount_type: "fixed",
status: "draft",
amount_description: "",
budget_amount: 0,
contract_type: "",
sign_date: "",
start_date: "",
end_date: "",
apply_date: "",
perform_period: "",
pay_method: "",
fund_source: "",
tender_agent: "",
purchase_method: "",
perform_status: "",
is_accepted: false,
purchase_category: "",
handler_admin_ids: "",
handler_admin_ids_array: [],
apply_handler_id: null,
purchase_handler_id: null,
owner_department_id: null,
owner_department_ids: "",
owner_department_ids_array: [],
remark: "",
attachment_id: null,
pay_plans: [],
savedAmountTotal: null,
};
this.attachmentFileList = [];
if (this.$refs.formRef) {
this.$refs.formRef.clearValidate();
}
},
async loadExistingContract() {
if (!this.flowId) {
return false;
}
this.loading = true;
try {
const res = await getContractByFlowId(this.flowId);
const contractData = res?.data || res;
let contractData = null;
// ID
if (this.hasValue && this.normalizedValue) {
try {
const contractId = parseInt(this.normalizedValue);
if (contractId) {
// ID使API
const res = await request.get(`/api/budget/contracts/${contractId}`);
// res.data res
if (res && res.id) {
contractData = res;
}
}
} catch (e) {
console.warn('通过合同ID加载失败尝试通过flowId加载:', e);
}
}
// IDflowIdflowId
if (!contractData && this.flowId) {
const res = await getContractByFlowId(this.flowId);
contractData = res?.data || res;
}
if (contractData) {
//
this.form.contract_no = contractData.contract_no || "";
this.form.title = contractData.title || "";
this.form.main_content = contractData.main_content || "";
this.form.party_a = contractData.party_a || "";
this.form.party_b = contractData.party_b || "";
this.form.amount_total = contractData.amount_total || 0;
this.form.amount_total = contractData.amount_total !== null && contractData.amount_total !== undefined ? contractData.amount_total : null;
this.form.amount_type = contractData.amount_type || "fixed";
this.form.status = contractData.status || "draft";
this.form.amount_description = contractData.amount_description || "";
this.form.budget_amount = contractData.budget_amount || 0;
this.form.contract_type = contractData.contract_type || "";
this.form.sign_date = contractData.sign_date || "";
this.form.start_date = contractData.start_date || "";
this.form.end_date = contractData.end_date || "";
this.form.apply_date = contractData.apply_date || "";
this.form.perform_period = contractData.perform_period || "";
this.form.pay_method = contractData.pay_method || "";
this.form.fund_source = contractData.fund_source || "";
this.form.tender_agent = contractData.tender_agent || "";
this.form.purchase_method = contractData.purchase_method || "";
this.form.perform_status = contractData.perform_status || "";
this.form.is_accepted = contractData.is_accepted || false;
this.form.purchase_category = contractData.purchase_category || "";
this.form.apply_handler_id = contractData.apply_handler_id || null;
this.form.purchase_handler_id = contractData.purchase_handler_id || null;
this.form.owner_department_id = contractData.owner_department_id || null;
this.form.remark = contractData.remark || "";
this.form.attachment_id = contractData.attachment_id || null;
// value
if (contractData.owner_department_ids) {
const ids = typeof contractData.owner_department_ids === 'string'
? contractData.owner_department_ids.split(',').filter(id => id)
: (Array.isArray(contractData.owner_department_ids) ? contractData.owner_department_ids : []);
this.form.owner_department_ids_array = ids.map(id => String(id));
} else {
this.form.owner_department_ids_array = [];
}
// value
if (contractData.handler_admin_ids) {
const ids = typeof contractData.handler_admin_ids === 'string'
? contractData.handler_admin_ids.split(',').filter(id => id)
: (Array.isArray(contractData.handler_admin_ids) ? contractData.handler_admin_ids : []);
this.form.handler_admin_ids_array = ids.map(id => String(id));
} else {
this.form.handler_admin_ids_array = [];
}
//
if (contractData.attachment_id && contractData.attachment) {
this.attachmentFileList = [{
name: contractData.attachment.original_name || contractData.attachment.name || '文件',
url: contractData.attachment.url || (contractData.attachment.folder && contractData.attachment.name ? `${contractData.attachment.folder}/${contractData.attachment.name}` : ''),
uid: contractData.attachment.id,
id: contractData.attachment.id
}];
} else {
this.attachmentFileList = [];
}
//
if (contractData.pay_plans && Array.isArray(contractData.pay_plans)) {
@ -278,7 +648,6 @@ export default {
this.loading = true;
try {
// flow
const [flowDetailRes, settingsRes] = await Promise.all([
getFlowDetailForContract(this.flowId),
getContractSettings(this.flowId),
@ -295,7 +664,6 @@ export default {
const contractMapping = (settings?.contract_field_mapping || []);
const payplanMapping = (settings?.payplan_field_mapping || []);
//
if (contractMapping && contractMapping.length > 0) {
contractMapping.forEach((map) => {
const budgetField = map.budget_field;
@ -308,9 +676,7 @@ export default {
});
}
//
if (settings.oa_custom_model_id_for_payplan && flowDetail.customModel) {
//
const payplanField = flowDetail.customModel.fields.find(
(f) => f.type === "relation" && f.sub_custom_model_id === settings.oa_custom_model_id_for_payplan
);
@ -328,7 +694,6 @@ export default {
}
}
} else if (flowData.pay_plans && Array.isArray(flowData.pay_plans)) {
// pay_plans
this.form.pay_plans = this.mapPayPlans(flowData.pay_plans, payplanMapping);
}
} catch (e) {
@ -387,19 +752,53 @@ export default {
this.saving = true;
try {
//
const ownerDepartmentIds = Array.isArray(this.form.owner_department_ids_array)
? this.form.owner_department_ids_array.join(',')
: (this.form.owner_department_ids || '');
//
const handlerAdminIds = Array.isArray(this.form.handler_admin_ids_array)
? this.form.handler_admin_ids_array.join(',')
: (this.form.handler_admin_ids || '');
// amount_total null
const amountTotal = this.form.amount_type === 'open' ? null : (this.form.amount_total || 0);
// IDID
const contractId = this.hasValue && this.normalizedValue ? parseInt(this.normalizedValue) : null;
const payload = {
contract_id: contractId, //
flow_id: this.flowId ? parseInt(this.flowId) : null,
contract: {
contract_no: this.form.contract_no,
title: this.form.title,
party_a: this.form.party_a,
party_b: this.form.party_b,
amount_total: this.form.amount_total,
main_content: this.form.main_content || "",
party_a: this.form.party_a || "",
party_b: this.form.party_b || "",
amount_total: amountTotal,
amount_type: this.form.amount_type,
status: this.form.status,
amount_description: this.form.amount_description || "",
budget_amount: this.form.budget_amount || 0,
contract_type: this.form.contract_type || "",
sign_date: this.form.sign_date || null,
start_date: this.form.start_date || null,
end_date: this.form.end_date || null,
apply_date: this.form.apply_date || null,
perform_period: this.form.perform_period || "",
pay_method: this.form.pay_method || "",
fund_source: this.form.fund_source || "",
tender_agent: this.form.tender_agent || "",
purchase_method: this.form.purchase_method || "",
perform_status: this.form.perform_status || "",
is_accepted: this.form.is_accepted || false,
purchase_category: this.form.purchase_category || "",
handler_admin_ids: handlerAdminIds,
apply_handler_id: this.form.apply_handler_id,
purchase_handler_id: this.form.purchase_handler_id,
owner_department_id: this.form.owner_department_id,
owner_department_ids: ownerDepartmentIds,
remark: this.form.remark || "",
attachment_id: this.form.attachment_id,
},
pay_plans: this.form.pay_plans.map((p) => ({
phase_no: p.phase_no,
@ -416,9 +815,9 @@ export default {
this.$emit("input", String(data.id));
this.$emit("update:display", displayText);
this.dialogVisible = false;
Message.success("合同创建成功");
Message.success(contractId ? "合同更新成功" : "合同创建成功");
} else {
Message.error("创建合同失败");
Message.error(contractId ? "更新合同失败" : "创建合同失败");
}
} catch (e) {
console.error("创建合同失败:", e);
@ -466,5 +865,20 @@ export default {
.mb-16 {
margin-bottom: 16px;
}
</style>
.payplan-table {
width: 100% !important;
}
.readonly-amount :deep(.el-input__inner) {
cursor: not-allowed;
background-color: #f5f7fa;
color: #909399;
}
.readonly-amount :deep(.el-input-number__decrease),
.readonly-amount :deep(.el-input-number__increase) {
display: none;
pointer-events: none;
}
</style>

@ -197,9 +197,17 @@ export default {
scriptContent(newVal) {
if (newVal) {
try {
new Function(newVal).bind(this)();
// 使 $nextTick DOM
this.$nextTick(() => {
try {
new Function(newVal).bind(this)();
} catch (err) {
console.error('脚本执行错误:', err);
// 使
}
});
} catch (err) {
console.error(err);
console.error('脚本编译错误:', err);
}
}
},

@ -138,9 +138,17 @@ export default {
scriptContent(newVal) {
if(newVal) {
try {
new Function(newVal).bind(this)()
// 使 $nextTick DOM
this.$nextTick(() => {
try {
new Function(newVal).bind(this)();
} catch (err) {
console.error('脚本执行错误:', err);
// 使
}
});
} catch (err) {
console.error(err)
console.error('脚本编译错误:', err);
}
}
},

@ -0,0 +1,701 @@
<template>
<div class="related-flows-component">
<el-card shadow="never" class="related-flows-card">
<div slot="header" class="card-header">
<div class="header-left">
<span class="header-title">
<i class="el-icon-link"></i>
关联流程
</span>
<span class="header-tip">可选择自己发起或经办过的任意流程进行关联</span>
</div>
<el-button
v-if="!readonly && collapsible"
type="text"
:icon="collapsed ? 'el-icon-arrow-down' : 'el-icon-arrow-up'"
@click.stop.prevent="handleToggleCollapse"
class="collapse-btn"
>
{{ collapsed ? '展开' : '收起' }}
</el-button>
</div>
<!-- 搜索区域 -->
<div class="search-area" v-show="!collapsed && !readonly" @click.stop>
<el-row :gutter="10">
<el-col :span="6">
<el-select
v-model="searchForm.custom_model_id"
placeholder="流程类型"
clearable
filterable
style="width: 100%"
@change="handleModelChange"
@click.native.stop
@visible-change="handleSelectVisible"
popper-class="related-flow-select-popper"
>
<el-option
v-for="model in customModels"
:key="model.id"
:label="model.name"
:value="model.id"
></el-option>
</el-select>
</el-col>
<el-col :span="10">
<el-input
v-model="searchForm.keyword"
placeholder="输入标题或编号搜索"
clearable
@keyup.enter.native="handleSearch"
>
<el-button slot="append" icon="el-icon-search" @click="handleSearch"></el-button>
</el-input>
</el-col>
<el-col :span="4">
<el-button type="primary" @click="handleSearch" :loading="loading">搜索</el-button>
</el-col>
</el-row>
</div>
<!-- 已选流程列表 -->
<div class="selected-flows" v-show="!collapsed && selectedFlows.length > 0">
<div class="selected-title">已关联的流程{{ selectedFlows.length }}</div>
<div class="selected-list">
<el-tag
v-for="flow in selectedFlows"
:key="flow.id"
:closable="!readonly"
@close="handleRemove(flow.id)"
@click="handleViewFlow(flow)"
class="flow-tag"
type="info"
effect="plain"
>
<span class="flow-title">{{ flow.title }}</span>
<span class="flow-meta">{{ flow.no }}</span>
</el-tag>
</div>
</div>
<!-- 可选流程列表 -->
<div class="available-flows" v-show="!collapsed && !readonly">
<div class="available-title">可选流程</div>
<div v-loading="loading" class="flow-list">
<el-empty v-if="!loading && availableFlows.length === 0" description="暂无可用流程"></el-empty>
<div
v-for="flow in availableFlows"
:key="flow.id"
class="flow-item"
:class="{ 'is-selected': isSelected(flow.id) }"
@click="handleToggleFlow(flow)"
>
<div class="flow-content">
<div class="flow-header">
<span class="flow-title-text">{{ flow.title }}</span>
<el-tag size="mini" :type="getStatusType(flow.status)">
{{ flow.status_text }}
</el-tag>
</div>
<div class="flow-info">
<span class="flow-no">编号{{ flow.no }}</span>
<span class="flow-type">类型{{ flow.custom_model_name }}</span>
<span class="flow-creator">发起人{{ flow.creator_name }}</span>
<span class="flow-date">{{ formatDate(flow.created_at) }}</span>
</div>
</div>
<div class="flow-actions">
<el-button
size="mini"
type="text"
icon="el-icon-view"
@click.stop="handleViewFlow(flow)"
>
查看
</el-button>
<el-button
v-if="!isSelected(flow.id)"
size="mini"
type="primary"
@click.stop="handleToggleFlow(flow)"
>
关联
</el-button>
<el-button
v-else
size="mini"
type="danger"
@click.stop="handleToggleFlow(flow)"
>
取消
</el-button>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination" v-if="pagination.total > 0">
<el-pagination
@current-change="handlePageChange"
:current-page="pagination.page"
:page-size="pagination.page_size"
:total="pagination.total"
layout="total, prev, pager, next"
small
></el-pagination>
</div>
</div>
<!-- 只读模式显示关联流程 -->
<div class="readonly-flows" v-show="readonly">
<div v-if="relatedFlows.length > 0" class="related-section">
<div class="section-title">关联的流程{{ relatedFlows.length }}</div>
<div class="related-list">
<div
v-for="flow in relatedFlows"
:key="flow.id"
class="related-item"
@click="handleViewFlow(flow)"
>
<span class="flow-title-text">{{ flow.title }}</span>
<span class="flow-meta">{{ flow.no }} - {{ flow.custom_model_name }}</span>
</div>
</div>
</div>
<div v-if="relatedByFlows.length > 0" class="related-section">
<div class="section-title">被关联的流程{{ relatedByFlows.length }}</div>
<div class="related-list">
<div
v-for="flow in relatedByFlows"
:key="flow.id"
class="related-item"
@click="handleViewFlow(flow)"
>
<span class="flow-title-text">{{ flow.title }}</span>
<span class="flow-meta">{{ flow.no }} - {{ flow.custom_model_name }}</span>
</div>
</div>
</div>
<div v-if="readonly && relatedFlows.length === 0 && relatedByFlows.length === 0" class="empty-tip">
<span class="empty-text">暂无关联流程</span>
</div>
</div>
</el-card>
<!-- 流程详情弹窗 -->
<el-dialog
:title="currentFlow ? currentFlow.title : '流程详情'"
:visible.sync="showFlowDetail"
width="90%"
:close-on-click-modal="false"
class="flow-detail-dialog"
>
<iframe
v-if="flowDetailUrl"
:src="flowDetailUrl"
style="width: 100%; height: 70vh; border: none;"
frameborder="0"
></iframe>
</el-dialog>
</div>
</template>
<script>
import { getRelationOptions, getRelationList, flowList, flow as flowIndex } from '@/api/flow'
import { getToken } from '@/utils/auth'
import moment from 'moment'
export default {
name: 'RelatedFlows',
props: {
value: {
type: [String, Array],
default: () => []
},
readonly: {
type: Boolean,
default: false
},
flowId: {
type: [Number, String],
default: null
},
collapsible: {
type: Boolean,
default: true
}
},
data() {
return {
collapsed: this.readonly ? false : true, // readonly
loading: false,
searchForm: {
keyword: '',
custom_model_id: ''
},
availableFlows: [],
selectedFlows: [],
relatedFlows: [],
relatedByFlows: [],
customModels: [],
pagination: {
page: 1,
page_size: 20,
total: 0
},
showFlowDetail: false,
currentFlow: null,
flowDetailUrl: ''
}
},
watch: {
value: {
immediate: true,
handler(newVal) {
if (newVal) {
const ids = Array.isArray(newVal) ? newVal : newVal.toString().split(',').filter(Boolean).map(Number)
if (ids.length > 0 && this.selectedFlows.length === 0) {
// selectedFlows
this.loadSelectedFlows(ids)
}
} else {
this.selectedFlows = []
}
}
},
readonly: {
immediate: true,
handler(newVal) {
if (newVal) {
// readonly
this.collapsed = false
if (this.flowId) {
this.loadRelationList()
}
}
}
},
flowId: {
immediate: true,
handler(newVal) {
if (newVal && this.readonly) {
this.loadRelationList()
}
}
},
collapsed(newVal) {
//
if (!newVal && !this.readonly && this.availableFlows.length === 0) {
this.$nextTick(() => {
this.handleSearch()
})
}
}
},
mounted() {
this.loadCustomModels()
},
methods: {
handleToggleCollapse(e) {
if (e) {
e.stopPropagation()
e.preventDefault()
}
this.collapsed = !this.collapsed
},
handleModelChange() {
//
// change searchForm.custom_model_id
},
handleSelectVisible(visible) {
// /
//
if (visible && this.collapsed && !this.readonly) {
this.collapsed = false
}
},
async loadCustomModels() {
try {
// 1: flowList API
const res = await flowList('all', { page: 1, page_size: 1, is_simple: 1 })
// customModels res res.data
if (res.customModels && res.customModels.length > 0) {
this.customModels = res.customModels
return
} else if (res.data && res.data.customModels) {
this.customModels = res.data.customModels
return
}
} catch (err) {
console.warn('从 flowList 加载流程类型失败,尝试备用方法', err)
}
// 2: flow index API
try {
const flowRes = await flowIndex()
// request.js res.data
if (flowRes && flowRes.cates) {
//
const models = []
flowRes.cates.forEach(cate => {
if (cate.customerModels && Array.isArray(cate.customerModels)) {
models.push(...cate.customerModels)
}
})
this.customModels = models.map(m => ({ id: m.id, name: m.name }))
}
} catch (e) {
console.error('备用加载流程类型失败', e)
this.$message.warning('加载流程类型失败,请刷新页面重试')
}
},
async handleSearch() {
this.loading = true
try {
const params = {
page: this.pagination.page,
page_size: this.pagination.page_size,
...this.searchForm
}
const res = await getRelationOptions(params)
// request.js res.data res { data: [...], total: ..., page: ... }
if (res) {
this.availableFlows = res.data || []
this.pagination.total = res.total || 0
this.pagination.page = res.page || 1
this.pagination.page_size = res.page_size || 20
}
} catch (err) {
this.$message.error('搜索失败:' + (err.message || '未知错误'))
} finally {
this.loading = false
}
},
async loadSelectedFlows(ids) {
if (!ids || ids.length === 0) {
this.selectedFlows = []
return
}
try {
const res = await flowList('all', {
ids: ids.join(','),
page: 1,
page_size: 9999,
is_simple: 1
})
// request.js res.data
const flows = res.data || []
if (flows.length > 0) {
this.selectedFlows = flows.map(flow => ({
id: flow.id,
title: flow.title,
no: flow.no,
custom_model_id: flow.custom_model_id,
custom_model_name: flow.customModel ? flow.customModel.name : (flow.custom_model_name || ''),
status: flow.status,
status_text: this.getStatusText(flow.status),
created_by: flow.created_by,
creator_name: flow.creator ? flow.creator.name : (flow.creator_name || ''),
created_at: flow.created_at
}))
}
} catch (err) {
console.error('加载已选流程失败', err)
}
},
async loadRelationList() {
if (!this.flowId) return
try {
const res = await getRelationList(this.flowId)
// request.js res.data
if (res) {
this.relatedFlows = res.related_flows || []
this.relatedByFlows = res.related_by_flows || []
}
} catch (err) {
console.error('加载关联列表失败', err)
}
},
handleToggleFlow(flow) {
if (this.isSelected(flow.id)) {
this.handleRemove(flow.id)
} else {
this.handleAdd(flow)
}
},
handleAdd(flow) {
if (this.isSelected(flow.id)) return
this.selectedFlows.push(flow)
this.updateValue()
},
handleRemove(flowId) {
this.selectedFlows = this.selectedFlows.filter(f => f.id !== flowId)
this.updateValue()
},
isSelected(flowId) {
return this.selectedFlows.some(f => f.id === flowId)
},
updateValue() {
const ids = this.selectedFlows.map(f => f.id)
this.$emit('input', ids.join(','))
this.$emit('change', ids)
},
handlePageChange(page) {
this.pagination.page = page
this.handleSearch()
},
handleViewFlow(flow) {
this.currentFlow = flow
const baseUrl = process.env.VUE_APP_BASE_API || ''
this.flowDetailUrl = `${baseUrl}/oa/#/flow/detail?module_id=${flow.custom_model_id}&flow_id=${flow.id}&isSinglePage=1&auth_token=${encodeURIComponent(getToken())}`
this.showFlowDetail = true
},
getStatusType(status) {
const statusMap = {
0: 'info',
1: 'success',
'-1': 'danger'
}
return statusMap[status] || 'info'
},
getStatusText(status) {
const statusMap = {
0: '办理中',
1: '已办结',
'-1': '已取消'
}
return statusMap[status] || '未知'
},
formatDate(date) {
if (!date) return ''
return moment(date).format('YYYY-MM-DD HH:mm')
}
}
}
</script>
<style lang="scss" scoped>
.related-flows-component {
margin-bottom: 20px;
.related-flows-card {
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header-title {
font-size: 16px;
font-weight: 500;
color: #303133;
i {
margin-right: 5px;
color: #409eff;
}
}
.header-tip {
font-size: 12px;
color: #909399;
}
.collapse-btn {
padding: 0;
font-size: 14px;
}
}
}
.search-area {
margin-bottom: 20px;
}
.selected-flows {
margin-bottom: 20px;
padding: 15px;
background: #f5f7fa;
border-radius: 4px;
.selected-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 10px;
color: #303133;
}
.selected-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
.flow-tag {
cursor: pointer;
padding: 5px 10px;
font-size: 13px;
.flow-title {
color: #409eff;
}
.flow-meta {
color: #909399;
font-size: 12px;
}
&:hover {
background-color: #ecf5ff;
}
}
}
}
.available-flows {
.available-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 15px;
color: #303133;
}
.flow-list {
min-height: 200px;
max-height: 400px;
overflow-y: auto;
.flow-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
margin-bottom: 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #409eff;
background-color: #ecf5ff;
}
&.is-selected {
border-color: #409eff;
background-color: #ecf5ff;
}
.flow-content {
flex: 1;
.flow-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.flow-title-text {
font-size: 14px;
font-weight: 500;
color: #303133;
}
}
.flow-info {
display: flex;
gap: 15px;
font-size: 12px;
color: #909399;
span {
white-space: nowrap;
}
}
}
.flow-actions {
margin-left: 15px;
}
}
}
.pagination {
margin-top: 15px;
text-align: right;
}
}
.readonly-flows {
.related-section {
margin-bottom: 20px;
.section-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 10px;
color: #303133;
}
.related-list {
.related-item {
padding: 8px 12px;
margin-bottom: 8px;
background: #f5f7fa;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: #ecf5ff;
}
.flow-title-text {
color: #409eff;
font-weight: 500;
}
.flow-meta {
color: #909399;
font-size: 12px;
margin-left: 8px;
}
}
}
.empty-tip {
padding: 8px 0;
text-align: center;
.empty-text {
color: #909399;
font-size: 13px;
}
}
}
}
}
.flow-detail-dialog {
::v-deep .el-dialog__body {
padding: 20px;
}
}
</style>
<style lang="scss">
//
.related-flow-select-popper {
.el-select-dropdown__item {
&:hover {
background-color: #f5f7fa;
}
}
}
</style>

@ -15,6 +15,16 @@
<template>
<Steps :logs="config.logs" :current-node="node"></Steps>
<!-- 关联流程组件 -->
<RelatedFlows
v-model="form.related_flow_ids"
:readonly="/\/detail/.test($route.path) || !!$route.query.flow_id"
:flow-id="$route.query.flow_id"
:collapsible="!$route.query.flow_id && !/\/detail/.test($route.path)"
style="margin-bottom: 20px;"
></RelatedFlows>
<div v-if="/\/detail/.test($route.path) && config && config.flow">
<div v-if="config.flow.hasOwnProperty('out_contracts') && config.flow.out_contracts && config.flow.out_contracts.length > 0" style="margin-bottom: 10px;color:#F56C6C;">
<!-- 单条数据 -->
@ -332,6 +342,7 @@ import MobileForm from "./MobileForm.vue";
import assign from "./components/assign.vue";
import forward from "./components/forward.vue";
import rollback from "./components/rollback.vue";
import RelatedFlows from "./components/RelatedFlows.vue";
import { generateRandomString } from '@/utils'
import { userListNoAuth} from "@/api/common";
import {
@ -356,6 +367,7 @@ export default {
assign,
forward,
rollback,
RelatedFlows,
},
data() {
return {
@ -381,7 +393,7 @@ export default {
readableFields: [],
subConfig: new Map(),
myStatus: new Map([
[-2, "会签退回"],
[-2, "会签退"],
[-1, "退回"],
[0, "办理中"],
[1, "已完成"],
@ -649,6 +661,10 @@ export default {
}
}
});
//
if (!object.hasOwnProperty('related_flow_ids')) {
object['related_flow_ids'] = '';
}
this.form['flow_title'] = this.config?.flow?.title ?? `${this.config.customModel.name}${this.$store.getters.name} ${this.$moment().format('YYYY-MM-DD HH:mm')}`
},
@ -696,6 +712,11 @@ export default {
this.writeableFields = this.config?.currentNode?.writeable || [];
// form
this.generateForm(this.form, fields);
// ID
if (res?.flow?.related_flows && res.flow.related_flows.length > 0) {
const relatedIds = res.flow.related_flows.map(f => f.id).join(',');
this.form.related_flow_ids = relatedIds;
}
// form
const { data } = res?.flow;
for (let key in data) {
@ -806,6 +827,11 @@ export default {
: this.config?.currentNode?.readable || [];
this.writeableFields = this.config?.currentNode?.writeable || [];
this.generateForm(this.form, fields);
// ID
if (res?.flow?.related_flows && res.flow.related_flows.length > 0) {
const relatedIds = res.flow.related_flows.map(f => f.id).join(',');
this.form.related_flow_ids = relatedIds;
}
this.handleDefaultJSON();
const { data } = res?.flow;
for (let key in data) {
@ -906,6 +932,11 @@ export default {
for (let key in copyForm) {
let myField = this.fields.find(i => i.name === key)
// ID使writeableFields
if (key === 'related_flow_ids') {
continue;
}
if (myField && this.writeableFields.indexOf(myField.id) === -1) {
delete copyForm[key]
}
@ -928,7 +959,11 @@ export default {
callback = () => (this.isShowAssign = true);
break;
}
// related_flow_ids
// 便
copyForm.related_flow_ids = this.form.related_flow_ids || ''
console.log("copyForm",copyForm,this.$refs["desktopForm"].form)
console.log("related_flow_ids:", copyForm.related_flow_ids, "form.related_flow_ids:", this.form.related_flow_ids)
// return
if (this.$route.query.flow_id) {
copyForm.id = this.$route.query.flow_id;

@ -0,0 +1,184 @@
# OA模块资源加载404错误诊断和解决方案
## 错误现象
```
GET http://192.167.20.118:8080/oa/static/css/chunk-libs.19fab405.css net::ERR_ABORTED 404 (Not Found)
GET http://192.167.20.118:8080/oa/static/js/chunk-libs.62099ae1.js net::ERR_ABORTED 404 (Not Found)
app.173e33f7.js:1 Uncaught SyntaxError: Unexpected token '<<' (at app.173e33f7.js:1:1)
```
## 问题分析
### 1. 资源文件不匹配
- **浏览器请求的文件**(旧版本):
- `chunk-libs.19fab405.css`
- `chunk-libs.62099ae1.js`
- `app.173e33f7.js`
- **实际构建后的文件**(新版本):
- `chunk-libs.6087fcbe.css`
- `chunk-libs.0f03f944.js`
- `app.edef6360.js`
### 2. 根本原因
浏览器加载的HTML文件是**缓存的旧版本**,其中引用的资源文件名与当前构建后的文件名不匹配。
## 解决方案
### 方案1清除浏览器缓存快速解决
1. **Chrome/Edge浏览器**
- 按 `Ctrl+Shift+Delete` (Windows) 或 `Cmd+Shift+Delete` (Mac)
- 选择"缓存的图片和文件"
- 时间范围选择"全部时间"
- 点击"清除数据"
2. **强制刷新**
- Windows: `Ctrl+F5``Ctrl+Shift+R`
- Mac: `Cmd+Shift+R`
3. **开发者工具禁用缓存**
- 打开开发者工具 (F12)
- 在Network标签页勾选"Disable cache"
- 刷新页面
### 方案2重新构建并部署推荐
#### 步骤1检查环境变量
确保构建时设置了正确的环境变量:
```bash
cd /Users/weizongsong/www/cz_hjjc/cz-hjjc-oa
# 检查是否有 .env 文件
ls -la .env*
# 如果没有,创建 .env.production 文件
cat > .env.production << EOF
VUE_APP_MODULE_NAME=oa
VUE_APP_BASE_API=/api
EOF
```
#### 步骤2重新构建
```bash
# 清理旧的构建文件
rm -rf dist
# 使用正确的环境变量构建
VUE_APP_MODULE_NAME=oa npm run build:prod
# 或者如果已有 .env.production 文件
npm run build:prod
```
#### 步骤3检查构建输出
```bash
# 检查构建后的文件
ls -la dist/static/css/chunk-libs.*
ls -la dist/static/js/chunk-libs.*
ls -la dist/static/js/app.*
# 检查 index.html 中的引用
grep -o 'chunk-libs\.[^"]*' dist/index.html
```
#### 步骤4部署到服务器
```bash
# 将构建后的文件复制到后端public目录
cp -r dist/* /Users/weizongsong/www/cz_hjjc/backend/public/oa/
```
### 方案3配置服务器缓存策略
如果使用Nginx需要配置正确的缓存策略
```nginx
# 对于HTML文件禁用缓存或设置短缓存时间
location ~* \.html$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# 对于静态资源CSS/JS可以设置长期缓存
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
```
### 方案4在HTML中添加版本号长期方案
修改 `vue.config.js`在HTML中添加版本号参数
```javascript
// vue.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
// ... 其他配置
chainWebpack(config) {
// ... 现有配置
// 在HTML中添加版本号防止缓存
config.plugin('html').tap(args => {
args[0].templateParameters = {
...args[0].templateParameters,
version: Date.now() // 或使用 package.json 中的版本号
}
return args
})
}
}
```
然后在 `public/index.html` 中引用资源时添加版本号:
```html
<link rel="stylesheet" href="<%= BASE_URL %>static/css/app.css?v=<%= version %>">
```
## 验证步骤
1. **清除浏览器缓存后访问**
```
http://192.167.20.118:8080/oa/
```
2. **检查网络请求**
- 打开开发者工具 (F12)
- 查看Network标签页
- 确认请求的文件名与构建后的文件名一致
3. **检查控制台错误**
- 应该不再有404错误
- 应该不再有语法错误
## 预防措施
1. **构建脚本**:在构建脚本中自动清理旧文件
2. **部署脚本**:使用部署脚本确保文件正确同步
3. **版本控制**在HTML中添加构建版本号或时间戳
4. **服务器配置**正确配置Web服务器的缓存策略
## 常见问题
### Q: 为什么会出现 `Uncaught SyntaxError: Unexpected token '<<'` 错误?
A: 这通常表示浏览器请求的JS文件实际上返回的是HTML错误页面如404页面而不是真正的JS文件。HTML内容以 `<!DOCTYPE``<html>` 开头导致JS解析失败。
### Q: 如何确认服务器上的文件是否正确?
A: 直接访问资源URL
```
http://192.167.20.118:8080/oa/static/css/chunk-libs.6087fcbe.css
http://192.167.20.118:8080/oa/static/js/chunk-libs.0f03f944.js
```
如果能正常访问说明文件存在如果404说明文件不存在或路径不正确。
### Q: 构建后文件名每次都不同怎么办?
A: 这是正常的Vue CLI使用内容哈希来生成文件名。只要HTML文件中的引用与构建后的文件名匹配即可。问题在于HTML文件被缓存了。
Loading…
Cancel
Save