diff --git a/backend/app/api/project_management.py b/backend/app/api/project_management.py
index 8711e85d..8b4e7845 100644
--- a/backend/app/api/project_management.py
+++ b/backend/app/api/project_management.py
@@ -45,6 +45,7 @@ class TeamBody(Body):
class ProjectBody(Body):
projectName: str = Field(min_length=1, max_length=200)
+ projectNo: str = Field(min_length=1, max_length=64)
teamId: str
leaderUserId: str
bizTypes: List[str]
diff --git a/backend/app/services/project_management.py b/backend/app/services/project_management.py
index 40b87a3c..a1747d3a 100644
--- a/backend/app/services/project_management.py
+++ b/backend/app/services/project_management.py
@@ -333,8 +333,17 @@ def save_entity(db, tenant, user, model, body, row_id=None):
if status not in ('pending', 'active', 'completed', 'disabled'):
raise HTTPException(400, '项目状态无效')
row.project_name, row.team_id, row.biz_types, row.status = body[name_key].strip(), team.id, ','.join(dict.fromkeys(kinds)), status
- if not row_id:
- row.project_no = 'XM' + datetime.now().strftime('%Y%m%d') + generate_uuid().replace('-', '')[:16].upper()
+ project_no = str(body.get('projectNo') or '').strip()
+ if not project_no:
+ raise HTTPException(400, '项目编号不能为空')
+ if len(project_no) > 64:
+ raise HTTPException(400, '项目编号不能超过 64 个字符')
+ conflict = scoped(db, Project, tenant).filter(Project.project_no == project_no)
+ if row_id:
+ conflict = conflict.filter(Project.id != row.id)
+ if conflict.with_for_update().first():
+ raise HTTPException(400, '项目编号已存在')
+ row.project_no = project_no
try:
row.start_date = date.fromisoformat(body['startDate']) if body.get('startDate') else None
row.end_date = date.fromisoformat(body['endDate']) if body.get('endDate') else None
diff --git a/backend/tests/test_project_management.py b/backend/tests/test_project_management.py
index 0d848fd7..8add57bd 100644
--- a/backend/tests/test_project_management.py
+++ b/backend/tests/test_project_management.py
@@ -148,8 +148,8 @@ class ProjectManagementTest(unittest.TestCase):
self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User'))
self.db.commit()
result = self.svc.save_entity(self.db, 't', self.user, self.Project,
- dict(projectName='New', leaderUserId='u', teamId='g', bizTypes=['basic_experiment'], startDate='', endDate=''))
- self.assertTrue(result['projectNo'].startswith('XM'))
+ dict(projectName='New', projectNo='CUSTOM-001', leaderUserId='u', teamId='g', bizTypes=['basic_experiment'], startDate='', endDate=''))
+ self.assertEqual(result['projectNo'], 'CUSTOM-001')
self.assertIsNone(result['startDate'])
def test_conversion_table_reverse_graph_and_audit(self):
diff --git a/web-admin/src/utils/project-management.ts b/web-admin/src/utils/project-management.ts
index 9ca2de21..cd393bf6 100644
--- a/web-admin/src/utils/project-management.ts
+++ b/web-admin/src/utils/project-management.ts
@@ -22,13 +22,14 @@ export function assignmentSnapshot(records: ProjectRecord[]) {
}))
}
-/** Write bodies intentionally exclude response-only labels, timestamps and generated numbers. */
+/** Write bodies intentionally exclude response-only labels and timestamps. */
export function managementPayload(kind: 'teams' | 'projects', form: ProjectRecord): ProjectRecord {
const fields =
kind === 'teams'
? ['teamName', 'leaderUserId', 'enabled', 'remark']
: [
'projectName',
+ 'projectNo',
'teamId',
'leaderUserId',
'bizTypes',
diff --git a/web-admin/src/views/project-management/ManagementPage.vue b/web-admin/src/views/project-management/ManagementPage.vue
index 7a0ac273..4151ce27 100644
--- a/web-admin/src/views/project-management/ManagementPage.vue
+++ b/web-admin/src/views/project-management/ManagementPage.vue
@@ -149,6 +149,13 @@
v-else
v-model="form.projectName"
/>
+