完善门户与会员审批交互
- 增加首页轮播图管理及首页动态展示。
- 完善企业微信入会申请、审批缴费确认和个人首页提醒。
- 增加随机账号首次设置密码入口,优化未关联会员时的资料页面。
- 优化表格操作栏溢出、课题申报成员展示及联系方式校验。
- 修正超级管理员调整门户字号的权限判断。
验证:相关后端测试已通过;前端页面和真实企业微信扫码流程尚未完成端到端验证。
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { requestClient } from '#/api/request'; |
| | | export interface HomeBanner { id?: number; title: string; imageUrl: string; sort: number; status: number } |
| | | export const getHomeBanners = () => requestClient.get<HomeBanner[]>('/system/home-banner/list'); |
| | | export const getEnabledHomeBanners = () => requestClient.get<HomeBanner[]>('/system/home-banner/enabled-list'); |
| | | export const saveHomeBanner = (data: HomeBanner) => requestClient.post('/system/home-banner/save', data); |
| | | export const deleteHomeBanner = (id: number) => requestClient.delete('/system/home-banner/delete', { params: { id } }); |
| | |
| | | workUnit?: string; // å·¥ä½åä½ï¼ä¸ªäººä¸æï¼ |
| | | title?: string; // èç§°ï¼ä¸ªäººä¸æï¼ |
| | | memberGrade?: string; // ç³è¯·ççº§ï¼æ memberType åä¸ååå
¸ï¼ |
| | | feePaid?: boolean; // ç³è¯·æ¶æ¯å¦ç¼´è´¹ |
| | | regionCode?: string; // å°åºç¼ç |
| | | industry?: string; // è¡ä¸ï¼åå
¸ ums_industry |
| | | status?: number; // ç¶æï¼åå
¸ ums_application_status |
| | |
| | | key: string; |
| | | name: string; |
| | | } |
| | | } |
| | | |
| | | export function canConfirmApplicationFee(id: number) { |
| | | return requestClient.get<boolean>('/ums/application/fee-confirm-available', { params: { id } }); |
| | | } |
| | | |
| | | export function confirmApplicationFee(id: number, feePaid: boolean) { |
| | | return requestClient.put<boolean>('/ums/application/confirm-fee', undefined, { params: { id, feePaid } }); |
| | | } |
| | | |
| | | /** æ¥è¯¢å
¥ä¼ç³è¯·å页 */ |
| | |
| | | return requestClient.get<UmsApplicationApi.ProcessDefinition[]>( |
| | | '/ums/application/approve-process-list', |
| | | ); |
| | | } |
| | | } |
| | |
| | | joinDate?: string; // å
¥ä¼æ¥æ |
| | | expireDate?: string; // æææè³ |
| | | feePaid?: boolean; // æ¯å¦ç¼´çº³ä¼è´¹ |
| | | passwordSetupRequired?: boolean; // 馿¬¡è®¾ç½®å¯ç |
| | | feeYear?: number; // 缴费年度ï¼å¦ 2026ï¼ |
| | | status?: number; // ç¶æï¼åå
¸ ums_member_status |
| | | archiveDocumentId?: number; // 彿¡£çæçæ¡£æ¡ IDï¼é空å³å·²å½æ¡£ï¼ |
| | |
| | | return requestClient.post<string>('/ums/member/archive', null, { |
| | | params: { id }, |
| | | }); |
| | | } |
| | | } |
| | |
| | | |
| | | /** è·å¾å½åç»å½ä¼åçä¸ªäººèµæ */ |
| | | export function getMyProfile() { |
| | | return requestClient.get<UmsMemberApi.Member>('/ums/member/profile/get'); |
| | | return requestClient.get<UmsMemberApi.Member | null>('/ums/member/profile/get'); |
| | | } |
| | | |
| | | export function setInitialPassword(password: string) { |
| | | return requestClient.post<string>('/ums/member/profile/initial-password', { password }); |
| | | } |
| | | |
| | | /** è·å¾å½åä¼åæè¿ä¸æ¬¡èµæåæ´ç³è¯· */ |
| | |
| | | |
| | | import type { ActionItem, PopConfirm } from './typing'; |
| | | |
| | | import { computed, unref, watch } from 'vue'; |
| | | import { computed, onBeforeUnmount, onMounted, ref, unref } from 'vue'; |
| | | |
| | | import { useAccess } from '@vben/access'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | |
| | | }); |
| | | |
| | | const { hasAccessByCodes } = useAccess(); |
| | | const containerRef = ref<HTMLElement>(); |
| | | const availableWidth = ref(0); |
| | | const isTableCell = ref(false); |
| | | let resizeObserver: ResizeObserver | undefined; |
| | | |
| | | /** æ£æ¥æ¯å¦æ¾ç¤º */ |
| | | function isIfShow(action: ActionItem): boolean { |
| | |
| | | } |
| | | |
| | | /** å¤çæé® actions */ |
| | | const getActions = computed(() => { |
| | | const visibleActions = computed(() => { |
| | | const actions = props.actions || []; |
| | | return actions.filter((action: ActionItem) => isIfShow(action)); |
| | | }); |
| | | |
| | | /** å¤ç䏿èå actions */ |
| | | const getDropdownList = computed(() => { |
| | | const explicitDropdownActions = computed(() => { |
| | | const dropDownActions = props.dropDownActions || []; |
| | | return dropDownActions.filter((action: ActionItem) => isIfShow(action)); |
| | | }); |
| | | |
| | | // å
è¿æ»¤æéåä¸å¡ç¶æï¼åæ ¹æ®æä½å宽度å³å®åªäºæé®æ¾å
¥âæ´å¤âã |
| | | // æå符å徿 ä¼°ç®æé®å®½åº¦ï¼å¹¶é¢çä½éï¼é¿å
åºå®åè£åã |
| | | function estimatedWidth(action: ActionItem) { |
| | | const label = String(action.label || ''); |
| | | const textWidth = Array.from(label).reduce( |
| | | (width, char) => width + (/[^\x00-\xff]/.test(char) ? 16 : 9), |
| | | 0, |
| | | ); |
| | | return textWidth + (action.icon ? 22 : 0) + 14; |
| | | } |
| | | |
| | | const inlineCount = computed(() => { |
| | | const actions = visibleActions.value; |
| | | if (!isTableCell.value) return actions.length; |
| | | const width = availableWidth.value; |
| | | if (!width) return 0; |
| | | const total = actions.reduce((sum, action) => sum + estimatedWidth(action), 0); |
| | | const hasExplicitDropdown = explicitDropdownActions.value.length > 0; |
| | | if (!hasExplicitDropdown && total <= width) return actions.length; |
| | | let remaining = Math.max(0, width - 66); |
| | | let count = 0; |
| | | for (const action of actions) { |
| | | const actionWidth = estimatedWidth(action); |
| | | if (actionWidth > remaining) break; |
| | | remaining -= actionWidth; |
| | | count++; |
| | | } |
| | | return count; |
| | | }); |
| | | const getActions = computed(() => visibleActions.value.slice(0, inlineCount.value)); |
| | | const getDropdownList = computed(() => [ |
| | | ...visibleActions.value.slice(inlineCount.value), |
| | | ...explicitDropdownActions.value, |
| | | ]); |
| | | |
| | | onMounted(() => { |
| | | if (!containerRef.value) return; |
| | | isTableCell.value = !!containerRef.value.closest('.vxe-body--column'); |
| | | if (!isTableCell.value) return; |
| | | resizeObserver = new ResizeObserver(([entry]) => { |
| | | if (entry) availableWidth.value = entry.contentRect.width; |
| | | }); |
| | | resizeObserver.observe(containerRef.value); |
| | | }); |
| | | onBeforeUnmount(() => resizeObserver?.disconnect()); |
| | | |
| | | /** Space ç»ä»¶ç size */ |
| | | const spaceSize = computed(() => { |
| | |
| | | } |
| | | } |
| | | |
| | | /** çå¬ props ååï¼å¼ºå¶éæ°è®¡ç® */ |
| | | watch( |
| | | () => [props.actions, props.dropDownActions], |
| | | () => { |
| | | // è¿éä¸éè¦é¢å¤å¤çï¼computed ä¼èªå¨éæ°è®¡ç® |
| | | }, |
| | | { deep: true }, |
| | | ); |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="table-actions"> |
| | | <div ref="containerRef" class="table-actions" :class="{ 'table-actions-cell': isTableCell }"> |
| | | <Space :size="spaceSize"> |
| | | <template |
| | | v-for="(action, index) in getActions" |
| | |
| | | </template> |
| | | </Space> |
| | | |
| | | <Dropdown v-if="getDropdownList.length > 0" :trigger="['hover']"> |
| | | <Dropdown v-if="getDropdownList.length > 0" :trigger="isTableCell ? ['click'] : ['hover']"> |
| | | <slot name="more"> |
| | | <Button type="link"> |
| | | <Button type="link" :aria-label="$t('page.action.more')"> |
| | | <template #icon> |
| | | {{ $t('page.action.more') }} |
| | | <IconifyIcon icon="lucide:ellipsis-vertical" /> |
| | | </template> |
| | | <span v-if="!isTableCell || availableWidth >= 66">{{ $t('page.action.more') }}</span> |
| | | </Button> |
| | | </slot> |
| | | <template #overlay> |
| | |
| | | </template> |
| | | |
| | | <style lang="scss"> |
| | | .table-actions-cell { |
| | | display: flex; |
| | | width: 100%; |
| | | min-width: 0; |
| | | align-items: center; |
| | | justify-content: center; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .table-actions { |
| | | .ant-btn-link { |
| | | padding: 4px; |
| | |
| | | VbenSegmented, |
| | | } from '../../../../../@core/ui-kit/shadcn-ui/src'; |
| | | import { globalShareState } from '../../../../../@core/base/shared/src/global-state'; |
| | | import { useAccessStore } from '../../../../../stores/src'; |
| | | import { useAccessStore, useUserStore } from '../../../../../stores/src'; |
| | | |
| | | import { useClipboard } from '@vueuse/core'; |
| | | |
| | |
| | | |
| | | // 鍿·åå·å±äºå
¨å±é
ç½®ï¼éå
·å¤åæ°é
ç½®ä¿®æ¹æéæè½è°æ´ |
| | | const accessStore = useAccessStore(); |
| | | const userStore = useUserStore(); |
| | | const canEditPortalFont = computed(() => |
| | | (accessStore.accessCodes ?? []).includes('infra:config:update'), |
| | | (userStore.userRoles ?? []).includes('super_admin') |
| | | || (accessStore.accessCodes ?? []).includes('infra:config:update'), |
| | | ); |
| | | |
| | | // const logoVisible = defineModel<boolean>('logoVisible'); |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, ref } from 'vue'; |
| | | import { computed, onMounted, onUnmounted, ref } from 'vue'; |
| | | |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { preferences } from '@vben/preferences'; |
| | | |
| | | import PortalFooter from '#/components/portal/portal-footer.vue'; |
| | | import { portalCategory, usePortalMenus } from '#/components/portal/use-portal-menus'; |
| | | import { getEnabledHomeBanners, type HomeBanner } from '#/api/system/home-banner'; |
| | | |
| | | import KpiCards from './modules/kpi-cards.vue'; |
| | | import NoticePanel from './modules/notice-panel.vue'; |
| | |
| | | const { menus, leaves, features, goMenu } = usePortalMenus(); |
| | | const appName = computed(() => preferences.copyright.companyName); |
| | | const slideIndex = ref(0); |
| | | const banners = ref<HomeBanner[]>([]); |
| | | let slideTimer: ReturnType<typeof setInterval> | undefined; |
| | | onMounted(async () => { |
| | | slideTimer = setInterval(() => { |
| | | const count = banners.value.length || slides.length; |
| | | if (count > 1) slideIndex.value = (slideIndex.value + 1) % count; |
| | | }, 5000); |
| | | try { banners.value = await getEnabledHomeBanners(); slideIndex.value = 0; } catch { /* 使ç¨é»è®¤é¦é¡µ */ } |
| | | }); |
| | | onUnmounted(() => { if (slideTimer) clearInterval(slideTimer); }); |
| | | const slides = [ |
| | | { title: 'æå¡ä¾éåä½ãå©åä¹¡ææ¯å
´', motto: '弿¾åä½ Â· 妿¯ç ç©¶ · æå¡åå±', description: 'æ¨å¨ä¾éåä½ç»æµç ç©¶ï¼æå¡è¡ä¸é«è´¨éåå±' }, |
| | | { title: 'åè妿¯åéãæ·±å课é¢ç ç©¶', motto: 'è¯¾é¢æå · å¨çº¿ç³æ¥ · è¿åº¦è·è¸ª', description: 'å´ç»ç 究课é¢ï¼è¿æ¥ç³æ¥ã审æ¹ä¸å使å¡' }, |
| | |
| | | |
| | | <template> |
| | | <div class="portal-home"> |
| | | <section class="portal-hero" aria-labelledby="portal-hero-title"> |
| | | <section class="portal-hero" :class="{ 'has-banner': banners.length }" :style="banners.length ? { backgroundImage: `url('${banners[slideIndex]?.imageUrl}')` } : undefined" :aria-label="banners.length ? banners[slideIndex]?.title : undefined" :aria-labelledby="banners.length ? undefined : 'portal-hero-title'"> |
| | | <div class="portal-hero-copy"> |
| | | <h1 id="portal-hero-title"><span v-for="part in currentSlide.title.split('ã')" :key="part">{{ part }}</span></h1> |
| | | <h1 id="portal-hero-title"><span v-for="part in (banners.length ? (banners[slideIndex]?.title || '') : currentSlide.title).split('ã')" :key="part">{{ part }}</span></h1> |
| | | <div class="portal-hero-organization"><span></span><strong>{{ appName }}</strong><span></span></div> |
| | | <p class="portal-hero-motto">{{ currentSlide.motto }}</p> |
| | | <p class="portal-hero-description">{{ currentSlide.description }}</p> |
| | | <p v-if="!banners.length" class="portal-hero-motto">{{ currentSlide.motto }}</p> |
| | | <p v-if="!banners.length" class="portal-hero-description">{{ currentSlide.description }}</p> |
| | | </div> |
| | | <div class="portal-hero-pages" aria-label="é¦é¡µä¸»é¢åæ¢"> |
| | | <button v-for="(slide, index) in slides" :key="slide.title" type="button" :aria-label="`æ¥ç主é¢ï¼${slide.title}`" :aria-pressed="slideIndex === index" :class="{ active: slideIndex === index }" @click="slideIndex = index"></button> |
| | | <button v-for="(slide, index) in (banners.length ? banners : slides)" :key="index" type="button" :aria-label="`æ¥ç主é¢ï¼${slide.title}`" :aria-pressed="slideIndex === index" :class="{ active: slideIndex === index }" @click="slideIndex = index"></button> |
| | | </div> |
| | | </section> |
| | | |
| | | <div class="portal-content"> |
| | | <QuickActions /> |
| | | |
| | | <section v-if="features.application || features.todo || features.notice" class="portal-news-grid" aria-label="ä¸å¡å¨æä¸éç¥"> |
| | | <section class="portal-news-grid" aria-label="ä¸å¡å¨æä¸éç¥"> |
| | | <ResearchHighlights v-if="features.application" :menu="features.application" /> |
| | | <TodoPanel v-else-if="features.todo" /> |
| | | <NoticePanel v-if="features.notice" :menu-path="features.notice.path" /> |
| | | <NoticePanel :menu-path="features.notice?.path" /> |
| | | </section> |
| | | |
| | | <section v-if="topicMenus.length" class="portal-topic-strip" aria-label="ä¸å¡ç´è¾¾"> |
| | |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { Button, Input, Modal, message } from 'ant-design-vue'; |
| | | |
| | | import { getNoticePage as getOaNoticePage } from '#/api/bpm/oa/notice'; |
| | | import { getNoticePage as getSystemNoticePage } from '#/api/system/notice'; |
| | | import { getMyProfile, setInitialPassword } from '#/api/ums/member/profile'; |
| | | |
| | | defineOptions({ name: 'DashboardNoticePanel' }); |
| | | const props = defineProps<{ menuPath: string }>(); |
| | | const props = defineProps<{ menuPath?: string }>(); |
| | | const router = useRouter(); |
| | | const loading = ref(true); |
| | | const failed = ref(false); |
| | | const isOa = computed(() => props.menuPath.includes('/bpm/')); |
| | | const isOa = computed(() => props.menuPath?.includes('/bpm/') ?? false); |
| | | const unpaid = ref(false); |
| | | const needsPassword = ref(false); |
| | | const passwordModal = ref(false); |
| | | const password = ref(''); |
| | | const passwordAgain = ref(''); |
| | | const savingPassword = ref(false); |
| | | const accountName = ref(''); |
| | | const notices = ref<{ id?: number; title: string; date: string }[]>([]); |
| | | |
| | | function dateText(value: string | Date | undefined) { |
| | |
| | | loading.value = true; |
| | | failed.value = false; |
| | | try { |
| | | if (isOa.value) { |
| | | const profile = await getMyProfile(); |
| | | unpaid.value = profile?.feePaid === false; |
| | | needsPassword.value = profile?.passwordSetupRequired === true; |
| | | } catch { |
| | | unpaid.value = false; |
| | | } |
| | | try { |
| | | if (!props.menuPath) { |
| | | notices.value = []; |
| | | } else if (isOa.value) { |
| | | const result = await getOaNoticePage({ pageNo: 1, pageSize: 6 }); |
| | | notices.value = (result.list ?? []).map((item) => ({ id: item.id, title: item.title, date: dateText(item.publishTime || item.createTime) })); |
| | | } else { |
| | |
| | | } |
| | | onMounted(load); |
| | | |
| | | async function savePassword() { |
| | | if (password.value.length < 8 || password.value.length > 16) { |
| | | message.warning('å¯ç é¿åº¦é¡»ä¸º 8 è³ 16 ä½'); return; |
| | | } |
| | | if (password.value !== passwordAgain.value) { |
| | | message.warning('两次è¾å
¥çå¯ç ä¸ä¸è´'); return; |
| | | } |
| | | savingPassword.value = true; |
| | | try { |
| | | accountName.value = await setInitialPassword(password.value); |
| | | needsPassword.value = false; |
| | | passwordModal.value = false; |
| | | password.value = ''; passwordAgain.value = ''; |
| | | Modal.success({ title: 'å¯ç 设置æå', content: `ç»å½è´¦å·ï¼${accountName.value}ï¼è¯·å¦¥åä¿åã` }); |
| | | } finally { savingPassword.value = false; } |
| | | } |
| | | |
| | | function openNotice(notice: { id?: number }) { |
| | | if (isOa.value && notice.id && router.hasRoute('OANoticeDetail')) { |
| | | router.push({ name: 'OANoticeDetail', query: { id: notice.id } }); |
| | | } else router.push(props.menuPath); |
| | | } else if (props.menuPath) router.push(props.menuPath); |
| | | } |
| | | </script> |
| | | |
| | |
| | | <div class="notice-panel"> |
| | | <div class="notice-head"> |
| | | <h2><IconifyIcon icon="lucide:megaphone" /> éç¥å
Œ</h2> |
| | | <button type="button" @click="router.push(menuPath)">æ´å¤ <IconifyIcon icon="lucide:chevron-right" /></button> |
| | | <button v-if="menuPath" type="button" @click="router.push(menuPath)">æ´å¤ <IconifyIcon icon="lucide:chevron-right" /></button> |
| | | </div> |
| | | <div v-if="loading" class="notice-empty" role="status">å è½½ä¸â¦</div> |
| | | <div v-else-if="failed" class="notice-empty notice-error" role="status"> |
| | | <span>å
¬åå 载失败ï¼è¯·éè¯</span> |
| | | <button type="button" @click="load">éè¯</button> |
| | | </div> |
| | | <div v-else-if="!notices.length" class="notice-empty">ææ éç¥å
Œ</div> |
| | | <ul v-else> |
| | | <div v-if="!loading && unpaid" class="fee-notice" role="status">ä¼è´¹å°æªç¼´çº³ï¼è¯·èç³»å¦ä¼ç®¡çååçã</div> |
| | | <div v-if="!loading && needsPassword" class="fee-notice" role="status"> |
| | | æ¨çè´¦å·éè¦é¦æ¬¡è®¾ç½®å¯ç ã<Button type="link" @click="passwordModal = true">设置å¯ç </Button> |
| | | </div> |
| | | <div v-if="!loading && !failed && !notices.length && !unpaid && !needsPassword" class="notice-empty">ææ éç¥å
Œ</div> |
| | | <ul v-if="!loading && notices.length"> |
| | | <li v-for="notice in notices" :key="notice.id"> |
| | | <button type="button" @click="openNotice(notice)"> |
| | | <span class="notice-title">{{ notice.title }}</span> |
| | |
| | | </button> |
| | | </li> |
| | | </ul> |
| | | <Modal v-model:open="passwordModal" title="馿¬¡è®¾ç½®å¯ç " :confirm-loading="savingPassword" @ok="savePassword" @cancel="password = ''; passwordAgain = ''"> |
| | | <p>请设置 8 è³ 16 ä½å¯ç ãè®¾ç½®å®æåå°æ¾ç¤ºæ¨çç»å½è´¦å·ã</p> |
| | | <Input.Password v-model:value="password" autocomplete="new-password" placeholder="请è¾å
¥æ°å¯ç " :maxlength="16" class="mt-3" /> |
| | | <Input.Password v-model:value="passwordAgain" autocomplete="new-password" placeholder="è¯·åæ¬¡è¾å
¥æ°å¯ç " :maxlength="16" class="mt-3" @press-enter="savePassword" /> |
| | | </Modal> |
| | | </div> |
| | | </template> |
| | | |
| | |
| | | li button { display: flex; width: 100%; min-height: 39px; align-items: center; gap: 12px; padding: 9px 2px; text-align: left; } |
| | | li button::before { width: 5px; height: 5px; flex: none; border-radius: 50%; background: #b7242d; content: ''; } |
| | | .notice-title { min-width: 0; flex: 1; overflow: hidden; color: #284563; font-size: calc(12px * var(--portal-font-scale, 1.15)); text-overflow: ellipsis; white-space: nowrap; } |
| | | .fee-notice { margin-bottom: 8px; padding: 10px 12px; border-radius: 4px; color: #8a4b09; background: #fff7e6; font-size: 13px; } |
| | | time { flex: none; color: #5c7187; font-size: calc(11px * var(--portal-font-scale, 1.15)); } |
| | | li button:hover .notice-title, .notice-head button:hover { color: #b7242d; } |
| | | button:focus-visible { outline: 2px solid #2775bd; outline-offset: 2px; } |
| | |
| | | maxlength: 128, |
| | | disabled: readonly, |
| | | }, |
| | | rules: z |
| | | .string({ message: 'èç³»é®ç®±ä¸è½ä¸ºç©º' }) |
| | | .trim() |
| | | .min(1, 'èç³»é®ç®±ä¸è½ä¸ºç©º') |
| | | .email('èç³»é®ç®±æ ¼å¼ä¸æ£ç¡®'), |
| | | }, |
| | | { |
| | | fieldName: 'budgetAmount', |
| | |
| | | fieldName: 'members', |
| | | label: '课é¢ç»æå', |
| | | component: 'Input', |
| | | formItemClass: 'col-span-2', |
| | | }, |
| | | { |
| | | fieldName: 'planContent', |
| | | label: 'ç³æ¥ä¹¦æ£æ', |
| | | component: 'Textarea', |
| | | componentProps: { |
| | | placeholder: |
| | | '请填åç ç©¶èæ¯ä¸æä¹ãç ç©¶ç®æ ãç ç©¶æ¹æ¡ä¸ææ¯è·¯çº¿ã颿ææä¸æççå
容', |
| | | autoSize: { minRows: 8, maxRows: 16 }, |
| | | disabled: readonly, |
| | | }, |
| | | formItemClass: 'col-span-2', |
| | | }, |
| | | { |
| | |
| | | |
| | | <!-- å岿°æ®å
¼å®¹ï¼æ§çææ¬æ ¼å¼ç课é¢ç»æå --> |
| | | <div |
| | | v-else-if="formData?.teamMembers" |
| | | v-if="formData?.teamMembers" |
| | | class="rounded-lg border border-border bg-background p-4" |
| | | > |
| | | <div class="mb-4 flex items-center gap-2"> |
| | |
| | | </div> |
| | | <pre class="whitespace-pre-wrap break-all font-sans">{{ |
| | | formData.teamMembers |
| | | }}</pre> |
| | | </div> |
| | | |
| | | <!-- ç³æ¥ä¹¦æ£æ --> |
| | | <div |
| | | v-if="formData?.planContent" |
| | | class="rounded-lg border border-border bg-background p-4" |
| | | > |
| | | <div class="mb-4 flex items-center gap-2"> |
| | | <span class="inline-block h-4 w-1 rounded bg-primary" /> |
| | | <span class="text-base font-semibold">ç³æ¥ä¹¦æ£æ</span> |
| | | </div> |
| | | <pre class="whitespace-pre-wrap break-all font-sans">{{ |
| | | formData.planContent |
| | | }}</pre> |
| | | </div> |
| | | |
| | |
| | | import { useVbenModal } from '@vben/common-ui'; |
| | | import { $t } from '@vben/locales'; |
| | | |
| | | import { message } from 'ant-design-vue'; |
| | | import { Alert, message } from 'ant-design-vue'; |
| | | |
| | | import { useVbenForm } from '#/adapter/form'; |
| | | import { |
| | |
| | | const guidelines = ref<ResearchGuidelineApi.Guideline[]>([]); |
| | | /** æåè¡¨æ ¼åæ¾æ°æ®æºï¼åªå¨æå¼å¼¹çªæ¶èµå¼ï¼å¢å æ¹åªéè¿ emit ååæµå°è¡¨åå¼ */ |
| | | const initialMembers = ref<ResearchApplicationApi.ApplicationMember[]>([]); |
| | | const legacyTeamMembers = ref(''); |
| | | const memberTableRef = ref<InstanceType<typeof MemberTable>>(); |
| | | |
| | | const getTitle = computed(() => { |
| | |
| | | contactEmail: values.contactEmail, |
| | | members: toSubmitMembers(values.members), |
| | | budgetAmount: values.budgetAmount, |
| | | planContent: values.planContent, |
| | | remark: values.remark, |
| | | // é件为å
¨éæ¿æ¢è¯ä¹ï¼æ¸
空å
¨é¨éä»¶æ¶å¿
é¡»æ¾å¼ä¼ 空æ°ç»ï¼ |
| | | // ä¼ null å端ä¼è§ä¸ºãä¸å¨éä»¶ãï¼å¯¼è´å 餿ä½ä¸çæ |
| | |
| | | async onOpenChange(isOpen: boolean) { |
| | | if (!isOpen) { |
| | | guidelines.value = []; |
| | | legacyTeamMembers.value = ''; |
| | | return; |
| | | } |
| | | const data = modalApi.getData<{ formType: FormType; id?: number }>(); |
| | |
| | | try { |
| | | if (data?.id) { |
| | | const application = await getApplication(data.id); |
| | | legacyTeamMembers.value = application.teamMembers || ''; |
| | | guidelines.value = await resolveGuidelineOptions(application); |
| | | formApi.setState({ |
| | | schema: useFormSchema(formType.value, formApi, guidelines.value), |
| | |
| | | > |
| | | <Form class="mx-3"> |
| | | <template #members> |
| | | <Alert |
| | | v-if="legacyTeamMembers" |
| | | class="mb-3" |
| | | type="info" |
| | | show-icon |
| | | message="å岿忿¬" |
| | | description="以ä¸ä¸ºæ§çå¡«åçæåä¿¡æ¯ï¼è¯·æéæ ¸å¯¹å¹¶æ·»å å°æå表ãåå§ææ¬ä¼ä¿çã" |
| | | /> |
| | | <pre v-if="legacyTeamMembers" class="mb-3 whitespace-pre-wrap break-all rounded border border-border p-3 font-sans">{{ legacyTeamMembers }}</pre> |
| | | <MemberTable |
| | | ref="memberTableRef" |
| | | :members="initialMembers" |
| | |
| | | removed.some( |
| | | (item) => item === row || (row.id != null && item.id === row.id), |
| | | ); |
| | | return toSubmitMembers([ |
| | | ...rows.filter((row) => !isRemoved(row)), |
| | | ...inserted, |
| | | ]); |
| | | return toSubmitMembers( |
| | | [...new Set([...rows, ...inserted])].filter((row) => !isRemoved(row)), |
| | | ); |
| | | } |
| | | |
| | | /** 忥æåæ°æ®ç»ç¶ç»ä»¶ */ |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { onMounted, ref } from 'vue'; |
| | | import { Button, Form, FormItem, Input, InputNumber, Modal, Popconfirm, Select, Table, Upload, message } from 'ant-design-vue'; |
| | | import { Page } from '@vben/common-ui'; |
| | | import { deleteHomeBanner, getHomeBanners, saveHomeBanner, type HomeBanner } from '#/api/system/home-banner'; |
| | | import { publicUploadFile } from '#/api/system/storage'; |
| | | |
| | | defineOptions({ name: 'SystemHomeBanner' }); |
| | | const rows = ref<HomeBanner[]>([]); |
| | | const loading = ref(false); |
| | | const saving = ref(false); |
| | | const uploading = ref(false); |
| | | const visible = ref(false); |
| | | const form = ref<HomeBanner>({ title: '', imageUrl: '', sort: 0, status: 0 }); |
| | | const columns = [ |
| | | { title: 'å¾ç', key: 'image', width: 220 }, |
| | | { title: 'æ é¢', dataIndex: 'title' }, |
| | | { title: 'æåº', dataIndex: 'sort', width: 90 }, |
| | | { title: 'ç¶æ', key: 'status', width: 90 }, |
| | | { title: 'æä½', key: 'action', width: 150 }, |
| | | ]; |
| | | async function load() { |
| | | loading.value = true; |
| | | try { rows.value = await getHomeBanners(); } finally { loading.value = false; } |
| | | } |
| | | function edit(row?: HomeBanner) { |
| | | form.value = row ? { ...row } : { title: '', imageUrl: '', sort: 0, status: 0 }; |
| | | visible.value = true; |
| | | } |
| | | async function upload(file: File) { |
| | | if (!file.type.startsWith('image/')) { message.error('è¯·éæ©å¾çæä»¶'); return false; } |
| | | uploading.value = true; |
| | | try { |
| | | const blob = (await publicUploadFile([file]))?.[0]; |
| | | const url = blob?.previewURL || blob?.url; |
| | | if (!url) throw new Error('ä¸ä¼ 失败'); |
| | | form.value.imageUrl = url; |
| | | } catch { message.error('å¾çä¸ä¼ 失败ï¼è¯·éè¯'); } finally { uploading.value = false; } |
| | | return false; |
| | | } |
| | | async function save() { |
| | | if (!form.value.title.trim() || !form.value.imageUrl) { message.warning('è¯·å¡«åæ é¢å¹¶ä¸ä¼ å¾ç'); return; } |
| | | saving.value = true; |
| | | try { await saveHomeBanner(form.value); message.success('ä¿åæå'); visible.value = false; await load(); } |
| | | finally { saving.value = false; } |
| | | } |
| | | async function remove(id: number) { |
| | | await deleteHomeBanner(id); message.success('å 餿å'); await load(); |
| | | } |
| | | onMounted(load); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page title="é¦é¡µè½®æå¾" description="管çé¦é¡µå±ç¤ºçè½®æå¾çï¼æåºæ°å¼è¶å°è¶é åã"> |
| | | <div class="rounded bg-white p-5"> |
| | | <div class="mb-4 flex justify-between"><span>建议ä¸ä¼ 宽å¹
å¾çï¼éé
é¦é¡µæ¨ªå¹
åºåã</span><Button v-access:code="['system:home-banner:update']" type="primary" @click="edit()">æ°å¢è½®æå¾</Button></div> |
| | | <Table :columns="columns" :data-source="rows" :loading="loading" :pagination="false" row-key="id"> |
| | | <template #bodyCell="{ column, record }"> |
| | | <img v-if="column.key === 'image'" :src="record.imageUrl" :alt="record.title" class="h-16 w-40 rounded object-cover" /> |
| | | <span v-else-if="column.key === 'status'">{{ record.status === 0 ? 'å¯ç¨' : 'åç¨' }}</span> |
| | | <div v-else-if="column.key === 'action'" v-access:code="['system:home-banner:update']" class="flex gap-2"> |
| | | <Button type="link" size="small" @click="edit(record)">ç¼è¾</Button> |
| | | <Popconfirm title="ç¡®å®å é¤è¯¥è½®æå¾ï¼" @confirm="remove(record.id)"><Button type="link" danger size="small">å é¤</Button></Popconfirm> |
| | | </div> |
| | | </template> |
| | | </Table> |
| | | </div> |
| | | <Modal v-model:open="visible" :title="form.id ? 'ç¼è¾è½®æå¾' : 'æ°å¢è½®æå¾'" :confirm-loading="saving" @ok="save"> |
| | | <Form layout="vertical"> |
| | | <FormItem label="æ é¢" required><Input v-model:value="form.title" :maxlength="100" placeholder="ç¨äºè¯å«å¾çåæ éç¢è¯´æ" /></FormItem> |
| | | <FormItem label="å¾ç" required> |
| | | <Upload accept="image/*" :show-upload-list="false" :before-upload="upload"><Button :loading="uploading">ä¸ä¼ å¾ç</Button></Upload> |
| | | <img v-if="form.imageUrl" :src="form.imageUrl" alt="è½®æå¾é¢è§" class="mt-2 max-h-40 max-w-full rounded" /> |
| | | </FormItem> |
| | | <FormItem label="æåº"><InputNumber v-model:value="form.sort" :min="0" :precision="0" /></FormItem> |
| | | <FormItem label="ç¶æ"><Select v-model:value="form.status" :options="[{ label: 'å¯ç¨', value: 0 }, { label: 'åç¨', value: 1 }]" /></FormItem> |
| | | </Form> |
| | | </Modal> |
| | | </Page> |
| | | </template> |
| | |
| | | |
| | | /** æ ¹æ®ä¼åç±»åè·åç级åå
¸é项 */ |
| | | export function getGradeDictOptions(memberType?: number) { |
| | | return getDictOptions( |
| | | memberType === MemberType.UNIT |
| | | ? DICT_TYPE.UMS_UNIT_GRADE |
| | | : DICT_TYPE.UMS_PERSONAL_GRADE, |
| | | 'string', |
| | | ); |
| | | return memberType === MemberType.UNIT |
| | | ? [{ label: 'ä¼é¿åä½', value: '4' }, { label: 'å¯ä¼é¿åä½', value: '3' }, { label: 'çäºåä½', value: '2' }, { label: 'ä¼ååä½', value: '1' }] |
| | | : [{ label: '个人ä¼å', value: '1' }]; |
| | | } |
| | | |
| | | export function getApplicationGradeLabel(memberType?: number, grade?: string) { |
| | | if (!grade) return '-'; |
| | | return getGradeDictOptions(memberType).find((option) => option.value === grade)?.label |
| | | || getDictOptions(memberType === MemberType.UNIT ? DICT_TYPE.UMS_UNIT_GRADE : DICT_TYPE.UMS_PERSONAL_GRADE, 'string').find((option) => option.value === grade)?.label |
| | | || grade; |
| | | } |
| | | |
| | | /** 表åé
ç½® */ |
| | |
| | | disabled: formType === 'detail', |
| | | }, |
| | | help: '审æ¹éè¿åå°èªå¨å建系ç»ç»å½è´¦å·ï¼ç¨æ·åä¸åå§å¯ç å为èç³»çµè¯ï¼è¯·å¡å¿
å¡«ååç¡®', |
| | | }, |
| | | { |
| | | fieldName: 'feePaid', |
| | | label: 'æ¯å¦ç¼´è´¹', |
| | | component: 'Select', |
| | | componentProps: { placeholder: 'è¯·æ ¸å®ç¼´è´¹ç¶æ', options: [{ label: '已缴费', value: true }, { label: 'æªç¼´è´¹', value: false }], disabled: formType === 'detail' }, |
| | | }, |
| | | { |
| | | fieldName: 'contactEmail', |
| | |
| | | slots: { default: 'memberGrade' }, |
| | | }, |
| | | { |
| | | field: 'feePaid', |
| | | title: 'æ¯å¦ç¼´è´¹', |
| | | minWidth: 90, |
| | | formatter: ({ cellValue }) => cellValue == null ? 'æªè®°å½' : cellValue ? '已缴' : 'æªç¼´', |
| | | }, |
| | | { |
| | | field: 'status', |
| | | title: 'ç¶æ', |
| | | minWidth: 95, |
| | |
| | | slots: { default: 'actions' }, |
| | | }, |
| | | ]; |
| | | } |
| | | } |
| | |
| | | import { DICT_TYPE } from '@vben/constants'; |
| | | import { formatDateTime } from '@vben/utils'; |
| | | |
| | | import { Alert, Spin } from 'ant-design-vue'; |
| | | import { Alert, Button, Select, Spin, message } from 'ant-design-vue'; |
| | | |
| | | import { getApplication } from '#/api/ums/application'; |
| | | import { canConfirmApplicationFee, confirmApplicationFee, getApplication } from '#/api/ums/application'; |
| | | import { DictTag } from '#/components/dict-tag'; |
| | | import { useDescription } from '#/components/description'; |
| | | |
| | | import { ApplicationStatus, MemberType } from '../../data'; |
| | | import { ApplicationStatus, MemberType, getApplicationGradeLabel } from '../../data'; |
| | | |
| | | defineOptions({ name: 'UmsApplicationApproveDetail' }); |
| | | |
| | |
| | | |
| | | const loading = ref(false); |
| | | const formData = ref<UmsApplicationApi.Application>(); |
| | | const canConfirmFee = ref(false); |
| | | const selectedFee = ref<boolean | undefined>(); |
| | | const savingFee = ref(false); |
| | | |
| | | /** ææ¬åæ®µç©ºå¼å ä½ */ |
| | | function textRender(val: unknown) { |
| | | return val === 0 || val ? String(val) : '-'; |
| | | } |
| | | |
| | | /** ç³è¯·ççº§ï¼æä¼åç±»åå个人/åä½ä¸åçç级åå
¸ */ |
| | | function getGradeDictType(memberType?: number) { |
| | | return memberType === MemberType.UNIT |
| | | ? DICT_TYPE.UMS_UNIT_GRADE |
| | | : DICT_TYPE.UMS_PERSONAL_GRADE; |
| | | } |
| | | |
| | | const [Descriptions] = useDescription({ |
| | |
| | | { |
| | | label: 'ç³è¯·ç级', |
| | | field: 'memberGrade', |
| | | render: (val, data) => |
| | | h(DictTag, { |
| | | type: getGradeDictType(data?.memberType), |
| | | value: val, |
| | | }), |
| | | render: (val, data) => getApplicationGradeLabel(data?.memberType, val as string), |
| | | }, |
| | | { label: 'æ¯å¦ç¼´è´¹', field: 'feePaid', render: (val) => val == null ? 'æªè®°å½' : val ? '已缴' : 'æªç¼´' }, |
| | | { |
| | | label: 'ç¶æ', |
| | | field: 'status', |
| | |
| | | if (status === ApplicationStatus.APPROVING) { |
| | | return { |
| | | type: 'warning' as const, |
| | | message: `审æ¹éè¿åå°èªå¨ä¸ºç³è¯·äººå建系ç»ç»å½è´¦å·ï¼ç¨æ·åä¸åå§å¯ç å为èç³»çµè¯ ${phone || 'æªå¡«å'}ï¼ï¼è¯·å
æ ¸å¯¹èç³»çµè¯æ¯å¦åç¡®ååéè¿ã`, |
| | | message: '审æ¹éè¿åå°èªå¨ä¸ºç³è¯·äººå建账å·ã请å
æ ¸å®ç¼´è´¹ç¶æï¼åæ§è¡å®¡æ¹ã', |
| | | }; |
| | | } |
| | | if (status === ApplicationStatus.APPROVED) { |
| | |
| | | return { |
| | | type: 'warning' as const, |
| | | message: |
| | | 'æªå¡«åèç³»çµè¯ï¼è´¦å·ç¨æ·å/å¯ç 使ç¨äºå
åºå¼ unknownï¼è¯·å°ä¼ååè¡¨æ ¸å®å¹¶æå·¥ä¿®æ£ã', |
| | | 'æªè·åå°ææºå·ï¼å·²åå»ºéæºè´¦å·ï¼ä¼åå¯ä½¿ç¨ä¼ä¸å¾®ä¿¡æ«ç ç»å½å¹¶è®¾ç½®å¯ç ã', |
| | | }; |
| | | } |
| | | return { |
| | |
| | | try { |
| | | loading.value = true; |
| | | formData.value = await getApplication(Number(props.id)); |
| | | if (formData.value?.status === ApplicationStatus.APPROVING) { |
| | | canConfirmFee.value = await canConfirmApplicationFee(Number(props.id)); |
| | | selectedFee.value = formData.value.feePaid ?? undefined; |
| | | } |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | }); |
| | | |
| | | async function saveFee() { |
| | | if (selectedFee.value === undefined) { message.warning('è¯·éæ©å·²ç¼´è´¹ææªç¼´è´¹'); return; } |
| | | savingFee.value = true; |
| | | try { |
| | | await confirmApplicationFee(Number(props.id), selectedFee.value); |
| | | if (formData.value) formData.value.feePaid = selectedFee.value; |
| | | message.success('ç¼´è´¹ç¶æå·²ç¡®è®¤ï¼å¯ä»¥ç»§ç»å®¡æ¹'); |
| | | } finally { savingFee.value = false; } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | |
| | | :message="credentialTip.message" |
| | | show-icon |
| | | /> |
| | | <div v-if="canConfirmFee" class="rounded-lg border border-border bg-background p-4"> |
| | | <div class="mb-2 font-semibold">确认ä¼è´¹ç¼´çº³æ
åµ</div> |
| | | <div class="flex flex-wrap items-center gap-3"> |
| | | <Select v-model:value="selectedFee" placeholder="è¯·éæ©ç¼´è´¹ç¶æ" class="w-40" |
| | | :options="[{ label: '已缴费', value: true }, { label: 'æªç¼´è´¹', value: false }]" /> |
| | | <Button type="primary" :loading="savingFee" @click="saveFee">ç¡®è®¤ç¼´è´¹ç¶æ</Button> |
| | | </div> |
| | | </div> |
| | | <!-- å
¥ä¼ç³è¯·åºæ¬ä¿¡æ¯ --> |
| | | <div class="rounded-lg border border-border bg-background p-4"> |
| | | <div class="mb-4 flex items-center gap-2"> |
| | |
| | | |
| | | import { message, Tag } from 'ant-design-vue'; |
| | | |
| | | import { DICT_TYPE } from '@vben/constants'; |
| | | import { getDictOptions } from '@vben/hooks'; |
| | | |
| | | import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { |
| | |
| | | |
| | | import { |
| | | ApplicationStatus, |
| | | MemberType, |
| | | getApplicationGradeLabel, |
| | | useGridColumns, |
| | | useGridFormSchema, |
| | | } from './data'; |
| | |
| | | } |
| | | |
| | | /** ç级åå
¸æ å°ï¼æ ¹æ®ä¼åç±»åæ¾ç¤ºå¯¹åºçç级åç§° */ |
| | | const personalGradeOptions = getDictOptions(DICT_TYPE.UMS_PERSONAL_GRADE, 'string'); |
| | | const unitGradeOptions = getDictOptions(DICT_TYPE.UMS_UNIT_GRADE, 'string'); |
| | | function getGradeLabel(row: UmsApplicationApi.Application) { |
| | | const options = |
| | | row.memberType === MemberType.UNIT |
| | | ? unitGradeOptions |
| | | : personalGradeOptions; |
| | | return options?.find((o) => o.value === row.memberGrade)?.label || row.memberGrade; |
| | | return getApplicationGradeLabel(row.memberType, row.memberGrade); |
| | | } |
| | | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | |
| | | </template> |
| | | </Grid> |
| | | </Page> |
| | | </template> |
| | | </template> |
| | |
| | | if (!phone) { |
| | | return { |
| | | type: 'warning' as const, |
| | | message: |
| | | 'æªå¡«åèç³»çµè¯ï¼è´¦å·ç¨æ·å/å¯ç 使ç¨äºå
åºå¼ unknownï¼è¯·å°ä¼ååè¡¨æ ¸å®å¹¶æå·¥ä¿®æ£ã', |
| | | message: 'æªå¡«åèç³»çµè¯ï¼è¯·è系管çå确认ä¼åè´¦å·åç»å½æ¹å¼ã', |
| | | }; |
| | | } |
| | | return { |
| | |
| | | /> |
| | | <Form class="mx-3" /> |
| | | </Modal> |
| | | </template> |
| | | </template> |
| | |
| | | |
| | | const loading = ref(false); |
| | | const submitting = ref(false); |
| | | const profileState = ref<'loading' | 'ready' | 'unlinked' | 'error'>('loading'); |
| | | const latestChange = ref<UmsMemberProfileApi.ChangeRecord>(); |
| | | |
| | | /** æè¿ä¸æ¬¡ç³è¯·å¾
å®¡æ ¸æ¶ç¦æ¢éå¤æäº¤ */ |
| | |
| | | |
| | | async function loadData() { |
| | | loading.value = true; |
| | | profileState.value = 'loading'; |
| | | try { |
| | | const [profile, latest] = await Promise.all([ |
| | | getMyProfile(), |
| | | getMyLatestChange(), |
| | | ]); |
| | | const profile = await getMyProfile(); |
| | | if (!profile) { |
| | | latestChange.value = undefined; |
| | | profileState.value = 'unlinked'; |
| | | return; |
| | | } |
| | | const latest = await getMyLatestChange(); |
| | | latestChange.value = latest ?? undefined; |
| | | await formApi.setValues( |
| | | { |
| | |
| | | }, |
| | | false, |
| | | ); |
| | | profileState.value = 'ready'; |
| | | } catch { |
| | | profileState.value = 'error'; |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | async function handleSubmit() { |
| | | if (profileState.value !== 'ready') return; |
| | | const { valid } = await formApi.validate(); |
| | | if (!valid) { |
| | | return; |
| | |
| | | </div> |
| | | |
| | | <Alert |
| | | v-if="latestAlert" |
| | | v-if="profileState === 'unlinked'" |
| | | type="info" |
| | | show-icon |
| | | message="å½åè´¦å·æªå
³èä¼åæ¡£æ¡" |
| | | description="æ¤é¡µé¢ç¨äºç»´æ¤ä¼åèµæã请èç³»å¦ä¼ç®¡çåå°è´¦å·å
³èå°å¯¹åºä¼åï¼ç³»ç»è´¦å·ä¿¡æ¯å¯å¨å³ä¸è§ä¸ªäººä¸å¿æ¥çã" |
| | | /> |
| | | <Alert |
| | | v-else-if="profileState === 'error'" |
| | | type="error" |
| | | show-icon |
| | | message="èµæå 载失败" |
| | | description="è¯·æ£æ¥ç½ç»è¿æ¥åéè¯ã" |
| | | > |
| | | <template #action><Button size="small" @click="loadData">éè¯</Button></template> |
| | | </Alert> |
| | | <Alert |
| | | v-if="profileState === 'ready' && latestAlert" |
| | | class="mb-4" |
| | | :type="latestAlert.type" |
| | | show-icon |
| | |
| | | :description="latestAlert.description" |
| | | /> |
| | | |
| | | <div class="rounded-lg border border-border bg-background p-4"> |
| | | <div v-if="profileState === 'ready'" class="rounded-lg border border-border bg-background p-4"> |
| | | <Form class="mx-3" /> |
| | | <div class="mt-4 flex justify-end"> |
| | | <Button |