From c25038a1669c346533e17ea056986f8171097f9e Mon Sep 17 00:00:00 2001 From: TalexDreamSoul Date: Tue, 7 Apr 2026 22:37:32 +0800 Subject: [PATCH 001/180] feat: extend feishu track sync --- app/components/admin/AdminAgentPanel.vue | 1 + .../admin/AdminFeishuBitableSyncEditor.vue | 40 ++ app/components/admin/ContestWorkspaceTabs.vue | 1 + app/layouts/admin.vue | 117 ++---- app/pages/admin/contests/[id].vue | 5 +- .../track-timelines/[timelineId]/edit.vue | 285 ++++++++++++++ .../contests/[id]/track-timelines/index.vue | 141 +++++++ .../contests/[id]/track-timelines/new.vue | 255 +++++++++++++ .../contests/[id]/tracks/[trackId]/edit.vue | 42 +++ .../admin/contests/[id]/tracks/index.vue | 12 + app/pages/admin/contests/[id]/tracks/new.vue | 35 ++ .../feishu-bitable-preview-draft.test.mjs | 40 +- .../tests/feishu-bitable-track-sync.test.mjs | 95 +++++ .../contests/[id]/track-timelines.get.ts | 50 +++ .../contests/[id]/track-timelines.patch.ts | 101 +++++ .../contests/[id]/track-timelines.post.ts | 97 +++++ .../api/admin/contests/[id]/tracks.patch.ts | 14 + server/api/admin/contests/[id]/tracks.post.ts | 14 + .../[id]/items/[itemId].patch.ts | 2 +- .../bitable-syncs/[id]/items/index.post.ts | 2 +- .../feishu-bitable-scheduler-worker.ts | 19 + server/plugins/feishu-post-sync-worker.ts | 17 +- server/services/feishu/bitable-sync.ts | 322 +++++++++++++++- server/utils/contest-store.ts | 347 +++++++++++++++++- server/utils/db.ts | 113 +++++- server/utils/feishu-integration-store.ts | 113 +++++- shared/types/domain.ts | 123 ++++++- shared/utils/feishu-bitable-sync-config.ts | 36 +- 28 files changed, 2302 insertions(+), 137 deletions(-) create mode 100644 app/pages/admin/contests/[id]/track-timelines/[timelineId]/edit.vue create mode 100644 app/pages/admin/contests/[id]/track-timelines/index.vue create mode 100644 app/pages/admin/contests/[id]/track-timelines/new.vue create mode 100644 scripts/tests/feishu-bitable-track-sync.test.mjs create mode 100644 server/api/admin/contests/[id]/track-timelines.get.ts create mode 100644 server/api/admin/contests/[id]/track-timelines.patch.ts create mode 100644 server/api/admin/contests/[id]/track-timelines.post.ts diff --git a/app/components/admin/AdminAgentPanel.vue b/app/components/admin/AdminAgentPanel.vue index 63d92cf..da328e2 100644 --- a/app/components/admin/AdminAgentPanel.vue +++ b/app/components/admin/AdminAgentPanel.vue @@ -96,6 +96,7 @@ const modulePathMap: Record = { overview: '/overview/edit', tracks: '/tracks', timelines: '/timelines', + track_timelines: '/track-timelines', rubrics: '/rubrics', resources: '/resources', } diff --git a/app/components/admin/AdminFeishuBitableSyncEditor.vue b/app/components/admin/AdminFeishuBitableSyncEditor.vue index 9e5d7ce..6bbe6c8 100644 --- a/app/components/admin/AdminFeishuBitableSyncEditor.vue +++ b/app/components/admin/AdminFeishuBitableSyncEditor.vue @@ -107,9 +107,30 @@ const MAPPING_OPTIONS: Record { key: 'contestExternalId', label: 'contestExternalId(赛事外部 ID)' }, { key: 'name', label: 'name(赛道名)' }, { key: 'summary', label: 'summary(简介)' }, + { key: 'coverImageUrl', label: 'coverImageUrl(封面)' }, + { key: 'location', label: 'location(具体位置)' }, + { key: 'organizer', label: 'organizer(主办方)' }, + { key: 'undertaker', label: 'undertaker(承办方)' }, + { key: 'participantRequirements', label: 'participantRequirements(参赛对象)' }, + { key: 'teamRule', label: 'teamRule(组队规则)' }, + { key: 'awardRatio', label: 'awardRatio(获奖比例)' }, { key: 'suitableMajors', label: 'suitableMajors(适用专业)' }, { key: 'deliverableTypes', label: 'deliverableTypes(交付物类型)' }, { key: 'sortOrder', label: 'sortOrder(排序)' }, + { key: 'evidenceRequirements', label: 'evidenceRequirements(必备项)' }, + { key: 'scoringPoints', label: 'scoringPoints(加分项)' }, + { key: 'deductionItems', label: 'deductionItems(扣分项)' }, + ], + track_timeline: [ + { key: 'externalId', label: 'externalId(主键)' }, + { key: 'contestExternalId', label: 'contestExternalId(赛事外部 ID)' }, + { key: 'trackExternalId', label: 'trackExternalId(赛道外部 ID)' }, + { key: 'year', label: 'year(年份)' }, + { key: 'nodeType', label: 'nodeType(节点类型)' }, + { key: 'startAt', label: 'startAt(开始时间)' }, + { key: 'endAt', label: 'endAt(结束时间)' }, + { key: 'note', label: 'note(备注)' }, + { key: 'sourceLink', label: 'sourceLink(来源链接)' }, ], resource: [ { key: 'externalId', label: 'externalId(主键)' }, @@ -139,9 +160,24 @@ const MAPPING_GUESS_ALIASES: Record = { keywords: ['keywords', '关键字', '关键词', '标签'], registrationWindow: ['registrationwindow', 'registration_window', '报名时间', '报名窗口'], submissionDeadline: ['submissiondeadline', 'submission_deadline', '截止时间', '提交截止时间', '提交时间'], + coverImageUrl: ['coverimageurl', 'cover_image_url', '封面', '封面图', '封面图片', '图片链接'], + location: ['location', '位置', '具体位置', '地点', '赛道位置'], + organizer: ['organizer', '主办方', '主办单位', '主办'], + undertaker: ['undertaker', '承办方', '承办单位', '承办'], + participantRequirements: ['participantrequirements', 'participant_requirements', '参赛对象', '适用对象', '参赛要求'], + teamRule: ['teamrule', 'team_rule', '组队规则', '组队要求'], + awardRatio: ['awardratio', 'award_ratio', '获奖比例'], suitableMajors: ['suitablemajors', '适合专业', '适用专业', '推荐专业'], deliverableTypes: ['deliverabletypes', '交付物', '成果类型', '提交物'], sortOrder: ['sortorder', '排序', '序号', 'sort', 'order'], + evidenceRequirements: ['evidencerequirements', 'evidence_requirements', '必备项', '必备材料', '必须项'], + scoringPoints: ['scoringpoints', 'scoring_points', '加分项', '亮点', '加分点'], + deductionItems: ['deductionitems', 'deduction_items', '扣分项', '风险项', '减分项'], + nodeType: ['nodetype', 'node_type', '节点类型', '阶段类型'], + startAt: ['startat', 'start_at', '开始时间', '开始日期'], + endAt: ['endat', 'end_at', '结束时间', '结束日期', '截止时间'], + note: ['note', '备注', '说明'], + sourceLink: ['sourcelink', 'source_link', '来源链接', '来源地址'], category: ['category', '分类', '资料分类'], url: ['url', '链接', '资料链接', '资源链接', '下载链接'], sourceType: ['sourcetype', '来源类型', '资源类型'], @@ -151,6 +187,7 @@ const MAPPING_GUESS_ALIASES: Record = { const ENTITY_TYPE_OPTIONS: SelectOption[] = [ { value: 'contest', label: '竞赛' }, { value: 'track', label: '赛道' }, + { value: 'track_timeline', label: '赛道时间线' }, { value: 'resource', label: '资料' }, ] @@ -211,6 +248,7 @@ const WRITEBACK_FIELD_CONFIGS: Array<{ key: SyncWritebackFieldKey, label: string const REQUIRED_MAPPING_FIELD_KEYS: Record = { contest: ['externalId', 'name', 'officialUrl'], track: ['externalId', 'contestExternalId', 'name'], + track_timeline: ['externalId', 'contestExternalId', 'trackExternalId', 'nodeType'], resource: ['externalId', 'contestExternalId', 'title', 'url'], } @@ -648,6 +686,8 @@ function previewRowStatusColor(status: string): string { function previewFocusFields(entityType: FeishuBitableSyncItemEntityType): string[] { if (entityType === 'track') return ['externalId', 'contestExternalId', 'name'] + if (entityType === 'track_timeline') + return ['externalId', 'contestExternalId', 'trackExternalId', 'nodeType', 'year'] if (entityType === 'resource') return ['externalId', 'contestExternalId', 'trackExternalId', 'title', 'url'] return ['externalId', 'name', 'officialUrl', 'registrationWindow', 'submissionDeadline'] diff --git a/app/components/admin/ContestWorkspaceTabs.vue b/app/components/admin/ContestWorkspaceTabs.vue index 2a24f50..36a1af5 100644 --- a/app/components/admin/ContestWorkspaceTabs.vue +++ b/app/components/admin/ContestWorkspaceTabs.vue @@ -12,6 +12,7 @@ const tabItems = computed(() => { { key: 'basic', label: '基础信息', to: `/admin/contests/${id}/overview/edit` }, { key: 'faq', label: 'FAQ', to: `/admin/contests/${id}/faq` }, { key: 'tracks', label: '赛道', to: `/admin/contests/${id}/tracks` }, + { key: 'track-timelines', label: '赛道时间线', to: `/admin/contests/${id}/track-timelines` }, { key: 'timelines', label: '时间节点', to: `/admin/contests/${id}/timelines` }, { key: 'rubrics', label: '评委细则', to: `/admin/contests/${id}/rubrics` }, { key: 'guidelines', label: '赛道详解', to: `/admin/contests/${id}/judge-guidelines` }, diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue index 8c6a06d..12fa5c9 100644 --- a/app/layouts/admin.vue +++ b/app/layouts/admin.vue @@ -4,7 +4,10 @@ import type { AuthMeResult, PlatformPermission, PlatformRole, + WorkspaceWithQuota, } from '~~/shared/types/domain' +import { resolveWorkspaceOptions } from '~/composables/team-ui' +import { readActiveWorkspacePreference } from '~/composables/useActiveWorkspacePreference' interface AdminNavItem { key: string @@ -24,8 +27,8 @@ const permissions = ref([]) const isPlatformAdmin = ref(false) const loadingProfile = ref(true) const profileDialogVisible = ref(false) -const loggingOut = ref(false) -const actionError = ref('') +const workspaceOptions = ref([]) +const activeWorkspaceId = ref('') const navItems: AdminNavItem[] = [ { key: 'admin-home', to: '/admin', label: '管理首页', icon: 'i-heroicons-outline-home', section: 'core' }, @@ -90,6 +93,10 @@ const showAdminBadge = computed(() => { return isPlatformAdmin.value || platformRoles.value.length > 0 || permissions.value.length > 0 }) +const userSubtitle = computed(() => { + return showAdminBadge.value ? '平台管理员' : '系统访问账号' +}) + const userInitial = computed(() => { const normalized = userName.value.trim() if (!normalized) @@ -138,6 +145,7 @@ function resolveContestModuleLabel(segment: string): string { 'overview': '基础信息', 'faq': 'FAQ', 'tracks': '赛道', + 'track-timelines': '赛道时间线', 'timelines': '时间节点', 'rubrics': '评委细则', 'judge-guidelines': '赛道详解', @@ -336,46 +344,32 @@ async function closeRouteTab(tabId: string) { } function openProfileDialog() { - actionError.value = '' profileDialogVisible.value = true } -function closeProfileDialog() { - if (loggingOut.value) - return - profileDialogVisible.value = false -} - -async function logout() { - loggingOut.value = true - actionError.value = '' - try { - await authApiFetch('/auth/logout', { method: 'POST' }) - profileDialogVisible.value = false - await navigateTo('/login') - } - catch (error: any) { - actionError.value = String(error?.data?.message || '退出失败,请稍后重试。') - } - finally { - loggingOut.value = false - } -} - async function loadProfile() { loadingProfile.value = true try { const response = await authApiFetch>('/auth/me') + const nextWorkspaceOptions = resolveWorkspaceOptions(response.data) + const preferredWorkspaceId = readActiveWorkspacePreference() + userName.value = response.data.user.username || '平台管理员' platformRoles.value = response.data.user.platformRoles || [] permissions.value = response.data.user.platformPermissions || [] isPlatformAdmin.value = Boolean(response.data.user.isPlatformAdmin) + workspaceOptions.value = nextWorkspaceOptions + activeWorkspaceId.value = preferredWorkspaceId && nextWorkspaceOptions.some(item => item.workspace.id === preferredWorkspaceId) + ? preferredWorkspaceId + : (nextWorkspaceOptions[0]?.workspace.id || '') } catch { userName.value = '未登录用户' platformRoles.value = [] permissions.value = [] isPlatformAdmin.value = false + workspaceOptions.value = [] + activeWorkspaceId.value = '' } finally { loadingProfile.value = false @@ -527,45 +521,14 @@ if (import.meta.client) { - -
- - -
- -

- {{ actionError }} -

- -
- - 关闭 - - - - 退出登录 - -
-
+ :user-name="userName" + :user-subtitle="userSubtitle" + :show-admin-badge="showAdminBadge" + :workspace-options="workspaceOptions" + :active-workspace-id="activeWorkspaceId" + /> @@ -892,25 +855,6 @@ if (import.meta.client) { padding: 12px; } -.admin-profile-card { - border: 1px solid #e2e8f0; - background: #f8fafc; - padding: 10px; -} - -.admin-profile-actions { - margin-top: 14px; - display: flex; - justify-content: flex-end; - gap: 8px; -} - -.admin-error-text { - margin: 10px 0 0; - color: #dc2626; - font-size: 11px; -} - :deep(*) { border-radius: 0 !important; } @@ -960,13 +904,4 @@ if (import.meta.client) { :deep(.arco-btn-size-mini) { font-size: 11px; } - -:deep(.arco-modal-header .arco-modal-title) { - font-size: 12px; - font-weight: 700; -} - -:deep(.arco-modal-body) { - padding-top: 10px; -} diff --git a/app/pages/admin/contests/[id].vue b/app/pages/admin/contests/[id].vue index b02fe49..c173cc5 100644 --- a/app/pages/admin/contests/[id].vue +++ b/app/pages/admin/contests/[id].vue @@ -22,7 +22,7 @@ const contestId = computed(() => { const workspaceRootPath = computed(() => `/admin/contests/${contestId.value}`) const normalizedRoutePath = computed(() => route.path.replace(/\/+$/, '')) -type WorkspaceModuleKey = 'overview' | 'faq' | 'tracks' | 'timelines' | 'rubrics' | 'resources' | 'prompts' | 'audit' +type WorkspaceModuleKey = 'overview' | 'faq' | 'tracks' | 'trackTimelines' | 'timelines' | 'rubrics' | 'resources' | 'prompts' | 'audit' const workspaceModules = computed(() => { const id = contestId.value @@ -30,6 +30,7 @@ const workspaceModules = computed(() => { { key: 'overview' as const, label: '基础信息', path: `/admin/contests/${id}/overview/edit` }, { key: 'faq' as const, label: 'FAQ', path: `/admin/contests/${id}/faq` }, { key: 'tracks' as const, label: '赛道管理', path: `/admin/contests/${id}/tracks` }, + { key: 'trackTimelines' as const, label: '赛道时间线', path: `/admin/contests/${id}/track-timelines` }, { key: 'timelines' as const, label: '时间节点', path: `/admin/contests/${id}/timelines` }, { key: 'rubrics' as const, label: '评委细则', path: `/admin/contests/${id}/rubrics` }, { key: 'resources' as const, label: '资料中心', path: `/admin/contests/${id}/resources` }, @@ -52,6 +53,8 @@ function resolveModuleFromPath(path: string): WorkspaceModuleKey { return 'faq' if (tail.startsWith('tracks')) return 'tracks' + if (tail.startsWith('track-timelines')) + return 'trackTimelines' if (tail.startsWith('timelines')) return 'timelines' if (tail.startsWith('rubrics')) diff --git a/app/pages/admin/contests/[id]/track-timelines/[timelineId]/edit.vue b/app/pages/admin/contests/[id]/track-timelines/[timelineId]/edit.vue new file mode 100644 index 0000000..073d965 --- /dev/null +++ b/app/pages/admin/contests/[id]/track-timelines/[timelineId]/edit.vue @@ -0,0 +1,285 @@ + + + diff --git a/app/pages/admin/contests/[id]/track-timelines/index.vue b/app/pages/admin/contests/[id]/track-timelines/index.vue new file mode 100644 index 0000000..b25be53 --- /dev/null +++ b/app/pages/admin/contests/[id]/track-timelines/index.vue @@ -0,0 +1,141 @@ + + + diff --git a/app/pages/admin/contests/[id]/track-timelines/new.vue b/app/pages/admin/contests/[id]/track-timelines/new.vue new file mode 100644 index 0000000..183121e --- /dev/null +++ b/app/pages/admin/contests/[id]/track-timelines/new.vue @@ -0,0 +1,255 @@ + + + diff --git a/app/pages/admin/contests/[id]/tracks/[trackId]/edit.vue b/app/pages/admin/contests/[id]/tracks/[trackId]/edit.vue index a771e86..0789455 100644 --- a/app/pages/admin/contests/[id]/tracks/[trackId]/edit.vue +++ b/app/pages/admin/contests/[id]/tracks/[trackId]/edit.vue @@ -60,6 +60,13 @@ const draftBridge = useAdminAgentDraft() const form = reactive<{ name: string summary: string + coverImageUrl: string + location: string + organizer: string + undertaker: string + participantRequirements: string + teamRule: string + awardRatio: string suitableMajorsCsv: string deliverableTypesCsv: string rubricId: string @@ -68,6 +75,13 @@ const form = reactive<{ }>({ name: '', summary: '', + coverImageUrl: '', + location: '', + organizer: '', + undertaker: '', + participantRequirements: '', + teamRule: '', + awardRatio: '', suitableMajorsCsv: '', deliverableTypesCsv: '', rubricId: '', @@ -90,6 +104,13 @@ function applyAiDraft() { const payload = moduleDraft.value?.payload || {} form.name = String(payload.name || '') form.summary = String(payload.summary || '') + form.coverImageUrl = String(payload.coverImageUrl || '') + form.location = String(payload.location || '') + form.organizer = String(payload.organizer || '') + form.undertaker = String(payload.undertaker || '') + form.participantRequirements = String(payload.participantRequirements || '') + form.teamRule = String(payload.teamRule || '') + form.awardRatio = String(payload.awardRatio || '') form.suitableMajorsCsv = toCsvFromUnknown(payload.suitableMajors) form.deliverableTypesCsv = toCsvFromUnknown(payload.deliverableTypes) form.rubricId = String(payload.rubricId || '') @@ -119,6 +140,13 @@ async function loadTrack() { } form.name = item.name form.summary = item.summary || '' + form.coverImageUrl = item.coverImageUrl || '' + form.location = item.location || '' + form.organizer = item.organizer || '' + form.undertaker = item.undertaker || '' + form.participantRequirements = item.participantRequirements || '' + form.teamRule = item.teamRule || '' + form.awardRatio = item.awardRatio || '' form.suitableMajorsCsv = toCsv(item.suitableMajors) form.deliverableTypesCsv = toCsv(item.deliverableTypes) form.rubricId = item.rubricId || '' @@ -148,6 +176,13 @@ async function save() { trackId: trackId.value, name: form.name.trim(), summary: form.summary.trim(), + coverImageUrl: form.coverImageUrl.trim(), + location: form.location.trim(), + organizer: form.organizer.trim(), + undertaker: form.undertaker.trim(), + participantRequirements: form.participantRequirements.trim(), + teamRule: form.teamRule.trim(), + awardRatio: form.awardRatio.trim(), suitableMajors: splitCsv(form.suitableMajorsCsv), deliverableTypes: splitCsv(form.deliverableTypesCsv), rubricId: form.rubricId.trim() || null, @@ -213,6 +248,13 @@ onMounted(loadTrack)
+ + + + + + + diff --git a/app/pages/admin/contests/[id]/tracks/index.vue b/app/pages/admin/contests/[id]/tracks/index.vue index 4705015..c9a7472 100644 --- a/app/pages/admin/contests/[id]/tracks/index.vue +++ b/app/pages/admin/contests/[id]/tracks/index.vue @@ -33,6 +33,9 @@ const errorText = ref('') const tracks = ref([]) const trackColumns = [ { title: '赛道名称', dataIndex: 'name', slotName: 'name', ellipsis: true, tooltip: true }, + { title: '位置', dataIndex: 'location', slotName: 'location', width: 160 }, + { title: '主办/承办', dataIndex: 'organizer', slotName: 'organizer', width: 220 }, + { title: '获奖比例', dataIndex: 'awardRatio', slotName: 'awardRatio', width: 140 }, { title: '交付物', dataIndex: 'deliverableTypes', slotName: 'deliverables', width: 220 }, { title: '状态', dataIndex: 'status', slotName: 'status', width: 120 }, { title: 'Rubric', dataIndex: 'rubricId', slotName: 'rubricId', width: 150 }, @@ -101,6 +104,15 @@ onMounted(loadTracks) + + + diff --git a/app/pages/admin/contests/[id]/tracks/new.vue b/app/pages/admin/contests/[id]/tracks/new.vue index f65dccd..52c3036 100644 --- a/app/pages/admin/contests/[id]/tracks/new.vue +++ b/app/pages/admin/contests/[id]/tracks/new.vue @@ -49,6 +49,13 @@ const draftBridge = useAdminAgentDraft() const form = reactive<{ name: string summary: string + coverImageUrl: string + location: string + organizer: string + undertaker: string + participantRequirements: string + teamRule: string + awardRatio: string suitableMajorsCsv: string deliverableTypesCsv: string rubricId: string @@ -57,6 +64,13 @@ const form = reactive<{ }>({ name: '', summary: '', + coverImageUrl: '', + location: '', + organizer: '', + undertaker: '', + participantRequirements: '', + teamRule: '', + awardRatio: '', suitableMajorsCsv: '', deliverableTypesCsv: '', rubricId: '', @@ -79,6 +93,13 @@ function applyAiDraft() { const payload = moduleDraft.value?.payload || {} form.name = String(payload.name || '') form.summary = String(payload.summary || '') + form.coverImageUrl = String(payload.coverImageUrl || '') + form.location = String(payload.location || '') + form.organizer = String(payload.organizer || '') + form.undertaker = String(payload.undertaker || '') + form.participantRequirements = String(payload.participantRequirements || '') + form.teamRule = String(payload.teamRule || '') + form.awardRatio = String(payload.awardRatio || '') form.suitableMajorsCsv = toCsv(payload.suitableMajors) form.deliverableTypesCsv = toCsv(payload.deliverableTypes) form.rubricId = String(payload.rubricId || '') @@ -110,6 +131,13 @@ async function save() { body: { name: form.name.trim(), summary: form.summary.trim(), + coverImageUrl: form.coverImageUrl.trim(), + location: form.location.trim(), + organizer: form.organizer.trim(), + undertaker: form.undertaker.trim(), + participantRequirements: form.participantRequirements.trim(), + teamRule: form.teamRule.trim(), + awardRatio: form.awardRatio.trim(), suitableMajors: splitCsv(form.suitableMajorsCsv), deliverableTypes: splitCsv(form.deliverableTypesCsv), rubricId: form.rubricId.trim() || null, @@ -167,6 +195,13 @@ async function save() {
+ + + + + + + diff --git a/scripts/tests/feishu-bitable-preview-draft.test.mjs b/scripts/tests/feishu-bitable-preview-draft.test.mjs index 937c766..f2f6a39 100644 --- a/scripts/tests/feishu-bitable-preview-draft.test.mjs +++ b/scripts/tests/feishu-bitable-preview-draft.test.mjs @@ -29,22 +29,30 @@ it('竞赛库映射会收敛到实际需要的字段并同步收窄默认模板 const componentSource = await readFile(resolve(process.cwd(), 'app/components/admin/AdminFeishuBitableSyncEditor.vue'), 'utf8') const configSource = await readFile(resolve(process.cwd(), 'shared/utils/feishu-bitable-sync-config.ts'), 'utf8') const serviceSource = await readFile(resolve(process.cwd(), 'server/services/feishu/bitable-sync.ts'), 'utf8') - - assert.doesNotMatch(componentSource, /organizer(主办方)/, '竞赛库映射仍然暴露主办方字段') - assert.doesNotMatch(componentSource, /coOrganizer(协办方)/, '竞赛库映射仍然暴露协办方字段') - assert.doesNotMatch(componentSource, /participantRequirements(参赛对象)/, '竞赛库映射仍然暴露参赛对象字段') - assert.doesNotMatch(componentSource, /teamRule(组队规则)/, '竞赛库映射仍然暴露组队规则字段') - assert.doesNotMatch(componentSource, /currentSeason(届次)/, '竞赛库映射仍然暴露届次字段') - assert.doesNotMatch(componentSource, /aliases(别名)/, '竞赛库映射仍然暴露别名字段') - assert.doesNotMatch(componentSource, /recommendedFor(推荐人群)/, '竞赛库映射仍然暴露推荐人群字段') - assert.match(componentSource, /registrationWindow(报名时间)/, '竞赛库映射缺少报名时间字段') - assert.match(componentSource, /submissionDeadline(截止时间)/, '竞赛库映射缺少截止时间字段') - assert.match(componentSource, /registrationWindow:\s*\[[\s\S]*报名时间[\s\S]*报名窗口[\s\S]*\]/, '竞赛库映射缺少报名时间字段猜测') - assert.match(componentSource, /submissionDeadline:\s*\[[\s\S]*截止时间[\s\S]*提交截止时间[\s\S]*提交时间[\s\S]*\]/, '竞赛库映射缺少截止时间字段猜测') - assert.match(componentSource, /return \['externalId', 'name', 'officialUrl', 'registrationWindow', 'submissionDeadline'\]/, '竞赛库预检重点字段未纳入时间字段') - assert.match(configSource, /fieldMap:\s*\{[\s\S]*name:\s*''[\s\S]*officialUrl:\s*''[\s\S]*summary:\s*''[\s\S]*level:\s*''[\s\S]*disciplines:\s*''[\s\S]*keywords:\s*''[\s\S]*registrationWindow:\s*''[\s\S]*submissionDeadline:\s*''[\s\S]*\}/, '竞赛库默认模板未收敛到精简字段并补齐时间字段') - assert.doesNotMatch(configSource, /organizer:\s*''/, '竞赛库默认模板仍然包含主办方') - assert.doesNotMatch(configSource, /recommendedFor:\s*''/, '竞赛库默认模板仍然包含推荐人群') + const contestMappingBlock = componentSource.match(/contest:\s*\[[\s\S]*?\n\s*\],\n\s*track:/)?.[0] || '' + const mappingAliasBlock = componentSource.match(/const MAPPING_GUESS_ALIASES: Record = \{[\s\S]*?\n\}/)?.[0] || '' + const previewFocusBlock = componentSource.match(/function previewFocusFields\(entityType: FeishuBitableSyncItemEntityType\): string\[] \{[\s\S]*?\n\}/)?.[0] || '' + const contestDefaultConfigBlock = configSource.match(/if \(entityType === 'contest'\) \{[\s\S]*?\n\s*\}\n\n\s*if \(entityType === 'track'\)/)?.[0] || '' + + assert.ok(contestMappingBlock, '未找到竞赛映射字段定义') + assert.ok(mappingAliasBlock, '未找到映射字段猜测定义') + assert.ok(previewFocusBlock, '未找到预检重点字段定义') + assert.ok(contestDefaultConfigBlock, '未找到竞赛默认模板定义') + assert.doesNotMatch(contestMappingBlock, /organizer(主办方)/, '竞赛库映射仍然暴露主办方字段') + assert.doesNotMatch(contestMappingBlock, /coOrganizer(协办方)/, '竞赛库映射仍然暴露协办方字段') + assert.doesNotMatch(contestMappingBlock, /participantRequirements(参赛对象)/, '竞赛库映射仍然暴露参赛对象字段') + assert.doesNotMatch(contestMappingBlock, /teamRule(组队规则)/, '竞赛库映射仍然暴露组队规则字段') + assert.doesNotMatch(contestMappingBlock, /currentSeason(届次)/, '竞赛库映射仍然暴露届次字段') + assert.doesNotMatch(contestMappingBlock, /aliases(别名)/, '竞赛库映射仍然暴露别名字段') + assert.doesNotMatch(contestMappingBlock, /recommendedFor(推荐人群)/, '竞赛库映射仍然暴露推荐人群字段') + assert.match(contestMappingBlock, /registrationWindow(报名时间)/, '竞赛库映射缺少报名时间字段') + assert.match(contestMappingBlock, /submissionDeadline(截止时间)/, '竞赛库映射缺少截止时间字段') + assert.match(mappingAliasBlock, /registrationWindow:\s*\[[\s\S]*报名时间[\s\S]*报名窗口[\s\S]*\]/, '竞赛库映射缺少报名时间字段猜测') + assert.match(mappingAliasBlock, /submissionDeadline:\s*\[[\s\S]*截止时间[\s\S]*提交截止时间[\s\S]*提交时间[\s\S]*\]/, '竞赛库映射缺少截止时间字段猜测') + assert.match(previewFocusBlock, /return \['externalId', 'name', 'officialUrl', 'registrationWindow', 'submissionDeadline'\]/, '竞赛库预检重点字段未纳入时间字段') + assert.match(contestDefaultConfigBlock, /fieldMap:\s*\{[\s\S]*name:\s*''[\s\S]*officialUrl:\s*''[\s\S]*summary:\s*''[\s\S]*level:\s*''[\s\S]*disciplines:\s*''[\s\S]*keywords:\s*''[\s\S]*registrationWindow:\s*''[\s\S]*submissionDeadline:\s*''[\s\S]*\}/, '竞赛库默认模板未收敛到精简字段并补齐时间字段') + assert.doesNotMatch(contestDefaultConfigBlock, /organizer:\s*''/, '竞赛库默认模板仍然包含主办方') + assert.doesNotMatch(contestDefaultConfigBlock, /recommendedFor:\s*''/, '竞赛库默认模板仍然包含推荐人群') assert.match(serviceSource, /contest:\s*\[[\s\S]*'externalId'[\s\S]*'name'[\s\S]*'officialUrl'[\s\S]*'summary'[\s\S]*'level'[\s\S]*'disciplines'[\s\S]*'keywords'[\s\S]*'registrationWindow'[\s\S]*'submissionDeadline'[\s\S]*\]/, '预检展示字段未同步补齐时间字段') assert.doesNotMatch(serviceSource, /ARRAY_PREVIEW_FIELDS[\s\S]*'aliases'/, '预检数组字段仍然保留了别名') assert.doesNotMatch(serviceSource, /ARRAY_PREVIEW_FIELDS[\s\S]*'recommendedFor'/, '预检数组字段仍然保留了推荐人群') diff --git a/scripts/tests/feishu-bitable-track-sync.test.mjs b/scripts/tests/feishu-bitable-track-sync.test.mjs new file mode 100644 index 0000000..a3be34c --- /dev/null +++ b/scripts/tests/feishu-bitable-track-sync.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { it } from 'vitest' + +it('赛道同步映射会补齐扩展字段,并暴露赛道时间线实体选项', async () => { + const componentSource = await readFile(resolve(process.cwd(), 'app/components/admin/AdminFeishuBitableSyncEditor.vue'), 'utf8') + const configSource = await readFile(resolve(process.cwd(), 'shared/utils/feishu-bitable-sync-config.ts'), 'utf8') + + assert.match(componentSource, /coverImageUrl(封面)/, '赛道映射缺少封面字段') + assert.match(componentSource, /location(具体位置)/, '赛道映射缺少位置字段') + assert.match(componentSource, /organizer(主办方)/, '赛道映射缺少主办方字段') + assert.match(componentSource, /undertaker(承办方)/, '赛道映射缺少承办方字段') + assert.match(componentSource, /participantRequirements(参赛对象)/, '赛道映射缺少参赛对象字段') + assert.match(componentSource, /teamRule(组队规则)/, '赛道映射缺少组队规则字段') + assert.match(componentSource, /awardRatio(获奖比例)/, '赛道映射缺少获奖比例字段') + assert.match(componentSource, /evidenceRequirements(必备项)/, '赛道映射缺少必备项字段') + assert.match(componentSource, /scoringPoints(加分项)/, '赛道映射缺少加分项字段') + assert.match(componentSource, /deductionItems(扣分项)/, '赛道映射缺少扣分项字段') + assert.match(componentSource, /value: 'track_timeline', label: '赛道时间线'/, '同步项实体类型缺少赛道时间线') + assert.match(componentSource, /nodeType(节点类型)/, '赛道时间线映射缺少节点类型字段') + assert.match(componentSource, /startAt(开始时间)/, '赛道时间线映射缺少开始时间字段') + assert.match(componentSource, /endAt(结束时间)/, '赛道时间线映射缺少结束时间字段') + + assert.match(configSource, /if \(entityType === 'track'\) \{[\s\S]*coverImageUrl:\s*''[\s\S]*location:\s*''[\s\S]*organizer:\s*''[\s\S]*undertaker:\s*''[\s\S]*participantRequirements:\s*''[\s\S]*teamRule:\s*''[\s\S]*awardRatio:\s*''[\s\S]*evidenceRequirements:\s*''[\s\S]*scoringPoints:\s*''[\s\S]*deductionItems:\s*''/m, '赛道默认模板未补齐扩展字段') + assert.match(configSource, /if \(entityType === 'track_timeline'\) \{[\s\S]*trackExternalIdField:\s*''[\s\S]*year:\s*''[\s\S]*nodeType:\s*''[\s\S]*startAt:\s*''[\s\S]*endAt:\s*''[\s\S]*note:\s*''[\s\S]*sourceLink:\s*''/m, '赛道时间线默认模板未生成') +}) + +it('赛道同步执行会写入扩展字段并派生 rubric', async () => { + const serviceSource = await readFile(resolve(process.cwd(), 'server/services/feishu/bitable-sync.ts'), 'utf8') + + const applyTrackMatch = serviceSource.match(/async function applyTrackRecord\([\s\S]*?\n\}\n\nasync function applyTrackTimelineRecord/) + assert.ok(applyTrackMatch, '未找到赛道同步执行逻辑') + const applyTrackBlock = applyTrackMatch[0] + + assert.match(applyTrackBlock, /getText\('coverImageUrl'\)/, '赛道同步未读取封面字段') + assert.match(applyTrackBlock, /getText\('location'\)/, '赛道同步未读取位置字段') + assert.match(applyTrackBlock, /getText\('organizer'\)/, '赛道同步未读取主办方字段') + assert.match(applyTrackBlock, /getText\('undertaker'\)/, '赛道同步未读取承办方字段') + assert.match(applyTrackBlock, /getText\('participantRequirements'\)/, '赛道同步未读取参赛对象字段') + assert.match(applyTrackBlock, /getText\('teamRule'\)/, '赛道同步未读取组队规则字段') + assert.match(applyTrackBlock, /getText\('awardRatio'\)/, '赛道同步未读取获奖比例字段') + assert.match(applyTrackBlock, /await syncTrackRubric\(db,\s*\{/, '赛道同步未在 upsert 后派生 rubric') + + assert.match(serviceSource, /async function syncTrackRubric\(/, '缺少赛道 rubric 派生 helper') + assert.match(serviceSource, /hasAnyRubricConfig = rubricKeys\.some/, '赛道 rubric 派生未按字段映射白名单触发') + assert.match(serviceSource, /patchAdminRubric\(db,\s*\{[\s\S]*scoringPoints,[\s\S]*deductionItems,[\s\S]*evidenceRequirements,/m, '赛道已有 rubric 时未走 patch') + assert.match(serviceSource, /createAdminRubric\(db,\s*\{[\s\S]*trackId: input\.trackId,[\s\S]*dimensions:\s*\[\{[\s\S]*weight:\s*100/m, '赛道缺少 rubric 时未自动创建默认 rubric') +}) + +it('赛道时间线会走独立模型、独立 API 和独立同步链路', async () => { + const domainSource = await readFile(resolve(process.cwd(), 'shared/types/domain.ts'), 'utf8') + const dbSource = await readFile(resolve(process.cwd(), 'server/utils/db.ts'), 'utf8') + const storeSource = await readFile(resolve(process.cwd(), 'server/utils/contest-store.ts'), 'utf8') + const serviceSource = await readFile(resolve(process.cwd(), 'server/services/feishu/bitable-sync.ts'), 'utf8') + const indexApiSource = await readFile(resolve(process.cwd(), 'server/api/admin/contests/[id]/track-timelines.get.ts'), 'utf8') + const postApiSource = await readFile(resolve(process.cwd(), 'server/api/admin/contests/[id]/track-timelines.post.ts'), 'utf8') + const patchApiSource = await readFile(resolve(process.cwd(), 'server/api/admin/contests/[id]/track-timelines.patch.ts'), 'utf8') + + assert.match(domainSource, /export interface TrackTimeline \{[\s\S]*trackId: string[\s\S]*nodeType: TimelineNodeType/m, '领域模型缺少 TrackTimeline') + assert.match(domainSource, /FeishuBitableSyncItemEntityType = 'contest' \| 'track' \| 'track_timeline' \| 'resource'/, '飞书同步实体类型未加入 track_timeline') + assert.match(dbSource, /CREATE TABLE IF NOT EXISTS contest_track_timelines \(/, '数据库未创建赛道时间线表') + assert.match(dbSource, /idx_contest_track_timelines_track/, '数据库未为赛道时间线建立索引') + assert.match(storeSource, /export async function listAdminTrackTimelines\(/, 'store 未暴露赛道时间线列表') + assert.match(storeSource, /export async function createAdminTrackTimeline\(/, 'store 未暴露赛道时间线创建') + assert.match(storeSource, /export async function patchAdminTrackTimeline\(/, 'store 未暴露赛道时间线更新') + assert.match(serviceSource, /async function applyTrackTimelineRecord\(/, '同步服务缺少赛道时间线执行逻辑') + assert.match(serviceSource, /scope: 'track_timeline'/, '赛道时间线执行链路未使用独立 scope') + assert.match(indexApiSource, /listAdminTrackTimelines/, '赛道时间线列表 API 未接 store') + assert.match(postApiSource, /createAdminTrackTimeline/, '赛道时间线创建 API 未接 store') + assert.match(patchApiSource, /patchAdminTrackTimeline/, '赛道时间线更新 API 未接 store') +}) + +it('后台赛道手工页与赛道时间线页面会暴露新增维护能力', async () => { + const trackNewSource = await readFile(resolve(process.cwd(), 'app/pages/admin/contests/[id]/tracks/new.vue'), 'utf8') + const trackEditSource = await readFile(resolve(process.cwd(), 'app/pages/admin/contests/[id]/tracks/[trackId]/edit.vue'), 'utf8') + const timelineIndexSource = await readFile(resolve(process.cwd(), 'app/pages/admin/contests/[id]/track-timelines/index.vue'), 'utf8') + const timelineNewSource = await readFile(resolve(process.cwd(), 'app/pages/admin/contests/[id]/track-timelines/new.vue'), 'utf8') + const timelineEditSource = await readFile(resolve(process.cwd(), 'app/pages/admin/contests/[id]/track-timelines/[timelineId]/edit.vue'), 'utf8') + const contestWorkspaceSource = await readFile(resolve(process.cwd(), 'app/pages/admin/contests/[id].vue'), 'utf8') + + assert.match(trackNewSource, /v-model="form\.coverImageUrl"/, '赛道新增页未暴露封面维护能力') + assert.match(trackNewSource, /v-model="form\.organizer"/, '赛道新增页未暴露主办方维护能力') + assert.match(trackNewSource, /v-model="form\.undertaker"/, '赛道新增页未暴露承办方维护能力') + assert.match(trackNewSource, /v-model="form\.participantRequirements"/, '赛道新增页未暴露参赛对象维护能力') + assert.match(trackNewSource, /v-model="form\.teamRule"/, '赛道新增页未暴露组队规则维护能力') + assert.match(trackNewSource, /v-model="form\.awardRatio"/, '赛道新增页未暴露获奖比例维护能力') + assert.match(trackEditSource, /form\.coverImageUrl = item\.coverImageUrl \|\| ''/, '赛道编辑页未回填封面字段') + assert.match(trackEditSource, /form\.awardRatio = item\.awardRatio \|\| ''/, '赛道编辑页未回填获奖比例字段') + + assert.match(timelineIndexSource, /赛道时间线管理/, '赛道时间线列表页未创建') + assert.match(timelineNewSource, /endpoint\(`\/admin\/contests\/\$\{contestId\.value\}\/track-timelines`\)/, '赛道时间线新增页未调用新 API') + assert.match(timelineEditSource, /trackTimelineId: timelineId\.value/, '赛道时间线编辑页未提交 trackTimelineId') + assert.match(contestWorkspaceSource, /label: '赛道时间线'/, '赛事工作台未接入赛道时间线模块入口') +}) diff --git a/server/api/admin/contests/[id]/track-timelines.get.ts b/server/api/admin/contests/[id]/track-timelines.get.ts new file mode 100644 index 0000000..f099b69 --- /dev/null +++ b/server/api/admin/contests/[id]/track-timelines.get.ts @@ -0,0 +1,50 @@ +import type { TrackTimeline } from '~~/shared/types/domain' +import { setResponseStatus } from 'h3' +import { fail, ok } from '~~/server/utils/api' +import { requireAuth } from '~~/server/utils/auth' +import { listAdminTrackTimelines } from '~~/server/utils/contest-store' +import { withClient } from '~~/server/utils/db' +import { readRuntimeSettings } from '~~/server/utils/env' +import { checkPlatformPermission } from '~~/server/utils/platform-access' + +export default defineEventHandler(async (event) => { + const startedAt = Date.now() + const runtime = readRuntimeSettings(event) + const { user } = await requireAuth(event) + const contestId = getRouterParam(event, 'id') || '' + + if (!contestId) { + setResponseStatus(event, 400) + return fail('缺少 contestId。', { + startedAt, + provider: runtime.ai.provider, + model: runtime.ai.model, + fallbackUsed: false, + attempts: 1, + }, 40074) + } + + const canWrite = await checkPlatformPermission(event, user, 'contest.write') + if (!canWrite) { + setResponseStatus(event, 403) + return fail('当前用户无权查看赛道时间线。', { + startedAt, + provider: runtime.ai.provider, + model: runtime.ai.model, + fallbackUsed: false, + attempts: 1, + }, 40374) + } + + const timelines = await withClient(event, async (db) => { + return listAdminTrackTimelines(db, contestId) + }) + + return ok(timelines, { + startedAt, + provider: runtime.ai.provider, + model: runtime.ai.model, + fallbackUsed: false, + attempts: 1, + }) +}) diff --git a/server/api/admin/contests/[id]/track-timelines.patch.ts b/server/api/admin/contests/[id]/track-timelines.patch.ts new file mode 100644 index 0000000..18b4cd2 --- /dev/null +++ b/server/api/admin/contests/[id]/track-timelines.patch.ts @@ -0,0 +1,101 @@ +import type { TimelineNodeType } from '~~/shared/types/domain' +import { setResponseStatus } from 'h3' +import { fail, ok } from '~~/server/utils/api' +import { requireAuth } from '~~/server/utils/auth' +import { patchAdminTrackTimeline } from '~~/server/utils/contest-store' +import { withTransaction } from '~~/server/utils/db' +import { readRuntimeSettings } from '~~/server/utils/env' +import { checkPlatformPermission } from '~~/server/utils/platform-access' + +interface PatchTrackTimelineBody { + trackTimelineId?: string + trackId?: string + year?: number + nodeType?: TimelineNodeType + startAt?: string | null + endAt?: string | null + note?: string + sourceLink?: string +} + +export default defineEventHandler(async (event) => { + const startedAt = Date.now() + const runtime = readRuntimeSettings(event) + const { user } = await requireAuth(event) + const contestId = getRouterParam(event, 'id') || '' + const body = await readBody(event) + + if (!contestId || !body?.trackTimelineId) { + setResponseStatus(event, 400) + return fail('缺少 contestId 或 trackTimelineId。', { + startedAt, + provider: runtime.ai.provider, + model: runtime.ai.model, + fallbackUsed: false, + attempts: 1, + }, 40078) + } + + const canWrite = await checkPlatformPermission(event, user, 'contest.write') + if (!canWrite) { + setResponseStatus(event, 403) + return fail('当前用户无权编辑赛道时间线。', { + startedAt, + provider: runtime.ai.provider, + model: runtime.ai.model, + fallbackUsed: false, + attempts: 1, + }, 40378) + } + + try { + const timeline = await withTransaction(event, async (db) => { + return patchAdminTrackTimeline(db, { + actorUserId: user.id, + contestId, + trackTimelineId: body.trackTimelineId!, + patch: { + trackId: body?.trackId, + year: body?.year, + nodeType: body?.nodeType, + startAt: body?.startAt, + endAt: body?.endAt, + note: body?.note, + sourceLink: body?.sourceLink, + }, + }) + }) + + if (!timeline) { + setResponseStatus(event, 404) + return fail('track timeline not found', { + startedAt, + provider: runtime.ai.provider, + model: runtime.ai.model, + fallbackUsed: false, + attempts: 1, + }, 40478) + } + + return ok(timeline, { + startedAt, + provider: runtime.ai.provider, + model: runtime.ai.model, + fallbackUsed: false, + attempts: 1, + }) + } + catch (error) { + if (error instanceof Error && error.message === 'TRACK_NOT_FOUND') { + setResponseStatus(event, 400) + return fail('trackId 不属于当前赛事。', { + startedAt, + provider: runtime.ai.provider, + model: runtime.ai.model, + fallbackUsed: false, + attempts: 1, + }, 40079) + } + throw error + } +}) diff --git a/server/api/admin/contests/[id]/track-timelines.post.ts b/server/api/admin/contests/[id]/track-timelines.post.ts new file mode 100644 index 0000000..9b0401c --- /dev/null +++ b/server/api/admin/contests/[id]/track-timelines.post.ts @@ -0,0 +1,97 @@ +import type { TimelineNodeType } from '~~/shared/types/domain' +import { setResponseStatus } from 'h3' +import { fail, ok } from '~~/server/utils/api' +import { requireAuth } from '~~/server/utils/auth' +import { createAdminTrackTimeline } from '~~/server/utils/contest-store' +import { withTransaction } from '~~/server/utils/db' +import { readRuntimeSettings } from '~~/server/utils/env' +import { checkPlatformPermission } from '~~/server/utils/platform-access' + +interface CreateTrackTimelineBody { + trackId?: string + year?: number + nodeType?: TimelineNodeType + startAt?: string | null + endAt?: string | null + note?: string + sourceLink?: string +} + +export default defineEventHandler(async (event) => { + const startedAt = Date.now() + const runtime = readRuntimeSettings(event) + const { user } = await requireAuth(event) + const contestId = getRouterParam(event, 'id') || '' + const body = await readBody(event) + + if (!contestId) { + setResponseStatus(event, 400) + return fail('缺少 contestId。', { + startedAt, + provider: runtime.ai.provider, + model: runtime.ai.model, + fallbackUsed: false, + attempts: 1, + }, 40075) + } + + const canWrite = await checkPlatformPermission(event, user, 'contest.write') + if (!canWrite) { + setResponseStatus(event, 403) + return fail('当前用户无权新增赛道时间线。', { + startedAt, + provider: runtime.ai.provider, + model: runtime.ai.model, + fallbackUsed: false, + attempts: 1, + }, 40375) + } + + if (!body?.trackId || !body?.nodeType) { + setResponseStatus(event, 400) + return fail('trackId 与 nodeType 不能为空。', { + startedAt, + provider: runtime.ai.provider, + model: runtime.ai.model, + fallbackUsed: false, + attempts: 1, + }, 40076) + } + + try { + const timeline = await withTransaction(event, async (db) => { + return createAdminTrackTimeline(db, { + actorUserId: user.id, + contestId, + trackId: body.trackId!, + year: Number(body?.year || new Date().getFullYear()), + nodeType: body.nodeType!, + startAt: body?.startAt || null, + endAt: body?.endAt || null, + note: body?.note, + sourceLink: body?.sourceLink, + }) + }) + + return ok(timeline, { + startedAt, + provider: runtime.ai.provider, + model: runtime.ai.model, + fallbackUsed: false, + attempts: 1, + }) + } + catch (error) { + if (error instanceof Error && error.message === 'TRACK_NOT_FOUND') { + setResponseStatus(event, 400) + return fail('trackId 不属于当前赛事。', { + startedAt, + provider: runtime.ai.provider, + model: runtime.ai.model, + fallbackUsed: false, + attempts: 1, + }, 40077) + } + throw error + } +}) diff --git a/server/api/admin/contests/[id]/tracks.patch.ts b/server/api/admin/contests/[id]/tracks.patch.ts index 6d6961f..2063da3 100644 --- a/server/api/admin/contests/[id]/tracks.patch.ts +++ b/server/api/admin/contests/[id]/tracks.patch.ts @@ -11,6 +11,13 @@ interface PatchTrackBody { trackId?: string name?: string summary?: string + coverImageUrl?: string + location?: string + organizer?: string + undertaker?: string + participantRequirements?: string + teamRule?: string + awardRatio?: string suitableMajors?: string[] deliverableTypes?: string[] rubricId?: string | null @@ -58,6 +65,13 @@ export default defineEventHandler(async (event) => { patch: { name: body?.name, summary: body?.summary, + coverImageUrl: body?.coverImageUrl, + location: body?.location, + organizer: body?.organizer, + undertaker: body?.undertaker, + participantRequirements: body?.participantRequirements, + teamRule: body?.teamRule, + awardRatio: body?.awardRatio, suitableMajors: body?.suitableMajors, deliverableTypes: body?.deliverableTypes, rubricId: body?.rubricId, diff --git a/server/api/admin/contests/[id]/tracks.post.ts b/server/api/admin/contests/[id]/tracks.post.ts index 90e6a03..475eddd 100644 --- a/server/api/admin/contests/[id]/tracks.post.ts +++ b/server/api/admin/contests/[id]/tracks.post.ts @@ -10,6 +10,13 @@ import { checkPlatformPermission } from '~~/server/utils/platform-access' interface CreateTrackBody { name?: string summary?: string + coverImageUrl?: string + location?: string + organizer?: string + undertaker?: string + participantRequirements?: string + teamRule?: string + awardRatio?: string suitableMajors?: string[] deliverableTypes?: string[] rubricId?: string | null @@ -65,6 +72,13 @@ export default defineEventHandler(async (event) => { contestId, name, summary: body?.summary, + coverImageUrl: body?.coverImageUrl, + location: body?.location, + organizer: body?.organizer, + undertaker: body?.undertaker, + participantRequirements: body?.participantRequirements, + teamRule: body?.teamRule, + awardRatio: body?.awardRatio, suitableMajors: body?.suitableMajors, deliverableTypes: body?.deliverableTypes, rubricId: body?.rubricId, diff --git a/server/api/admin/integrations/feishu/bitable-syncs/[id]/items/[itemId].patch.ts b/server/api/admin/integrations/feishu/bitable-syncs/[id]/items/[itemId].patch.ts index 6cea06e..d55de28 100644 --- a/server/api/admin/integrations/feishu/bitable-syncs/[id]/items/[itemId].patch.ts +++ b/server/api/admin/integrations/feishu/bitable-syncs/[id]/items/[itemId].patch.ts @@ -26,7 +26,7 @@ interface PatchItemBody { schedule?: Partial } -const ENTITY_TYPES: FeishuBitableSyncItemEntityType[] = ['contest', 'track', 'resource'] +const ENTITY_TYPES: FeishuBitableSyncItemEntityType[] = ['contest', 'track', 'track_timeline', 'resource'] export default defineEventHandler(async (event) => { const startedAt = Date.now() diff --git a/server/api/admin/integrations/feishu/bitable-syncs/[id]/items/index.post.ts b/server/api/admin/integrations/feishu/bitable-syncs/[id]/items/index.post.ts index 4f87fa6..84b6728 100644 --- a/server/api/admin/integrations/feishu/bitable-syncs/[id]/items/index.post.ts +++ b/server/api/admin/integrations/feishu/bitable-syncs/[id]/items/index.post.ts @@ -26,7 +26,7 @@ interface CreateItemBody { schedule?: Partial } -const ENTITY_TYPES: FeishuBitableSyncItemEntityType[] = ['contest', 'track', 'resource'] +const ENTITY_TYPES: FeishuBitableSyncItemEntityType[] = ['contest', 'track', 'track_timeline', 'resource'] function toText(raw: unknown): string { return String(raw || '').trim() diff --git a/server/plugins/feishu-bitable-scheduler-worker.ts b/server/plugins/feishu-bitable-scheduler-worker.ts index 26f9601..4fcf2f3 100644 --- a/server/plugins/feishu-bitable-scheduler-worker.ts +++ b/server/plugins/feishu-bitable-scheduler-worker.ts @@ -9,6 +9,7 @@ import { } from '~~/server/utils/feishu-integration-store' import { computeNextScheduledRunAtOrNull } from '~~/server/utils/feishu-task-schedule' import { readEffectivePlatformRuntimeSettings } from '~~/server/utils/platform-runtime-config-store' +import { captureServerException } from '~~/server/utils/sentry' const WORKER_RUNTIME_STATE_KEY = Symbol.for('winloop.feishu-bitable-scheduler-worker.runtime.v1') @@ -57,6 +58,9 @@ function ensureTickTimer(intervalMs: number): void { runtimeState.timer = setInterval(() => { void runTick().catch((error) => { console.error('[feishu-bitable-scheduler-worker] tick failed:', toErrorMessage(error)) + captureServerException(error, { + module: 'feishu-bitable-scheduler-worker', + }) }) }, nextInterval) runtimeState.timer.unref?.() @@ -97,6 +101,9 @@ async function executeClaimedSync(input: { syncItemId: item.id, error: message, }) + captureServerException(error, { + module: 'feishu-bitable-scheduler-worker', + }) } } lastError = errors.slice(0, 3).join(';') @@ -108,6 +115,9 @@ async function executeClaimedSync(input: { syncId: input.syncId, error: lastError, }) + captureServerException(error, { + module: 'feishu-bitable-scheduler-worker', + }) } let completed = false @@ -131,6 +141,9 @@ async function executeClaimedSync(input: { syncId: input.syncId, error: toErrorMessage(error), }) + captureServerException(error, { + module: 'feishu-bitable-scheduler-worker', + }) } if (!completed) { @@ -144,6 +157,9 @@ async function executeClaimedSync(input: { syncId: input.syncId, error: toErrorMessage(error), }) + captureServerException(error, { + module: 'feishu-bitable-scheduler-worker', + }) }) } } @@ -205,6 +221,9 @@ export default defineNitroPlugin((nitroApp) => { void runTick().catch((error) => { console.error('[feishu-bitable-scheduler-worker] bootstrap failed:', toErrorMessage(error)) + captureServerException(error, { + module: 'feishu-bitable-scheduler-worker', + }) }) nitroApp.hooks.hookOnce('close', () => { diff --git a/server/plugins/feishu-post-sync-worker.ts b/server/plugins/feishu-post-sync-worker.ts index 073f088..16168da 100644 --- a/server/plugins/feishu-post-sync-worker.ts +++ b/server/plugins/feishu-post-sync-worker.ts @@ -17,6 +17,7 @@ import { upsertFeishuSearchIndexDoc, upsertFeishuVectorChunk, } from '~~/server/utils/feishu-integration-store' +import { captureServerException } from '~~/server/utils/sentry' const FEISHU_POST_SYNC_WORKER_KEY = Symbol.for('winloop.feishu.post-sync-worker.runtime.v1') @@ -123,7 +124,7 @@ async function handleEmbeddingUpsert( id: string syncItemId: string | null runId: string | null - scope: 'contest' | 'track' | 'resource' + scope: 'contest' | 'track' | 'track_timeline' | 'resource' entityId: string externalId: string sourceHash: string @@ -194,7 +195,7 @@ async function handleSearchIndexRefresh( id: string syncItemId: string | null runId: string | null - scope: 'contest' | 'track' | 'resource' + scope: 'contest' | 'track' | 'track_timeline' | 'resource' entityId: string externalId: string sourceHash: string @@ -234,7 +235,7 @@ async function handleEntityAnalysis( id: string syncItemId: string | null runId: string | null - scope: 'contest' | 'track' | 'resource' + scope: 'contest' | 'track' | 'track_timeline' | 'resource' entityId: string externalId: string sourceHash: string @@ -379,6 +380,10 @@ async function processSingleTask(): Promise { }, }) }) + captureServerException(error, { + module: 'feishu-post-sync-worker', + taskId: task.id, + }) } return true @@ -415,11 +420,17 @@ export default defineNitroPlugin((nitroApp) => { void runTick().catch((error) => { console.error('[feishu-post-sync-worker] bootstrap failed:', toErrorMessage(error)) + captureServerException(error, { + module: 'feishu-post-sync-worker', + }) }) runtimeState.timer = setInterval(() => { void runTick().catch((error) => { console.error('[feishu-post-sync-worker] tick failed:', toErrorMessage(error)) + captureServerException(error, { + module: 'feishu-post-sync-worker', + }) }) }, intervalMs) runtimeState.timer.unref?.() diff --git a/server/services/feishu/bitable-sync.ts b/server/services/feishu/bitable-sync.ts index 1f4ce7d..862ffd6 100644 --- a/server/services/feishu/bitable-sync.ts +++ b/server/services/feishu/bitable-sync.ts @@ -18,6 +18,7 @@ import type { ResourceCategory, ResourceStatus, ScopeType, + TimelineNodeType, } from '~~/shared/types/domain' import { createHash } from 'node:crypto' import jsonata from 'jsonata' @@ -32,10 +33,14 @@ import { import { createAdminContest, createAdminResource, + createAdminRubric, createAdminTrack, + createAdminTrackTimeline, patchAdminContest, + patchAdminRubric, patchAdminResource, patchAdminTrack, + patchAdminTrackTimeline, syncContestDerivedTimelineNodes, } from '~~/server/utils/contest-store' import { withClient, withTransaction } from '~~/server/utils/db' @@ -151,9 +156,30 @@ const TARGET_PREVIEW_FIELDS: Record = 'contestExternalId', 'name', 'summary', + 'coverImageUrl', + 'location', + 'organizer', + 'undertaker', + 'participantRequirements', + 'teamRule', + 'awardRatio', 'suitableMajors', 'deliverableTypes', 'sortOrder', + 'evidenceRequirements', + 'scoringPoints', + 'deductionItems', + ], + track_timeline: [ + 'externalId', + 'contestExternalId', + 'trackExternalId', + 'year', + 'nodeType', + 'startAt', + 'endAt', + 'note', + 'sourceLink', ], resource: [ 'externalId', @@ -173,6 +199,7 @@ const TARGET_PREVIEW_FIELDS: Record = const REQUIRED_TARGET_FIELDS: Record = { contest: ['name', 'officialUrl'], track: ['contestExternalId', 'name'], + track_timeline: ['contestExternalId', 'trackExternalId', 'nodeType'], resource: ['contestExternalId', 'title', 'url'], } @@ -181,6 +208,9 @@ const ARRAY_PREVIEW_FIELDS = new Set([ 'keywords', 'suitableMajors', 'deliverableTypes', + 'evidenceRequirements', + 'scoringPoints', + 'deductionItems', ]) function parseJsonObject(raw: unknown): Record { @@ -205,7 +235,7 @@ function toStringArray(raw: unknown): string[] { if (Array.isArray(raw)) { const result: string[] = [] for (const item of raw) { - const normalized = toText(typeof item === 'object' && item ? ((item as any).text ?? (item as any).name ?? item) : item) + const normalized = toText(typeof item === 'object' && item ? ((item as any).text ?? (item as any).name ?? (item as any).url ?? item) : item) if (normalized) result.push(normalized) } @@ -220,7 +250,7 @@ function toStringArray(raw: unknown): string[] { } function isEntityType(raw: unknown): raw is FeishuBitableSyncItemEntityType { - return raw === 'contest' || raw === 'track' || raw === 'resource' + return raw === 'contest' || raw === 'track' || raw === 'track_timeline' || raw === 'resource' } function resolvePreviewOverrideString( @@ -258,7 +288,7 @@ function normalizeSpecialText(raw: unknown): string { } if (raw && typeof raw === 'object') { const objectRaw = raw as Record - return toText(objectRaw.text ?? objectRaw.name ?? '') + return toText(objectRaw.text ?? objectRaw.name ?? objectRaw.url ?? '') } return toText(raw) } @@ -439,6 +469,50 @@ function mapResourceCategory(raw: string, fallback: ResourceCategory): ResourceC return fallback } +function mapTimelineNodeType(raw: string): TimelineNodeType | null { + const value = String(raw || '').trim().toLowerCase() + if (!value) + return null + if (value === 'registration' || value.includes('报名')) + return 'registration' + if (value === 'submission' || value.includes('提交') || value.includes('截止')) + return 'submission' + if (value === 'preliminary' || value.includes('初赛') || value.includes('初审') || value.includes('预赛')) + return 'preliminary' + if (value === 'final' || value.includes('决赛') || value.includes('终审') || value.includes('答辩')) + return 'final' + if (value === 'other' || value.includes('其他')) + return 'other' + return null +} + +function normalizeTimelineDateText(raw: string): string | null { + const text = toText(raw) + if (!text) + return null + const timestamp = new Date(text).getTime() + if (Number.isNaN(timestamp)) + return null + return new Date(timestamp).toISOString() +} + +function inferTimelineYear(input: { + yearText: string + startAt: string | null + endAt: string | null +}): number { + const explicitYear = Number(input.yearText || 0) + if (Number.isInteger(explicitYear) && explicitYear >= 2000 && explicitYear <= 2100) + return explicitYear + + const fromDate = [input.startAt, input.endAt] + .filter(Boolean) + .map(value => new Date(String(value)).getFullYear()) + .find(value => Number.isInteger(value) && value >= 2000 && value <= 2100) + + return fromDate || new Date().getFullYear() +} + function normalizeFieldMap(raw: unknown): Record { const source = parseJsonObject(raw) const result: Record = {} @@ -1263,6 +1337,68 @@ async function applyContestRecord( } } +async function syncTrackRubric( + db: Queryable, + input: { + actorUserId: string + contestId: string + trackId: string + rubricId?: string | null + mapping: NormalizedMapping + resolver: RecordValueResolver + dryRun: boolean + }, +): Promise { + const rubricKeys = ['evidenceRequirements', 'scoringPoints', 'deductionItems'] + const hasAnyRubricConfig = rubricKeys.some(key => hasMappingValue(input.mapping, key)) + if (!hasAnyRubricConfig) + return input.rubricId || null + + const evidenceRequirements = hasMappingValue(input.mapping, 'evidenceRequirements') + ? await input.resolver.getStringArray('evidenceRequirements') + : undefined + const scoringPoints = hasMappingValue(input.mapping, 'scoringPoints') + ? await input.resolver.getStringArray('scoringPoints') + : undefined + const deductionItems = hasMappingValue(input.mapping, 'deductionItems') + ? await input.resolver.getStringArray('deductionItems') + : undefined + + if (input.dryRun) + return input.rubricId || null + + if (input.rubricId) { + const rubric = await patchAdminRubric(db, { + actorUserId: input.actorUserId, + contestId: input.contestId, + rubricId: input.rubricId, + patch: { + scoringPoints, + deductionItems, + evidenceRequirements, + }, + }) + return rubric?.id || input.rubricId + } + + const rubric = await createAdminRubric(db, { + actorUserId: input.actorUserId, + contestId: input.contestId, + trackId: input.trackId, + dimensions: [{ + key: 'overall', + name: '综合评估', + weight: 100, + description: '赛道同步自动创建的默认维度。', + }], + scoringPoints, + deductionItems, + evidenceRequirements, + status: 'draft', + }) + return rubric.id +} + async function applyTrackRecord( db: Queryable, input: { @@ -1312,7 +1448,7 @@ async function applyTrackRecord( if (existingRef) { if (!input.dryRun) { - await patchAdminTrack(db, { + const updatedTrack = await patchAdminTrack(db, { actorUserId: input.actorUserId, contestId: contestLink.contestId, trackId: existingRef.entityId, @@ -1320,11 +1456,27 @@ async function applyTrackRecord( patch: { name, summary: await input.resolver.getText('summary'), + coverImageUrl: await input.resolver.getText('coverImageUrl'), + location: await input.resolver.getText('location'), + organizer: await input.resolver.getText('organizer'), + undertaker: await input.resolver.getText('undertaker'), + participantRequirements: await input.resolver.getText('participantRequirements'), + teamRule: await input.resolver.getText('teamRule'), + awardRatio: await input.resolver.getText('awardRatio'), suitableMajors: await input.resolver.getStringArray('suitableMajors'), deliverableTypes: await input.resolver.getStringArray('deliverableTypes'), sortOrder: Number(await input.resolver.getText('sortOrder') || 0), }, }) + await syncTrackRubric(db, { + actorUserId: input.actorUserId, + contestId: contestLink.contestId, + trackId: existingRef.entityId, + rubricId: updatedTrack?.rubricId || null, + mapping: input.mapping, + resolver: input.resolver, + dryRun: input.dryRun, + }) await upsertFeishuExternalRef(db, { syncItemId: input.syncItemId, scope: 'track', @@ -1347,10 +1499,26 @@ async function applyTrackRecord( contestId: contestLink.contestId, name, summary: await input.resolver.getText('summary'), + coverImageUrl: await input.resolver.getText('coverImageUrl'), + location: await input.resolver.getText('location'), + organizer: await input.resolver.getText('organizer'), + undertaker: await input.resolver.getText('undertaker'), + participantRequirements: await input.resolver.getText('participantRequirements'), + teamRule: await input.resolver.getText('teamRule'), + awardRatio: await input.resolver.getText('awardRatio'), suitableMajors: await input.resolver.getStringArray('suitableMajors'), deliverableTypes: await input.resolver.getStringArray('deliverableTypes'), sortOrder: Number(await input.resolver.getText('sortOrder') || 0), }) + await syncTrackRubric(db, { + actorUserId: input.actorUserId, + contestId: contestLink.contestId, + trackId: created.id, + rubricId: created.rubricId || null, + mapping: input.mapping, + resolver: input.resolver, + dryRun: input.dryRun, + }) await upsertFeishuExternalRef(db, { syncItemId: input.syncItemId, scope: 'track', @@ -1367,6 +1535,139 @@ async function applyTrackRecord( } } +async function applyTrackTimelineRecord( + db: Queryable, + input: { + actorUserId: string + syncItemId: string + record: FeishuBitableRecord + externalId: string + mapping: NormalizedMapping + options: NormalizedOptions + resolver: RecordValueResolver + dryRun: boolean + }, +): Promise { + const contestLink = await resolveContestIdByExternal(db, { + options: input.options, + resolver: input.resolver, + }) + if (!contestLink.contestId) { + return { + status: 'skipped', + externalId: input.externalId, + reasonCode: 'CONTEST_REF_NOT_FOUND', + message: '赛道时间线记录未找到关联赛事(contestExternalId 未映射或未完成绑定)。', + payload: { + contestExternalId: contestLink.contestExternalId, + }, + } + } + + const trackLink = await resolveTrackIdByExternal(db, { + resolver: input.resolver, + }) + if (!trackLink.trackId) { + return { + status: 'skipped', + externalId: input.externalId, + reasonCode: 'TRACK_REF_NOT_FOUND', + message: '赛道时间线记录未找到关联赛道(trackExternalId 未映射或未完成绑定)。', + payload: { + trackExternalId: trackLink.trackExternalId, + }, + } + } + + const nodeType = mapTimelineNodeType(await input.resolver.getText('nodeType')) + if (!nodeType) { + return { + status: 'skipped', + externalId: input.externalId, + reasonCode: 'MISSING_REQUIRED_FIELD', + message: '赛道时间线记录缺少必要字段 nodeType。', + payload: { + hasNodeType: false, + }, + } + } + + const startAt = normalizeTimelineDateText(await input.resolver.getText('startAt')) + const endAt = normalizeTimelineDateText(await input.resolver.getText('endAt')) + const year = inferTimelineYear({ + yearText: await input.resolver.getText('year'), + startAt, + endAt, + }) + + const existingRef = await getFeishuExternalRef(db, { + scope: 'track_timeline', + externalId: input.externalId, + }) + + if (existingRef) { + if (!input.dryRun) { + await patchAdminTrackTimeline(db, { + actorUserId: input.actorUserId, + contestId: contestLink.contestId, + trackTimelineId: existingRef.entityId, + patch: { + trackId: trackLink.trackId, + year, + nodeType, + startAt, + endAt, + note: await input.resolver.getText('note'), + sourceLink: await input.resolver.getText('sourceLink'), + }, + }) + await upsertFeishuExternalRef(db, { + syncItemId: input.syncItemId, + scope: 'track_timeline', + externalId: input.externalId, + entityId: existingRef.entityId, + metadata: { + contestId: contestLink.contestId, + trackId: trackLink.trackId, + }, + }) + } + return { + status: 'updated', + externalId: input.externalId, + } + } + + if (!input.dryRun) { + const created = await createAdminTrackTimeline(db, { + actorUserId: input.actorUserId, + contestId: contestLink.contestId, + trackId: trackLink.trackId, + year, + nodeType, + startAt, + endAt, + note: await input.resolver.getText('note'), + sourceLink: await input.resolver.getText('sourceLink'), + }) + await upsertFeishuExternalRef(db, { + syncItemId: input.syncItemId, + scope: 'track_timeline', + externalId: input.externalId, + entityId: created.id, + metadata: { + contestId: contestLink.contestId, + trackId: trackLink.trackId, + }, + }) + } + + return { + status: 'created', + externalId: input.externalId, + } +} + async function applyResourceRecord( db: Queryable, input: { @@ -1563,6 +1864,19 @@ async function applySingleRecord( }) } + if (input.entityType === 'track_timeline') { + return applyTrackTimelineRecord(db, { + actorUserId: input.actorUserId, + syncItemId: input.syncItemId, + record: input.record, + externalId, + mapping: input.mapping, + options: input.options, + resolver, + dryRun: input.dryRun, + }) + } + return applyResourceRecord(db, { actorUserId: input.actorUserId, syncItemId: input.syncItemId, diff --git a/server/utils/contest-store.ts b/server/utils/contest-store.ts index cc9fb66..7bb89cc 100644 --- a/server/utils/contest-store.ts +++ b/server/utils/contest-store.ts @@ -26,6 +26,7 @@ import type { RubricScoringMode, TimelineNodeType, Track, + TrackTimeline, WorkspaceBillingEstimate, } from '~~/shared/types/domain' import { randomUUID } from 'node:crypto' @@ -1538,6 +1539,13 @@ interface TrackRow { contest_id: string name: string summary: string + cover_image_url: string + location: string + organizer: string + undertaker: string + participant_requirements: string + team_rule: string + award_ratio: string suitable_majors: string[] deliverable_types: string[] rubric_id: string | null @@ -1556,6 +1564,18 @@ interface TimelineRow { source_link: string } +interface TrackTimelineRow { + id: string + contest_id: string + track_id: string + year: number + node_type: TimelineNodeType + start_at: string | null + end_at: string | null + note: string + source_link: string +} + interface RubricRow { id: string contest_id: string @@ -1707,12 +1727,31 @@ function dedupeBy(items: T[], keyOf: (item: T) => string): T[] { return result } +async function assertTrackExistsForContest(db: Queryable, contestId: string, trackId: string): Promise { + const result = await db.query<{ id: string }>( + `SELECT id + FROM contest_tracks + WHERE id = $1 AND contest_id = $2 + LIMIT 1`, + [trackId, contestId], + ) + if (!result.rows[0]) + throw new Error('TRACK_NOT_FOUND') +} + function mapTrack(row: TrackRow): Track { return { id: row.id, contestId: row.contest_id, name: row.name, summary: row.summary, + coverImageUrl: row.cover_image_url, + location: row.location, + organizer: row.organizer, + undertaker: row.undertaker, + participantRequirements: row.participant_requirements, + teamRule: row.team_rule, + awardRatio: row.award_ratio, suitableMajors: normalizeStringArray(row.suitable_majors), deliverableTypes: normalizeStringArray(row.deliverable_types), rubricId: row.rubric_id || null, @@ -1734,6 +1773,20 @@ function mapTimeline(row: TimelineRow): ContestTimeline { } } +function mapTrackTimeline(row: TrackTimelineRow): TrackTimeline { + return { + id: row.id, + contestId: row.contest_id, + trackId: row.track_id, + year: Number(row.year || 0), + nodeType: row.node_type, + startAt: row.start_at, + endAt: row.end_at, + note: row.note, + sourceLink: row.source_link, + } +} + function formatDateOnly(value: string | null | undefined): string { if (!value) return '' @@ -2073,9 +2126,9 @@ export async function ensureDefaultBillingPlans(db: Queryable): Promise { name: 'Personal Team', planTier: 'personal_team', basePriceCents: 0, - includedSeats: 5, + includedSeats: 15, extraSeatPriceCents: 0, - includedAiQuota: 500, + includedAiQuota: 100, includedProjects: 0, projectsUnlimited: true, extraProjectSlotPriceCents: 0, @@ -2471,7 +2524,23 @@ async function loadTracks(db: Queryable, contestIds: string[], includeInternal: return [] const result = await db.query( - `SELECT id, contest_id, name, summary, suitable_majors, deliverable_types, rubric_id, sort_order, status + `SELECT + id, + contest_id, + name, + summary, + cover_image_url, + location, + organizer, + undertaker, + participant_requirements, + team_rule, + award_ratio, + suitable_majors, + deliverable_types, + rubric_id, + sort_order, + status FROM contest_tracks WHERE contest_id = ANY($1::TEXT[]) AND ($2::BOOLEAN = TRUE OR status = 'published') @@ -2497,6 +2566,30 @@ async function loadTimelines(db: Queryable, contestIds: string[]): Promise { + if (contestIds.length === 0) + return [] + + const result = await db.query( + `SELECT + id, + contest_id, + track_id, + year, + node_type, + start_at::TEXT, + end_at::TEXT, + note, + source_link + FROM contest_track_timelines + WHERE contest_id = ANY($1::TEXT[]) + ORDER BY year DESC, created_at ASC`, + [contestIds], + ) + + return result.rows +} + function isUpcomingDeadline(contest: Contest): boolean { if (!contest.submissionDeadline) return false @@ -3594,6 +3687,13 @@ export async function createAdminTrack( contestId: string name: string summary?: string + coverImageUrl?: string + location?: string + organizer?: string + undertaker?: string + participantRequirements?: string + teamRule?: string + awardRatio?: string suitableMajors?: string[] deliverableTypes?: string[] rubricId?: string | null @@ -3610,6 +3710,13 @@ export async function createAdminTrack( contest_id, name, summary, + cover_image_url, + location, + organizer, + undertaker, + participant_requirements, + team_rule, + award_ratio, suitable_majors, deliverable_types, rubric_id, @@ -3618,13 +3725,20 @@ export async function createAdminTrack( created_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5::TEXT[], $6::TEXT[], $7, $8, $9, $10, $10 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::TEXT[], $13::TEXT[], $14, $15, $16, $17, $17 )`, [ trackId, input.contestId, normalizeString(input.name), normalizeString(input.summary), + normalizeString(input.coverImageUrl), + normalizeString(input.location), + normalizeString(input.organizer), + normalizeString(input.undertaker), + normalizeString(input.participantRequirements), + normalizeString(input.teamRule), + normalizeString(input.awardRatio), normalizeStringArray(input.suitableMajors), normalizeStringArray(input.deliverableTypes), normalizeString(input.rubricId) || null, @@ -3645,7 +3759,23 @@ export async function createAdminTrack( }) const result = await db.query( - `SELECT id, contest_id, name, summary, suitable_majors, deliverable_types, rubric_id, sort_order, status + `SELECT + id, + contest_id, + name, + summary, + cover_image_url, + location, + organizer, + undertaker, + participant_requirements, + team_rule, + award_ratio, + suitable_majors, + deliverable_types, + rubric_id, + sort_order, + status FROM contest_tracks WHERE id = $1 LIMIT 1`, @@ -3665,6 +3795,13 @@ export async function patchAdminTrack( patch: { name?: string summary?: string + coverImageUrl?: string + location?: string + organizer?: string + undertaker?: string + participantRequirements?: string + teamRule?: string + awardRatio?: string suitableMajors?: string[] deliverableTypes?: string[] rubricId?: string | null @@ -3685,6 +3822,20 @@ export async function patchAdminTrack( addSet('name', normalizeString(input.patch.name)) if (input.patch.summary !== undefined) addSet('summary', normalizeString(input.patch.summary)) + if (input.patch.coverImageUrl !== undefined) + addSet('cover_image_url', normalizeString(input.patch.coverImageUrl)) + if (input.patch.location !== undefined) + addSet('location', normalizeString(input.patch.location)) + if (input.patch.organizer !== undefined) + addSet('organizer', normalizeString(input.patch.organizer)) + if (input.patch.undertaker !== undefined) + addSet('undertaker', normalizeString(input.patch.undertaker)) + if (input.patch.participantRequirements !== undefined) + addSet('participant_requirements', normalizeString(input.patch.participantRequirements)) + if (input.patch.teamRule !== undefined) + addSet('team_rule', normalizeString(input.patch.teamRule)) + if (input.patch.awardRatio !== undefined) + addSet('award_ratio', normalizeString(input.patch.awardRatio)) if (input.patch.suitableMajors !== undefined) addSet('suitable_majors', normalizeStringArray(input.patch.suitableMajors)) if (input.patch.deliverableTypes !== undefined) @@ -3725,7 +3876,23 @@ export async function patchAdminTrack( }) const result = await db.query( - `SELECT id, contest_id, name, summary, suitable_majors, deliverable_types, rubric_id, sort_order, status + `SELECT + id, + contest_id, + name, + summary, + cover_image_url, + location, + organizer, + undertaker, + participant_requirements, + team_rule, + award_ratio, + suitable_majors, + deliverable_types, + rubric_id, + sort_order, + status FROM contest_tracks WHERE id = $1 AND contest_id = $2 LIMIT 1`, @@ -3875,6 +4042,174 @@ export async function patchAdminTimeline( return row ? mapTimeline(row) : null } +export async function listAdminTrackTimelines(db: Queryable, contestId: string): Promise { + const rows = await loadTrackTimelines(db, [contestId]) + return rows.map(mapTrackTimeline) +} + +export async function createAdminTrackTimeline( + db: Queryable, + input: { + actorUserId: string + contestId: string + trackId: string + year: number + nodeType: TimelineNodeType + startAt?: string | null + endAt?: string | null + note?: string + sourceLink?: string + }, +): Promise { + await assertTrackExistsForContest(db, input.contestId, input.trackId) + const timelineId = randomUUID() + const now = new Date().toISOString() + + await db.query( + `INSERT INTO contest_track_timelines ( + id, + contest_id, + track_id, + year, + node_type, + start_at, + end_at, + note, + source_link, + created_at, + updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)`, + [ + timelineId, + input.contestId, + input.trackId, + Number(input.year || new Date().getFullYear()), + input.nodeType, + input.startAt || null, + input.endAt || null, + normalizeString(input.note), + normalizeString(input.sourceLink), + now, + ], + ) + + await appendAuditLog(db, { + actorUserId: input.actorUserId, + action: 'track_timeline.create', + contestId: input.contestId, + payload: { + timelineId, + trackId: input.trackId, + nodeType: input.nodeType, + }, + }) + + const result = await db.query( + `SELECT + id, + contest_id, + track_id, + year, + node_type, + start_at::TEXT, + end_at::TEXT, + note, + source_link + FROM contest_track_timelines + WHERE id = $1 + LIMIT 1`, + [timelineId], + ) + + return mapTrackTimeline(result.rows[0]!) +} + +export async function patchAdminTrackTimeline( + db: Queryable, + input: { + actorUserId: string + contestId: string + trackTimelineId: string + patch: { + trackId?: string + year?: number + nodeType?: TimelineNodeType + startAt?: string | null + endAt?: string | null + note?: string + sourceLink?: string + } + }, +): Promise { + if (input.patch.trackId !== undefined) + await assertTrackExistsForContest(db, input.contestId, input.patch.trackId) + + const values: unknown[] = [input.trackTimelineId, input.contestId] + const sets: string[] = [] + + const addSet = (column: string, value: unknown) => { + values.push(value) + sets.push(`${column} = $${values.length}`) + } + + if (input.patch.trackId !== undefined) + addSet('track_id', input.patch.trackId) + if (input.patch.year !== undefined) + addSet('year', Number(input.patch.year || new Date().getFullYear())) + if (input.patch.nodeType !== undefined) + addSet('node_type', input.patch.nodeType) + if (input.patch.startAt !== undefined) + addSet('start_at', input.patch.startAt || null) + if (input.patch.endAt !== undefined) + addSet('end_at', input.patch.endAt || null) + if (input.patch.note !== undefined) + addSet('note', normalizeString(input.patch.note)) + if (input.patch.sourceLink !== undefined) + addSet('source_link', normalizeString(input.patch.sourceLink)) + + if (sets.length === 0) + return null + + sets.push('updated_at = NOW()') + + await db.query( + `UPDATE contest_track_timelines + SET ${sets.join(', ')} + WHERE id = $1 AND contest_id = $2`, + values, + ) + + await appendAuditLog(db, { + actorUserId: input.actorUserId, + action: 'track_timeline.patch', + contestId: input.contestId, + payload: { + trackTimelineId: input.trackTimelineId, + ...input.patch, + }, + }) + + const result = await db.query( + `SELECT + id, + contest_id, + track_id, + year, + node_type, + start_at::TEXT, + end_at::TEXT, + note, + source_link + FROM contest_track_timelines + WHERE id = $1 AND contest_id = $2 + LIMIT 1`, + [input.trackTimelineId, input.contestId], + ) + + const row = result.rows[0] + return row ? mapTrackTimeline(row) : null +} + function validateRubricDimensions(dimensions: RubricDimension[], scoringMode: RubricScoringMode = 'weighted'): void { if (!Array.isArray(dimensions) || dimensions.length === 0) throw new Error('RUBRIC_DIMENSIONS_REQUIRED') diff --git a/server/utils/db.ts b/server/utils/db.ts index 317b544..56f77dc 100644 --- a/server/utils/db.ts +++ b/server/utils/db.ts @@ -114,6 +114,7 @@ CREATE TABLE IF NOT EXISTS projects ( risks TEXT[] NOT NULL DEFAULT '{}', deliverables TEXT[] NOT NULL DEFAULT '{}', summary TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::JSONB, source TEXT NOT NULL CHECK (source IN ('chat', 'form')), status TEXT NOT NULL CHECK (status IN ('draft', 'in_progress', 'completed')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -376,7 +377,7 @@ CREATE TABLE IF NOT EXISTS feishu_bitable_sync_items ( id TEXT PRIMARY KEY, sync_id TEXT REFERENCES feishu_bitable_syncs(id) ON DELETE CASCADE, name TEXT NOT NULL, - entity_type TEXT NOT NULL CHECK (entity_type IN ('contest', 'track', 'resource')), + entity_type TEXT NOT NULL CHECK (entity_type IN ('contest', 'track', 'track_timeline', 'resource')), app_token TEXT NOT NULL, table_id TEXT NOT NULL, view_id TEXT NOT NULL DEFAULT '', @@ -424,7 +425,7 @@ CREATE TABLE IF NOT EXISTS feishu_bitable_sync_item_runs ( CREATE TABLE IF NOT EXISTS feishu_external_refs ( id TEXT PRIMARY KEY, provider TEXT NOT NULL CHECK (provider IN ('feishu_bitable')), - scope TEXT NOT NULL CHECK (scope IN ('contest', 'track', 'resource')), + scope TEXT NOT NULL CHECK (scope IN ('contest', 'track', 'track_timeline', 'resource')), external_id TEXT NOT NULL, sync_item_id TEXT REFERENCES feishu_bitable_sync_items(id) ON DELETE SET NULL, entity_id TEXT NOT NULL, @@ -449,7 +450,7 @@ CREATE TABLE IF NOT EXISTS feishu_post_sync_tasks ( id TEXT PRIMARY KEY, sync_item_id TEXT REFERENCES feishu_bitable_sync_items(id) ON DELETE SET NULL, run_id TEXT REFERENCES feishu_bitable_sync_item_runs(id) ON DELETE SET NULL, - scope TEXT NOT NULL CHECK (scope IN ('contest', 'track', 'resource')), + scope TEXT NOT NULL CHECK (scope IN ('contest', 'track', 'track_timeline', 'resource')), entity_id TEXT NOT NULL, external_id TEXT NOT NULL DEFAULT '', task_type TEXT NOT NULL CHECK (task_type IN ('embedding_upsert', 'search_index_refresh', 'entity_analysis', 'writeback_retry')), @@ -483,7 +484,7 @@ BEGIN ) THEN CREATE TABLE IF NOT EXISTS feishu_vectors ( id TEXT PRIMARY KEY, - scope TEXT NOT NULL CHECK (scope IN ('contest', 'track', 'resource')), + scope TEXT NOT NULL CHECK (scope IN ('contest', 'track', 'track_timeline', 'resource')), entity_id TEXT NOT NULL, chunk_index INTEGER NOT NULL DEFAULT 0, content TEXT NOT NULL DEFAULT '', @@ -497,7 +498,7 @@ BEGIN ELSE CREATE TABLE IF NOT EXISTS feishu_vectors ( id TEXT PRIMARY KEY, - scope TEXT NOT NULL CHECK (scope IN ('contest', 'track', 'resource')), + scope TEXT NOT NULL CHECK (scope IN ('contest', 'track', 'track_timeline', 'resource')), entity_id TEXT NOT NULL, chunk_index INTEGER NOT NULL DEFAULT 0, content TEXT NOT NULL DEFAULT '', @@ -513,7 +514,7 @@ END $$; CREATE TABLE IF NOT EXISTS feishu_search_index ( id TEXT PRIMARY KEY, - scope TEXT NOT NULL CHECK (scope IN ('contest', 'track', 'resource')), + scope TEXT NOT NULL CHECK (scope IN ('contest', 'track', 'track_timeline', 'resource')), entity_id TEXT NOT NULL, external_id TEXT NOT NULL DEFAULT '', sync_item_id TEXT REFERENCES feishu_bitable_sync_items(id) ON DELETE SET NULL, @@ -531,7 +532,7 @@ CREATE TABLE IF NOT EXISTS feishu_search_index ( CREATE TABLE IF NOT EXISTS feishu_entity_analysis ( id TEXT PRIMARY KEY, - scope TEXT NOT NULL CHECK (scope IN ('contest', 'track', 'resource')), + scope TEXT NOT NULL CHECK (scope IN ('contest', 'track', 'track_timeline', 'resource')), entity_id TEXT NOT NULL, external_id TEXT NOT NULL DEFAULT '', sync_item_id TEXT REFERENCES feishu_bitable_sync_items(id) ON DELETE SET NULL, @@ -708,7 +709,7 @@ CREATE TABLE IF NOT EXISTS rule_annotations ( CREATE TABLE IF NOT EXISTS feishu_sync_issues ( id TEXT PRIMARY KEY, sync_item_id TEXT NOT NULL REFERENCES feishu_bitable_sync_items(id) ON DELETE CASCADE, - entity_type TEXT NOT NULL CHECK (entity_type IN ('contest', 'track', 'resource')), + entity_type TEXT NOT NULL CHECK (entity_type IN ('contest', 'track', 'track_timeline', 'resource')), record_id TEXT NOT NULL, external_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'ignored')), @@ -756,6 +757,13 @@ CREATE TABLE IF NOT EXISTS contest_tracks ( contest_id TEXT NOT NULL REFERENCES contests(id) ON DELETE CASCADE, name TEXT NOT NULL, summary TEXT NOT NULL DEFAULT '', + cover_image_url TEXT NOT NULL DEFAULT '', + location TEXT NOT NULL DEFAULT '', + organizer TEXT NOT NULL DEFAULT '', + undertaker TEXT NOT NULL DEFAULT '', + participant_requirements TEXT NOT NULL DEFAULT '', + team_rule TEXT NOT NULL DEFAULT '', + award_ratio TEXT NOT NULL DEFAULT '', suitable_majors TEXT[] NOT NULL DEFAULT '{}', deliverable_types TEXT[] NOT NULL DEFAULT '{}', rubric_id TEXT, @@ -819,6 +827,20 @@ CREATE TABLE IF NOT EXISTS contest_timelines ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE TABLE IF NOT EXISTS contest_track_timelines ( + id TEXT PRIMARY KEY, + contest_id TEXT NOT NULL REFERENCES contests(id) ON DELETE CASCADE, + track_id TEXT NOT NULL REFERENCES contest_tracks(id) ON DELETE CASCADE, + year INTEGER NOT NULL, + node_type TEXT NOT NULL CHECK (node_type IN ('registration', 'submission', 'preliminary', 'final', 'other')), + start_at TIMESTAMPTZ, + end_at TIMESTAMPTZ, + note TEXT NOT NULL DEFAULT '', + source_link TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + CREATE TABLE IF NOT EXISTS contest_rubrics ( id TEXT PRIMARY KEY, contest_id TEXT NOT NULL REFERENCES contests(id) ON DELETE CASCADE, @@ -1506,6 +1528,55 @@ BEGIN END IF; END $$; +ALTER TABLE feishu_bitable_sync_items + DROP CONSTRAINT IF EXISTS feishu_bitable_sync_items_entity_type_check; + +ALTER TABLE feishu_bitable_sync_items + ADD CONSTRAINT feishu_bitable_sync_items_entity_type_check + CHECK (entity_type IN ('contest', 'track', 'track_timeline', 'resource')); + +ALTER TABLE feishu_sync_issues + DROP CONSTRAINT IF EXISTS feishu_sync_issues_entity_type_check; + +ALTER TABLE feishu_sync_issues + ADD CONSTRAINT feishu_sync_issues_entity_type_check + CHECK (entity_type IN ('contest', 'track', 'track_timeline', 'resource')); + +ALTER TABLE feishu_external_refs + DROP CONSTRAINT IF EXISTS feishu_external_refs_scope_check; + +ALTER TABLE feishu_external_refs + ADD CONSTRAINT feishu_external_refs_scope_check + CHECK (scope IN ('contest', 'track', 'track_timeline', 'resource')); + +ALTER TABLE feishu_post_sync_tasks + DROP CONSTRAINT IF EXISTS feishu_post_sync_tasks_scope_check; + +ALTER TABLE feishu_post_sync_tasks + ADD CONSTRAINT feishu_post_sync_tasks_scope_check + CHECK (scope IN ('contest', 'track', 'track_timeline', 'resource')); + +ALTER TABLE feishu_vectors + DROP CONSTRAINT IF EXISTS feishu_vectors_scope_check; + +ALTER TABLE feishu_vectors + ADD CONSTRAINT feishu_vectors_scope_check + CHECK (scope IN ('contest', 'track', 'track_timeline', 'resource')); + +ALTER TABLE feishu_search_index + DROP CONSTRAINT IF EXISTS feishu_search_index_scope_check; + +ALTER TABLE feishu_search_index + ADD CONSTRAINT feishu_search_index_scope_check + CHECK (scope IN ('contest', 'track', 'track_timeline', 'resource')); + +ALTER TABLE feishu_entity_analysis + DROP CONSTRAINT IF EXISTS feishu_entity_analysis_scope_check; + +ALTER TABLE feishu_entity_analysis + ADD CONSTRAINT feishu_entity_analysis_scope_check + CHECK (scope IN ('contest', 'track', 'track_timeline', 'resource')); + CREATE UNIQUE INDEX IF NOT EXISTS idx_feishu_sync_issues_sync_item_record_external_unique ON feishu_sync_issues(sync_item_id, record_id, external_id); @@ -1573,6 +1644,8 @@ CREATE INDEX IF NOT EXISTS idx_contests_status_visibility ON contests(status, vi CREATE INDEX IF NOT EXISTS idx_contests_level ON contests(level); CREATE INDEX IF NOT EXISTS idx_contest_tracks_contest ON contest_tracks(contest_id); CREATE INDEX IF NOT EXISTS idx_contest_timelines_contest ON contest_timelines(contest_id); +CREATE INDEX IF NOT EXISTS idx_contest_track_timelines_contest ON contest_track_timelines(contest_id); +CREATE INDEX IF NOT EXISTS idx_contest_track_timelines_track ON contest_track_timelines(track_id); CREATE INDEX IF NOT EXISTS idx_contest_rubrics_contest_track ON contest_rubrics(contest_id, track_id); CREATE INDEX IF NOT EXISTS idx_contest_resources_contest_category ON contest_resources(contest_id, category); CREATE INDEX IF NOT EXISTS idx_contest_resources_status ON contest_resources(status); @@ -1881,6 +1954,27 @@ ALTER TABLE contests ALTER TABLE contest_tracks ADD COLUMN IF NOT EXISTS rubric_id TEXT; +ALTER TABLE contest_tracks + ADD COLUMN IF NOT EXISTS cover_image_url TEXT NOT NULL DEFAULT ''; + +ALTER TABLE contest_tracks + ADD COLUMN IF NOT EXISTS location TEXT NOT NULL DEFAULT ''; + +ALTER TABLE contest_tracks + ADD COLUMN IF NOT EXISTS organizer TEXT NOT NULL DEFAULT ''; + +ALTER TABLE contest_tracks + ADD COLUMN IF NOT EXISTS undertaker TEXT NOT NULL DEFAULT ''; + +ALTER TABLE contest_tracks + ADD COLUMN IF NOT EXISTS participant_requirements TEXT NOT NULL DEFAULT ''; + +ALTER TABLE contest_tracks + ADD COLUMN IF NOT EXISTS team_rule TEXT NOT NULL DEFAULT ''; + +ALTER TABLE contest_tracks + ADD COLUMN IF NOT EXISTS award_ratio TEXT NOT NULL DEFAULT ''; + ALTER TABLE contest_rubrics ADD COLUMN IF NOT EXISTS scoring_mode TEXT NOT NULL DEFAULT 'weighted'; @@ -1919,6 +2013,9 @@ CREATE INDEX IF NOT EXISTS idx_ai_chat_sessions_workspace_project_mode_updated ALTER TABLE projects ADD COLUMN IF NOT EXISTS contest_ids TEXT[] NOT NULL DEFAULT '{}'; +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::JSONB; + ALTER TABLE project_resources ADD COLUMN IF NOT EXISTS resource_kind TEXT NOT NULL DEFAULT 'binary'; diff --git a/server/utils/feishu-integration-store.ts b/server/utils/feishu-integration-store.ts index 370c83b..f005dd7 100644 --- a/server/utils/feishu-integration-store.ts +++ b/server/utils/feishu-integration-store.ts @@ -1,5 +1,7 @@ import type { Queryable } from '~~/server/utils/db' import type { + CasdoorAuthBindStatus, + CasdoorIntegrationConfig, FeishuAdminCandidate, FeishuAdminGroupReconcileResult, FeishuAdminManualAddResult, @@ -43,6 +45,7 @@ import { import { decryptConfigSecretSafe, encryptConfigSecret, hasConfigMasterKey, isEncryptedConfigValue } from '~~/server/utils/secure-config' const FEISHU_CONFIG_META_KEY = 'feishu_integration_config.v1' +const CASDOOR_CONFIG_META_KEY = 'casdoor_integration_config.v1' const DEFAULT_WEBSDK_SCRIPT_URL = 'https://lf1-cdn-tos.bytegoofy.com/goofy/lark/op/h5-js-sdk-1.5.22.js' interface AuthIdentityRow { @@ -233,6 +236,17 @@ export interface FeishuIntegrationConfigInternal { updatedByUserId: string } +export interface CasdoorIntegrationConfigInternal { + enabled: boolean + issuer: string + clientId: string + clientSecret: string + scope: string + redirectUri: string + updatedAt: string + updatedByUserId: string +} + function hasOwn(source: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(source, key) } @@ -428,6 +442,20 @@ function normalizeFeishuConfigInternal(raw: unknown): FeishuIntegrationConfigInt } } +function normalizeCasdoorConfigInternal(raw: unknown): CasdoorIntegrationConfigInternal { + const source = parseJsonObject(raw) + return { + enabled: hasOwn(source, 'enabled') ? toBoolean(source.enabled, false) : false, + issuer: hasOwn(source, 'issuer') ? toText(source.issuer) : '', + clientId: hasOwn(source, 'clientId') ? toText(source.clientId) : '', + clientSecret: hasOwn(source, 'clientSecret') ? decryptConfigSecretSafe(source.clientSecret) : '', + scope: hasOwn(source, 'scope') ? toText(source.scope) : 'openid profile email', + redirectUri: hasOwn(source, 'redirectUri') ? toText(source.redirectUri) : '', + updatedAt: hasOwn(source, 'updatedAt') ? toText(source.updatedAt) : '', + updatedByUserId: hasOwn(source, 'updatedByUserId') ? toText(source.updatedByUserId) : '', + } +} + function toSync(row: FeishuBitableSyncRow): FeishuBitableSync { const source = normalizeBitableSource(row.source_json, { appToken: toText(parseJsonObject(row.source_json).appToken), @@ -699,6 +727,19 @@ export function toPublicFeishuIntegrationConfig(config: FeishuIntegrationConfigI } } +export function toPublicCasdoorIntegrationConfig(config: CasdoorIntegrationConfigInternal): CasdoorIntegrationConfig { + return { + enabled: config.enabled, + issuer: config.issuer, + clientId: config.clientId, + clientSecretConfigured: Boolean(config.clientSecret), + scope: config.scope || 'openid profile email', + redirectUri: config.redirectUri, + updatedAt: config.updatedAt, + updatedByUserId: config.updatedByUserId, + } +} + export async function readFeishuIntegrationConfig(db: Queryable): Promise { const result = await db.query<{ value: string }>( 'SELECT value FROM migrations_meta WHERE key = $1 LIMIT 1', @@ -717,6 +758,24 @@ export async function readFeishuIntegrationConfig(db: Queryable): Promise { + const result = await db.query<{ value: string }>( + 'SELECT value FROM migrations_meta WHERE key = $1 LIMIT 1', + [CASDOOR_CONFIG_META_KEY], + ) + + const raw = String(result.rows[0]?.value || '').trim() + if (!raw) + return normalizeCasdoorConfigInternal({}) + + try { + return normalizeCasdoorConfigInternal(JSON.parse(raw)) + } + catch { + return normalizeCasdoorConfigInternal({}) + } +} + export async function writeFeishuIntegrationConfig( db: Queryable, config: FeishuIntegrationConfigInternal, @@ -745,10 +804,32 @@ export async function writeFeishuIntegrationConfig( return normalized } +export async function writeCasdoorIntegrationConfig( + db: Queryable, + config: CasdoorIntegrationConfigInternal, +): Promise { + const normalized = normalizeCasdoorConfigInternal(config) + const hasMasterKey = hasConfigMasterKey() + const persistable = { + ...normalized, + clientSecret: hasMasterKey && normalized.clientSecret && !isEncryptedConfigValue(normalized.clientSecret) + ? encryptConfigSecret(normalized.clientSecret) + : normalized.clientSecret, + } + await db.query( + `INSERT INTO migrations_meta (key, value, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (key) + DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at`, + [CASDOOR_CONFIG_META_KEY, JSON.stringify(persistable)], + ) + return normalized +} + export async function findAuthIdentityByProviderUserId( db: Queryable, input: { - provider: 'feishu' + provider: 'feishu' | 'casdoor' providerUserId: string }, ): Promise { @@ -774,7 +855,7 @@ export async function findAuthIdentityByProviderUserId( export async function findAuthIdentityByProviderAndUserId( db: Queryable, input: { - provider: 'feishu' + provider: 'feishu' | 'casdoor' userId: string }, ): Promise { @@ -801,7 +882,7 @@ export async function findAuthIdentityByProviderAndUserId( export async function upsertAuthIdentity( db: Queryable, input: { - provider: 'feishu' + provider: 'feishu' | 'casdoor' providerUserId: string userId: string profile?: Record @@ -861,6 +942,32 @@ export async function getFeishuAuthBindStatusByUserId( } } +export async function getCasdoorAuthBindStatusByUserId( + db: Queryable, + userId: string, +): Promise { + const identity = await findAuthIdentityByProviderAndUserId(db, { + provider: 'casdoor', + userId, + }) + + if (!identity) { + return { + linked: false, + } + } + + const profile = parseJsonObject(identity.profile_json) + return { + linked: true, + subject: String(identity.provider_user_id || '').trim() || '', + name: toText(profile.name), + preferredUsername: toText(profile.preferredUsername), + email: toText(profile.email), + updatedAt: identity.updated_at || '', + } +} + export async function unbindFeishuAuthByUserId( db: Queryable, userId: string, diff --git a/shared/types/domain.ts b/shared/types/domain.ts index 3d98a64..fb5110e 100644 --- a/shared/types/domain.ts +++ b/shared/types/domain.ts @@ -90,6 +90,13 @@ export interface Track { contestId: string name: string summary: string + coverImageUrl?: string + location?: string + organizer?: string + undertaker?: string + participantRequirements?: string + teamRule?: string + awardRatio?: string deliverableTypes: string[] suitableMajors: string[] sortOrder?: number @@ -108,6 +115,18 @@ export interface ContestTimeline { sourceLink: string } +export interface TrackTimeline { + id: string + contestId: string + trackId: string + year: number + nodeType: TimelineNodeType + startAt: string | null + endAt: string | null + note: string + sourceLink: string +} + export interface Contest { id: string name: string @@ -397,6 +416,18 @@ export interface AuthSession { createdAt: string } +export type AuthSessionHistoryStatus = 'current' | 'active' | 'expired' | 'revoked' + +export interface AuthSessionHistoryItem { + id: string + userId: string + createdAt: string + expiresAt: string + revokedAt?: string | null + status: AuthSessionHistoryStatus + isCurrent: boolean +} + export interface AuthLoginResult { user: AuthUser session: AuthSession @@ -416,6 +447,27 @@ export interface AuthMeResult { } } +export interface CasdoorAuthMeta { + enabled: boolean +} + +export interface CasdoorIntegrationConfig { + enabled: boolean + issuer: string + clientId: string + clientSecretConfigured: boolean + scope: string + redirectUri: string + updatedAt: string + updatedByUserId: string +} + +export interface AuthLoginMeta { + registrationEnabled: boolean + feishu: FeishuIntegrationConfig + casdoor: CasdoorAuthMeta +} + export interface Invitation { id: string teamId: string @@ -462,6 +514,34 @@ export interface WorkspaceMemberManagementSnapshot { invitations: WorkspaceInvitationSummary[] } +export interface WorkspaceAiUsageMemberSummary { + userId: string + username: string + units: number + calls: number + lastUsedAt: string | null +} + +export interface WorkspaceAiUsageHistoryItem { + id: string + userId: string + username: string + route: string + units: number + createdAt: string +} + +export interface WorkspaceAiUsageHistory { + workspaceId: string + page: number + pageSize: number + total: number + totalCalls: number + totalUnits: number + memberSummaries: WorkspaceAiUsageMemberSummary[] + items: WorkspaceAiUsageHistoryItem[] +} + export interface ProjectCollegeBinding { collegeCode: string collegeName: string @@ -486,6 +566,33 @@ export interface ProjectPayload { summary?: string } +export type ProjectDisplayIcon + = | 'rocket_launch' + | 'shield' + | 'lightbulb' + | 'architecture' + | 'hub' + | 'science' + | 'public' + | 'school' + +export type ProjectDisplayPresetAccentColor + = | 'blue' + | 'cyan' + | 'violet' + | 'emerald' + | 'amber' + | 'rose' + | 'slate' + | 'teal' + +export type ProjectDisplayAccentColor = ProjectDisplayPresetAccentColor | `#${string}` + +export interface ProjectDisplayConfig { + icon: ProjectDisplayIcon + accentColor: ProjectDisplayAccentColor +} + export interface ProjectSeatQuotaSummary { seatLimit: number seatUsed: number @@ -502,6 +609,7 @@ export interface Project extends ProjectPayload { status: ProjectStatus collegeBindings: ProjectCollegeBinding[] advisorBindings: ProjectAdvisorBinding[] + display?: ProjectDisplayConfig | null projectSeatQuota?: ProjectSeatQuotaSummary | null createdAt: string updatedAt: string @@ -583,6 +691,8 @@ export interface ProjectOutlineSnapshot { export interface ProjectSettingsDraftCommon { title: string summary: string + icon: string + accentColor: string problemStatement: string innovationPointsText: string techRouteStepsText: string @@ -958,7 +1068,7 @@ export type AdminAgentTaskType | 'import_sync_analysis' | 'general' -export type AdminDraftModule = 'overview' | 'tracks' | 'timelines' | 'rubrics' | 'resources' +export type AdminDraftModule = 'overview' | 'tracks' | 'timelines' | 'track_timelines' | 'rubrics' | 'resources' export interface AdminAgentRunRequest { workspaceId: string @@ -1103,7 +1213,7 @@ export interface PlatformRoleAssignment { updatedAt: string } -export type FeishuBitableSyncItemEntityType = 'contest' | 'track' | 'resource' +export type FeishuBitableSyncItemEntityType = 'contest' | 'track' | 'track_timeline' | 'resource' export type FeishuBitableSyncRunStatus = 'running' | 'success' | 'partial_success' | 'failed' export type FeishuBitableSyncRunTriggerSource = 'manual' | 'event' | 'scheduled' export type FeishuSyncRunMode = 'full' | 'delta' @@ -1537,6 +1647,15 @@ export interface FeishuAuthAuditItem { payload: Record } +export interface CasdoorAuthBindStatus { + linked: boolean + subject?: string + name?: string + preferredUsername?: string + email?: string + updatedAt?: string +} + export interface FeishuAdminGroupReconcileResult { synchronizedAt: string groupIds: string[] diff --git a/shared/utils/feishu-bitable-sync-config.ts b/shared/utils/feishu-bitable-sync-config.ts index fcb6b21..0e4c98c 100644 --- a/shared/utils/feishu-bitable-sync-config.ts +++ b/shared/utils/feishu-bitable-sync-config.ts @@ -12,12 +12,14 @@ export interface FeishuDefaultSyncItemConfig { const ENTITY_TYPE_SOURCE_HINTS: Record = { contest: ['竞赛', '赛事', 'contest', 'match'], track: ['赛道', '方向', 'track'], + track_timeline: ['赛道时间线', '赛道节点', '赛道日程', 'tracktimeline', 'track_timeline'], resource: ['资料', '资源', '素材', '文档', 'resource', 'material'], } const REQUIRED_MAPPING_FIELD_KEYS: Record = { contest: ['externalId', 'name', 'officialUrl'], track: ['externalId', 'contestExternalId', 'name'], + track_timeline: ['externalId', 'contestExternalId', 'trackExternalId', 'nodeType'], resource: ['externalId', 'contestExternalId', 'title', 'url'], } @@ -70,9 +72,19 @@ export function buildDefaultSyncItemConfig(entityType: FeishuBitableSyncItemEnti fieldMap: { name: '', summary: '', + coverImageUrl: '', + location: '', + organizer: '', + undertaker: '', + participantRequirements: '', + teamRule: '', + awardRatio: '', suitableMajors: '', deliverableTypes: '', sortOrder: '', + evidenceRequirements: '', + scoringPoints: '', + deductionItems: '', }, }, options: { @@ -82,6 +94,26 @@ export function buildDefaultSyncItemConfig(entityType: FeishuBitableSyncItemEnti } } + if (entityType === 'track_timeline') { + return { + mapping: { + externalIdField: '', + contestExternalIdField: '', + trackExternalIdField: '', + fieldMap: { + year: '', + nodeType: '', + startAt: '', + endAt: '', + note: '', + sourceLink: '', + }, + }, + options: {}, + writeback: buildDefaultWriteback(), + } + } + return { mapping: { externalIdField: '', @@ -134,7 +166,7 @@ export function suggestSyncItemEntityType(input: { if (!sourceText) return null - for (const entityType of ['track', 'resource', 'contest'] as const) { + for (const entityType of ['track_timeline', 'track', 'resource', 'contest'] as const) { const hints = ENTITY_TYPE_SOURCE_HINTS[entityType] if (hints.some(hint => sourceText.includes(normalizeSourceHintText(hint)))) return entityType @@ -154,6 +186,8 @@ export function buildSuggestedSyncItemName( ? '竞赛同步' : entityType === 'track' ? '赛道同步' + : entityType === 'track_timeline' + ? '赛道时间线同步' : '资料同步' if (normalizedTableName && normalizedViewName) From 9f83ba8cefa2541b42f18469ad75e3f2c19adf48 Mon Sep 17 00:00:00 2001 From: TalexDreamSoul Date: Tue, 7 Apr 2026 22:39:38 +0800 Subject: [PATCH 002/180] feat: refine workspace dashboard --- app/components/UserSettingsDialog.vue | 2017 +++++++++++++++++ .../dashboard/DashboardOverviewShell.vue | 223 ++ .../dashboard/DashboardRightRail.vue | 2 +- app/components/dashboard/DashboardSidebar.vue | 391 +--- app/components/team/TeamProjectOverview.vue | 428 ++-- .../workspace/WorkspaceMainPanel.vue | 231 ++ .../workspace/WorkspaceSwitchEntry.vue | 252 +- app/composables/team-ui.ts | 21 + app/layouts/dashboard.vue | 24 +- app/pages/dashboard.vue | 245 +- app/pages/team/[teamId]/index.vue | 113 +- .../team/[teamId]/project/[projectId].vue | 10 + app/types/workspace.ts | 2 + scripts/tests/workspace-dashboard-ui.test.mjs | 75 +- .../api/projects/[id]/settings-draft.patch.ts | 2 + server/api/projects/[id]/settings.patch.ts | 6 + server/api/realtime/ws.get.ts | 6 + server/api/teams/[id]/ai/usage.get.ts | 161 ++ server/api/teams/[id]/members.get.ts | 2 +- server/plugins/document-task-worker.ts | 8 + .../project-document-preview-worker.ts | 9 + .../project-resource-recycle-worker.ts | 4 + server/plugins/realtime-pg-bus.ts | 7 + server/utils/team-quota-store.ts | 23 +- shared/constants/project-display.ts | 168 ++ 25 files changed, 3628 insertions(+), 802 deletions(-) create mode 100644 app/components/UserSettingsDialog.vue create mode 100644 app/components/dashboard/DashboardOverviewShell.vue create mode 100644 server/api/teams/[id]/ai/usage.get.ts create mode 100644 shared/constants/project-display.ts diff --git a/app/components/UserSettingsDialog.vue b/app/components/UserSettingsDialog.vue new file mode 100644 index 0000000..5eed311 --- /dev/null +++ b/app/components/UserSettingsDialog.vue @@ -0,0 +1,2017 @@ + + + + + diff --git a/app/components/dashboard/DashboardOverviewShell.vue b/app/components/dashboard/DashboardOverviewShell.vue new file mode 100644 index 0000000..277a526 --- /dev/null +++ b/app/components/dashboard/DashboardOverviewShell.vue @@ -0,0 +1,223 @@ + + + diff --git a/app/components/dashboard/DashboardRightRail.vue b/app/components/dashboard/DashboardRightRail.vue index a4760e8..1c8b132 100644 --- a/app/components/dashboard/DashboardRightRail.vue +++ b/app/components/dashboard/DashboardRightRail.vue @@ -17,7 +17,7 @@ withDefaults(defineProps<{