云
5 天以前 115bc268902a9899d7d159133c2ba693eddfe00d
完善门户与会员审批交互

- 增加首页轮播图管理及首页动态展示。
- 完善企业微信入会申请、审批缴费确认和个人首页提醒。
- 增加随机账号首次设置密码入口,优化未关联会员时的资料页面。
- 优化表格操作栏溢出、课题申报成员展示及联系方式校验。
- 修正超级管理员调整门户字号的权限判断。

验证:相关后端测试已通过;前端页面和真实企业微信扫码流程尚未完成端到端验证。
已添加2个文件
已修改16个文件
469 ■■■■■ 文件已修改
src/api/system/home-banner/index.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ums/application/index.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ums/member/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ums/member/profile.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/table-action/table-action.vue 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/index.vue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/notice-panel.vue 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/data.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/detail/approve/index.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/form.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/member-table.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/home-banner/index.vue 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/application/data.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/application/detail/approve/index.vue 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/application/index.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/application/modules/form.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/member/profile/index.vue 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/home-banner/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,6 @@
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 } });
src/api/ums/application/index.ts
@@ -19,6 +19,7 @@
    workUnit?: string; // å·¥ä½œå•位(个人专有)
    title?: string; // èŒç§°ï¼ˆä¸ªäººä¸“有)
    memberGrade?: string; // ç”³è¯·ç­‰çº§ï¼ˆæŒ‰ memberType å–不同字典)
    feePaid?: boolean; // ç”³è¯·æ—¶æ˜¯å¦ç¼´è´¹
    regionCode?: string; // åœ°åŒºç¼–码
    industry?: string; // è¡Œä¸šï¼Œå­—å…¸ ums_industry
    status?: number; // çŠ¶æ€ï¼Œå­—å…¸ ums_application_status
@@ -39,6 +40,14 @@
    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 } });
}
/** æŸ¥è¯¢å…¥ä¼šç”³è¯·åˆ†é¡µ */
@@ -85,4 +94,4 @@
  return requestClient.get<UmsApplicationApi.ProcessDefinition[]>(
    '/ums/application/approve-process-list',
  );
}
}
src/api/ums/member/index.ts
@@ -29,6 +29,7 @@
    joinDate?: string; // å…¥ä¼šæ—¥æœŸ
    expireDate?: string; // æœ‰æ•ˆæœŸè‡³
    feePaid?: boolean; // æ˜¯å¦ç¼´çº³ä¼šè´¹
    passwordSetupRequired?: boolean; // é¦–次设置密码
    feeYear?: number; // ç¼´è´¹å¹´åº¦ï¼ˆå¦‚ 2026)
    status?: number; // çŠ¶æ€ï¼Œå­—å…¸ ums_member_status
    archiveDocumentId?: number; // å½’档生成的档案 ID(非空即已归档)
@@ -82,4 +83,4 @@
  return requestClient.post<string>('/ums/member/archive', null, {
    params: { id },
  });
}
}
src/api/ums/member/profile.ts
@@ -49,7 +49,11 @@
/** èŽ·å¾—å½“å‰ç™»å½•ä¼šå‘˜çš„ä¸ªäººèµ„æ–™ */
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 });
}
/** èŽ·å¾—å½“å‰ä¼šå‘˜æœ€è¿‘ä¸€æ¬¡èµ„æ–™å˜æ›´ç”³è¯· */
src/components/table-action/table-action.vue
@@ -4,7 +4,7 @@
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';
@@ -40,6 +40,10 @@
});
const { hasAccessByCodes } = useAccess();
const containerRef = ref<HTMLElement>();
const availableWidth = ref(0);
const isTableCell = ref(false);
let resizeObserver: ResizeObserver | undefined;
/** æ£€æŸ¥æ˜¯å¦æ˜¾ç¤º */
function isIfShow(action: ActionItem): boolean {
@@ -59,16 +63,62 @@
}
/** å¤„理按钮 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(() => {
@@ -139,18 +189,10 @@
  }
}
/** ç›‘听 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"
@@ -186,13 +228,13 @@
      </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>
@@ -242,6 +284,15 @@
</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;
src/packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue
@@ -34,7 +34,7 @@
  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';
@@ -150,8 +150,10 @@
// é—¨æˆ·å­—号属于全局配置,需具备参数配置修改权限才能调整
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');
src/views/dashboard/analytics/index.vue
@@ -1,11 +1,12 @@
<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';
@@ -18,6 +19,16 @@
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: '围绕研究课题,连接申报、审批与协作服务' },
@@ -31,25 +42,25 @@
<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="业务直达">
src/views/dashboard/analytics/modules/notice-panel.vue
@@ -3,16 +3,25 @@
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) {
@@ -25,7 +34,16 @@
  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 {
@@ -41,10 +59,27 @@
}
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>
@@ -52,15 +87,19 @@
  <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>
@@ -68,6 +107,11 @@
        </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>
@@ -85,6 +129,7 @@
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; }
src/views/research/application/data.ts
@@ -227,6 +227,11 @@
        maxlength: 128,
        disabled: readonly,
      },
      rules: z
        .string({ message: '联系邮箱不能为空' })
        .trim()
        .min(1, '联系邮箱不能为空')
        .email('联系邮箱格式不正确'),
    },
    {
      fieldName: 'budgetAmount',
@@ -246,18 +251,6 @@
      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',
    },
    {
src/views/research/application/detail/approve/index.vue
@@ -219,7 +219,7 @@
        <!-- åŽ†å²æ•°æ®å…¼å®¹ï¼šæ—§ç‰ˆæ–‡æœ¬æ ¼å¼çš„è¯¾é¢˜ç»„æˆå‘˜ -->
        <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">
@@ -228,20 +228,6 @@
          </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>
src/views/research/application/modules/form.vue
@@ -8,7 +8,7 @@
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 {
@@ -33,6 +33,7 @@
const guidelines = ref<ResearchGuidelineApi.Guideline[]>([]);
/** æˆå‘˜è¡¨æ ¼å›žæ˜¾æ•°æ®æºï¼šåªåœ¨æ‰“开弹窗时赋值,增删改只通过 emit å•向流到表单值 */
const initialMembers = ref<ResearchApplicationApi.ApplicationMember[]>([]);
const legacyTeamMembers = ref('');
const memberTableRef = ref<InstanceType<typeof MemberTable>>();
const getTitle = computed(() => {
@@ -73,7 +74,6 @@
    contactEmail: values.contactEmail,
    members: toSubmitMembers(values.members),
    budgetAmount: values.budgetAmount,
    planContent: values.planContent,
    remark: values.remark,
    // é™„件为全量替换语义:清空全部附件时必须显式传空数组,
    // ä¼  null åŽç«¯ä¼šè§†ä¸ºã€Œä¸åŠ¨é™„ä»¶ã€ï¼Œå¯¼è‡´åˆ é™¤æ“ä½œä¸ç”Ÿæ•ˆ
@@ -109,6 +109,7 @@
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      guidelines.value = [];
      legacyTeamMembers.value = '';
      return;
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
@@ -117,6 +118,7 @@
    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),
@@ -181,6 +183,15 @@
  >
    <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"
src/views/research/application/modules/member-table.vue
@@ -91,10 +91,9 @@
    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)),
  );
}
/** åŒæ­¥æˆå‘˜æ•°æ®ç»™çˆ¶ç»„ä»¶ */
src/views/system/home-banner/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,80 @@
<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>
src/views/ums/application/data.ts
@@ -26,12 +26,16 @@
/** æ ¹æ®ä¼šå‘˜ç±»åž‹èŽ·å–ç­‰çº§å­—å…¸é€‰é¡¹ */
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;
}
/** è¡¨å•配置 */
@@ -103,6 +107,12 @@
        disabled: formType === 'detail',
      },
      help: '审批通过后将自动创建系统登录账号:用户名与初始密码均为联系电话,请务必填写准确',
    },
    {
      fieldName: 'feePaid',
      label: '是否缴费',
      component: 'Select',
      componentProps: { placeholder: '请核实缴费状态', options: [{ label: '已缴费', value: true }, { label: '未缴费', value: false }], disabled: formType === 'detail' },
    },
    {
      fieldName: 'contactEmail',
@@ -313,6 +323,12 @@
      slots: { default: 'memberGrade' },
    },
    {
      field: 'feePaid',
      title: '是否缴费',
      minWidth: 90,
      formatter: ({ cellValue }) => cellValue == null ? '未记录' : cellValue ? '已缴' : '未缴',
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 95,
@@ -345,4 +361,4 @@
      slots: { default: 'actions' },
    },
  ];
}
}
src/views/ums/application/detail/approve/index.vue
@@ -8,13 +8,13 @@
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' });
@@ -22,17 +22,13 @@
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({
@@ -50,12 +46,9 @@
    {
      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',
@@ -121,7 +114,7 @@
  if (status === ApplicationStatus.APPROVING) {
    return {
      type: 'warning' as const,
      message: `审批通过后将自动为申请人创建系统登录账号(用户名与初始密码均为联系电话 ${phone || '未填写'}),请先核对联系电话是否准确后再通过。`,
      message: '审批通过后将自动为申请人创建账号。请先核实缴费状态,再执行审批。',
    };
  }
  if (status === ApplicationStatus.APPROVED) {
@@ -129,7 +122,7 @@
      return {
        type: 'warning' as const,
        message:
          '未填写联系电话,账号用户名/密码使用了兜底值 unknown,请到会员列表核实并手工修正。',
          '未获取到手机号,已创建随机账号,会员可使用企业微信扫码登录并设置密码。',
      };
    }
    return {
@@ -144,10 +137,24 @@
  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>
@@ -161,6 +168,14 @@
          :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">
src/views/ums/application/index.vue
@@ -10,8 +10,6 @@
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 {
@@ -22,7 +20,7 @@
import {
  ApplicationStatus,
  MemberType,
  getApplicationGradeLabel,
  useGridColumns,
  useGridFormSchema,
} from './data';
@@ -120,14 +118,8 @@
}
/** ç­‰çº§å­—典映射:根据会员类型显示对应的等级名称 */
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({
@@ -258,4 +250,4 @@
      </template>
    </Grid>
  </Page>
</template>
</template>
src/views/ums/application/modules/form.vue
@@ -60,8 +60,7 @@
  if (!phone) {
    return {
      type: 'warning' as const,
      message:
        '未填写联系电话,账号用户名/密码使用了兜底值 unknown,请到会员列表核实并手工修正。',
      message: '未填写联系电话,请联系管理员确认会员账号及登录方式。',
    };
  }
  return {
@@ -158,4 +157,4 @@
    />
    <Form class="mx-3" />
  </Modal>
</template>
</template>
src/views/ums/member/profile/index.vue
@@ -20,6 +20,7 @@
const loading = ref(false);
const submitting = ref(false);
const profileState = ref<'loading' | 'ready' | 'unlinked' | 'error'>('loading');
const latestChange = ref<UmsMemberProfileApi.ChangeRecord>();
/** æœ€è¿‘一次申请待审核时禁止重复提交 */
@@ -58,11 +59,15 @@
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(
      {
@@ -83,12 +88,16 @@
      },
      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;
@@ -143,7 +152,23 @@
      </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
@@ -151,7 +176,7 @@
        :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