diff --git a/README.md b/README.md index 46cc959..03d5ca2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Frontend -`Frontend/` 是 AppPlatform 的前端工作区,采用 `pnpm workspace + Turbo` 的 monorepo 组织方式。当前目录主要承载主业务应用、文档站、一组共享包和工程脚本。 +`Frontend/` 是 AppPlatform 的前端工作区,采用 `pnpm workspace + Turbo` 的 monorepo 组织方式。当前目录主要承载主业务应用、一组共享包和工程脚本。 项目整体基于 Vue 3 技术栈,并延续 `Vue Vben Admin v5` 的分层方式做业务化扩展。 @@ -13,7 +13,7 @@ - Vue Router - Turbo - pnpm workspace -- VitePress + - Ant Design Vue / antdv-next ## 2. 工作区结构 @@ -21,7 +21,6 @@ ```text Frontend/ ├── apps/ 主业务应用(数据平台) -├── docs/ VitePress 文档站 ├── packages/ 业务与框架共享包 ├── internal/ 内部工程配置与构建基础设施 ├── scripts/ CLI 与部署脚本 @@ -54,15 +53,9 @@ Frontend/ - `adapter`:表单、组件等适配层 - `locales`:国际化资源 -### 3.2 docs -文档站应用,包名为 `@vben/docs`,基于 VitePress。 -- 配置入口在 `docs/.vitepress/config/index.mts` -- 同时提供中文与英文站点 -- 首页内容位于 `docs/src/index.md` 和 `docs/src/en/index.md` - -### 3.3 packages +### 3.2 packages 共享包分为几层: @@ -80,7 +73,7 @@ Frontend/ - `packages/constants|icons|locales|preferences|stores|styles|types|utils` - 面向应用的共享常量、样式、类型、工具与状态能力 -### 3.4 internal +### 3.3 internal 内部工程基础设施,不直接承载业务页面: @@ -90,7 +83,7 @@ Frontend/ - `internal/tailwind-config`:Tailwind 配置 - `internal/node-utils`:Node 侧工具能力 -### 3.5 scripts +### 3.4 scripts 工程脚本目录: @@ -127,9 +120,6 @@ pnpm dev # 启动主业务应用 pnpm dev:antdv-next - -# 启动文档站 -pnpm dev:docs ``` ### 4.3 构建命令 @@ -141,9 +131,6 @@ pnpm build # 构建主应用 pnpm build:antdv-next -# 构建文档站 -pnpm build:docs - # 本地构建 Docker 镜像 pnpm build:docker ``` @@ -200,8 +187,7 @@ pnpm test:unit 4. `apps/package.json` 5. `apps/src/main.ts` 6. `apps/src/bootstrap.ts` -7. `docs/.vitepress/config/index.mts` -8. `internal/vite-config/src/config/application.ts` +7. `internal/vite-config/src/config/application.ts` ## 8. 补充说明 diff --git a/apps/src/api/legacy/props.ts b/apps/src/api/legacy/props.ts index 5eab827..39044ee 100644 --- a/apps/src/api/legacy/props.ts +++ b/apps/src/api/legacy/props.ts @@ -78,23 +78,6 @@ export async function offPropsActivityRewardGroup( return requestClient.get(`/props/activity/reward/group/off/shelf/${id}/${offShelf}`); } -export async function pagePropsActivityRuleConfig(params: Record) { - return requestClient.get>>( - '/props/activity/rule/config/page', - { - params, - }, - ); -} - -export async function savePropsActivityRuleConfig(data: Record) { - return requestClient.post('/props/activity/rule/config/save-or-update', data); -} - -export async function delPropsActivityRuleConfig(id: number | string) { - return requestClient.get(`/props/activity/rule/config/del/${id}`); -} - export async function getGroupActivityList() { return requestClient.get>>( '/props/activity/reward/group/get/activity/list', diff --git a/apps/src/app.vue b/apps/src/app.vue index 59ca861..1962ba6 100644 --- a/apps/src/app.vue +++ b/apps/src/app.vue @@ -2,7 +2,7 @@ import { computed } from 'vue'; import { useAntdDesignTokens } from '@vben/hooks'; -import { preferences, usePreferences } from '@vben/preferences'; +import { preferences } from '@vben/preferences'; import { App, ConfigProvider, theme } from 'antdv-next'; @@ -10,13 +10,10 @@ import { antdLocale } from '#/locales'; defineOptions({ name: 'App' }); -const { isDark } = usePreferences(); const { tokens } = useAntdDesignTokens(); const tokenTheme = computed(() => { - const algorithm = isDark.value - ? [theme.darkAlgorithm] - : [theme.defaultAlgorithm]; + const algorithm = [theme.defaultAlgorithm]; // antd 紧凑模式算法 if (preferences.app.compact) { diff --git a/apps/src/assets/auth/yumi-logo.png b/apps/src/assets/auth/yumi-logo.png new file mode 100644 index 0000000..6647022 Binary files /dev/null and b/apps/src/assets/auth/yumi-logo.png differ diff --git a/apps/src/layouts/auth.vue b/apps/src/layouts/auth.vue index 816bcd0..4c684c7 100644 --- a/apps/src/layouts/auth.vue +++ b/apps/src/layouts/auth.vue @@ -1,25 +1,11 @@ diff --git a/apps/src/layouts/basic.vue b/apps/src/layouts/basic.vue index ae6f6bb..1926768 100644 --- a/apps/src/layouts/basic.vue +++ b/apps/src/layouts/basic.vue @@ -1,17 +1,10 @@ - - - - diff --git a/apps/src/views/props/components/activity-rule-drawer.vue b/apps/src/views/props/components/activity-rule-drawer.vue deleted file mode 100644 index ac7ebcb..0000000 --- a/apps/src/views/props/components/activity-rule-drawer.vue +++ /dev/null @@ -1,568 +0,0 @@ - - - - - diff --git a/apps/src/views/props/components/resource-batch-drawer.vue b/apps/src/views/props/components/resource-batch-drawer.vue new file mode 100644 index 0000000..f1ed6c7 --- /dev/null +++ b/apps/src/views/props/components/resource-batch-drawer.vue @@ -0,0 +1,1390 @@ + + + + + diff --git a/apps/src/views/props/components/resource-config-modal.vue b/apps/src/views/props/components/resource-config-modal.vue index ee33810..7cc108b 100644 --- a/apps/src/views/props/components/resource-config-modal.vue +++ b/apps/src/views/props/components/resource-config-modal.vue @@ -7,8 +7,8 @@ import { FormItem, Image, Input, + InputNumber, message, - Modal, Select, Switch, } from 'antdv-next'; @@ -241,6 +241,18 @@ function handleNotSelectInput(event: Event) { void uploadFile(event, 'notSelectUrl', notSelectUploading, true); } +function clearUpload(targetKey: 'cover' | 'expand' | 'notSelectUrl' | 'sourceUrl') { + form[targetKey] = ''; +} + +function getAssetName(value: unknown) { + const text = String(value || '').trim(); + if (!text) { + return ''; + } + return decodeURIComponent(text.split('/').pop() || text); +} + async function uploadFile( event: Event, targetKey: 'cover' | 'expand' | 'notSelectUrl' | 'sourceUrl', @@ -299,7 +311,13 @@ function validateForm() { message.warning('请输入编码'); return false; } - if (isAmountRequired.value && (form.amount === '' || Number.isNaN(Number(form.amount)))) { + if ( + isAmountRequired.value && + (form.amount === '' || + form.amount === null || + form.amount === undefined || + Number.isNaN(Number(form.amount))) + ) { message.warning('请输入底价'); return false; } @@ -335,141 +353,259 @@ async function handleSubmit() { diff --git a/apps/src/views/props/components/reward-icon.vue b/apps/src/views/props/components/reward-icon.vue index 2903c44..25cdd55 100644 --- a/apps/src/views/props/components/reward-icon.vue +++ b/apps/src/views/props/components/reward-icon.vue @@ -77,10 +77,20 @@ const tokenClassName = computed(() => { align-items: center; background: #fff; border: 1px solid rgb(226 232 240); - border-radius: 14px; + border-radius: 8px; display: inline-flex; justify-content: center; overflow: hidden; + transition: + border-color 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; +} + +.reward-icon:hover { + border-color: rgb(191 219 254); + box-shadow: 0 6px 18px rgb(15 23 42 / 10%); + transform: scale(1.06); } .reward-icon__image { diff --git a/apps/src/views/props/resource-config.vue b/apps/src/views/props/resource-config.vue index 3df2e86..f3c3c34 100644 --- a/apps/src/views/props/resource-config.vue +++ b/apps/src/views/props/resource-config.vue @@ -2,11 +2,19 @@ import { computed, nextTick, + onBeforeUnmount, + onMounted, reactive, ref, } from 'vue'; import { Page } from '@vben/common-ui'; +import { + Ellipsis, + RotateCw, + Search as SearchIcon, + SquarePen, +} from '@vben/icons'; import { useAccessStore } from '@vben/stores'; import { @@ -17,8 +25,8 @@ import { InputNumber, message, Pagination, + Popover, Select, - Space, Switch, Table, Tooltip, @@ -30,13 +38,11 @@ import { pagePropsSource, updatePropsSource, } from '#/api/legacy/props'; -import InlineFilterField from '#/views/_shared/inline-filter-field.vue'; -import InlineFilterToolbar from '#/views/_shared/inline-filter-toolbar.vue'; import { getAllowedSysOrigins } from '#/views/system/shared'; +import ResourceBatchDrawer from './components/resource-batch-drawer.vue'; import ResourceConfigModal from './components/resource-config-modal.vue'; import ResourceOverviewModal from './components/resource-overview-modal.vue'; -import ResourceSalesModal from './components/resource-sales-modal.vue'; import RewardIcon from './components/reward-icon.vue'; import { PROPS_DEL_OPTIONS, PROPS_RESOURCE_CONFIG_TYPES } from './shared'; @@ -58,43 +64,188 @@ function hasPermission(code: string) { const canQuery = computed(() => hasPermission('props:config:query')); const canCreate = computed(() => hasPermission('props:config:add')); const canEdit = computed(() => hasPermission('props:config:edit')); -const canQuerySingleSales = computed(() => hasPermission('props:config:sales:single')); const canQueryOverviewSales = computed(() => hasPermission('props:config:sales:total')); +const hasPageActions = computed(() => canCreate.value || canQueryOverviewSales.value); const loading = ref(false); const total = ref(0); const list = ref>>([]); +const batchOpen = ref(false); const formOpen = ref(false); -const salesOpen = ref(false); const overviewOpen = ref(false); const activeRow = ref>(null); const amountEditingId = ref(null); const amountSavingId = ref(null); const amountDraft = ref(null); +const filterOpen = reactive>({}); +const pageActionOpen = ref(false); +const tableAreaRef = ref(null); +const pagerRef = ref(null); +const tableScrollY = ref(320); +const amountFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, +}); +const tableScroll = computed(() => ({ x: 1000, y: tableScrollY.value })); +const adminFreeOptions = [ + { label: '是', value: true as any }, + { label: '否', value: false as any }, +]; +let tableResizeObserver: null | ResizeObserver = null; const query = reactive({ cursor: 1, + adminFree: undefined as boolean | undefined, + amountMax: undefined as number | undefined, + amountMin: undefined as number | undefined, + code: '', del: false as boolean | undefined, id: '', limit: 20, name: '', sysOrigin: sysOriginOptions.value[0]?.value ?? 'LIKEI', - type: PROPS_RESOURCE_CONFIG_TYPES[0]?.value ?? 'AVATAR_FRAME', + type: (PROPS_RESOURCE_CONFIG_TYPES[0]?.value ?? 'AVATAR_FRAME') as + | string + | undefined, }); const columns = [ - { dataIndex: 'id', key: 'id', title: 'ID', width: 160 }, - { dataIndex: 'code', key: 'code', title: '编号', width: 140 }, + { align: 'center' as const, dataIndex: 'cover', key: 'cover', title: '资源', width: 104 }, + { dataIndex: 'code', key: 'code', title: '编号', width: 150 }, { dataIndex: 'typeName', key: 'typeName', title: '类型', width: 120 }, { dataIndex: 'name', key: 'name', title: '名称', width: 180 }, - { dataIndex: 'cover', key: 'cover', title: '资源', width: 120 }, - { dataIndex: 'del', key: 'del', title: '上/下架', width: 120 }, - { dataIndex: 'amount', key: 'amount', title: '底价', width: 100 }, - { dataIndex: 'storeShelfStatus', key: 'storeShelfStatus', title: '是否上架道具商店', width: 160 }, - { dataIndex: 'adminFree', key: 'adminFree', title: '经理赠送权限', width: 140 }, - { dataIndex: 'actions', key: 'actions', title: '操作', width: 180 }, + { align: 'right' as const, dataIndex: 'amount', key: 'amount', title: '底价', width: 120 }, + { align: 'center' as const, className: 'switch-column', dataIndex: 'del', fixed: 'right' as const, key: 'del', title: '上/下架', width: 84 }, + { align: 'center' as const, className: 'switch-column', dataIndex: 'adminFree', fixed: 'right' as const, key: 'adminFree', title: '经理赠送', width: 96 }, + { align: 'center' as const, className: 'switch-column', dataIndex: 'storeShelfStatus', fixed: 'right' as const, key: 'storeShelfStatus', title: '道具商店', width: 72 }, + { align: 'center' as const, className: 'action-column', dataIndex: 'actions', fixed: 'right' as const, key: 'actions', title: '操作', width: 92 }, ]; +const filterableColumnKeys = new Set([ + 'adminFree', + 'amount', + 'code', + 'del', + 'name', + 'typeName', +]); + +function stringifyCellValue(value: any) { + if (value === undefined || value === null || value === '') { + return '-'; + } + return String(value); +} + +function isFilterableColumn(key: unknown) { + return filterableColumnKeys.has(String(key)); +} + +function setFilterOpen(key: unknown, open: boolean) { + filterOpen[String(key)] = open; +} + +function hasColumnFilter(key: unknown) { + switch (String(key)) { + case 'adminFree': { + return query.adminFree !== undefined; + } + case 'amount': { + return query.amountMin !== undefined || query.amountMax !== undefined; + } + case 'code': { + return query.code.trim() !== ''; + } + case 'del': { + return query.del !== undefined; + } + case 'name': { + return query.name.trim() !== ''; + } + case 'typeName': { + return Boolean(query.type); + } + default: { + return false; + } + } +} + +function resetColumnFilter(key: unknown) { + switch (String(key)) { + case 'adminFree': { + query.adminFree = undefined; + break; + } + case 'amount': { + query.amountMin = undefined; + query.amountMax = undefined; + break; + } + case 'code': { + query.code = ''; + break; + } + case 'del': { + query.del = undefined; + break; + } + case 'name': { + query.name = ''; + break; + } + case 'typeName': { + query.type = undefined; + break; + } + } +} + +function validateAmountFilter() { + const { amountMax, amountMin } = query; + if ( + amountMin !== undefined && + amountMax !== undefined && + Number(amountMin) > Number(amountMax) + ) { + message.warning('底价最小值不能大于最大值'); + return false; + } + return true; +} + +function applyColumnFilter(key: unknown) { + if (String(key) === 'amount' && !validateAmountFilter()) { + return; + } + filterOpen[String(key)] = false; + void loadData(true); +} + +function clearColumnFilter(key: unknown) { + resetColumnFilter(key); + applyColumnFilter(key); +} + +function resetAllColumnFilters() { + query.adminFree = undefined; + query.amountMax = undefined; + query.amountMin = undefined; + query.code = ''; + query.del = undefined; + query.id = ''; + query.name = ''; + query.type = undefined; + void loadData(true); +} + +function formatAmount(value: any) { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric)) { + return stringifyCellValue(value); + } + return amountFormatter.format(numeric); +} + async function loadData(reset = false) { if (!canQuery.value || !query.sysOrigin) { return; @@ -109,9 +260,29 @@ async function loadData(reset = false) { total.value = result.total || 0; } finally { loading.value = false; + void refreshTableScroll(); } } +function updateTableScrollY() { + const area = tableAreaRef.value; + if (!area) { + return; + } + const headerHeight = + area.querySelector('.ant-table-thead')?.getBoundingClientRect().height || 48; + const scrollbarReserve = 8; + tableScrollY.value = Math.max( + 160, + Math.floor(area.clientHeight - headerHeight - scrollbarReserve), + ); +} + +async function refreshTableScroll() { + await nextTick(); + updateTableScrollY(); +} + function handlePageChange(page: number, pageSize: number) { query.cursor = page; query.limit = pageSize; @@ -142,13 +313,20 @@ function isAmountSaving(record: Record) { return amountSavingId.value === getRecordId(record); } +async function focusAmountInput() { + await nextTick(); + const input = document.querySelector('.amount-input input'); + input?.focus(); + input?.select(); +} + function startAmountEdit(record: Record) { if (!canEdit.value) { return; } amountEditingId.value = getRecordId(record); amountDraft.value = Number(record.amount ?? 0); - void nextTick(); + void focusAmountInput(); } function cancelAmountEdit() { @@ -156,6 +334,16 @@ function cancelAmountEdit() { amountDraft.value = null; } +function getNextAmountEditableRecord(record: Record) { + const currentIndex = list.value.findIndex( + (item) => getRecordId(item) === getRecordId(record), + ); + if (currentIndex === -1) { + return null; + } + return list.value[currentIndex + 1] || null; +} + function buildPropsSourceUpdatePayload( record: Record, overrides: Record = {}, @@ -177,17 +365,17 @@ function buildPropsSourceUpdatePayload( async function handleAmountConfirm(record: Record) { if (!isAmountEditing(record) || isAmountSaving(record)) { - return; + return false; } const amount = Number(amountDraft.value); if (!Number.isFinite(amount) || amount < 0) { message.warning('请输入正确的底价'); - return; + return false; } const previous = record.amount; if (Number(previous ?? 0) === amount) { cancelAmountEdit(); - return; + return true; } amountSavingId.value = getRecordId(record); record.amount = amount; @@ -195,6 +383,7 @@ async function handleAmountConfirm(record: Record) { await updatePropsSource(buildPropsSourceUpdatePayload(record, { amount })); message.success('底价已更新'); cancelAmountEdit(); + return true; } catch (error) { record.amount = previous; throw error; @@ -203,6 +392,14 @@ async function handleAmountConfirm(record: Record) { } } +async function handleAmountEnter(record: Record) { + const nextRecord = getNextAmountEditableRecord(record); + const confirmed = await handleAmountConfirm(record); + if (confirmed && nextRecord) { + startAmountEdit(nextRecord); + } +} + async function handleAdminFreeChange(record: Record, checked: boolean) { const previous = record.adminFree; record.adminFree = checked; @@ -249,163 +446,320 @@ async function handleStoreShelfStatusChange(record: Record, checked } function openCreate() { + pageActionOpen.value = false; activeRow.value = null; formOpen.value = true; } +function openBatchCreate() { + pageActionOpen.value = false; + activeRow.value = null; + batchOpen.value = true; +} + +function openOverview() { + pageActionOpen.value = false; + overviewOpen.value = true; +} + function openEdit(record: Record) { activeRow.value = { ...record }; formOpen.value = true; } -function openSales(record: Record) { - activeRow.value = { ...record }; - salesOpen.value = true; -} +onMounted(async () => { + await nextTick(); + void refreshTableScroll(); + if (typeof ResizeObserver !== 'undefined') { + tableResizeObserver = new ResizeObserver(() => { + void refreshTableScroll(); + }); + if (tableAreaRef.value) { + tableResizeObserver.observe(tableAreaRef.value); + } + if (pagerRef.value) { + tableResizeObserver.observe(pagerRef.value); + } + } + window.addEventListener('resize', updateTableScrollY); +}); + +onBeforeUnmount(() => { + tableResizeObserver?.disconnect(); + window.removeEventListener('resize', updateTableScrollY); +}); loadData(true);