Compare commits

...

9 Commits

Author SHA1 Message Date
zhx
cf291546de 大屏 2026-05-18 22:01:56 +08:00
zhx
2dcd19a3ff vip修改 2026-05-18 19:58:36 +08:00
zhx
15fbfa95de 修改商店 2026-05-18 18:55:40 +08:00
zhx
9ad05f8aef 增加默认商店配置 2026-05-18 10:55:20 +08:00
zhx
02cbe30117 去掉多语言 2026-05-17 14:27:13 +08:00
zhx
0788ddfb8a 优化布局 2026-05-17 13:46:34 +08:00
zhx
3dcf7bbd3e Optimize admin production bundle 2026-05-17 13:43:35 +08:00
zhx
5ddd79d116 feat: toggle manager gift permission 2026-05-17 01:04:07 +08:00
zhx
fa5663bdbf feat: inline edit resource store status 2026-05-17 01:02:17 +08:00
323 changed files with 5652 additions and 31588 deletions

View File

@ -1,6 +1,6 @@
# Frontend # Frontend
`Frontend/` 是 AppPlatform 的前端工作区,采用 `pnpm workspace + Turbo` 的 monorepo 组织方式。当前目录主要承载主业务应用、文档站、一组共享包和工程脚本。 `Frontend/` 是 AppPlatform 的前端工作区,采用 `pnpm workspace + Turbo` 的 monorepo 组织方式。当前目录主要承载主业务应用、一组共享包和工程脚本。
项目整体基于 Vue 3 技术栈,并延续 `Vue Vben Admin v5` 的分层方式做业务化扩展。 项目整体基于 Vue 3 技术栈,并延续 `Vue Vben Admin v5` 的分层方式做业务化扩展。
@ -13,7 +13,7 @@
- Vue Router - Vue Router
- Turbo - Turbo
- pnpm workspace - pnpm workspace
- VitePress
- Ant Design Vue / antdv-next - Ant Design Vue / antdv-next
## 2. 工作区结构 ## 2. 工作区结构
@ -21,7 +21,6 @@
```text ```text
Frontend/ Frontend/
├── apps/ 主业务应用(数据平台) ├── apps/ 主业务应用(数据平台)
├── docs/ VitePress 文档站
├── packages/ 业务与框架共享包 ├── packages/ 业务与框架共享包
├── internal/ 内部工程配置与构建基础设施 ├── internal/ 内部工程配置与构建基础设施
├── scripts/ CLI 与部署脚本 ├── scripts/ CLI 与部署脚本
@ -54,15 +53,9 @@ Frontend/
- `adapter`:表单、组件等适配层 - `adapter`:表单、组件等适配层
- `locales`:国际化资源 - `locales`:国际化资源
### 3.2 docs
文档站应用,包名为 `@vben/docs`,基于 VitePress。
- 配置入口在 `docs/.vitepress/config/index.mts` ### 3.2 packages
- 同时提供中文与英文站点
- 首页内容位于 `docs/src/index.md``docs/src/en/index.md`
### 3.3 packages
共享包分为几层: 共享包分为几层:
@ -80,7 +73,7 @@ Frontend/
- `packages/constants|icons|locales|preferences|stores|styles|types|utils` - `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/tailwind-config`Tailwind 配置
- `internal/node-utils`Node 侧工具能力 - `internal/node-utils`Node 侧工具能力
### 3.5 scripts ### 3.4 scripts
工程脚本目录: 工程脚本目录:
@ -127,9 +120,6 @@ pnpm dev
# 启动主业务应用 # 启动主业务应用
pnpm dev:antdv-next pnpm dev:antdv-next
# 启动文档站
pnpm dev:docs
``` ```
### 4.3 构建命令 ### 4.3 构建命令
@ -141,9 +131,6 @@ pnpm build
# 构建主应用 # 构建主应用
pnpm build:antdv-next pnpm build:antdv-next
# 构建文档站
pnpm build:docs
# 本地构建 Docker 镜像 # 本地构建 Docker 镜像
pnpm build:docker pnpm build:docker
``` ```
@ -200,8 +187,7 @@ pnpm test:unit
4. `apps/package.json` 4. `apps/package.json`
5. `apps/src/main.ts` 5. `apps/src/main.ts`
6. `apps/src/bootstrap.ts` 6. `apps/src/bootstrap.ts`
7. `docs/.vitepress/config/index.mts` 7. `internal/vite-config/src/config/application.ts`
8. `internal/vite-config/src/config/application.ts`
## 8. 补充说明 ## 8. 补充说明

View File

@ -14,19 +14,6 @@
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 --> <!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
<title><%= VITE_APP_TITLE %></title> <title><%= VITE_APP_TITLE %></title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<script>
// 生产环境下注入百度统计
if (window._VBEN_ADMIN_PRO_APP_CONF_) {
var _hmt = _hmt || [];
(function () {
var hm = document.createElement('script');
hm.src =
'https://hm.baidu.com/hm.js?b38e689f40558f20a9a686d7f6f33edf';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(hm, s);
})();
}
</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -23,14 +23,14 @@ async function initSetupVbenForm() {
}, },
}, },
defineRules: { defineRules: {
// 输入项目必填国际化适配 // 输入项目必填提示
required: (value, _params, ctx) => { required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) { if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]); return $t('ui.formRules.required', [ctx.label]);
} }
return true; return true;
}, },
// 选择项目必填国际化适配 // 选择项目必填提示
selectRequired: (value, _params, ctx) => { selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]); return $t('ui.formRules.selectRequired', [ctx.label]);

View File

@ -145,12 +145,6 @@ export async function approveReportedNotPass(data: Record<string, any>[]) {
return requestClient.post('/approval/reported/not/pass', data); return requestClient.post('/approval/reported/not/pass', data);
} }
export async function getDynamicReportPage(params: Record<string, any>) {
return requestClient.get<LegacyPageResult>('/dynamic/report/page', {
params,
});
}
export async function pageUserIntegralOriginStream( export async function pageUserIntegralOriginStream(
params: Record<string, any>, params: Record<string, any>,
) { ) {
@ -162,26 +156,6 @@ export async function pageUserIntegralOriginStream(
); );
} }
export async function processDynamicReport(data: Record<string, any>) {
return requestClient.post('/dynamic/report', data);
}
export async function getDynamicContentPage(params: Record<string, any>) {
return requestClient.get<LegacyPageResult>('/approval/dynamic/content/page', {
params,
});
}
export async function approveDynamicContentPass(data: Array<number | string>) {
return requestClient.post('/approval/dynamic/content/pass', data);
}
export async function approveDynamicContentNotPass(
data: Array<number | string>,
) {
return requestClient.post('/approval/dynamic/content/not/pass', data);
}
export async function getViolationHistoryPage(params: Record<string, any>) { export async function getViolationHistoryPage(params: Record<string, any>) {
return requestClient.get<LegacyPageResult>('/approval/history/page', { return requestClient.get<LegacyPageResult>('/approval/history/page', {
params, params,

View File

@ -37,6 +37,7 @@ export interface CountryDashboardMetric {
periodKey: string; periodKey: string;
periodName: string; periodName: string;
salaryExchange: number | string; salaryExchange: number | string;
salaryTransfer: number | string;
totalRecharge: number | string; totalRecharge: number | string;
} }

View File

@ -1,65 +0,0 @@
import { requestClient } from '#/api/request';
export interface LegacyPageResult<T = Record<string, any>> {
records: T[];
total: number;
}
export async function dynamicBlacklistPage(params: Record<string, any>) {
return requestClient.get<LegacyPageResult<Record<string, any>>>(
'/dynamic/blacklist/page',
{
params,
},
);
}
export async function addDynamicBlacklist(data: Record<string, any>) {
return requestClient.post('/dynamic/blacklist/add', data);
}
export async function deleteDynamicBlacklist(userId: number | string) {
return requestClient.get(`/dynamic/blacklist/delete/${userId}`);
}
export async function dynamicTagTable(params: Record<string, any>) {
return requestClient.get<LegacyPageResult<Record<string, any>>>(
'/dynamic/tag/page',
{
params,
},
);
}
export async function saveDynamicTag(data: Record<string, any>) {
return requestClient.post('/dynamic/tag/add-or-update', data);
}
export async function getDynamicPopularConfig() {
return requestClient.get<Record<string, any>>('/dynamic/popular/config');
}
export async function saveDynamicPopularConfig(data: Record<string, any>) {
return requestClient.post('/dynamic/popular/config', data);
}
export async function userDynamicTable(params: Record<string, any>) {
return requestClient.get<LegacyPageResult<Record<string, any>>>(
'/user/dynamic/page',
{
params,
},
);
}
export async function deleteUserDynamic(data: Array<number | string>) {
return requestClient.post('/user/dynamic/delete', data);
}
export async function setUserDynamicTop(data: Record<string, any>) {
return requestClient.post('/user/dynamic/setUpTop', data);
}
export async function closeUserDynamicTop(dynamicId: number | string) {
return requestClient.get(`/user/dynamic/closeTop/${dynamicId}`);
}

View File

@ -78,23 +78,6 @@ export async function offPropsActivityRewardGroup(
return requestClient.get(`/props/activity/reward/group/off/shelf/${id}/${offShelf}`); return requestClient.get(`/props/activity/reward/group/off/shelf/${id}/${offShelf}`);
} }
export async function pagePropsActivityRuleConfig(params: Record<string, any>) {
return requestClient.get<LegacyPageResult<Record<string, any>>>(
'/props/activity/rule/config/page',
{
params,
},
);
}
export async function savePropsActivityRuleConfig(data: Record<string, any>) {
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() { export async function getGroupActivityList() {
return requestClient.get<Array<Record<string, any>>>( return requestClient.get<Array<Record<string, any>>>(
'/props/activity/reward/group/get/activity/list', '/props/activity/reward/group/get/activity/list',
@ -102,11 +85,11 @@ export async function getGroupActivityList() {
} }
export async function sendPropsGiveUser(data: Record<string, any>) { export async function sendPropsGiveUser(data: Record<string, any>) {
return requestClient.post<string | null>('/props/give/send', data); return requestClient.post<null | string>('/props/give/send', data);
} }
export async function sendPropsTicketGiveUser(data: Record<string, any>) { export async function sendPropsTicketGiveUser(data: Record<string, any>) {
return requestClient.post<string | null>('/props/give/grant-coupon', data); return requestClient.post<null | string>('/props/give/grant-coupon', data);
} }
export async function pagePropsStore(params: Record<string, any>) { export async function pagePropsStore(params: Record<string, any>) {
@ -118,6 +101,17 @@ export async function pagePropsStore(params: Record<string, any>) {
); );
} }
export async function mapPropsStoreBySourceIds(data: Record<string, any>) {
return requestClient.post<Record<string, Record<string, any>>>(
'/go/props/store/source-map',
data,
);
}
export async function addOrUpdatePropsStore(data: Record<string, any>) { export async function addOrUpdatePropsStore(data: Record<string, any>) {
return requestClient.post('/go/props/store/add-or-update', data);
}
export async function addOrUpdatePropsStoreLegacy(data: Record<string, any>) {
return requestClient.post('/props/store/add-or-update', data); return requestClient.post('/props/store/add-or-update', data);
} }

View File

@ -60,7 +60,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
} else { } else {
delete config.headers.Authorization; delete config.headers.Authorization;
} }
config.headers['Accept-Language'] = preferences.app.locale; config.headers['Accept-Language'] = 'zh-CN';
config.headers['Req-Client'] = 'Ops'; config.headers['Req-Client'] = 'Ops';
config.headers['Req-Version'] = 'v2'; config.headers['Req-Version'] = 'v2';
return config; return config;

View File

@ -2,7 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useAntdDesignTokens } from '@vben/hooks'; import { useAntdDesignTokens } from '@vben/hooks';
import { preferences, usePreferences } from '@vben/preferences'; import { preferences } from '@vben/preferences';
import { App, ConfigProvider, theme } from 'antdv-next'; import { App, ConfigProvider, theme } from 'antdv-next';
@ -10,13 +10,10 @@ import { antdLocale } from '#/locales';
defineOptions({ name: 'App' }); defineOptions({ name: 'App' });
const { isDark } = usePreferences();
const { tokens } = useAntdDesignTokens(); const { tokens } = useAntdDesignTokens();
const tokenTheme = computed(() => { const tokenTheme = computed(() => {
const algorithm = isDark.value const algorithm = [theme.defaultAlgorithm];
? [theme.darkAlgorithm]
: [theme.defaultAlgorithm];
// antd // antd
if (preferences.app.compact) { if (preferences.app.compact) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

View File

@ -9,14 +9,14 @@ import '@vben/styles/antdv-next';
import { useTitle } from '@vueuse/core'; import { useTitle } from '@vueuse/core';
import { $t, setupI18n } from '#/locales';
import SysOriginSelect from '#/components/sys-origin-select.vue'; import SysOriginSelect from '#/components/sys-origin-select.vue';
import { $t, setupLocale } from '#/locales';
import { initComponentAdapter } from './adapter/component'; import { initComponentAdapter } from './adapter/component';
import { initSetupVbenForm } from './adapter/form'; import { initSetupVbenForm } from './adapter/form';
import App from './app.vue'; import App from './app.vue';
import { setupRuntimeLocaleSync } from './locales/runtime';
import { router } from './router'; import { router } from './router';
import './styles/table-filters.css'; import './styles/table-filters.css';
async function bootstrap(namespace: string) { async function bootstrap(namespace: string) {
@ -44,8 +44,8 @@ async function bootstrap(namespace: string) {
spinning: 'spinning', spinning: 'spinning',
}); });
// 国际化 i18n 配置 // 固定中文基础文案
await setupI18n(app); await setupLocale(app);
// 配置 pinia-tore // 配置 pinia-tore
await initStores(app, { namespace }); await initStores(app, { namespace });
@ -75,7 +75,6 @@ async function bootstrap(namespace: string) {
}); });
app.mount('#app'); app.mount('#app');
setupRuntimeLocaleSync();
} }
export { bootstrap }; export { bootstrap };

View File

@ -1,25 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue';
import { AuthPageLayout } from '@vben/layouts'; import { AuthPageLayout } from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { $t } from '#/locales';
const appName = computed(() => $t(preferences.app.name));
const logo = computed(() => preferences.logo.source);
const logoDark = computed(() => preferences.logo.sourceDark);
</script> </script>
<template> <template>
<AuthPageLayout <AuthPageLayout
:app-name="appName" :copyright="false"
:logo="logo" :toolbar="false"
:logo-dark="logoDark" minimal
:page-description="$t('authentication.pageDesc')" />
:page-title="$t('authentication.pageTitle')"
>
<!-- 自定义工具栏 -->
<!-- <template #toolbar></template> -->
</AuthPageLayout>
</template> </template>

View File

@ -1,17 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { NotificationItem } from '@vben/layouts'; import { computed, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui'; import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { useWatermark } from '@vben/hooks'; import { useWatermark } from '@vben/hooks';
import { import { BasicLayout, LockScreen, UserDropdown } from '@vben/layouts';
BasicLayout,
LockScreen,
Notification,
UserDropdown,
} from '@vben/layouts';
import { preferences } from '@vben/preferences'; import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores'; import { useAccessStore, useUserStore } from '@vben/stores';
@ -21,67 +14,11 @@ import DevApiSwitch from '#/components/dev-api-switch.vue';
import LoginForm from '#/views/_core/authentication/login.vue'; import LoginForm from '#/views/_core/authentication/login.vue';
const isDev = import.meta.env.DEV; const isDev = import.meta.env.DEV;
const notifications = ref<NotificationItem[]>([
{
id: 1,
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
date: '3小时前',
isRead: true,
message: '描述信息描述信息描述信息',
title: '收到了 14 份新周报',
},
{
id: 2,
avatar: 'https://avatar.vercel.sh/1',
date: '刚刚',
isRead: false,
message: '描述信息描述信息描述信息',
title: '朱偏右 回复了你',
},
{
id: 3,
avatar: 'https://avatar.vercel.sh/1',
date: '2024-01-01',
isRead: false,
message: '描述信息描述信息描述信息',
title: '曲丽丽 评论了你',
},
{
id: 4,
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '代办提醒',
},
{
id: 5,
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '跳转Workspace示例',
link: '/workspace',
},
{
id: 6,
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '跳转外部链接示例',
link: 'https://doc.vben.pro',
},
]);
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
const accessStore = useAccessStore(); const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark(); const { destroyWatermark, updateWatermark } = useWatermark();
const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
const menus = computed(() => [ const menus = computed(() => [
{ {
@ -100,25 +37,6 @@ const avatar = computed(() => {
async function handleLogout() { async function handleLogout() {
await authStore.logout(false); await authStore.logout(false);
} }
function handleNoticeClear() {
notifications.value = [];
}
function markRead(id: number | string) {
const item = notifications.value.find((item) => item.id === id);
if (item) {
item.isRead = true;
}
}
function remove(id: number | string) {
notifications.value = notifications.value.filter((item) => item.id !== id);
}
function handleMakeAll() {
notifications.value.forEach((item) => (item.isRead = true));
}
watch( watch(
() => ({ () => ({
enable: preferences.app.watermark, enable: preferences.app.watermark,
@ -156,16 +74,6 @@ watch(
@logout="handleLogout" @logout="handleLogout"
/> />
</template> </template>
<template #notification>
<Notification
:dot="showDot"
:notifications="notifications"
@clear="handleNoticeClear"
@read="(item) => item.id && markRead(item.id)"
@remove="(item) => item.id && remove(item.id)"
@make-all="handleMakeAll"
/>
</template>
<template #extra> <template #extra>
<AuthenticationLoginExpiredModal <AuthenticationLoginExpiredModal
v-model:open="accessStore.loginExpired" v-model:open="accessStore.loginExpired"

View File

@ -1,3 +1,3 @@
# locale # locale
每个app使用的国际化可能不同这里用于扩展国际化的功能例如扩展 dayjs、antd组件库的多语言切换以及app本身的国际化文件 后台固定使用中文,这里只保留 dayjs、antd 组件库和应用基础文案的中文配置

View File

@ -2,117 +2,71 @@ import type { Locale } from 'antdv-next/dist/locale/index';
import type { App } from 'vue'; import type { App } from 'vue';
import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales';
import { ref } from 'vue'; import { ref } from 'vue';
import { import {
$t, $t,
setupI18n as coreSetup, setupLocale as coreSetup,
loadLocalesMapFromDir, mergeLocaleMessages,
} from '@vben/locales'; } from '@vben/locales';
import { preferences } from '@vben/preferences';
import antdEnLocale from 'antdv-next/dist/locale/en_US';
import antdDefaultLocale from 'antdv-next/dist/locale/zh_CN'; import antdDefaultLocale from 'antdv-next/dist/locale/zh_CN';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
const antdLocale = ref<Locale>(antdDefaultLocale); const antdLocale = ref<Locale>(antdDefaultLocale);
const modules = import.meta.glob('./langs/**/*.json'); const modules = import.meta.glob('./langs/zh-CN/*.json', { eager: true });
const runtimeModules = import.meta.glob('./langs/*/runtime.json');
const localesMap = loadLocalesMapFromDir( function normalizeModule(module: unknown) {
/\.\/langs\/([^/]+)\/(.*)\.json$/, return module && typeof module === 'object' && 'default' in module
modules, ? (module as { default: Record<string, any> }).default
); : module;
/**
*
*
* @param lang
*/
async function loadMessages(lang: SupportedLanguagesType) {
const [appLocaleMessages, runtimeMessages] = await Promise.all([
localesMap[lang]?.(),
loadRuntimeMessages(lang),
loadThirdPartyMessage(lang),
]);
const appMessages = appLocaleMessages?.default ?? {};
const { runtime: _runtime, ...rest } = appMessages;
return {
...(runtimeMessages ?? {}),
...rest,
};
} }
async function loadRuntimeMessages(lang: SupportedLanguagesType) { function loadAppMessages() {
const runtimeLoader = runtimeModules[`./langs/${lang}/runtime.json`]; const messages: Record<string, any> = {};
const runtimeMessages = runtimeLoader for (const [path, module] of Object.entries(modules)) {
? ((await runtimeLoader()) as { default?: Record<string, string> }) const fileName = path.match(/\.\/langs\/zh-CN\/(.*)\.json$/)?.[1];
: undefined; if (!fileName) {
return runtimeMessages?.default; continue;
}
const value = normalizeModule(module);
if (fileName === 'runtime') {
Object.assign(messages, value);
} else {
messages[fileName] = value;
}
}
return messages;
} }
mergeLocaleMessages(loadAppMessages());
/** /**
* *
* @param lang
*/ */
async function loadThirdPartyMessage(lang: SupportedLanguagesType) { async function loadThirdPartyMessage() {
await Promise.all([loadAntdLocale(lang), loadDayjsLocale(lang)]); await Promise.all([loadAntdLocale(), loadDayjsLocale()]);
} }
/** /**
* dayjs的语言包 * dayjs的语言包
* @param lang
*/ */
async function loadDayjsLocale(lang: SupportedLanguagesType) { async function loadDayjsLocale() {
let locale; await import('dayjs/locale/zh-cn');
switch (lang) { dayjs.locale('zh-cn');
case 'en-US': {
locale = await import('dayjs/locale/en');
break;
}
case 'zh-CN': {
locale = await import('dayjs/locale/zh-cn');
break;
}
// 默认使用英语
default: {
locale = await import('dayjs/locale/en');
}
}
if (locale) {
dayjs.locale(locale);
} else {
console.error(`Failed to load dayjs locale for ${lang}`);
}
} }
/** /**
* antd的语言包 * antd的语言包
* @param lang
*/ */
async function loadAntdLocale(lang: SupportedLanguagesType) { async function loadAntdLocale() {
switch (lang) { antdLocale.value = antdDefaultLocale;
case 'en-US': {
antdLocale.value = antdEnLocale;
break;
}
case 'zh-CN': {
antdLocale.value = antdDefaultLocale;
break;
}
}
} }
async function setupI18n(app: App, options: LocaleSetupOptions = {}) { async function setupLocale(app: App) {
await coreSetup(app, { await coreSetup(app);
defaultLocale: preferences.app.locale, await loadThirdPartyMessage();
loadMessages,
missingWarn: !import.meta.env.PROD,
...options,
});
} }
export { $t, antdLocale, setupI18n }; export { $t, antdLocale, setupLocale };

View File

@ -1,14 +0,0 @@
{
"auth": {
"login": "Login",
"register": "Register",
"codeLogin": "Code Login",
"qrcodeLogin": "Qr Code Login",
"forgetPassword": "Forget Password",
"profile": "Profile"
},
"dashboard": {
"title": "Data Platform",
"workspace": "Data Platform"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -107,7 +107,6 @@
"编辑应用": "编辑应用", "编辑应用": "编辑应用",
"编辑用户": "编辑用户", "编辑用户": "编辑用户",
"编辑游戏": "编辑游戏", "编辑游戏": "编辑游戏",
"编辑置顶动态": "编辑置顶动态",
"编辑资料": "编辑资料", "编辑资料": "编辑资料",
"编辑资源": "编辑资源", "编辑资源": "编辑资源",
"编码": "编码", "编码": "编码",
@ -161,7 +160,6 @@
"不允许跨年查询": "不允许跨年查询", "不允许跨年查询": "不允许跨年查询",
"不在预计状态内": "不在预计状态内", "不在预计状态内": "不在预计状态内",
"财富": "财富", "财富": "财富",
"财富、魅力等级多少级才能发布动态": "财富、魅力等级多少级才能发布动态",
"财富等级": "财富等级", "财富等级": "财富等级",
"菜单": "菜单", "菜单": "菜单",
"菜单编辑": "菜单编辑", "菜单编辑": "菜单编辑",
@ -223,7 +221,6 @@
"超级货运": "超级货运", "超级货运": "超级货运",
"超级经销商": "超级经销商", "超级经销商": "超级经销商",
"超商条码": "超商条码", "超商条码": "超商条码",
"超限制发动态支付金币": "超限制发动态支付金币",
"朝拜": "朝拜", "朝拜": "朝拜",
"朝拜描述": "朝拜描述", "朝拜描述": "朝拜描述",
"车辆": "车辆", "车辆": "车辆",
@ -443,13 +440,6 @@
"动画": "动画", "动画": "动画",
"动画资源": "动画资源", "动画资源": "动画资源",
"动画资源图": "动画资源图", "动画资源图": "动画资源图",
"动态-禁止用户发动态": "动态-禁止用户发动态",
"动态管理": "动态管理",
"动态举报审批": "动态举报审批",
"动态内容": "动态内容",
"动态内容审批": "动态内容审批",
"动态数量": "动态数量",
"动态图片": "动态图片",
"动效": "动效", "动效": "动效",
"冻结": "冻结", "冻结": "冻结",
"豆子": "豆子", "豆子": "豆子",
@ -805,7 +795,6 @@
"会员": "会员", "会员": "会员",
"会员名": "会员名", "会员名": "会员名",
"活动": "活动", "活动": "活动",
"活动道具规则配置": "活动道具规则配置",
"活动奖励": "活动奖励", "活动奖励": "活动奖励",
"活动结束后不可再查看排行榜": "活动结束后不可再查看排行榜", "活动结束后不可再查看排行榜": "活动结束后不可再查看排行榜",
"活动结束后不可再查看排行榜,请通过奖品记录查看获奖快照": "活动结束后不可再查看排行榜,请通过奖品记录查看获奖快照", "活动结束后不可再查看排行榜,请通过奖品记录查看获奖快照": "活动结束后不可再查看排行榜,请通过奖品记录查看获奖快照",
@ -857,7 +846,6 @@
"几时": "几时", "几时": "几时",
"记录数": "记录数", "记录数": "记录数",
"记录ID": "记录ID", "记录ID": "记录ID",
"继续发送每条动态支付多少金币": "继续发送每条动态支付多少金币",
"加入": "加入", "加入": "加入",
"加入成员金币": "加入成员金币", "加入成员金币": "加入成员金币",
"加入房间": "加入房间", "加入房间": "加入房间",
@ -1199,7 +1187,6 @@
"美元": "美元", "美元": "美元",
"美元汇率": "美元汇率", "美元汇率": "美元汇率",
"魅力等级": "魅力等级", "魅力等级": "魅力等级",
"魅力等级多少级才能发布动态": "魅力等级多少级才能发布动态",
"门票": "门票", "门票": "门票",
"密码": "密码", "密码": "密码",
"密码不能少于4位": "密码不能少于4位", "密码不能少于4位": "密码不能少于4位",
@ -1701,7 +1688,6 @@
"确认审核选中记录吗": "确认审核选中记录吗", "确认审核选中记录吗": "确认审核选中记录吗",
"确认选择": "确认选择", "确认选择": "确认选择",
"热门": "热门", "热门": "热门",
"热门动态权重计分设置": "热门动态权重计分设置",
"热门房间": "热门房间", "热门房间": "热门房间",
"人": "人", "人": "人",
"人身攻击": "人身攻击", "人身攻击": "人身攻击",
@ -2348,10 +2334,7 @@
"用户:数据分配图": "用户:数据分配图", "用户:数据分配图": "用户:数据分配图",
"用户1V1": "用户1V1", "用户1V1": "用户1V1",
"用户充值抽奖活动奖励": "用户充值抽奖活动奖励", "用户充值抽奖活动奖励": "用户充值抽奖活动奖励",
"用户当天发送动态数量达到限制后": "用户当天发送动态数量达到限制后",
"用户当天发送动态数量达到限制后,继续发送每条动态支付多少金币": "用户当天发送动态数量达到限制后,继续发送每条动态支付多少金币",
"用户道具流水": "用户道具流水", "用户道具流水": "用户道具流水",
"用户动态": "用户动态",
"用户房间区域变动": "用户房间区域变动", "用户房间区域变动": "用户房间区域变动",
"用户个性签名审批": "用户个性签名审批", "用户个性签名审批": "用户个性签名审批",
"用户观看广告配置": "用户观看广告配置", "用户观看广告配置": "用户观看广告配置",
@ -2487,7 +2470,6 @@
"暂无备注": "暂无备注", "暂无备注": "暂无备注",
"暂无查看权限": "暂无查看权限", "暂无查看权限": "暂无查看权限",
"暂无道具配置": "暂无道具配置", "暂无道具配置": "暂无道具配置",
"暂无动态": "暂无动态",
"暂无购买记录": "暂无购买记录", "暂无购买记录": "暂无购买记录",
"暂无可复制内容": "暂无可复制内容", "暂无可复制内容": "暂无可复制内容",
"暂无可选资源": "暂无可选资源", "暂无可选资源": "暂无可选资源",

View File

@ -1,267 +0,0 @@
import { nextTick, watch } from 'vue';
import { i18n } from '@vben/locales';
const CHINESE_RE = /[\u3400-\u9fff]/;
const SKIP_ATTRIBUTE_SELECTOR =
'[data-no-runtime-locale], .ace_editor, .monaco-editor, noscript, script, style';
const SKIP_TEXT_SELECTOR = `${SKIP_ATTRIBUTE_SELECTOR}, [contenteditable="true"], code, pre`;
const TRANSLATABLE_ATTRIBUTES = [
'alt',
'aria-label',
'placeholder',
'title',
] as const;
const elementOriginals = new WeakMap<Element, Map<string, string>>();
const textOriginals = new WeakMap<Text, string>();
let initialized = false;
let observer: MutationObserver | undefined;
let runtimeMatcherCache:
| {
dict: Record<string, string>;
locale: string;
pattern?: RegExp;
}
| undefined;
function escapeRegExp(text: string) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getRuntimeDictionary(locale: string) {
const messages = i18n.global.getLocaleMessage(locale) as Record<string, unknown>;
const dictionary: Record<string, string> = {};
for (const [key, value] of Object.entries(messages)) {
if (typeof value === 'string' && CHINESE_RE.test(key)) {
dictionary[key] = value;
}
}
return dictionary;
}
function getRuntimeMatcher(locale: string) {
if (runtimeMatcherCache?.locale === locale) {
return runtimeMatcherCache;
}
const dict = locale === 'en-US' ? getRuntimeDictionary(locale) : {};
const keys = Object.keys(dict).sort((left, right) => right.length - left.length);
runtimeMatcherCache = {
dict,
locale,
pattern:
keys.length > 0
? new RegExp(keys.map((key) => escapeRegExp(key)).join('|'), 'g')
: undefined,
};
return runtimeMatcherCache;
}
function getElementOriginalMap(element: Element) {
let originalMap = elementOriginals.get(element);
if (!originalMap) {
originalMap = new Map<string, string>();
elementOriginals.set(element, originalMap);
}
return originalMap;
}
function shouldSkipElement(element: Element) {
return element.matches(SKIP_ATTRIBUTE_SELECTOR) || !!element.closest(SKIP_ATTRIBUTE_SELECTOR);
}
function shouldSkipTextNode(node: Text) {
return !!node.parentElement?.closest(SKIP_TEXT_SELECTOR);
}
function getCurrentLocale() {
return String(i18n.global.locale.value);
}
function translateText(text: string) {
if (!text || !CHINESE_RE.test(text)) {
return text;
}
const locale = getCurrentLocale();
if (locale !== 'en-US') {
return text;
}
const { dict, pattern } = getRuntimeMatcher(locale);
if (!pattern) {
return text;
}
return text.replace(pattern, (matched) => dict[matched] ?? matched);
}
function translateElementAttributes(element: Element) {
if (shouldSkipElement(element)) {
return;
}
const locale = getCurrentLocale();
const originals = getElementOriginalMap(element);
for (const attribute of TRANSLATABLE_ATTRIBUTES) {
const rawValue = element.getAttribute(attribute);
if (rawValue == null) {
continue;
}
if (locale === 'en-US') {
const originalValue = CHINESE_RE.test(rawValue)
? rawValue
: (originals.get(attribute) ?? rawValue);
if (!CHINESE_RE.test(originalValue)) {
continue;
}
const translatedValue = translateText(originalValue);
if (translatedValue !== originalValue) {
originals.set(attribute, originalValue);
if (rawValue !== translatedValue) {
element.setAttribute(attribute, translatedValue);
}
}
continue;
}
const originalValue = originals.get(attribute);
if (originalValue !== undefined && rawValue !== originalValue) {
element.setAttribute(attribute, originalValue);
}
}
}
function translateTextNode(node: Text) {
if (shouldSkipTextNode(node)) {
return;
}
const rawValue = node.nodeValue ?? '';
const locale = getCurrentLocale();
if (locale === 'en-US') {
const originalValue = CHINESE_RE.test(rawValue)
? rawValue
: (textOriginals.get(node) ?? rawValue);
if (!CHINESE_RE.test(originalValue)) {
return;
}
const translatedValue = translateText(originalValue);
if (translatedValue !== originalValue) {
textOriginals.set(node, originalValue);
if (rawValue !== translatedValue) {
node.nodeValue = translatedValue;
}
}
return;
}
const originalValue = textOriginals.get(node);
if (originalValue !== undefined && rawValue !== originalValue) {
node.nodeValue = originalValue;
}
}
function processNode(root: Node) {
const stack: Node[] = [root];
while (stack.length > 0) {
const node = stack.pop();
if (!node) {
continue;
}
if (node.nodeType === Node.TEXT_NODE) {
translateTextNode(node as Text);
continue;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
continue;
}
const element = node as Element;
translateElementAttributes(element);
for (let index = element.childNodes.length - 1; index >= 0; index -= 1) {
const childNode = element.childNodes[index];
if (childNode) {
stack.push(childNode);
}
}
}
}
function refreshRuntimeLocale() {
if (typeof document === 'undefined' || !document.body) {
return;
}
processNode(document.body);
}
function startObserver() {
if (
observer ||
typeof document === 'undefined' ||
typeof MutationObserver === 'undefined' ||
!document.body
) {
return;
}
observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' || mutation.type === 'characterData') {
processNode(mutation.target);
continue;
}
mutation.addedNodes.forEach((node) => processNode(node));
}
});
observer.observe(document.body, {
attributeFilter: [...TRANSLATABLE_ATTRIBUTES],
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
}
function setupRuntimeLocaleSync() {
if (initialized) {
return;
}
initialized = true;
watch(
() => getCurrentLocale(),
async () => {
runtimeMatcherCache = undefined;
await nextTick();
refreshRuntimeLocale();
startObserver();
},
{
flush: 'post',
immediate: true,
},
);
}
export { setupRuntimeLocaleSync, translateText as translateLocaleText };

View File

@ -1,8 +1,9 @@
import type { RouteRecordRaw } from 'vue-router';
import type { import type {
ComponentRecordType, ComponentRecordType,
GenerateMenuAndRoutesOptions, GenerateMenuAndRoutesOptions,
} from '@vben/types'; } from '@vben/types';
import type { RouteRecordRaw } from 'vue-router';
import { generateAccessible } from '@vben/access'; import { generateAccessible } from '@vben/access';
import { preferences } from '@vben/preferences'; import { preferences } from '@vben/preferences';
@ -24,8 +25,6 @@ const ROUTE_MENU_OVERRIDES: Record<
} }
> = { > = {
BdLead: { aliases: ['BdLead'], routers: ['team/bd-lead'] }, BdLead: { aliases: ['BdLead'], routers: ['team/bd-lead'] },
DynamicTagList: { aliases: ['DynamicTag'], routers: ['dynamic/tag'] },
DynamicUserList: { aliases: ['UserDynamicList'], routers: ['user/dynamic/list'] },
OperateBusinessDevelopment: { OperateBusinessDevelopment: {
aliases: ['BusinessDevelopment'], aliases: ['BusinessDevelopment'],
routers: ['room/business-development'], routers: ['room/business-development'],
@ -63,16 +62,16 @@ const ROUTE_MENU_OVERRIDES: Record<
}; };
const LOCAL_RESIDENT_ACTIVITY_FALLBACK_ROUTES = new Set([ const LOCAL_RESIDENT_ACTIVITY_FALLBACK_ROUTES = new Set([
'ResidentWheelConfig',
'ResidentSmashGoldenEggConfig', 'ResidentSmashGoldenEggConfig',
'ResidentVoiceRoomRocket', 'ResidentVoiceRoomRocket',
'ResidentWheelConfig',
]); ]);
const LOCAL_APP_SYSTEM_FALLBACK_ROUTES = new Set([ const LOCAL_APP_SYSTEM_FALLBACK_ROUTES = new Set([
'AppSystemAgoraErrorLog', 'AppSystemAgoraErrorLog',
]); ]);
function normalizeRoutePath(path?: string | null) { function normalizeRoutePath(path?: null | string) {
return String(path || '') return String(path || '')
.trim() .trim()
.replace(/^\/+/, '') .replace(/^\/+/, '')
@ -95,9 +94,9 @@ function collectRoutePaths(route: RouteRecordRaw) {
const values = [normalizeRoutePath(route.path)]; const values = [normalizeRoutePath(route.path)];
const aliases = Array.isArray(route.alias) const aliases = Array.isArray(route.alias)
? route.alias ? route.alias
: route.alias : (route.alias
? [route.alias] ? [route.alias]
: []; : []);
values.push(...aliases.map((item) => normalizeRoutePath(String(item)))); values.push(...aliases.map((item) => normalizeRoutePath(String(item))));
const override = ROUTE_MENU_OVERRIDES[String(route.name || '')]; const override = ROUTE_MENU_OVERRIDES[String(route.name || '')];
if (override?.routers) { if (override?.routers) {

View File

@ -11,7 +11,6 @@ const AuthPageLayout = () => import('#/layouts/auth.vue');
const fallbackNotFoundRoute: RouteRecordRaw = { const fallbackNotFoundRoute: RouteRecordRaw = {
component: () => import('#/views/_core/fallback/not-found.vue'), component: () => import('#/views/_core/fallback/not-found.vue'),
meta: { meta: {
hideInBreadcrumb: true,
hideInMenu: true, hideInMenu: true,
hideInTab: true, hideInTab: true,
title: '404', title: '404',
@ -30,7 +29,6 @@ const coreRoutes: RouteRecordRaw[] = [
{ {
component: BasicLayout, component: BasicLayout,
meta: { meta: {
hideInBreadcrumb: true,
title: 'Root', title: 'Root',
}, },
name: 'Root', name: 'Root',

View File

@ -8,7 +8,6 @@ const routes: RouteRecordRaw[] = [
name: 'LegacyCommonRoot', name: 'LegacyCommonRoot',
component: BasicLayout, component: BasicLayout,
meta: { meta: {
hideInBreadcrumb: true,
hideInMenu: true, hideInMenu: true,
hideInTab: true, hideInTab: true,
title: 'Common', title: 'Common',
@ -20,7 +19,6 @@ const routes: RouteRecordRaw[] = [
name: 'LegacyUserDetails', name: 'LegacyUserDetails',
component: () => import('#/views/legacy/common/user-details.vue'), component: () => import('#/views/legacy/common/user-details.vue'),
meta: { meta: {
hideInBreadcrumb: true,
hideInMenu: true, hideInMenu: true,
keepAlive: true, keepAlive: true,
title: '用户详情', title: '用户详情',

View File

@ -82,18 +82,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/approval/index.vue'), component: () => import('#/views/approval/index.vue'),
meta: { title: '家族头像审批' }, meta: { title: '家族头像审批' },
}, },
{
name: 'ApprovalDynamicContent',
path: 'dynamic/content/approval',
component: () => import('#/views/approval/index.vue'),
meta: { title: '动态内容审批' },
},
{
name: 'ApprovalDynamicReport',
path: 'dynamic/report',
component: () => import('#/views/approval/index.vue'),
meta: { title: '动态举报审批' },
},
{ {
name: 'ApprovalUserBankCard', name: 'ApprovalUserBankCard',
path: 'user/bank-card/approval', path: 'user/bank-card/approval',

View File

@ -1,41 +0,0 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:messages-square',
order: 14,
title: '动态管理',
},
name: 'DynamicManager',
path: '/dynamic/manager',
children: [
{
name: 'DynamicTagList',
path: 'dynamic/tag',
component: () => import('#/views/dynamic/tag-list.vue'),
meta: { title: '标签列表' },
},
{
name: 'DynamicPopularConfig',
path: 'dynamic/popular/config',
component: () => import('#/views/dynamic/popular-config.vue'),
meta: { title: '权重配置' },
},
{
name: 'DynamicUserList',
path: 'user/dynamic/list',
component: () => import('#/views/dynamic/user-dynamic-list.vue'),
meta: { title: '用户动态' },
},
{
name: 'DynamicBlacklist',
path: 'dynamic/blacklist',
component: () => import('#/views/dynamic/dynamic-blacklist.vue'),
meta: { title: '黑名单' },
},
],
},
];
export default routes;

View File

@ -8,7 +8,6 @@ const routes: RouteRecordRaw[] = [
name: 'LegacyLogin', name: 'LegacyLogin',
redirect: LOGIN_PATH, redirect: LOGIN_PATH,
meta: { meta: {
hideInBreadcrumb: true,
hideInMenu: true, hideInMenu: true,
hideInTab: true, hideInTab: true,
ignoreAccess: true, ignoreAccess: true,
@ -20,7 +19,6 @@ const routes: RouteRecordRaw[] = [
name: 'LegacyForbidden', name: 'LegacyForbidden',
component: () => import('#/views/_core/fallback/forbidden.vue'), component: () => import('#/views/_core/fallback/forbidden.vue'),
meta: { meta: {
hideInBreadcrumb: true,
hideInMenu: true, hideInMenu: true,
hideInTab: true, hideInTab: true,
ignoreAccess: true, ignoreAccess: true,
@ -32,7 +30,6 @@ const routes: RouteRecordRaw[] = [
name: 'LegacyNotFound', name: 'LegacyNotFound',
component: () => import('#/views/_core/fallback/not-found.vue'), component: () => import('#/views/_core/fallback/not-found.vue'),
meta: { meta: {
hideInBreadcrumb: true,
hideInMenu: true, hideInMenu: true,
hideInTab: true, hideInTab: true,
ignoreAccess: true, ignoreAccess: true,

View File

@ -28,12 +28,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/props/source-group.vue'), component: () => import('#/views/props/source-group.vue'),
meta: { title: '道具资源组配置' }, meta: { title: '道具资源组配置' },
}, },
{
name: 'PropsActivityRuleConfig',
path: 'props-activity-rule-config',
component: () => import('#/views/props/activity-rule-config.vue'),
meta: { title: '活动道具规则配置' },
},
{ {
name: 'PropsStore', name: 'PropsStore',
path: 'props_store', path: 'props_store',

View File

@ -5,6 +5,7 @@ import { computed } from 'vue';
import { AuthenticationLogin, z } from '@vben/common-ui'; import { AuthenticationLogin, z } from '@vben/common-ui';
import yumiLogo from '#/assets/auth/yumi-logo.png';
import { useAuthStore } from '#/store'; import { useAuthStore } from '#/store';
defineOptions({ name: 'Login' }); defineOptions({ name: 'Login' });
@ -16,6 +17,9 @@ const formSchema = computed((): VbenFormSchema[] => {
{ {
component: 'VbenInput', component: 'VbenInput',
componentProps: { componentProps: {
autocomplete: 'username',
autofocus: true,
class: 'h-11',
placeholder: '请输入账号', placeholder: '请输入账号',
}, },
fieldName: 'username', fieldName: 'username',
@ -25,6 +29,8 @@ const formSchema = computed((): VbenFormSchema[] => {
{ {
component: 'VbenInputPassword', component: 'VbenInputPassword',
componentProps: { componentProps: {
autocomplete: 'current-password',
class: 'h-11',
placeholder: '请输入密码', placeholder: '请输入密码',
}, },
fieldName: 'password', fieldName: 'password',
@ -42,11 +48,23 @@ const formSchema = computed((): VbenFormSchema[] => {
:show-code-login="false" :show-code-login="false"
:show-forget-password="false" :show-forget-password="false"
:show-qrcode-login="false" :show-qrcode-login="false"
:show-remember-me="false"
:show-register="false" :show-register="false"
:show-third-party-login="false" :show-third-party-login="false"
sub-title="用于处理审核、运营、配置和数据管理任务。" submit-button-text="登录"
submit-button-text="进入系统"
title="内部运营后台"
@submit="authStore.authLogin" @submit="authStore.authLogin"
/> >
<template #title>
<img
:src="yumiLogo"
alt="YUMI"
class="mx-auto mb-2 size-[72px] rounded-full border border-amber-200/80 object-cover shadow-[0_0_18px_rgba(20,184,166,0.32),0_0_0_4px_rgba(245,196,81,0.1)]"
/>
<h1
class="mb-5 text-center text-[28px] font-medium tracking-[0.08em] text-[#2d3538]"
>
YUMI
</h1>
</template>
</AuthenticationLogin>
</template> </template>

View File

@ -1,7 +1,5 @@
import { import {
approveData, approveData,
approveDynamicContentNotPass,
approveDynamicContentPass,
approvePhotoWallNotPass, approvePhotoWallNotPass,
approvePhotoWallPass, approvePhotoWallPass,
approveReportedNotPass, approveReportedNotPass,
@ -10,8 +8,6 @@ import {
approveUserBankCardNotPass, approveUserBankCardNotPass,
approveUserBankCardPass, approveUserBankCardPass,
batchProcessFeedback, batchProcessFeedback,
getDynamicContentPage,
getDynamicReportPage,
getFamilyApprovalPage, getFamilyApprovalPage,
getFeedbackPage, getFeedbackPage,
getPhotoWallApprovalPage, getPhotoWallApprovalPage,
@ -23,7 +19,6 @@ import {
getUserProfileDescApprovalPage, getUserProfileDescApprovalPage,
getViolationHistoryPage, getViolationHistoryPage,
markFamilyNotPass, markFamilyNotPass,
processDynamicReport,
} from '#/api/legacy/approval'; } from '#/api/legacy/approval';
type ApprovalActionType = 'danger' | 'primary'; type ApprovalActionType = 'danger' | 'primary';
@ -181,12 +176,6 @@ const REPORT_STATUS_OPTIONS: ApprovalFilterOption[] = [
{ label: '无效', value: 2 }, { label: '无效', value: 2 },
]; ];
const DYNAMIC_REPORT_STATUS_OPTIONS: ApprovalFilterOption[] = [
{ label: '待审核', value: 0 },
{ label: '违规', value: 1 },
{ label: '正常', value: 2 },
];
const REPORT_TYPE_OPTIONS: ApprovalFilterOption[] = [ const REPORT_TYPE_OPTIONS: ApprovalFilterOption[] = [
{ label: '非法信息', value: 0 }, { label: '非法信息', value: 0 },
{ label: '人身攻击', value: 1 }, { label: '人身攻击', value: 1 },
@ -230,7 +219,7 @@ function splitCsv(value: any) {
return []; return [];
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.filter(Boolean).map((item) => String(item)); return value.filter(Boolean).map(String);
} }
return String(value) return String(value)
.split(',') .split(',')
@ -248,7 +237,7 @@ function buildApprovalDataAction(
await approveData({ await approveData({
approvalStatus, approvalStatus,
approvalType, approvalType,
waitApprovalUser: records.map(mapRecord), waitApprovalUser: records.map((record) => mapRecord(record)),
}); });
}, },
label: approvalStatus === 'PASS' ? '鉴定通过' : '鉴定违规', label: approvalStatus === 'PASS' ? '鉴定通过' : '鉴定违规',
@ -262,7 +251,7 @@ function familyNotPassAction(
): ApprovalBulkAction { ): ApprovalBulkAction {
return { return {
execute: async (records) => { execute: async (records) => {
const approvalRecords = records.map(mapRecord); const approvalRecords = records.map((record) => mapRecord(record));
await approveData({ await approveData({
approvalStatus: 'NOT_PASS', approvalStatus: 'NOT_PASS',
approvalType, approvalType,
@ -867,9 +856,9 @@ const approvalPages: Record<string, ApprovalPage> = {
`年龄:${record.userProfile?.age || '-'}`, `年龄:${record.userProfile?.age || '-'}`,
`创建时间:${record.createTime || '-'}`, `创建时间:${record.createTime || '-'}`,
`修改时间:${record.updateTime || '-'}`, `修改时间:${record.updateTime || '-'}`,
...(query.themeStatus !== 'PENDING' ...(query.themeStatus === 'PENDING'
? [`审核人:${record.optUserNickname || '-'}`] ? []
: []), : [`审核人:${record.optUserNickname || '-'}`]),
], ],
title: record.userProfile?.userNickname || '-', title: record.userProfile?.userNickname || '-',
titleUserId: record.userProfile?.id, titleUserId: record.userProfile?.id,
@ -1183,10 +1172,10 @@ const approvalPages: Record<string, ApprovalPage> = {
record.violationType === 'PHOTO_WALL' || record.violationType === 'PHOTO_WALL' ||
record.violationType === 'ROOM_AVATAR' record.violationType === 'ROOM_AVATAR'
? 'images' ? 'images'
: record.violationType === 'LIVE' || : (record.violationType === 'LIVE' ||
record.violationType === 'SHORT_VIDEO' record.violationType === 'SHORT_VIDEO'
? 'videos' ? 'videos'
: 'text'; : 'text');
return { return {
content: content:
contentType === 'text' ? record.content || '-' : splitCsv(record.content), contentType === 'text' ? record.content || '-' : splitCsv(record.content),
@ -1471,238 +1460,6 @@ const approvalPages: Record<string, ApprovalPage> = {
}, },
title: '家族审批', title: '家族审批',
}, },
'/approval/manager/dynamic/content/approval': {
actions: [
{
execute: async (records) => {
await approveDynamicContentPass(records.map((record) => record.dynamicId));
},
label: '鉴定通过',
show: (query) => query.approveStatus === 'PENDING',
type: 'primary',
},
{
execute: async (records) => {
await approveDynamicContentNotPass(
records.map((record) => record.dynamicId),
);
},
label: '鉴定违规',
show: (query) =>
query.approveStatus === 'PENDING' || query.approveStatus === 'PASS',
type: 'danger',
},
],
buildCard: (record, query) => ({
actions: [{ label: '账号处理', type: 'account', value: record.userId }],
galleryImages: (record.pictures || [])
.map((item: any) => item.resourceUrl)
.filter(Boolean),
leadText: record.dynamicContent || '',
lines: [
`性别:${record.userSexName || record.userBaseInfo?.userSexName || '-'}`,
`年龄:${record.userBaseInfo?.age || '-'}`,
`创建时间:${record.createTime || '-'}`,
...(query.approveStatus !== 'PENDING'
? [`审批时间:${record.updateTime || '-'}`]
: []),
],
overlay:
(record.pictures || [])
.map((item: any) => item.labelNames)
.filter(Boolean)
.join(' / ') || '',
title: record.userBaseInfo?.userNickname || '-',
titleUserId: record.userId,
}),
defaultQuery: {
approveStatus: 'PENDING',
cursor: 1,
endDateTime: '',
limit: 20,
startDateTime: '',
sysOrigin: DEFAULT_SYS_ORIGIN,
userId: '',
},
fetch: getDynamicContentPage,
filters: [
{ field: 'sysOrigin', label: '系统', placeholder: '系统', type: 'sysOrigin' },
{
field: 'approveStatus',
label: '审核',
options: COMMON_APPROVAL_ALL_OPTIONS,
placeholder: '审核',
type: 'select',
},
{
endField: 'endDateTime',
label: '时间范围',
placeholder: '时间范围',
startField: 'startDateTime',
type: 'dateRange',
},
{ field: 'userId', label: '用户ID', placeholder: '用户ID', type: 'input' },
],
kind: 'gallery',
selectable: (query) =>
query.approveStatus === 'PENDING' || query.approveStatus === 'PASS',
title: '动态内容审批',
},
'/approval/manager/dynamic/report': {
actions: [
{
execute: async (records) => {
await processDynamicReport({
approvalStatus: 2,
contentIds: records.map((record) => record.dynamicContentId),
});
},
label: '正常',
show: (query) => query.approvalStatus === 0,
type: 'primary',
},
{
execute: async (records) => {
await processDynamicReport({
approvalStatus: 1,
contentIds: records.map((record) => record.dynamicContentId),
});
},
label: '违规',
show: (query) => query.approvalStatus === 0,
type: 'danger',
},
],
columns: [
{
key: 'sysOrigin',
render: (record) => ({
text: record.sysOrigin || '-',
type: 'origin',
}),
title: '系统',
width: 90,
},
{
key: 'reportUser',
render: (record) => ({
text: record.reportUser?.userNickname || '-',
type: 'link',
userId: record.reportUser?.id,
}),
title: '举报人',
},
{
key: 'reportedUser',
render: (record) => ({
text: record.reportedUser?.userNickname || '-',
type: 'link',
userId: record.reportedUser?.id,
}),
title: '被举报人',
},
{
key: 'reportType',
render: (record) => ({
text:
REPORT_TYPE_OPTIONS.find((item) => item.value === record.reportType)
?.label || '-',
type: 'text',
}),
title: '举报类型',
},
{
key: 'dynamicContent',
render: (record) => ({
text: record.dynamicContent || '-',
type: 'text',
}),
title: '动态内容',
},
{
key: 'time',
render: (record) => ({
text:
record.createTime && record.updateTime
? `${record.createTime} / ${record.updateTime}`
: record.createTime || '-',
type: 'text',
}),
title: '时间',
width: 240,
},
],
defaultQuery: {
approvalStatus: 0,
cursor: 1,
endTime: '',
limit: 20,
reportType: '',
reportUserId: '',
reportedUserId: '',
startTime: '',
sysOrigin: DEFAULT_SYS_ORIGIN,
},
expandable: (record) => [
{ label: '举报图片', type: 'images', value: splitCsv(record.reportedUrls) },
{
label: '动态图片',
type: 'images',
value: (record.dynamicPictures || [])
.map((item: any) => item.resourceUrl)
.filter(Boolean),
},
{ label: '举报内容', value: record.reportedContent || '-' },
],
fetch: getDynamicReportPage,
filters: [
{ field: 'sysOrigin', label: '系统', placeholder: '系统', type: 'sysOrigin' },
{
field: 'approvalStatus',
label: '状态',
options: DYNAMIC_REPORT_STATUS_OPTIONS,
placeholder: '状态',
type: 'select',
},
{
clearable: true,
field: 'reportType',
label: '举报类型',
options: REPORT_TYPE_OPTIONS,
placeholder: '举报类型',
type: 'select',
},
{
field: 'reportUserId',
label: '举报人ID',
placeholder: '举报人ID',
type: 'input',
},
{
field: 'reportedUserId',
label: '被举报人ID',
placeholder: '被举报人ID',
type: 'input',
},
{
endField: 'endTime',
label: '时间范围',
placeholder: '时间范围',
startField: 'startTime',
type: 'dateRange',
},
],
kind: 'table',
rowActions: (record) => [
{
label: '被举报账号处理',
type: 'account',
value: record.reportedUser?.id,
},
],
selectable: (query) => query.approvalStatus === 0,
title: '动态举报审批',
},
'/approval/manager/user/bank-card/approval': { '/approval/manager/user/bank-card/approval': {
actions: [ actions: [
{ {
@ -1760,15 +1517,15 @@ const approvalPages: Record<string, ApprovalPage> = {
color: color:
record.status === 'PENDING' record.status === 'PENDING'
? 'default' ? 'default'
: record.status === 'PASS' : (record.status === 'PASS'
? 'success' ? 'success'
: 'error', : 'error'),
text: text:
record.status === 'PENDING' record.status === 'PENDING'
? '待审核' ? '待审核'
: record.status === 'PASS' : (record.status === 'PASS'
? '通过' ? '通过'
: '驳回', : '驳回'),
type: 'tag', type: 'tag',
}), }),
title: '状态', title: '状态',
@ -1821,12 +1578,11 @@ const approvalPages: Record<string, ApprovalPage> = {
export { export {
APPROVAL_TYPE_OPTIONS, APPROVAL_TYPE_OPTIONS,
approvalPages,
BANK_CARD_OPTIONS, BANK_CARD_OPTIONS,
DYNAMIC_REPORT_STATUS_OPTIONS,
FEEDBACK_STATUS_OPTIONS, FEEDBACK_STATUS_OPTIONS,
PHOTO_WALL_STATUS_OPTIONS, PHOTO_WALL_STATUS_OPTIONS,
REPORT_STATUS_OPTIONS, REPORT_STATUS_OPTIONS,
REPORT_TYPE_OPTIONS, REPORT_TYPE_OPTIONS,
SYS_ORIGIN_OPTIONS, SYS_ORIGIN_OPTIONS,
approvalPages,
}; };

View File

@ -1,256 +0,0 @@
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import {
OSS_FILE_BUCKETS,
getAccessImgUrl,
simpleUploadFile,
} from '#/api/legacy/oss';
import { saveDynamicTag } from '#/api/legacy/dynamic';
import { LANGUAGE_OPTIONS } from '#/views/system/shared';
import {
Button,
Form,
FormItem,
Image,
Input,
InputNumber,
Modal,
Select,
Space,
message,
} from 'antdv-next';
const props = defineProps<{
open: boolean;
record: null | Record<string, any>;
sysOriginOptions: Array<{ label: string; value: string }>;
}>();
const emit = defineEmits<{
close: [];
success: [];
}>();
const fileInputRef = ref<HTMLInputElement | null>(null);
const saving = ref(false);
const uploadLoading = ref(false);
const form = reactive({
del: false as boolean,
description: '',
id: '',
imageUrl: '',
languageType: '',
sort: undefined as number | string | undefined,
sysOrigin: '',
tag: '',
});
watch(
() => props.open,
(open) => {
if (!open) {
return;
}
const record = props.record;
form.id = String(record?.id || '');
form.imageUrl = record?.imageUrl || '';
form.sysOrigin =
record?.sysOrigin || props.sysOriginOptions[0]?.value || 'LIKEI';
form.tag = record?.tag || '';
form.description = record?.description || '';
form.del = Boolean(record?.del);
form.sort = record?.sort ?? undefined;
form.languageType = record?.languageType || '';
},
{ immediate: true },
);
const title = ref('新增标签');
watch(
() => props.record,
(value) => {
title.value = value?.id ? '修改标签' : '新增标签';
},
{ immediate: true },
);
function openFileDialog() {
fileInputRef.value?.click();
}
async function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
target.value = '';
if (!file) {
return;
}
uploadLoading.value = true;
try {
const result = await simpleUploadFile(file, OSS_FILE_BUCKETS.other);
form.imageUrl = getAccessImgUrl(result.name);
message.success('上传成功');
} finally {
uploadLoading.value = false;
}
}
function resetImage() {
form.imageUrl = '';
}
async function handleSubmit() {
if (!form.imageUrl) {
message.warning('请上传背景图');
return;
}
if (!form.sysOrigin) {
message.warning('请选择系统');
return;
}
if (!form.tag.trim()) {
message.warning('请输入标签');
return;
}
if (!form.description.trim()) {
message.warning('请输入描述');
return;
}
if (!form.languageType) {
message.warning('请选择语言');
return;
}
if (form.sort === undefined || form.sort === null || form.sort === '') {
message.warning('请输入排序');
return;
}
saving.value = true;
try {
await saveDynamicTag({
del: form.del,
description: form.description.trim(),
id: form.id || undefined,
imageUrl: form.imageUrl,
languageType: form.languageType,
sort: Number(form.sort),
sysOrigin: form.sysOrigin,
tag: form.tag.trim(),
});
message.success('保存成功');
emit('success');
emit('close');
} finally {
saving.value = false;
}
}
</script>
<template>
<Modal
:confirm-loading="saving"
:open="open"
destroy-on-close
:title="title"
width="680"
@cancel="emit('close')"
@ok="handleSubmit"
>
<Form layout="vertical">
<FormItem label="背景">
<input
ref="fileInputRef"
accept="image/*"
class="hidden-input"
type="file"
@change="handleFileChange"
/>
<div class="cover-field">
<div v-if="form.imageUrl" class="cover-preview">
<Image :src="form.imageUrl" class="cover-image" />
</div>
<div v-else class="cover-empty">请选择背景图</div>
<Space>
<Button :loading="uploadLoading" @click="openFileDialog">
{{ form.imageUrl ? '重新上传' : '上传图片' }}
</Button>
<Button v-if="form.imageUrl" danger @click="resetImage">移除</Button>
</Space>
</div>
</FormItem>
<FormItem label="系统">
<SysOriginSelect v-model:value="form.sysOrigin"
:options="sysOriginOptions"
></SysOriginSelect>
</FormItem>
<FormItem label="标签">
<Input v-model:value="form.tag" />
</FormItem>
<FormItem label="描述">
<Input v-model:value="form.description" />
</FormItem>
<FormItem label="语言">
<Select
option-label-prop="label"
v-model:value="form.languageType"
:options="LANGUAGE_OPTIONS.map((item) => ({ label: item.name, value: item.value }))"
/>
</FormItem>
<FormItem label="状态">
<Select
option-label-prop="label"
v-model:value="form.del"
:options="[
{ label: '上架', value: false as any },
{ label: '下架', value: true as any },
]"
/>
</FormItem>
<FormItem label="排序">
<InputNumber
v-model:value="form.sort"
:min="0"
placeholder="数字越大越靠前"
style="width: 100%"
/>
</FormItem>
</Form>
</Modal>
</template>
<style scoped>
.hidden-input {
display: none;
}
.cover-field {
display: grid;
gap: 12px;
}
.cover-preview,
.cover-empty {
align-items: center;
background: #f8fafc;
border: 1px dashed rgb(148 163 184 / 35%);
border-radius: 16px;
display: flex;
justify-content: center;
min-height: 180px;
overflow: hidden;
}
.cover-empty {
color: #94a3b8;
}
.cover-image {
height: 180px;
object-fit: cover;
width: 100%;
}
</style>

View File

@ -1,267 +0,0 @@
<script lang="ts" setup>
import {
reactive,
ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import {
addDynamicBlacklist,
deleteDynamicBlacklist,
dynamicBlacklistPage,
} from '#/api/legacy/dynamic';
import AccountInput from '#/components/account-input.vue';
import SysOriginLabel from '#/components/sys-origin-label.vue';
import { formatDate,
getAllowedSysOrigins } from '#/views/system/shared';
import {
Button,
Card,
Form,
FormItem,
Input,
Modal,
Pagination,
Table,
Tag,
message,
} from 'antdv-next';
defineOptions({ name: 'DynamicBlacklist' });
const router = useRouter();
const accessStore = useAccessStore();
const sysOriginOptions = getAllowedSysOrigins(accessStore.accessCodes || []);
const loading = ref(false);
const total = ref(0);
const list = ref<Array<Record<string, any>>>([]);
const modalOpen = ref(false);
const saving = ref(false);
const query = reactive({
cursor: 1,
limit: 20,
sysOrigin: sysOriginOptions[0]?.value ?? 'LIKEI',
userId: '',
});
const form = reactive({
account: '',
});
const columns = [
{ dataIndex: 'originSys', key: 'originSys', title: '系统', width: 120 },
{ dataIndex: 'user', key: 'user', title: '用户', width: 220 },
{ dataIndex: 'nickname', key: 'nickname', title: '昵称', width: 180 },
{ dataIndex: 'userSex', key: 'userSex', title: '性别', width: 100 },
{ dataIndex: 'country', key: 'country', title: '国家/地区', width: 160 },
{ dataIndex: 'createTime', key: 'createTime', title: '拉黑时间', width: 180 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 100 },
];
async function loadData(reset = false) {
if (reset) {
query.cursor = 1;
}
loading.value = true;
try {
const result = await dynamicBlacklistPage({ ...query });
list.value = result.records || [];
total.value = result.total || 0;
} finally {
loading.value = false;
}
}
function handleSearch() {
loadData(true);
}
function handlePageChange(page: number, pageSize: number) {
query.cursor = page;
query.limit = pageSize;
loadData();
}
function openCreate() {
form.account = '';
modalOpen.value = true;
}
function openUserDetails(record: Record<string, any>) {
const userId = record.id || record.userId;
if (!userId) {
return;
}
router.push(`/common/user/deatils/${userId}`);
}
async function handleDelete(record: Record<string, any>) {
loading.value = true;
try {
await deleteDynamicBlacklist(record.id);
message.success('删除成功');
await loadData();
} finally {
loading.value = false;
}
}
async function handleSubmit() {
if (!form.account.trim()) {
message.warning('请输入账号');
return;
}
saving.value = true;
try {
await addDynamicBlacklist({
account: form.account.trim(),
sysOrigin: query.sysOrigin,
});
message.success('保存成功');
modalOpen.value = false;
await loadData(true);
} finally {
saving.value = false;
}
}
loadData(true);
</script>
<template>
<Page title="黑名单">
<Card>
<div class="toolbar">
<SysOriginSelect
v-model:value="query.sysOrigin"
style="width: 140px"
@change="handleSearch"
:options="sysOriginOptions"
></SysOriginSelect>
<AccountInput
v-model:value="query.userId"
:sys-origin="query.sysOrigin"
placeholder="用户ID"
style="width: 220px"
/>
<Button :loading="loading" type="primary" @click="handleSearch">
搜索
</Button>
<Button @click="openCreate">加入黑名单</Button>
</div>
<Table
:columns="columns"
:data-source="list"
:loading="loading"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'originSys'">
<SysOriginLabel :value="record.originSys || record.sysOrigin" />
</template>
<template v-else-if="column.key === 'user'">
<div class="user-cell">
<img
:src="record.userAvatar || 'https://dummyimage.com/44x44/e2e8f0/64748b&text=U'"
alt=""
class="user-avatar"
>
<div class="user-info">
<div>{{ record.actualAccount || '-' }}</div>
<Tag v-if="record.countryCode">{{ record.countryCode }}</Tag>
</div>
</div>
</template>
<template v-else-if="column.key === 'nickname'">
<Button type="link" @click="openUserDetails(record)">
{{ record.userNickname || '-' }}
</Button>
</template>
<template v-else-if="column.key === 'userSex'">
{{ Number(record.userSex) === 1 ? '男' : '女' }}
</template>
<template v-else-if="column.key === 'country'">
{{ record.countryName || '-' }}
</template>
<template v-else-if="column.key === 'createTime'">
{{ formatDate(record.createTime) }}
</template>
<template v-else-if="column.key === 'actions'">
<Button danger size="small" type="link" @click="handleDelete(record)">
删除
</Button>
</template>
</template>
</Table>
<div class="pagination">
<Pagination
:current="query.cursor"
:page-size="query.limit"
:total="total"
show-size-changer
@change="handlePageChange"
@showSizeChange="handlePageChange"
/>
</div>
</Card>
<Modal
:confirm-loading="saving"
:open="modalOpen"
destroy-on-close
title="动态-禁止用户发动态"
@cancel="modalOpen = false"
@ok="handleSubmit"
>
<Form layout="vertical">
<FormItem label="平台">
<Input :value="query.sysOrigin" disabled />
</FormItem>
<FormItem label="账号">
<Input v-model:value="form.account" />
</FormItem>
</Form>
</Modal>
</Page>
</template>
<style scoped>
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.user-cell {
align-items: center;
display: flex;
gap: 10px;
}
.user-avatar {
border-radius: 999px;
height: 44px;
object-fit: cover;
width: 44px;
}
.user-info {
min-width: 0;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>

View File

@ -1,139 +0,0 @@
<script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import {
getDynamicPopularConfig,
saveDynamicPopularConfig,
} from '#/api/legacy/dynamic';
import {
Button,
Card,
Form,
FormItem,
InputNumber,
Modal,
message,
} from 'antdv-next';
defineOptions({ name: 'DynamicPopularConfig' });
const loading = ref(false);
const saving = ref(false);
const form = reactive({
commentScore: undefined as number | undefined,
dynamicFees: undefined as number | undefined,
levelLimit: undefined as number | undefined,
likeScore: undefined as number | undefined,
quantityLimit: undefined as number | undefined,
});
async function loadData() {
loading.value = true;
try {
const result = await getDynamicPopularConfig();
form.likeScore = result?.likeScore;
form.commentScore = result?.commentScore;
form.levelLimit = result?.levelLimit;
form.quantityLimit = result?.quantityLimit;
form.dynamicFees = result?.dynamicFees;
} finally {
loading.value = false;
}
}
function handleSubmit() {
Modal.confirm({
title: '是否确定修改配置值?',
async onOk() {
saving.value = true;
try {
await saveDynamicPopularConfig({ ...form });
message.success('保存成功');
await loadData();
} finally {
saving.value = false;
}
},
});
}
onMounted(loadData);
</script>
<template>
<Page title="权重配置">
<Card :loading="loading">
<Form layout="vertical">
<div class="section">
<div class="section-title">热门动态权重计分设置</div>
<div class="grid">
<FormItem label="点赞增加分数">
<InputNumber v-model:value="form.likeScore" :min="0" style="width: 100%" />
</FormItem>
<FormItem label="评论增加分数">
<InputNumber v-model:value="form.commentScore" :min="0" style="width: 100%" />
</FormItem>
</div>
</div>
<div class="section">
<div class="section-title">财富魅力等级多少级才能发布动态</div>
<FormItem label="等级限制">
<InputNumber v-model:value="form.levelLimit" :min="0" style="width: 100%" />
</FormItem>
</div>
<div class="section">
<div class="section-title">每个用户当天允许发送多少条动态</div>
<FormItem label="动态数量">
<InputNumber v-model:value="form.quantityLimit" :min="0" style="width: 100%" />
</FormItem>
</div>
<div class="section">
<div class="section-title">
用户当天发送动态数量达到限制后继续发送每条动态支付多少金币
</div>
<FormItem label="金币数">
<InputNumber v-model:value="form.dynamicFees" :min="0" style="width: 100%" />
</FormItem>
</div>
<Button :loading="saving" type="primary" @click="handleSubmit">
修改
</Button>
</Form>
</Card>
</Page>
</template>
<style scoped>
.section + .section {
margin-top: 20px;
}
.section-title {
background: rgb(37 99 235 / 6%);
border-left: 3px solid #2563eb;
border-radius: 10px;
color: #1e293b;
margin-bottom: 16px;
padding: 12px 14px;
}
.grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (width <= 768px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,216 +0,0 @@
<script lang="ts" setup>
import {
computed,
reactive,
ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import { dynamicTagTable } from '#/api/legacy/dynamic';
import SysOriginLabel from '#/components/sys-origin-label.vue';
import {
LANGUAGE_OPTIONS,
formatDate,
getAllowedSysOrigins,
} from '#/views/system/shared';
import {
Button,
Card,
Image,
Pagination,
Select,
Space,
Table,
Tag
} from 'antdv-next';
import TagEditModal from './components/tag-edit-modal.vue';
defineOptions({ name: 'DynamicTagList' });
const accessStore = useAccessStore();
const sysOriginOptions = getAllowedSysOrigins(accessStore.accessCodes || []);
const loading = ref(false);
const total = ref(0);
const list = ref<Array<Record<string, any>>>([]);
const modalOpen = ref(false);
const activeRecord = ref<null | Record<string, any>>(null);
const query = reactive({
cursor: 1,
del: false as boolean,
languageType: '',
limit: 20,
sysOrigin: sysOriginOptions[0]?.value ?? 'LIKEI',
});
const columns = [
{ dataIndex: 'id', key: 'id', title: 'ID', width: 90 },
{ dataIndex: 'sysOrigin', key: 'sysOrigin', title: '归属系统', width: 120 },
{ dataIndex: 'imageUrl', key: 'imageUrl', title: '背景', width: 240 },
{ dataIndex: 'tag', key: 'tag', title: '标签', width: 160 },
{ dataIndex: 'description', key: 'description', title: '描述', width: 220 },
{ dataIndex: 'sort', key: 'sort', title: '排序', width: 100 },
{ dataIndex: 'languageType', key: 'languageType', title: '语言', width: 120 },
{ dataIndex: 'del', key: 'del', title: '状态', width: 120 },
{ dataIndex: 'updateTime', key: 'updateTime', title: '更新时间', width: 180 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 100 },
];
const scroll = computed(() => ({ x: 1500 }));
function getLanguage(value?: string) {
return LANGUAGE_OPTIONS.find((item) => item.value === value)?.name || '-';
}
async function loadData(reset = false) {
if (reset) {
query.cursor = 1;
}
loading.value = true;
try {
const result = await dynamicTagTable({ ...query });
list.value = result.records || [];
total.value = result.total || 0;
} finally {
loading.value = false;
}
}
function handleSearch() {
loadData(true);
}
function handlePageChange(page: number, pageSize: number) {
query.cursor = page;
query.limit = pageSize;
loadData();
}
function openCreate() {
activeRecord.value = null;
modalOpen.value = true;
}
function openEdit(record: Record<string, any>) {
activeRecord.value = { ...record };
modalOpen.value = true;
}
loadData(true);
</script>
<template>
<Page title="标签列表">
<Card>
<div class="toolbar">
<Space wrap>
<SysOriginSelect
v-model:value="query.sysOrigin"
style="width: 140px"
@change="handleSearch"
:options="sysOriginOptions"
></SysOriginSelect>
<Select
v-model:value="query.del"
option-label-prop="label"
style="width: 140px"
@change="handleSearch"
:options="[{ label: '上架', value: false as any }, { label: '下架', value: true as any }]"
/>
<Select
v-model:value="query.languageType"
allow-clear
option-label-prop="label"
placeholder="语言"
style="width: 160px"
@change="handleSearch"
:options="LANGUAGE_OPTIONS.map((item) => ({ label: item.name, value: item.value as any }))"
/>
<Button :loading="loading" type="primary" @click="handleSearch">
搜索
</Button>
<Button @click="openCreate">新增</Button>
</Space>
</div>
<Table
:columns="columns"
:data-source="list"
:loading="loading"
:pagination="false"
:scroll="scroll"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'sysOrigin'">
<SysOriginLabel :value="record.sysOrigin" />
</template>
<template v-else-if="column.key === 'imageUrl'">
<Image :src="record.imageUrl" class="cover-image" />
</template>
<template v-else-if="column.key === 'languageType'">
{{ getLanguage(record.languageType) }}
</template>
<template v-else-if="column.key === 'del'">
<Tag :color="record.del ? 'error' : 'success'">
{{ record.del ? '已下架' : '已上架' }}
</Tag>
</template>
<template v-else-if="column.key === 'updateTime'">
{{ formatDate(record.updateTime || record.createTime) }}
</template>
<template v-else-if="column.key === 'actions'">
<Button size="small" type="link" @click="openEdit(record)">
修改
</Button>
</template>
</template>
</Table>
<div class="pagination">
<Pagination
:current="query.cursor"
:page-size="query.limit"
:total="total"
show-size-changer
@change="handlePageChange"
@showSizeChange="handlePageChange"
/>
</div>
</Card>
<TagEditModal
:open="modalOpen"
:record="activeRecord"
:sys-origin-options="sysOriginOptions"
@close="modalOpen = false"
@success="loadData(true)"
/>
</Page>
</template>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
.cover-image {
border-radius: 12px;
height: 84px;
object-fit: cover;
width: 168px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>

View File

@ -1,519 +0,0 @@
<script lang="ts" setup>
import {
computed,
reactive,
ref,
watch } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import {
closeUserDynamicTop,
deleteUserDynamic,
setUserDynamicTop,
userDynamicTable,
} from '#/api/legacy/dynamic';
import AccountInput from '#/components/account-input.vue';
import SysOriginTag from '#/components/sys-origin-tag.vue';
import { formatDate,
getAllowedSysOrigins } from '#/views/system/shared';
import {
Button,
Card,
Checkbox,
Col,
DateRangePicker,
Empty,
Image,
InputNumber,
Modal,
Pagination,
Row,
Select,
Space,
Spin,
Tag,
message
} from 'antdv-next';
import AccountHandleModal from '#/views/approval/components/account-handle-modal.vue';
defineOptions({ name: 'DynamicUserDynamicList' });
const router = useRouter();
const accessStore = useAccessStore();
const sysOriginOptions = getAllowedSysOrigins(accessStore.accessCodes || []);
const loading = ref(false);
const total = ref(0);
const list = ref<Array<Record<string, any>>>([]);
const rangeDate = ref<[string, string] | null>(null);
const selectedKeys = ref<string[]>([]);
const topModalOpen = ref(false);
const topSaving = ref(false);
const accountHandleOpen = ref(false);
const activeUserId = ref('');
const topForm = reactive({
dynamicId: '',
sysOrigin: '',
weights: undefined as number | undefined,
});
const query = reactive({
cursor: 1,
endTime: '',
limit: 30,
startTime: '',
sysOrigin: sysOriginOptions[0]?.value ?? 'LIKEI',
top: '',
userId: '',
});
watch(rangeDate, (value) => {
query.startTime = value?.[0] || '';
query.endTime = value?.[1] || '';
});
const allChecked = computed(
() => list.value.length > 0 && selectedKeys.value.length === list.value.length,
);
const indeterminate = computed(
() =>
selectedKeys.value.length > 0 &&
selectedKeys.value.length < list.value.length,
);
function getPictures(record: Record<string, any>) {
return (Array.isArray(record.pictures) ? record.pictures : [])
.map((item: any) => item?.resourceUrl || item?.url || '')
.filter(Boolean);
}
function getUserAvatar(record: Record<string, any>) {
return record.userBaseInfo?.userAvatar || record.userAvatar || '';
}
function getUserName(record: Record<string, any>) {
return record.userBaseInfo?.userNickname || record.userNickname || '-';
}
function getUserAccount(record: Record<string, any>) {
return record.userBaseInfo?.actualAccount || record.actualAccount || '-';
}
function getRowKey(record: Record<string, any>) {
return String(record.id || '');
}
async function loadData(reset = false) {
if (reset) {
query.cursor = 1;
}
loading.value = true;
try {
const result = await userDynamicTable({ ...query });
list.value = result.records || [];
total.value = result.total || 0;
selectedKeys.value = [];
} finally {
loading.value = false;
}
}
function handleSearch() {
loadData(true);
}
function handlePageChange(page: number, pageSize: number) {
query.cursor = page;
query.limit = pageSize;
loadData();
}
function toggleAll(checked: boolean) {
selectedKeys.value = checked ? list.value.map((item) => getRowKey(item)) : [];
}
function toggleRecord(record: Record<string, any>) {
const key = getRowKey(record);
const index = selectedKeys.value.findIndex((item) => item === key);
if (index >= 0) {
selectedKeys.value.splice(index, 1);
return;
}
selectedKeys.value.push(key);
}
function openUserDetails(record: Record<string, any>) {
const userId = record.userId || record.userBaseInfo?.id;
if (!userId) {
return;
}
router.push(`/common/user/deatils/${userId}`);
}
function openAccountHandle(record: Record<string, any>) {
activeUserId.value = String(record.userId || record.userBaseInfo?.id || '');
accountHandleOpen.value = true;
}
function openTopEdit(record: Record<string, any>) {
topForm.dynamicId = String(record.id || '');
topForm.sysOrigin = query.sysOrigin;
topForm.weights = Number(record.weights || 0);
topModalOpen.value = true;
}
async function handleCloseTop(record: Record<string, any>) {
await closeUserDynamicTop(record.id);
message.success('操作成功');
await loadData(true);
}
async function handleSubmitTop() {
if (!topForm.dynamicId) {
return;
}
if (topForm.weights === undefined || topForm.weights === null) {
message.warning('请输入权重');
return;
}
topSaving.value = true;
try {
await setUserDynamicTop({
dynamicId: topForm.dynamicId,
sysOrigin: topForm.sysOrigin,
weights: topForm.weights,
});
message.success('保存成功');
topModalOpen.value = false;
await loadData(true);
} finally {
topSaving.value = false;
}
}
function handleDeleteSelected() {
if (selectedKeys.value.length === 0) {
message.warning('请勾选审批项');
return;
}
Modal.confirm({
title: '确认删除选中记录吗?',
async onOk() {
await deleteUserDynamic(selectedKeys.value);
message.success('删除成功');
await loadData();
},
});
}
loadData(true);
</script>
<template>
<Page title="用户动态">
<div class="page-grid">
<Card>
<div class="toolbar">
<Space wrap>
<SysOriginSelect
v-model:value="query.sysOrigin"
style="width: 140px"
@change="handleSearch"
:options="sysOriginOptions"
></SysOriginSelect>
<Select
v-model:value="query.top"
option-label-prop="label"
style="width: 140px"
@change="handleSearch"
:options="[{ label: '全部', value: '' as any }, { label: '已置顶', value: '1' as any }, { label: '未置顶', value: '0' as any }]"
/>
<DateRangePicker
v-model:value="rangeDate"
show-time
style="width: 360px"
value-format="x"
/>
<AccountInput
v-model:value="query.userId"
:sys-origin="query.sysOrigin"
placeholder="用户ID"
style="width: 220px"
/>
<Button :loading="loading" type="primary" @click="handleSearch">
搜索
</Button>
</Space>
</div>
</Card>
<Card>
<div class="selection-bar">
<Checkbox
:checked="allChecked"
:indeterminate="indeterminate"
@change="(event) => toggleAll(!!event.target.checked)"
>
全选
</Checkbox>
<span class="selection-text">
已勾选 <strong>{{ selectedKeys.length }}</strong>
</span>
<Button danger @click="handleDeleteSelected">删除</Button>
</div>
<Spin :spinning="loading">
<Empty v-if="list.length === 0" description="暂无动态" />
<Row v-else :gutter="[16, 16]">
<Col
v-for="record in list"
:key="getRowKey(record)"
:lg="8"
:md="12"
:sm="24"
:xs="24"
>
<Card class="dynamic-card">
<div class="card-header">
<Checkbox
:checked="selectedKeys.includes(getRowKey(record))"
@change="() => toggleRecord(record)"
>
选中
</Checkbox>
<Tag v-if="record.top === true" color="magenta">
置顶权重{{ record.weights || 0 }}
</Tag>
</div>
<div class="content-block">
<div class="content-label">动态内容</div>
<div class="content-text">{{ record.content || '-' }}</div>
</div>
<div v-if="getPictures(record).length > 0" class="picture-row">
<div
v-for="item in getPictures(record)"
:key="item"
class="picture-item"
>
<Image :src="item" class="picture-image" />
</div>
</div>
<div class="user-row">
<img
:src="getUserAvatar(record) || 'https://dummyimage.com/48x48/e2e8f0/64748b&text=U'"
alt=""
class="user-avatar"
>
<div class="user-meta">
<Button class="user-link" type="link" @click="openUserDetails(record)">
{{ getUserName(record) }}
</Button>
<div class="meta-line">{{ getUserAccount(record) }}</div>
<div class="meta-line">
性别{{ record.userSexName || '-' }} / 年龄{{ record.userBaseInfo?.age || '-' }}
</div>
<div class="meta-line">创建时间{{ formatDate(record.createTime) }}</div>
</div>
</div>
<div class="card-actions">
<Button size="small" type="link" @click="openAccountHandle(record)">
账号处理
</Button>
<Button size="small" type="link" @click="openTopEdit(record)">
置顶
</Button>
<Button
v-if="record.top === true"
size="small"
type="link"
@click="handleCloseTop(record)"
>
取消置顶
</Button>
</div>
</Card>
</Col>
</Row>
</Spin>
<div class="pagination">
<Pagination
:current="query.cursor"
:page-size="query.limit"
:total="total"
show-size-changer
@change="handlePageChange"
@showSizeChange="handlePageChange"
/>
</div>
</Card>
</div>
<Modal
:confirm-loading="topSaving"
:open="topModalOpen"
destroy-on-close
title="编辑置顶动态"
@cancel="topModalOpen = false"
@ok="handleSubmitTop"
>
<div class="top-form">
<div class="top-row">
<span>平台</span>
<SysOriginTag :value="query.sysOrigin" />
</div>
<div class="top-row top-input">
<span>权重</span>
<InputNumber
v-model:value="topForm.weights"
:min="0"
placeholder="数字越大越排在前面"
style="width: 100%"
/>
</div>
</div>
</Modal>
<AccountHandleModal
:open="accountHandleOpen"
:user-id="activeUserId"
@close="accountHandleOpen = false"
@success="loadData()"
/>
</Page>
</template>
<style scoped>
.page-grid {
display: grid;
gap: 16px;
}
.toolbar {
min-height: 40px;
}
.selection-bar {
align-items: center;
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.selection-text {
color: #64748b;
}
.dynamic-card {
height: 100%;
}
.card-header {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
.content-block {
margin-bottom: 14px;
}
.content-label {
color: #2563eb;
font-weight: 600;
margin-bottom: 6px;
}
.content-text {
color: #334155;
line-height: 1.7;
min-height: 48px;
overflow-wrap: anywhere;
}
.picture-row {
display: flex;
gap: 8px;
margin-bottom: 14px;
overflow-x: auto;
}
.picture-item {
flex: 0 0 auto;
}
.picture-image {
border-radius: 12px;
height: 220px;
object-fit: cover;
width: 180px;
}
.user-row {
align-items: flex-start;
display: flex;
gap: 12px;
}
.user-avatar {
border-radius: 999px;
height: 48px;
object-fit: cover;
width: 48px;
}
.user-meta {
min-width: 0;
}
.user-link {
padding-left: 0;
}
.meta-line {
color: #64748b;
line-height: 1.8;
overflow-wrap: anywhere;
}
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 12px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.top-form {
display: grid;
gap: 12px;
}
.top-row {
align-items: center;
display: flex;
gap: 12px;
}
.top-input {
align-items: stretch;
flex-direction: column;
}
</style>

View File

@ -232,7 +232,6 @@ export const CURRENCY_ORIGIN_OPTIONS = [
{ value: 'USER_REFUND', name: '用户退款' }, { value: 'USER_REFUND', name: '用户退款' },
{ value: 'ACTIVITY_FRIENDSHIP_CARD_REWARDS', name: '每周特殊关系卡片奖励' }, { value: 'ACTIVITY_FRIENDSHIP_CARD_REWARDS', name: '每周特殊关系卡片奖励' },
{ value: 'WEEKLY_GAME_TASKS', name: '每周游戏任务奖励' }, { value: 'WEEKLY_GAME_TASKS', name: '每周游戏任务奖励' },
{ value: 'SEND_DYNAMIC_PAY_GOLD', name: '超限制发动态支付金币' },
{ value: 'BUY_EMOJI', name: '购买表情包' }, { value: 'BUY_EMOJI', name: '购买表情包' },
{ value: 'BET_TEEN_PATTI', name: '炸金花' }, { value: 'BET_TEEN_PATTI', name: '炸金花' },
{ value: 'TEEN_PATTI', name: '炸金花游戏 ' }, { value: 'TEEN_PATTI', name: '炸金花游戏 ' },

View File

@ -1,307 +0,0 @@
<script lang="ts" setup>
import {
computed,
reactive,
ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import {
delPropsActivityRuleConfig,
pagePropsActivityRuleConfig,
savePropsActivityRuleConfig,
} 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 {
Button,
Card,
Col,
Input,
Modal,
Pagination,
Row,
Select,
Space,
message
} from 'antdv-next';
import RewardRow from './components/reward-row.vue';
import ActivityResourceGroupSelectDrawer from './components/activity-resource-group-select-drawer.vue';
import ActivityRuleDrawer from './components/activity-rule-drawer.vue';
import { PROP_ACTIVITY_TYPES } from './shared';
defineOptions({ name: 'PropsActivityRuleConfig' });
const accessStore = useAccessStore();
const sysOriginOptions = computed(() => {
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
return options.length > 0 ? options : getAllowedSysOrigins([]);
});
const loading = ref(false);
const total = ref(0);
const list = ref<Array<Record<string, any>>>([]);
const formOpen = ref(false);
const resourceDrawerOpen = ref(false);
const activeRow = ref<Record<string, any> | null>(null);
const query = reactive({
activityType: 'STAR',
cursor: 1,
id: '',
limit: 20,
resourceGroupId: '',
ruleDescription: '',
sysOrigin: sysOriginOptions.value[0]?.value ?? 'LIKEI',
});
async function loadData(reset = false) {
if (reset) {
query.cursor = 1;
}
loading.value = true;
try {
const result = await pagePropsActivityRuleConfig({ ...query });
list.value = [...(result.records || [])].sort(
(left, right) => Number(left.sort || 0) - Number(right.sort || 0),
);
total.value = result.total || 0;
} finally {
loading.value = false;
}
}
function handlePageChange(page: number, pageSize: number) {
query.cursor = page;
query.limit = pageSize;
void loadData();
}
function openCreate() {
activeRow.value = null;
formOpen.value = true;
}
function openEdit(record: Record<string, any>) {
activeRow.value = { ...record };
formOpen.value = true;
}
function openResourceDrawer(record: Record<string, any>) {
activeRow.value = { ...record };
resourceDrawerOpen.value = true;
}
function formatJson(value: string) {
if (!value) {
return '{}';
}
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
async function handleDelete(record: Record<string, any>) {
Modal.confirm({
async onOk() {
await delPropsActivityRuleConfig(record.id);
message.success('删除成功');
await loadData();
},
title: '确认删除吗?',
});
}
async function handleResourceSelect(row: Record<string, any>) {
if (!activeRow.value) {
return;
}
await savePropsActivityRuleConfig({
...activeRow.value,
resourceGroupId: row.id,
});
message.success('资源组已关联');
resourceDrawerOpen.value = false;
await loadData();
}
loadData(true);
</script>
<template>
<Page title="活动道具规则配置">
<Card>
<InlineFilterToolbar class="toolbar">
<InlineFilterField label="系统">
<SysOriginSelect
v-model:value="query.sysOrigin"
style="width: 140px"
@change="loadData(true)"
:options="sysOriginOptions"
></SysOriginSelect>
</InlineFilterField>
<InlineFilterField label="活动类型">
<Select option-label-prop="label"
v-model:value="query.activityType"
allow-clear
style="width: 180px"
@change="loadData(true)"
:options="PROP_ACTIVITY_TYPES.map((item) => ({ label: `${item.name}`, value: item.value as any }))"
/>
</InlineFilterField>
<InlineFilterField label="ID">
<Input v-model:value="query.id" allow-clear style="width: 160px" />
</InlineFilterField>
<InlineFilterField label="规则描述">
<Input
v-model:value="query.ruleDescription"
allow-clear
style="width: 200px"
/>
</InlineFilterField>
<InlineFilterField label="资源组ID" :label-width="88">
<Input
v-model:value="query.resourceGroupId"
allow-clear
style="width: 180px"
/>
</InlineFilterField>
<Button :loading="loading" type="primary" @click="loadData(true)">
搜索
</Button>
<Button @click="openCreate">新增</Button>
</InlineFilterToolbar>
<Row :gutter="[16, 16]">
<Col
v-for="item in list"
:key="item.id"
:lg="8"
:md="12"
:sm="24"
:xs="24"
>
<Card :title="item.activityTypeName || item.activityType">
<template #extra>
<Space size="small">
<Button size="small" type="link" @click="openEdit(item)">
编辑规则
</Button>
<Button
size="small"
type="link"
@click="openResourceDrawer(item)"
>
关联资源
</Button>
<Button danger size="small" type="link" @click="handleDelete(item)">
删除
</Button>
</Space>
</template>
<div class="rule-meta">
<div>ID{{ item.id }}</div>
<div>排序{{ item.sort }}</div>
<div class="rule-meta__full">描述{{ item.ruleDescription || '-' }}</div>
<div class="rule-meta__full">资源组{{ item.resourceGroupId || '-' }}</div>
</div>
<div class="rule-section">
<div class="rule-section__title">规则</div>
<pre class="rule-json">{{ formatJson(item.jsonData) }}</pre>
</div>
<div class="rule-section">
<div class="rule-section__title">
资源信息{{ item.resourceGroupId || '-' }}
</div>
<RewardRow :list="item.activityRewards" />
</div>
</Card>
</Col>
</Row>
<div class="pager">
<Pagination
:current="query.cursor"
:page-size="query.limit"
:total="total"
show-size-changer
@change="handlePageChange"
@showSizeChange="handlePageChange"
/>
</div>
</Card>
<ActivityRuleDrawer
:open="formOpen"
:record="activeRow"
:sys-origin-options="sysOriginOptions"
@close="formOpen = false"
@success="loadData()"
/>
<ActivityResourceGroupSelectDrawer
:open="resourceDrawerOpen"
:sys-origin="activeRow?.sysOrigin || query.sysOrigin"
@close="resourceDrawerOpen = false"
@select="handleResourceSelect"
/>
</Page>
</template>
<style scoped>
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.rule-json {
background: rgb(248 250 252);
border: 1px solid rgb(226 232 240);
border-radius: 12px;
font-size: 12px;
margin: 0;
max-height: 180px;
overflow: auto;
padding: 12px;
white-space: pre-wrap;
word-break: break-all;
}
.rule-meta {
color: rgb(71 85 105);
display: grid;
gap: 6px;
margin-bottom: 12px;
}
.rule-meta__full {
word-break: break-all;
}
.rule-section {
margin-top: 12px;
}
.rule-section__title {
color: rgb(15 23 42);
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@ -1,568 +0,0 @@
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import {
OSS_FILE_BUCKETS,
getAccessImgUrl,
simpleUploadFile,
} from '#/api/legacy/oss';
import { listFirstChargeRewards } from '#/api/legacy/product';
import { savePropsActivityRuleConfig } from '#/api/legacy/props';
import { flowGameMatchConfig } from '#/api/legacy/system';
import {
Button,
Drawer,
Form,
FormItem,
Image,
Input,
Select,
Space,
Switch,
message,
} from 'antdv-next';
import {
PROP_ACTIVITY_TYPE_HELP,
PROP_ACTIVITY_TYPES,
createActivityRuleDraft,
} from '../shared';
const props = defineProps<{
open: boolean;
record: null | Record<string, any>;
sysOriginOptions: Array<{ label: string; value: string }>;
}>();
const emit = defineEmits<{
close: [];
success: [];
}>();
const saving = ref(false);
const productsLoading = ref(false);
const gamesLoading = ref(false);
const smallIconUploading = ref(false);
const mediumIconUploading = ref(false);
const sourceUploading = ref(false);
const products = ref<Array<Record<string, any>>>([]);
const games = ref<Array<Record<string, any>>>([]);
const smallIconInputRef = ref<HTMLInputElement>();
const mediumIconInputRef = ref<HTMLInputElement>();
const sourceInputRef = ref<HTMLInputElement>();
const form = reactive<Record<string, any>>({
activityType: '',
id: '',
jsonData: '',
ruleDescription: '',
sort: '',
sysOrigin: '',
});
const ruleState = ref<any>(createActivityRuleDraft());
const isUpdate = computed(() => Boolean(form.id));
const isCrystal = computed(() => form.activityType === 'CRYSTAL');
const isFirstChargeReward = computed(() => form.activityType === 'FIRST_CHARGE_REWARD');
const isLuckyBox = computed(() => form.activityType === 'LUCKY_BOX');
const isQuantityMark = computed(() => form.activityType === 'SVIP_REWARD');
const isWeeklyGameTask = computed(() => form.activityType === 'WEEKLY_GAME_TASKS');
const isGameFruit = computed(() =>
['GAME_FRUIT_BOX_REWARD_WIN', 'GAME_FRUIT_BOX_REWARD_TIMES'].includes(
form.activityType,
),
);
const luckyBoxRules = computed<Array<Record<string, any>>>(() =>
Array.isArray(ruleState.value) ? ruleState.value : [],
);
const tips = computed(() => PROP_ACTIVITY_TYPE_HELP[form.activityType] || {});
const title = computed(() => (isUpdate.value ? '修改规则' : '新增规则'));
const activityTypeOptions = PROP_ACTIVITY_TYPES.map((item) => ({
label: item.name,
value: item.value as any,
}));
const productOptions = computed(() =>
products.value.map((item) => ({
label: `${item.unitPrice || '-'} / ${item.productId} / ${item.description || '-'}`,
value: item.productId as any,
})),
);
const crystalLevelOptions = [1, 2, 3, 4, 5].map((level) => ({
label: `${level}`,
value: level as any,
}));
const gameOptions = computed(() =>
games.value.map((item) => ({
label: item.name || '-',
value: item.id as any,
})),
);
function resetForm() {
form.activityType = '';
form.id = '';
form.jsonData = '';
form.ruleDescription = '';
form.sort = '';
form.sysOrigin = props.sysOriginOptions[0]?.value ?? 'LIKEI';
ruleState.value = createActivityRuleDraft();
}
function syncRuleDraft(value: string) {
ruleState.value = createActivityRuleDraft(value);
}
async function ensureProducts() {
if (!isFirstChargeReward.value || !form.sysOrigin || products.value.length > 0) {
return;
}
productsLoading.value = true;
try {
products.value = await listFirstChargeRewards(form.sysOrigin);
} finally {
productsLoading.value = false;
}
}
async function ensureGames() {
if (!isWeeklyGameTask.value || !form.sysOrigin || games.value.length > 0) {
return;
}
gamesLoading.value = true;
try {
games.value = await flowGameMatchConfig({ limit: 50, sysOrigin: form.sysOrigin });
} finally {
gamesLoading.value = false;
}
}
async function handleActivityTypeChange(value: string) {
syncRuleDraft(value);
if (value === 'FIRST_CHARGE_REWARD') {
await ensureProducts();
}
if (value === 'WEEKLY_GAME_TASKS') {
await ensureGames();
}
}
watch(
() => props.open,
(open) => {
if (!open) {
return;
}
const record = props.record;
products.value = [];
games.value = [];
if (!record) {
resetForm();
return;
}
form.activityType = record.activityType ?? '';
form.id = record.id ?? '';
form.jsonData = record.jsonData ?? '';
form.ruleDescription = record.ruleDescription ?? '';
form.sort = record.sort ?? '';
form.sysOrigin = record.sysOrigin || props.sysOriginOptions[0]?.value || 'LIKEI';
try {
ruleState.value = form.jsonData
? JSON.parse(form.jsonData)
: createActivityRuleDraft(form.activityType);
} catch {
ruleState.value = createActivityRuleDraft(form.activityType);
}
if (form.activityType === 'FIRST_CHARGE_REWARD') {
void ensureProducts();
}
if (form.activityType === 'WEEKLY_GAME_TASKS') {
void ensureGames();
}
},
{ immediate: true },
);
function validateRule() {
if (!form.sysOrigin) {
message.warning('请选择系统');
return false;
}
if (!form.activityType) {
message.warning('请选择类型');
return false;
}
if (!String(form.ruleDescription || '').trim()) {
message.warning('请输入规则描述');
return false;
}
if (!isCrystal.value && (form.sort === '' || Number.isNaN(Number(form.sort)))) {
message.warning('请输入排序');
return false;
}
if (isQuantityMark.value) {
if (!String(ruleState.value.mark || '').trim()) {
message.warning('请输入标识');
return false;
}
if (ruleState.value.quantity === '' || Number.isNaN(Number(ruleState.value.quantity))) {
message.warning('请输入数量');
return false;
}
return true;
}
if (isFirstChargeReward.value) {
if (!ruleState.value.productId) {
message.warning('请选择内购产品');
return false;
}
return true;
}
if (isLuckyBox.value) {
const valid = Array.isArray(ruleState.value)
&& ruleState.value.every(
(item) =>
item.quantity !== '' &&
!Number.isNaN(Number(item.quantity)) &&
item.opportunityNumber !== '' &&
!Number.isNaN(Number(item.opportunityNumber)),
);
if (!valid) {
message.warning('请完整填写 LuckyBox 规则');
return false;
}
return true;
}
if (isCrystal.value) {
const { level, mediumIcon, milestone, smallIcon, sourceUrl } = ruleState.value || {};
if (!level || milestone === '' || !smallIcon || !mediumIcon || !sourceUrl) {
message.warning('请完整填写爆水晶规则');
return false;
}
return true;
}
if (isWeeklyGameTask.value) {
if (!ruleState.value.gameConfId || ruleState.value.target === '') {
message.warning('请完整填写游戏任务规则');
return false;
}
return true;
}
if (isGameFruit.value) {
if (ruleState.value.quantity === '' || Number.isNaN(Number(ruleState.value.quantity))) {
message.warning('请输入数量');
return false;
}
return true;
}
if (ruleState.value.quantity === '' || ruleState.value.quantity === undefined) {
message.warning('请输入数量');
return false;
}
return true;
}
async function uploadAsset(
event: Event,
key: 'mediumIcon' | 'smallIcon' | 'sourceUrl',
loadingRef: typeof smallIconUploading,
imageBucket = true,
) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
target.value = '';
if (!file) {
return;
}
loadingRef.value = true;
try {
const result = await simpleUploadFile(
file,
imageBucket ? OSS_FILE_BUCKETS.svgaCover : OSS_FILE_BUCKETS.svgasource,
);
ruleState.value[key] = getAccessImgUrl(result.name);
message.success('上传成功');
} finally {
loadingRef.value = false;
}
}
function handleSmallIconChange(event: Event) {
void uploadAsset(event, 'smallIcon', smallIconUploading);
}
function handleMediumIconChange(event: Event) {
void uploadAsset(event, 'mediumIcon', mediumIconUploading);
}
function handleSourceAssetChange(event: Event) {
void uploadAsset(event, 'sourceUrl', sourceUploading, false);
}
async function handleSubmit() {
if (!validateRule()) {
return;
}
saving.value = true;
try {
const payload = {
activityType: form.activityType,
id: form.id || undefined,
jsonData: JSON.stringify(ruleState.value),
ruleDescription: form.ruleDescription.trim(),
sort: isCrystal.value ? Number(ruleState.value.level) : Number(form.sort),
sysOrigin: form.sysOrigin,
};
await savePropsActivityRuleConfig(payload);
message.success('保存成功');
emit('success');
emit('close');
} finally {
saving.value = false;
}
}
</script>
<template>
<Drawer
:open="open"
:title="title"
width="760"
@close="emit('close')"
>
<Form layout="vertical">
<FormItem label="系统">
<SysOriginSelect
v-model:value="form.sysOrigin"
:disabled="isUpdate"
:options="sysOriginOptions"
></SysOriginSelect>
</FormItem>
<FormItem label="类型">
<Select option-label-prop="label"
v-model:value="form.activityType"
:disabled="isUpdate"
:options="activityTypeOptions"
@change="handleActivityTypeChange"
/>
</FormItem>
<template v-if="form.activityType">
<FormItem v-if="isQuantityMark" :label="tips.mark || '标识'">
<Input v-model:value="ruleState.mark" />
</FormItem>
<template v-if="isFirstChargeReward">
<FormItem label="内购产品">
<Select option-label-prop="label"
v-model:value="ruleState.productId"
:loading="productsLoading"
:options="productOptions"
option-filter-prop="label"
show-search
/>
</FormItem>
</template>
<template v-else-if="isLuckyBox">
<div class="rule-array">
<div
v-for="(item, index) in luckyBoxRules"
:key="item.id || index"
class="rule-array__item"
>
<div class="rule-array__title"> {{ index + 1 }} </div>
<FormItem label="消耗糖果">
<Input v-model:value="item.quantity" />
</FormItem>
<FormItem label="抽奖次数">
<Input v-model:value="item.opportunityNumber" />
</FormItem>
</div>
</div>
</template>
<template v-else-if="isCrystal">
<FormItem :label="tips.level || '等级'">
<Select
option-label-prop="label"
v-model:value="ruleState.level"
:options="crystalLevelOptions"
/>
</FormItem>
<FormItem :label="tips.milestone || '里程碑'">
<Input v-model:value="ruleState.milestone" />
</FormItem>
<div class="upload-grid">
<FormItem :label="tips.smallIcon || '小号图标'">
<input
ref="smallIconInputRef"
accept="image/*"
class="hidden-input"
type="file"
@change="handleSmallIconChange"
/>
<div class="upload-box">
<Image v-if="ruleState.smallIcon" class="upload-box__image" :src="ruleState.smallIcon" />
<div v-else class="upload-box__empty">请上传小图标</div>
<Button :loading="smallIconUploading" @click="smallIconInputRef?.click()">
{{ ruleState.smallIcon ? '重新上传' : '上传图片' }}
</Button>
</div>
</FormItem>
<FormItem :label="tips.mediumIcon || '中号图标'">
<input
ref="mediumIconInputRef"
accept="image/*"
class="hidden-input"
type="file"
@change="handleMediumIconChange"
/>
<div class="upload-box">
<Image v-if="ruleState.mediumIcon" class="upload-box__image" :src="ruleState.mediumIcon" />
<div v-else class="upload-box__empty">请上传中图标</div>
<Button :loading="mediumIconUploading" @click="mediumIconInputRef?.click()">
{{ ruleState.mediumIcon ? '重新上传' : '上传图片' }}
</Button>
</div>
</FormItem>
</div>
<FormItem :label="tips.sourceUrl || '动画资源图'">
<input
ref="sourceInputRef"
accept=".svga,.pag"
class="hidden-input"
type="file"
@change="handleSourceAssetChange"
/>
<div class="upload-box">
<div v-if="ruleState.sourceUrl" class="upload-box__file">
{{ ruleState.sourceUrl }}
</div>
<div v-else class="upload-box__empty">请上传 svga/pag 资源</div>
<Button :loading="sourceUploading" @click="sourceInputRef?.click()">
{{ ruleState.sourceUrl ? '重新上传' : '上传资源' }}
</Button>
</div>
</FormItem>
</template>
<template v-else-if="isWeeklyGameTask">
<FormItem label="游戏">
<Select option-label-prop="label"
v-model:value="ruleState.gameConfId"
:loading="gamesLoading"
:options="gameOptions"
option-filter-prop="label"
show-search
/>
</FormItem>
<FormItem label="目标">
<Input v-model:value="ruleState.target" />
</FormItem>
</template>
<template v-else-if="isGameFruit">
<FormItem label="上架状态">
<Switch
v-model:checked="ruleState.status"
checked-children="上架"
un-checked-children="下架"
/>
</FormItem>
<FormItem label="数量">
<Input v-model:value="ruleState.quantity" />
</FormItem>
</template>
<template v-else>
<FormItem :label="tips.quantity || '数量'">
<Input v-model:value="ruleState.quantity" />
</FormItem>
</template>
</template>
<FormItem label="规则描述">
<Input
v-model:value="form.ruleDescription"
:maxlength="200"
show-count
/>
</FormItem>
<FormItem v-if="!isCrystal" label="排序">
<Input v-model:value="form.sort" />
</FormItem>
</Form>
<template #footer>
<Space>
<Button @click="emit('close')">取消</Button>
<Button :loading="saving" type="primary" @click="handleSubmit">
保存
</Button>
</Space>
</template>
</Drawer>
</template>
<style scoped>
.hidden-input {
display: none;
}
.rule-array {
display: grid;
gap: 12px;
}
.rule-array__item {
background: rgb(248 250 252);
border: 1px solid rgb(226 232 240);
border-radius: 16px;
padding: 12px;
}
.rule-array__title {
font-weight: 600;
margin-bottom: 8px;
}
.upload-box {
display: grid;
gap: 12px;
}
.upload-box__empty,
.upload-box__file {
align-items: center;
background: rgb(248 250 252);
border: 1px dashed rgb(148 163 184 / 35%);
border-radius: 16px;
color: rgb(148 163 184);
display: flex;
justify-content: center;
min-height: 120px;
padding: 12px;
}
.upload-box__file {
color: rgb(51 65 85);
word-break: break-all;
}
.upload-box__image {
height: 120px;
object-fit: contain;
width: 100%;
}
.upload-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -7,8 +7,8 @@ import {
FormItem, FormItem,
Image, Image,
Input, Input,
InputNumber,
message, message,
Modal,
Select, Select,
Switch, Switch,
} from 'antdv-next'; } from 'antdv-next';
@ -241,6 +241,18 @@ function handleNotSelectInput(event: Event) {
void uploadFile(event, 'notSelectUrl', notSelectUploading, true); 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( async function uploadFile(
event: Event, event: Event,
targetKey: 'cover' | 'expand' | 'notSelectUrl' | 'sourceUrl', targetKey: 'cover' | 'expand' | 'notSelectUrl' | 'sourceUrl',
@ -299,7 +311,13 @@ function validateForm() {
message.warning('请输入编码'); message.warning('请输入编码');
return false; 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('请输入底价'); message.warning('请输入底价');
return false; return false;
} }
@ -335,141 +353,259 @@ async function handleSubmit() {
</script> </script>
<template> <template>
<Modal <section v-if="open" class="quick-editor">
:confirm-loading="saving" <input
:open="open" ref="coverInputRef"
destroy-on-close accept="image/*"
:title="title" class="hidden-input"
width="720" type="file"
@cancel="emit('close')" @change="handleCoverInput"
@ok="handleSubmit" />
> <input
<Form layout="vertical"> ref="notSelectInputRef"
<FormItem label="系统"> accept="image/*"
<SysOriginSelect class="hidden-input"
v-model:value="form.sysOrigin" type="file"
:options="sysOriginOptions" @change="handleNotSelectInput"
/> />
</FormItem> <input
<FormItem label="类型"> ref="sourceInputRef"
<Select :accept="isImageSourceType ? 'image/*' : '.svga,.pag,.mp4'"
option-label-prop="label" class="hidden-input"
v-model:value="form.type" type="file"
:options="propsTypeOptions" @change="handleSourceInput"
/> />
</FormItem> <input
<template v-if="form.type === 'BADGE'"> ref="expandInputRef"
<FormItem label="展示形态"> accept="image/*"
<Select class="hidden-input"
v-model:value="form.badgeDisplayType" type="file"
:options="badgeDisplayTypeOptions" @change="handleExpandInput"
option-label-prop="label" />
/>
</FormItem> <div class="quick-editor__bar">
</template> <div class="quick-editor__title">{{ title }}</div>
<FormItem label="封面"> <div class="quick-editor__actions">
<input <Button @click="emit('close')">取消</Button>
ref="coverInputRef" <Button :loading="saving" type="primary" @click="handleSubmit">
accept="image/*" 保存
class="hidden-input" </Button>
type="file" </div>
@change="handleCoverInput" </div>
/>
<div class="upload-field"> <Form class="resource-form" layout="vertical">
<div v-if="form.cover" class="upload-preview"> <section class="compact-section">
<Image class="upload-preview__image" :src="form.cover" /> <div class="section-title">基础配置</div>
</div> <div class="form-grid">
<div v-else class="upload-empty">请上传封面</div> <FormItem label="系统">
<Button :loading="coverUploading" @click="openCoverPicker"> <SysOriginSelect
{{ form.cover ? '重新上传' : '上传封面' }} v-model:value="form.sysOrigin"
</Button> :options="sysOriginOptions"
</div>
</FormItem>
<FormItem v-if="form.type === 'BADGE'" label="未选中图(选填)">
<input
ref="notSelectInputRef"
accept="image/*"
class="hidden-input"
type="file"
@change="handleNotSelectInput"
/>
<div class="upload-field">
<div v-if="form.notSelectUrl" class="upload-preview">
<Image class="upload-preview__image" :src="form.notSelectUrl" />
</div>
<div v-else class="upload-empty">未选中图可不上传</div>
<Button :loading="notSelectUploading" @click="openNotSelectPicker">
{{ form.notSelectUrl ? '重新上传' : '上传未选中图' }}
</Button>
</div>
</FormItem>
<FormItem v-if="showSourceUpload" :label="sourceUploadLabel">
<input
ref="sourceInputRef"
:accept="isImageSourceType ? 'image/*' : '.svga,.pag,.mp4'"
class="hidden-input"
type="file"
@change="handleSourceInput"
/>
<div class="upload-field">
<div v-if="form.sourceUrl" class="upload-preview">
<Image
v-if="isImageSourceType"
class="upload-preview__image"
:src="form.sourceUrl"
/> />
<div v-else class="upload-file"> </FormItem>
{{ form.sourceUrl }} <FormItem label="类型">
<Select
v-model:value="form.type"
option-label-prop="label"
:options="propsTypeOptions"
/>
</FormItem>
<FormItem v-if="form.type === 'BADGE'" label="展示形态">
<Select
v-model:value="form.badgeDisplayType"
option-label-prop="label"
:options="badgeDisplayTypeOptions"
/>
</FormItem>
<FormItem v-if="isAmountRequired" label="底价">
<InputNumber
v-model:value="form.amount"
:min="0"
placeholder="请输入底价"
style="width: 100%"
/>
</FormItem>
<FormItem label="Admin Free">
<div class="switch-line">
<Switch
v-model:checked="form.adminFree"
checked-children="是"
un-checked-children="否"
/>
</div>
</FormItem>
<FormItem class="form-grid__wide" label="名称">
<Select
v-if="form.type === 'NOBLE_VIP'"
v-model:value="form.name"
option-label-prop="label"
:options="nobleVipNameOptions"
/>
<Input v-else v-model:value="form.name" />
</FormItem>
<FormItem v-if="!isUpdate" label="编码">
<Input v-model:value="form.code" placeholder="添加后不可修改" />
</FormItem>
</div>
</section>
<section class="compact-section">
<div class="section-title">资源文件</div>
<div class="asset-grid">
<div class="asset-card">
<div class="asset-preview">
<Image
v-if="form.cover"
class="asset-preview__image"
:src="form.cover"
/>
<div v-else class="asset-empty">未上传</div>
</div>
<div class="asset-detail">
<div class="asset-card__head">
<span>封面</span>
<span
class="asset-status"
:class="{ 'asset-status--ready': form.cover }"
>
{{ form.cover ? '已上传' : '必填' }}
</span>
</div>
<div class="asset-meta">{{ getAssetName(form.cover) || '-' }}</div>
</div>
<div class="asset-actions">
<Button :loading="coverUploading" @click="openCoverPicker">
{{ form.cover ? '替换' : '上传' }}
</Button>
<Button
v-if="form.cover"
danger
type="text"
@click="clearUpload('cover')"
>
删除
</Button>
</div> </div>
</div> </div>
<div v-else class="upload-empty">请上传资源</div>
<Button :loading="sourceUploading" @click="openSourcePicker"> <div v-if="showSourceUpload" class="asset-card">
{{ form.sourceUrl ? '重新上传' : '上传资源' }} <div class="asset-preview">
</Button> <Image
</div> v-if="form.sourceUrl && isImageSourceType"
</FormItem> class="asset-preview__image"
<FormItem v-if="showExpandUpload" label="Android资源"> :src="form.sourceUrl"
<input />
ref="expandInputRef" <div v-else-if="form.sourceUrl" class="asset-file">
accept="image/*" {{ getAssetName(form.sourceUrl) }}
class="hidden-input" </div>
type="file" <div v-else class="asset-empty">未上传</div>
@change="handleExpandInput" </div>
/> <div class="asset-detail">
<div class="upload-field"> <div class="asset-card__head">
<div v-if="form.expand" class="upload-preview"> <span>{{ sourceUploadLabel }}</span>
<Image class="upload-preview__image" :src="form.expand" /> <span
class="asset-status"
:class="{ 'asset-status--ready': form.sourceUrl }"
>
{{ form.sourceUrl ? '已上传' : isSourceRequired ? '必填' : '选填' }}
</span>
</div>
<div class="asset-meta">
{{ getAssetName(form.sourceUrl) || '-' }}
</div>
</div>
<div class="asset-actions">
<Button :loading="sourceUploading" @click="openSourcePicker">
{{ form.sourceUrl ? '替换' : '上传' }}
</Button>
<Button
v-if="form.sourceUrl"
danger
type="text"
@click="clearUpload('sourceUrl')"
>
删除
</Button>
</div>
</div>
<div v-if="showExpandUpload" class="asset-card">
<div class="asset-preview">
<Image
v-if="form.expand"
class="asset-preview__image"
:src="form.expand"
/>
<div v-else class="asset-empty">未上传</div>
</div>
<div class="asset-detail">
<div class="asset-card__head">
<span>Android资源</span>
<span
class="asset-status"
:class="{ 'asset-status--ready': form.expand }"
>
{{ form.expand ? '已上传' : '必填' }}
</span>
</div>
<div class="asset-meta">{{ getAssetName(form.expand) || '-' }}</div>
</div>
<div class="asset-actions">
<Button :loading="expandUploading" @click="openExpandPicker">
{{ form.expand ? '替换' : '上传' }}
</Button>
<Button
v-if="form.expand"
danger
type="text"
@click="clearUpload('expand')"
>
删除
</Button>
</div>
</div>
<div v-if="form.type === 'BADGE'" class="asset-card">
<div class="asset-preview">
<Image
v-if="form.notSelectUrl"
class="asset-preview__image"
:src="form.notSelectUrl"
/>
<div v-else class="asset-empty">未上传</div>
</div>
<div class="asset-detail">
<div class="asset-card__head">
<span>未选中图</span>
<span
class="asset-status"
:class="{ 'asset-status--ready': form.notSelectUrl }"
>
{{ form.notSelectUrl ? '已上传' : '选填' }}
</span>
</div>
<div class="asset-meta">
{{ getAssetName(form.notSelectUrl) || '-' }}
</div>
</div>
<div class="asset-actions">
<Button :loading="notSelectUploading" @click="openNotSelectPicker">
{{ form.notSelectUrl ? '替换' : '上传' }}
</Button>
<Button
v-if="form.notSelectUrl"
danger
type="text"
@click="clearUpload('notSelectUrl')"
>
删除
</Button>
</div>
</div> </div>
<div v-else class="upload-empty">请上传 Android 资源</div>
<Button :loading="expandUploading" @click="openExpandPicker">
{{ form.expand ? '重新上传' : '上传资源' }}
</Button>
</div> </div>
</FormItem> </section>
<FormItem label="名称">
<Select
v-if="form.type === 'NOBLE_VIP'"
option-label-prop="label"
v-model:value="form.name"
:options="nobleVipNameOptions"
/>
<Input v-else v-model:value="form.name" />
</FormItem>
<FormItem v-if="!isUpdate" label="编码">
<Input v-model:value="form.code" placeholder="编码添加后不可修改" />
</FormItem>
<FormItem v-if="isAmountRequired" label="底价">
<Input v-model:value="form.amount" />
</FormItem>
<FormItem label="Admin Free">
<Switch
v-model:checked="form.adminFree"
checked-children="是"
un-checked-children="否"
/>
</FormItem>
</Form> </Form>
</Modal> </section>
</template> </template>
<style scoped> <style scoped>
@ -477,37 +613,221 @@ async function handleSubmit() {
display: none; display: none;
} }
.upload-field { .quick-editor {
background: #fff;
border: 1px solid #d8dee8;
border-radius: 8px;
box-shadow: 0 1px 2px rgb(16 24 40 / 4%);
margin-bottom: 8px;
padding: 12px;
}
.quick-editor__bar {
align-items: center;
border-bottom: 1px solid #eef0f3;
display: flex;
gap: 16px;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 10px;
}
.quick-editor__title {
color: #111827;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.quick-editor__actions {
align-items: center;
display: flex;
flex: 0 0 auto;
gap: 8px;
}
.section-title {
color: #111827;
font-size: 14px;
font-weight: 600;
line-height: 20px;
margin-bottom: 10px;
}
.resource-form {
display: grid; display: grid;
gap: 12px; gap: 12px;
} }
.upload-preview, .resource-form :deep(.ant-form-item) {
.upload-empty { margin-bottom: 0;
}
.resource-form :deep(.ant-form-item-label) {
padding-bottom: 3px;
}
.resource-form :deep(.ant-form-item-label > label) {
color: #475467;
font-size: 13px;
height: 18px;
}
.compact-section {
border-bottom: 1px solid #eef0f3;
padding-bottom: 12px;
}
.compact-section:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.form-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(170px, 1fr));
}
.form-grid__wide {
grid-column: span 2;
}
.switch-line {
align-items: center; align-items: center;
background: rgb(248 250 252);
border: 1px dashed rgb(148 163 184 / 35%);
border-radius: 16px;
display: flex; display: flex;
justify-content: center; gap: 8px;
min-height: 180px; min-height: 32px;
overflow: hidden; }
.asset-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(320px, 1fr));
}
.asset-card {
align-items: center;
background: #fbfdff;
border: 1px solid #e5e7eb;
border-radius: 6px;
display: grid;
gap: 12px;
grid-template-columns: 96px minmax(0, 1fr) auto;
min-width: 0;
padding: 12px; padding: 12px;
} }
.upload-empty { .asset-card__head,
color: rgb(148 163 184); .asset-actions {
align-items: center;
display: flex;
} }
.upload-preview__image { .asset-card__head {
height: 180px; color: #344054;
gap: 8px;
font-weight: 600;
min-width: 0;
}
.asset-card__head span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-status {
background: #f3f4f6;
border-radius: 999px;
color: #667085;
flex: 0 0 auto;
font-size: 12px;
font-weight: 500;
line-height: 20px;
padding: 0 8px;
}
.asset-status--ready {
background: #e8f3ff;
color: #1677ff;
}
.asset-preview {
align-items: center;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 6px;
display: flex;
height: 76px;
justify-content: center;
min-height: 0;
overflow: hidden;
padding: 6px;
}
.asset-preview__image {
height: 64px;
object-fit: contain; object-fit: contain;
width: 100%; width: 100%;
} }
.upload-file { .asset-empty {
color: rgb(51 65 85); color: #98a2b3;
line-height: 1.5; font-size: 13px;
word-break: break-all; }
.asset-file {
color: #344054;
font-size: 12px;
line-height: 1.35;
max-width: 100%;
overflow-wrap: anywhere;
text-align: center;
}
.asset-meta {
color: #667085;
font-size: 12px;
line-height: 18px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-actions {
gap: 6px;
justify-content: flex-end;
}
@media (max-width: 1120px) {
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-grid__wide {
grid-column: span 2;
}
}
@media (max-width: 720px) {
.asset-grid,
.form-grid {
grid-template-columns: 1fr;
}
.form-grid__wide {
grid-column: span 1;
}
.asset-card {
grid-template-columns: 80px minmax(0, 1fr);
}
.asset-actions {
grid-column: 1 / -1;
justify-content: flex-start;
}
} }
</style> </style>

View File

@ -77,10 +77,20 @@ const tokenClassName = computed(() => {
align-items: center; align-items: center;
background: #fff; background: #fff;
border: 1px solid rgb(226 232 240); border: 1px solid rgb(226 232 240);
border-radius: 14px; border-radius: 8px;
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
overflow: hidden; 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 { .reward-icon__image {

View File

@ -1,28 +1,28 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import {
addOrUpdatePropsStore,
listSysOriginTypeList,
} from '#/api/legacy/props';
import { import {
Form, Form,
FormItem, FormItem,
Input, Input,
message,
Modal, Modal,
Select, Select,
Switch, Switch,
message,
} from 'antdv-next'; } from 'antdv-next';
import RewardIcon from './reward-icon.vue'; import {
addOrUpdatePropsStoreLegacy,
listSysOriginTypeList,
} from '#/api/legacy/props';
import { import {
NOBLE_VIP_OPTIONS, NOBLE_VIP_OPTIONS,
PROPS_CURRENCY_TYPES, PROPS_CURRENCY_TYPES,
PROPS_STORE_TYPES, PROPS_STORE_TYPES,
PROPS_VALID_DAYS, PROPS_VALID_DAYS,
} from '../shared'; } from '../shared';
import RewardIcon from './reward-icon.vue';
const props = defineProps<{ const props = defineProps<{
open: boolean; open: boolean;
@ -42,14 +42,15 @@ const sourceOptionsMap = reactive<
Record<string, { list: Array<Record<string, any>>; loaded: boolean; loading: boolean }> Record<string, { list: Array<Record<string, any>>; loaded: boolean; loading: boolean }>
>({}); >({});
const mainSource = ref<Record<string, any> | null>(null); const mainSource = ref<null | Record<string, any>>(null);
const rideSource = ref<Record<string, any> | null>(null); const rideSource = ref<null | Record<string, any>>(null);
const avatarSource = ref<Record<string, any> | null>(null); const avatarSource = ref<null | Record<string, any>>(null);
const dataCardSource = ref<Record<string, any> | null>(null); const dataCardSource = ref<null | Record<string, any>>(null);
const chatBubbleSource = ref<Record<string, any> | null>(null); const chatBubbleSource = ref<null | Record<string, any>>(null);
const form = reactive<Record<string, any>>({ const form = reactive<Record<string, any>>({
currencyTypes: '', currencyTypes: '',
del: false,
discount: 0, discount: 0,
id: '', id: '',
label: '', label: '',
@ -161,6 +162,7 @@ watch(
const propsAbility = record.propsAbility || {}; const propsAbility = record.propsAbility || {};
const nobleVipAbility = propsAbility.nobleVipAbility || {}; const nobleVipAbility = propsAbility.nobleVipAbility || {};
form.currencyTypes = commodity.currencyTypes || ''; form.currencyTypes = commodity.currencyTypes || '';
form.del = commodity.del ?? false;
form.discount = commodity.discount || 0; form.discount = commodity.discount || 0;
form.id = commodity.id || ''; form.id = commodity.id || '';
form.label = commodity.label || ''; form.label = commodity.label || '';
@ -203,7 +205,7 @@ watch(
{ immediate: true }, { immediate: true },
); );
function updateSelectedSource(type: string, id: string | number) { function updateSelectedSource(type: string, id: number | string) {
const item = sourceOptionsMap[type]?.list.find( const item = sourceOptionsMap[type]?.list.find(
(option) => String(option.id) === String(id), (option) => String(option.id) === String(id),
); );
@ -295,7 +297,7 @@ async function handleSubmit() {
} }
saving.value = true; saving.value = true;
try { try {
await addOrUpdatePropsStore({ await addOrUpdatePropsStoreLegacy({
...form, ...form,
currencyTypes: form.tmpCurrencyTypes.join(','), currencyTypes: form.tmpCurrencyTypes.join(','),
discount: Number(form.discount) >= 0 && Number(form.discount) <= 1 ? Number(form.discount) : 1, discount: Number(form.discount) >= 0 && Number(form.discount) <= 1 ? Number(form.discount) : 1,
@ -324,9 +326,11 @@ async function handleSubmit() {
<Form layout="vertical"> <Form layout="vertical">
<div class="form-grid"> <div class="form-grid">
<FormItem label="归属系统"> <FormItem label="归属系统">
<SysOriginSelect v-model:value="form.sysOrigin" disabled <SysOriginSelect
v-model:value="form.sysOrigin"
disabled
:options="sysOriginOptions" :options="sysOriginOptions"
></SysOriginSelect> />
</FormItem> </FormItem>
<FormItem label="道具类型"> <FormItem label="道具类型">
<Select <Select
@ -339,7 +343,8 @@ async function handleSubmit() {
</div> </div>
<FormItem label="道具资源"> <FormItem label="道具资源">
<Select option-label-prop="label" <Select
option-label-prop="label"
v-model:value="form.sourceId" v-model:value="form.sourceId"
:loading="sourceOptionsMap[form.propsType]?.loading" :loading="sourceOptionsMap[form.propsType]?.loading"
:options="getSourceSelectOptions(form.propsType)" :options="getSourceSelectOptions(form.propsType)"
@ -429,7 +434,8 @@ async function handleSubmit() {
<div class="ability-grid"> <div class="ability-grid">
<div class="ability-item"> <div class="ability-item">
<div class="ability-item__label">车辆</div> <div class="ability-item__label">车辆</div>
<Select option-label-prop="label" <Select
option-label-prop="label"
v-model:value="form.propsAbility.carId" v-model:value="form.propsAbility.carId"
:loading="sourceOptionsMap.RIDE?.loading" :loading="sourceOptionsMap.RIDE?.loading"
:options="getSourceSelectOptions('RIDE')" :options="getSourceSelectOptions('RIDE')"
@ -447,7 +453,8 @@ async function handleSubmit() {
<div class="ability-item"> <div class="ability-item">
<div class="ability-item__label">头像框</div> <div class="ability-item__label">头像框</div>
<Select option-label-prop="label" <Select
option-label-prop="label"
v-model:value="form.propsAbility.avatarFrameId" v-model:value="form.propsAbility.avatarFrameId"
:loading="sourceOptionsMap.AVATAR_FRAME?.loading" :loading="sourceOptionsMap.AVATAR_FRAME?.loading"
:options="getSourceSelectOptions('AVATAR_FRAME')" :options="getSourceSelectOptions('AVATAR_FRAME')"
@ -465,7 +472,8 @@ async function handleSubmit() {
<div class="ability-item"> <div class="ability-item">
<div class="ability-item__label">资料卡</div> <div class="ability-item__label">资料卡</div>
<Select option-label-prop="label" <Select
option-label-prop="label"
v-model:value="form.propsAbility.dataCardId" v-model:value="form.propsAbility.dataCardId"
:loading="sourceOptionsMap.DATA_CARD?.loading" :loading="sourceOptionsMap.DATA_CARD?.loading"
:options="getSourceSelectOptions('DATA_CARD')" :options="getSourceSelectOptions('DATA_CARD')"
@ -483,7 +491,8 @@ async function handleSubmit() {
<div class="ability-item"> <div class="ability-item">
<div class="ability-item__label">聊天气泡</div> <div class="ability-item__label">聊天气泡</div>
<Select option-label-prop="label" <Select
option-label-prop="label"
v-model:value="form.propsAbility.chatBubbleId" v-model:value="form.propsAbility.chatBubbleId"
:loading="sourceOptionsMap.CHAT_BUBBLE?.loading" :loading="sourceOptionsMap.CHAT_BUBBLE?.loading"
:options="getSourceSelectOptions('CHAT_BUBBLE')" :options="getSourceSelectOptions('CHAT_BUBBLE')"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -328,40 +328,3 @@ export function formatRewardText(item: Record<string, any>) {
const quantity = Number(item.quantity || 0); const quantity = Number(item.quantity || 0);
return `天数:${quantity <= 0 ? '永久' : quantity}`; return `天数:${quantity <= 0 ? '永久' : quantity}`;
} }
export function createActivityRuleDraft(type = '') {
switch (type) {
case 'CRYSTAL': {
return {
level: '',
mediumIcon: '',
milestone: '',
smallIcon: '',
sourceUrl: '',
};
}
case 'FIRST_CHARGE_REWARD': {
return { productId: '' };
}
case 'GAME_FRUIT_BOX_REWARD_TIMES':
case 'GAME_FRUIT_BOX_REWARD_WIN': {
return { quantity: '', status: true };
}
case 'LUCKY_BOX': {
return [
{ id: 1, opportunityNumber: '', quantity: '' },
{ id: 2, opportunityNumber: '', quantity: '' },
{ id: 3, opportunityNumber: '', quantity: '' },
];
}
case 'SVIP_REWARD': {
return { mark: '', quantity: '' };
}
case 'WEEKLY_GAME_TASKS': {
return { gameConfId: '', target: '' };
}
default: {
return { quantity: '' };
}
}
}

View File

@ -1,35 +1,31 @@
<script lang="ts" setup> <script lang="ts" setup>
import { import { computed, reactive, ref } from 'vue';
computed,
reactive,
ref } from 'vue';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores'; import { useAccessStore } from '@vben/stores';
import {
addOrUpdatePropsStore,
pagePropsStore,
} from '#/api/legacy/props';
import SysOriginLabel from '#/components/sys-origin-label.vue';
import InlineFilterField from '#/views/_shared/inline-filter-field.vue';
import InlineFilterToolbar from '#/views/_shared/inline-filter-toolbar.vue';
import { formatDate,
getAllowedSysOrigins } from '#/views/system/shared';
import { import {
Button, Button,
Card, Card,
Input, Input,
message,
Pagination, Pagination,
Select, Select,
Space, Space,
Switch, Switch,
Table, Table,
Tag, Tag,
message
} from 'antdv-next'; } from 'antdv-next';
import {
addOrUpdatePropsStore,
pagePropsStore,
} from '#/api/legacy/props';
import SysOriginLabel from '#/components/sys-origin-label.vue';
import InlineFilterField from '#/views/_shared/inline-filter-field.vue';
import InlineFilterToolbar from '#/views/_shared/inline-filter-toolbar.vue';
import { formatDate, getAllowedSysOrigins } from '#/views/system/shared';
import RewardIcon from './components/reward-icon.vue'; import RewardIcon from './components/reward-icon.vue';
import StoreEditModal from './components/store-edit-modal.vue'; import StoreEditModal from './components/store-edit-modal.vue';
import { import {
@ -50,7 +46,7 @@ const loading = ref(false);
const total = ref(0); const total = ref(0);
const list = ref<Array<Record<string, any>>>([]); const list = ref<Array<Record<string, any>>>([]);
const formOpen = ref(false); const formOpen = ref(false);
const activeRow = ref<Record<string, any> | null>(null); const activeRow = ref<null | Record<string, any>>(null);
const query = reactive({ const query = reactive({
cursor: 1, cursor: 1,
@ -145,34 +141,34 @@ loadData(true);
@change="loadData(true)" @change="loadData(true)"
:options="sysOriginOptions" :options="sysOriginOptions"
></SysOriginSelect> />
</InlineFilterField> </InlineFilterField>
<InlineFilterField label="道具类型"> <InlineFilterField label="道具类型">
<Select option-label-prop="label" <Select
option-label-prop="label"
v-model:value="query.propsType" v-model:value="query.propsType"
style="width: 140px" style="width: 140px"
@change="loadData(true)" @change="loadData(true)"
:options="PROPS_STORE_TYPES.map((item) => ({ label: `${item.name}`, value: item.value as any }))" :options="PROPS_STORE_TYPES.map((item) => ({ label: `${item.name}`, value: item.value as any }))"
/> />
</InlineFilterField> </InlineFilterField>
<InlineFilterField label="状态"> <InlineFilterField label="状态">
<Select option-label-prop="label" <Select
option-label-prop="label"
v-model:value="query.shelfStatus" v-model:value="query.shelfStatus"
allow-clear allow-clear
style="width: 120px" style="width: 120px"
@change="loadData(true)" @change="loadData(true)"
:options="PROPS_SHELF_STATUS_OPTIONS.map((item) => ({ label: `${item.name}`, value: item.value as any }))" :options="PROPS_SHELF_STATUS_OPTIONS.map((item) => ({ label: `${item.name}`, value: item.value as any }))"
/> />
</InlineFilterField> </InlineFilterField>
<InlineFilterField label="付费类型" :label-width="72"> <InlineFilterField label="付费类型" :label-width="72">
<Select option-label-prop="label" <Select
option-label-prop="label"
v-model:value="query.currencyTypes" v-model:value="query.currencyTypes"
allow-clear allow-clear
style="width: 120px" style="width: 120px"
@change="loadData(true)" @change="loadData(true)"
:options="PROPS_CURRENCY_TYPES.map((item) => ({ label: `${item.name}`, value: item.value as any }))" :options="PROPS_CURRENCY_TYPES.map((item) => ({ label: `${item.name}`, value: item.value as any }))"
/> />
</InlineFilterField> </InlineFilterField>
@ -262,7 +258,7 @@ loadData(true);
:total="total" :total="total"
show-size-changer show-size-changer
@change="handlePageChange" @change="handlePageChange"
@showSizeChange="handlePageChange" @show-size-change="handlePageChange"
/> />
</div> </div>
</Card> </Card>

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,7 @@ function formatPrice(value: any) {
if (!Number.isFinite(amount)) { if (!Number.isFinite(amount)) {
return String(value || '-'); return String(value || '-');
} }
return amount.toLocaleString('en-US', { return amount.toLocaleString('zh-CN', {
maximumFractionDigits: 2, maximumFractionDigits: 2,
}); });
} }
@ -123,7 +123,7 @@ onBeforeUnmount(() => {
" "
alt="" alt=""
class="latest-purchase__avatar" class="latest-purchase__avatar"
> />
<div class="latest-purchase__content"> <div class="latest-purchase__content">
<div class="latest-purchase__header"> <div class="latest-purchase__header">
<span <span

View File

@ -26,7 +26,7 @@ function normalizeNumber(value: any) {
} }
function formatValue(value: number) { function formatValue(value: number) {
return value.toLocaleString('en-US', { return value.toLocaleString('zh-CN', {
maximumFractionDigits: 2, maximumFractionDigits: 2,
}); });
} }

View File

@ -198,6 +198,7 @@ const metricColumns = new Set([
'mifapayRecharge', 'mifapayRecharge',
'newUserRecharge', 'newUserRecharge',
'salaryExchange', 'salaryExchange',
'salaryTransfer',
'totalRecharge', 'totalRecharge',
]); ]);
@ -227,6 +228,7 @@ const summaryMetricKeys = [
'mifapayRecharge', 'mifapayRecharge',
'googleRecharge', 'googleRecharge',
'salaryExchange', 'salaryExchange',
'salaryTransfer',
'totalRecharge', 'totalRecharge',
'giftConsume', 'giftConsume',
'luckyGiftTotalFlow', 'luckyGiftTotalFlow',
@ -253,6 +255,7 @@ const summaryMetricDefinitions = [
{ key: 'mifapayRecharge', label: 'MifaPay 充值' }, { key: 'mifapayRecharge', label: 'MifaPay 充值' },
{ key: 'googleRecharge', label: 'Google 充值' }, { key: 'googleRecharge', label: 'Google 充值' },
{ key: 'salaryExchange', label: '工资兑换' }, { key: 'salaryExchange', label: '工资兑换' },
{ key: 'salaryTransfer', label: '工资转移' },
{ key: 'totalRecharge', label: '总充值' }, { key: 'totalRecharge', label: '总充值' },
{ key: 'giftConsume', label: '礼物消耗' }, { key: 'giftConsume', label: '礼物消耗' },
{ key: 'luckyGiftTotalFlow', label: '幸运礼物流水' }, { key: 'luckyGiftTotalFlow', label: '幸运礼物流水' },
@ -301,6 +304,7 @@ const overviewColumns: any[] = [
{ align: 'right', dataIndex: 'mifapayRecharge', key: 'mifapayRecharge', title: 'MifaPay 充值', width: 150 }, { align: 'right', dataIndex: 'mifapayRecharge', key: 'mifapayRecharge', title: 'MifaPay 充值', width: 150 },
{ align: 'right', dataIndex: 'googleRecharge', key: 'googleRecharge', title: 'Google 充值', width: 140 }, { align: 'right', dataIndex: 'googleRecharge', key: 'googleRecharge', title: 'Google 充值', width: 140 },
{ align: 'right', dataIndex: 'salaryExchange', key: 'salaryExchange', title: '工资兑换', width: 130 }, { align: 'right', dataIndex: 'salaryExchange', key: 'salaryExchange', title: '工资兑换', width: 130 },
{ align: 'right', dataIndex: 'salaryTransfer', key: 'salaryTransfer', title: '工资转移', width: 130 },
{ align: 'right', dataIndex: 'totalRecharge', key: 'totalRecharge', title: '总充值', width: 130 }, { align: 'right', dataIndex: 'totalRecharge', key: 'totalRecharge', title: '总充值', width: 130 },
{ align: 'right', dataIndex: 'giftConsume', key: 'giftConsume', title: '礼物消耗', width: 130 }, { align: 'right', dataIndex: 'giftConsume', key: 'giftConsume', title: '礼物消耗', width: 130 },
{ align: 'right', dataIndex: 'luckyGiftTotalFlow', key: 'luckyGiftTotalFlow', title: '幸运礼物流水', width: 150 }, { align: 'right', dataIndex: 'luckyGiftTotalFlow', key: 'luckyGiftTotalFlow', title: '幸运礼物流水', width: 150 },
@ -768,13 +772,13 @@ function calcPercent(numerator?: null | number | string, denominator?: null | nu
function formatInteger(value?: null | number | string) { function formatInteger(value?: null | number | string) {
const number = Number(value || 0); const number = Number(value || 0);
return Number.isFinite(number) ? number.toLocaleString('en-US', { maximumFractionDigits: 0 }) : '0'; return Number.isFinite(number) ? number.toLocaleString('zh-CN', { maximumFractionDigits: 0 }) : '0';
} }
function formatAmount(value?: null | number | string) { function formatAmount(value?: null | number | string) {
const number = Number(value || 0); const number = Number(value || 0);
return Number.isFinite(number) return Number.isFinite(number)
? number.toLocaleString('en-US', { ? number.toLocaleString('zh-CN', {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 0, minimumFractionDigits: 0,
}) })
@ -784,7 +788,7 @@ function formatAmount(value?: null | number | string) {
function formatPercent(value?: null | number | string) { function formatPercent(value?: null | number | string) {
const number = Number(value || 0); const number = Number(value || 0);
return Number.isFinite(number) return Number.isFinite(number)
? `${number.toLocaleString('en-US', { ? `${number.toLocaleString('zh-CN', {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2, minimumFractionDigits: 2,
})}%` })}%`
@ -893,6 +897,7 @@ function emptySummary(): SummaryMetric {
mifapayRecharge: 0, mifapayRecharge: 0,
newUserRecharge: 0, newUserRecharge: 0,
salaryExchange: 0, salaryExchange: 0,
salaryTransfer: 0,
totalRecharge: 0, totalRecharge: 0,
}; };
} }
@ -1109,6 +1114,7 @@ async function renderVisualCharts() {
amountSeries('MifaPay 充值', 'mifapayRecharge', list), amountSeries('MifaPay 充值', 'mifapayRecharge', list),
amountSeries('Google 充值', 'googleRecharge', list), amountSeries('Google 充值', 'googleRecharge', list),
amountSeries('工资兑换', 'salaryExchange', list), amountSeries('工资兑换', 'salaryExchange', list),
amountSeries('工资转移', 'salaryTransfer', list),
amountSeries('总充值', 'totalRecharge', list), amountSeries('总充值', 'totalRecharge', list),
])); ]));
renderFlowChart(baseChartOption('核心流水', list, [ renderFlowChart(baseChartOption('核心流水', list, [

View File

@ -8,6 +8,37 @@ const DEV_INTERNAL_GOLANG_TARGET = 'http://127.0.0.1:2900';
const DEV_PROD_GOLANG_TARGET = 'https://jvapi.haiyihy.com'; const DEV_PROD_GOLANG_TARGET = 'https://jvapi.haiyihy.com';
const DEV_PROD_TARGET = 'https://yumi-admin.haiyihy.com'; const DEV_PROD_TARGET = 'https://yumi-admin.haiyihy.com';
function getBundledAssetFileName(assetInfo: {
name?: string;
names?: string[];
}) {
const names = [
assetInfo.name,
...(Array.isArray(assetInfo.names) ? assetInfo.names : []),
]
.filter(Boolean)
.join(',');
if (names.endsWith('.css') || names.includes('.css,')) {
return 'css/app.css';
}
return '[ext]/[name]-[hash].[ext]';
}
function addBuildTimestampToBundledAssets() {
const buildTimestamp = Date.now().toString();
return {
name: 'chatapp-build-asset-timestamp',
transformIndexHtml(html: string) {
return html
.replaceAll('/js/app.js"', `/js/app.js?v=${buildTimestamp}"`)
.replaceAll('/css/app.css"', `/css/app.css?v=${buildTimestamp}"`);
},
};
}
function createPrefixRegExp(prefix: string) { function createPrefixRegExp(prefix: string) {
return new RegExp(`^${prefix.replaceAll('/', '\\/')}`); return new RegExp(`^${prefix.replaceAll('/', '\\/')}`);
} }
@ -16,6 +47,19 @@ export default defineConfig(async () => {
return { return {
application: {}, application: {},
vite: { vite: {
build: {
cssCodeSplit: false,
modulePreload: false,
rolldownOptions: {
output: {
assetFileNames: getBundledAssetFileName,
chunkFileNames: 'js/[name]-[hash].js',
codeSplitting: false,
entryFileNames: 'js/app.js',
},
},
},
plugins: [addBuildTimestampToBundledAssets()],
server: { server: {
proxy: { proxy: {
[`${DEV_API_INTERNAL_PREFIX}/go`]: { [`${DEV_API_INTERNAL_PREFIX}/go`]: {

View File

@ -1,45 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import PreviewGroup from './preview-group.vue';
interface Props {
files?: string;
}
const props = withDefaults(defineProps<Props>(), { files: '() => []' });
const parsedFiles = computed(() => {
try {
return JSON.parse(decodeURIComponent(props.files ?? ''));
} catch {
return [];
}
});
</script>
<template>
<div class="relative rounded-xl border border-border shadow-float">
<div
class="not-prose relative w-full overflow-x-auto rounded-t-lg px-4 py-6"
>
<div class="flex w-full max-w-[700px] px-2">
<ClientOnly>
<slot v-if="parsedFiles.length > 0"></slot>
<div v-else class="text-sm text-destructive">
<span class="rounded-sm bg-destructive p-1 text-foreground">
ERROR:
</span>
The preview directory does not exist. Please check the 'dir'
parameter.
</div>
</ClientOnly>
</div>
</div>
<PreviewGroup v-if="parsedFiles.length > 0" :files="parsedFiles">
<template v-for="file in parsedFiles" #[file]>
<slot :name="file"></slot>
</template>
</PreviewGroup>
</div>
</template>

View File

@ -1 +0,0 @@
export { default as DemoPreview } from './demo-preview.vue';

View File

@ -1,110 +0,0 @@
<script setup lang="ts">
import type { SetupContext } from 'vue';
import { computed, ref, useSlots } from 'vue';
import { VbenTooltip } from '@vben-core/shadcn-ui';
import { Code } from 'lucide-vue-next';
import {
TabsContent,
TabsIndicator,
TabsList,
TabsRoot,
TabsTrigger,
} from 'reka-ui';
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(
defineProps<{
files?: string[];
}>(),
{ files: () => [] },
);
const open = ref(false);
const slots: SetupContext['slots'] = useSlots();
const tabs = computed(() => {
return props.files.map((file) => {
return {
component: slots[file],
label: file,
};
});
});
const currentTab = ref('index.vue');
const toggleOpen = () => {
open.value = !open.value;
};
</script>
<template>
<TabsRoot
v-model="currentTab"
class="overflow-hidden rounded-b-xl border-t border-border bg-background-deep"
@update:model-value="open = true"
>
<div class="flex border-b-2 border-border bg-background pr-2">
<div class="flex w-full items-center justify-between text-[13px]">
<TabsList class="relative flex">
<template v-if="open">
<TabsIndicator
class="absolute bottom-0 left-0 h-[2px] w-(--reka-tabs-indicator-size) translate-x-(--reka-tabs-indicator-position) rounded-full transition-[width,transform] duration-300"
>
<div class="size-full bg-(--vp-c-indigo-1)"></div>
</TabsIndicator>
<TabsTrigger
v-for="(tab, index) in tabs"
:key="index"
:value="tab.label"
class="border-box px-4 py-3 text-foreground data-[state=active]:text-(--vp-c-indigo-1)"
tabindex="-1"
>
{{ tab.label }}
</TabsTrigger>
</template>
</TabsList>
<div
:class="{
'py-2': !open,
}"
class="flex items-center"
>
<VbenTooltip side="top">
<template #trigger>
<Code
class="size-7 cursor-pointer rounded-full p-1.5 hover:bg-accent"
@click="toggleOpen"
/>
</template>
{{ open ? 'Collapse code' : 'Expand code' }}
</VbenTooltip>
</div>
</div>
</div>
<div
:class="`${open ? 'h-[unset] max-h-[80vh]' : 'h-0'}`"
class="block overflow-y-scroll bg-(--vp-code-block-bg) transition-all duration-300"
>
<TabsContent
v-for="tab in tabs"
:key="tab.label"
:value="tab.label"
as-child
class="rounded-xl"
>
<div class="relative rounded-xl text-foreground">
<component :is="tab.component" class="border-0" />
</div>
</TabsContent>
</div>
</TabsRoot>
</template>

View File

@ -1,302 +0,0 @@
import type { DefaultTheme } from 'vitepress';
import { defineConfig } from 'vitepress';
import { version } from '../../../package.json';
export const en = defineConfig({
description: 'Vben Admin & Enterprise level management system framework',
lang: 'en-US',
themeConfig: {
darkModeSwitchLabel: 'Theme',
darkModeSwitchTitle: 'Switch to Dark Mode',
docFooter: {
next: 'Next Page',
prev: 'Previous Page',
},
editLink: {
pattern:
'https://github.com/vbenjs/vue-vben-admin/edit/main/docs/src/:path',
text: 'Edit this page on GitHub',
},
footer: {
copyright: `Copyright © 2020-${new Date().getFullYear()} Vben`,
message: 'Released under the MIT License.',
},
langMenuLabel: 'Language',
lastUpdated: {
formatOptions: {
dateStyle: 'short',
timeStyle: 'medium',
},
text: 'Last updated on',
},
lightModeSwitchTitle: 'Switch to Light Mode',
nav: nav(),
outline: {
label: 'Navigate',
},
returnToTopLabel: 'Back to top',
sidebar: {
'/en/commercial/': {
base: '/en/commercial/',
items: sidebarCommercial(),
},
'/en/components/': {
base: '/en/components/',
items: sidebarComponents(),
},
'/en/guide/': { base: '/en/guide/', items: sidebarGuide() },
},
},
});
function sidebarGuide(): DefaultTheme.SidebarItem[] {
return [
{
collapsed: false,
text: 'Introduction',
items: [
{
link: 'introduction/vben',
text: 'About Vben Admin',
},
{
link: 'introduction/why',
text: 'Why Choose Us?',
},
{ link: 'introduction/quick-start', text: 'Quick Start' },
{ link: 'introduction/thin', text: 'Lite Version' },
{
base: '/',
link: 'components/introduction',
text: 'Components',
},
],
},
{
text: 'Basics',
items: [
{ link: 'essentials/concept', text: 'Basic Concepts' },
{ link: 'essentials/development', text: 'Local Development' },
{ link: 'essentials/route', text: 'Routing and Menu' },
{ link: 'essentials/settings', text: 'Configuration' },
{ link: 'essentials/icons', text: 'Icons' },
{ link: 'essentials/styles', text: 'Styles' },
{ link: 'essentials/external-module', text: 'External Modules' },
{ link: 'essentials/build', text: 'Build and Deployment' },
{ link: 'essentials/server', text: 'Server Interaction and Data Mock' },
],
},
{
text: 'Advanced',
items: [
{ link: 'in-depth/login', text: 'Login' },
{ link: 'in-depth/theme', text: 'Theme' },
{ link: 'in-depth/access', text: 'Access Control' },
{ link: 'in-depth/locale', text: 'Internationalization' },
{ link: 'in-depth/features', text: 'Common Features' },
{ link: 'in-depth/check-updates', text: 'Check Updates' },
{ link: 'in-depth/loading', text: 'Global Loading' },
{ link: 'in-depth/ui-framework', text: 'UI Framework Switching' },
],
},
{
text: 'Engineering',
items: [
{ link: 'project/standard', text: 'Standards' },
{ link: 'project/cli', text: 'CLI' },
{ link: 'project/dir', text: 'Directory Explanation' },
{ link: 'project/test', text: 'Unit Testing' },
{ link: 'project/tailwindcss', text: 'Tailwind CSS' },
{ link: 'project/changeset', text: 'Changeset' },
{ link: 'project/vite', text: 'Vite Config' },
],
},
{
text: 'Others',
items: [
{ link: 'other/project-update', text: 'Project Update' },
{ link: 'other/remove-code', text: 'Remove Code' },
{ link: 'other/faq', text: 'FAQ' },
],
},
];
}
function sidebarCommercial(): DefaultTheme.SidebarItem[] {
return [
{
link: 'community',
text: 'Community',
},
{
link: 'technical-support',
text: 'Technical-support',
},
{
link: 'customized',
text: 'Customized',
},
];
}
function sidebarComponents(): DefaultTheme.SidebarItem[] {
return [
{
text: 'Components',
items: [
{
link: 'introduction',
text: 'Introduction',
},
],
},
{
collapsed: false,
text: 'Layout UI',
items: [
{
link: 'layout-ui/page',
text: 'Page',
},
],
},
{
collapsed: false,
text: 'Common UI',
items: [
{
link: 'common-ui/vben-api-component',
text: 'ApiComponent',
},
{
link: 'common-ui/vben-alert',
text: 'Alert',
},
{
link: 'common-ui/vben-modal',
text: 'Modal',
},
{
link: 'common-ui/vben-drawer',
text: 'Drawer',
},
{
link: 'common-ui/vben-form',
text: 'Form',
},
{
link: 'common-ui/vben-vxe-table',
text: 'Vxe Table',
},
{
link: 'common-ui/vben-count-to-animator',
text: 'CountToAnimator',
},
{
link: 'common-ui/vben-ellipsis-text',
text: 'EllipsisText',
},
],
},
];
}
function nav(): DefaultTheme.NavItem[] {
return [
{
activeMatch: '^/en/(guide|components)/',
text: 'Doc',
items: [
{
activeMatch: '^/en/guide/',
link: '/en/guide/introduction/vben',
text: 'Guide',
},
{
activeMatch: '^/en/components/',
link: '/en/components/introduction',
text: 'Components',
},
{
text: 'Historical Versions',
items: [
{
link: 'https://doc.vvbin.cn',
text: '2.x Version Documentation',
},
],
},
],
},
{
text: 'Demo',
items: [
{
text: 'Vben Admin',
items: [
{
link: 'https://www.vben.pro',
text: 'Demo Version',
},
{
link: 'https://ant.vben.pro',
text: 'Ant Design Vue Version',
},
{
link: 'https://naive.vben.pro',
text: 'Naive Version',
},
{
link: 'https://ele.vben.pro',
text: 'Element Plus Version',
},
],
},
{
text: 'Others',
items: [
{
link: 'https://vben.vvbin.cn',
text: 'Vben Admin 2.x',
},
],
},
],
},
{
text: version,
items: [
{
link: 'https://github.com/vbenjs/vue-vben-admin/releases',
text: 'Changelog',
},
{
link: 'https://github.com/orgs/vbenjs/projects/5',
text: 'Roadmap',
},
{
link: 'https://github.com/vbenjs/vue-vben-admin/blob/main/.github/contributing.md',
text: 'Contribution',
},
],
},
{
link: '/commercial/technical-support',
text: '🦄 Tech Support',
},
{
link: '/sponsor/personal',
text: '✨ Sponsor',
},
{
link: '/commercial/community',
text: '👨‍👦‍👦 Community',
},
// {
// link: '/friend-links/',
// text: '🤝 Friend Links',
// },
];
}

View File

@ -1,25 +0,0 @@
import { withPwa } from '@vite-pwa/vitepress';
import { defineConfigWithTheme } from 'vitepress';
import { en } from './en.mts';
import { shared } from './shared.mts';
import { zh } from './zh.mts';
export default withPwa(
defineConfigWithTheme({
...shared,
locales: {
en: {
label: 'English',
lang: 'en',
link: '/en/',
...en,
},
root: {
label: '简体中文',
lang: 'zh-CN',
...zh,
},
},
}),
);

View File

@ -1,142 +0,0 @@
import type { MarkdownEnv, MarkdownRenderer } from 'vitepress';
import crypto from 'node:crypto';
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
export const rawPathRegexp =
/^(.+?(?:\.([\da-z]+))?)(#[\w-]+)?(?: ?{(\d+(?:[,-]\d+)*)? ?(\S+)?})? ?(?:\[(.+)])?$/;
function rawPathToToken(rawPath: string) {
const [
filepath = '',
extension = '',
region = '',
lines = '',
lang = '',
rawTitle = '',
] = (rawPathRegexp.exec(rawPath) || []).slice(1);
const title = rawTitle || filepath.split('/').pop() || '';
return { extension, filepath, lang, lines, region, title };
}
export const demoPreviewPlugin = (md: MarkdownRenderer) => {
md.core.ruler.after('inline', 'demo-preview', (state) => {
const insertComponentImport = (importString: string) => {
const index = state.tokens.findIndex(
(i) => i.type === 'html_block' && i.content.match(/<script setup>/g),
);
if (index === -1) {
const importComponent = new state.Token('html_block', '', 0);
importComponent.content = `<script setup>\n${importString}\n</script>\n`;
state.tokens.splice(0, 0, importComponent);
} else {
if (state.tokens[index]) {
const content = state.tokens[index].content;
state.tokens[index].content = content.replace(
'</script>',
`${importString}\n</script>`,
);
}
}
};
// Define the regular expression to match the desired pattern
const regex = /<DemoPreview[^>]*\sdir="([^"]*)"/g;
// Iterate through the Markdown content and replace the pattern
state.src = state.src.replaceAll(regex, (_match, dir) => {
const componentDir = join(process.cwd(), 'src', dir).replaceAll(
'\\',
'/',
);
let childFiles: string[] = [];
let dirExists = true;
try {
childFiles =
readdirSync(componentDir, {
encoding: 'utf8',
recursive: false,
withFileTypes: false,
}) || [];
} catch {
dirExists = false;
}
if (!dirExists) {
return '';
}
const uniqueWord = generateContentHash(componentDir);
const ComponentName = `DemoComponent_${uniqueWord}`;
insertComponentImport(
`import ${ComponentName} from '${componentDir}/index.vue'`,
);
const { path: _path } = state.env as MarkdownEnv;
const index = state.tokens.findIndex((i) => i.content.match(regex));
if (!state.tokens[index]) {
return '';
}
const firstString = 'index.vue';
childFiles = childFiles.toSorted((a, b) => {
if (a === firstString) return -1;
if (b === firstString) return 1;
return a.localeCompare(b, 'en', { sensitivity: 'base' });
});
state.tokens[index].content =
`<DemoPreview files="${encodeURIComponent(JSON.stringify(childFiles))}" ><${ComponentName}/>
`;
const _dummyToken = new state.Token('', '', 0);
const tokenArray: Array<typeof _dummyToken> = [];
childFiles.forEach((filename) => {
// const slotName = filename.replace(extname(filename), '');
const templateStart = new state.Token('html_inline', '', 0);
templateStart.content = `<template #${filename}>`;
tokenArray.push(templateStart);
const resolvedPath = join(componentDir, filename);
const { extension, filepath, lang, lines, title } =
rawPathToToken(resolvedPath);
// Add code tokens for each line
const token = new state.Token('fence', 'code', 0);
token.info = `${lang || extension}${lines ? `{${lines}}` : ''}${
title ? `[${title}]` : ''
}`;
token.content = `<<< ${filepath}`;
(token as any).src = [resolvedPath];
tokenArray.push(token);
const templateEnd = new state.Token('html_inline', '', 0);
templateEnd.content = '</template>';
tokenArray.push(templateEnd);
});
const endTag = new state.Token('html_inline', '', 0);
endTag.content = '</DemoPreview>';
tokenArray.push(endTag);
state.tokens.splice(index + 1, 0, ...tokenArray);
// console.log(
// state.md.renderer.render(state.tokens, state?.options ?? [], state.env),
// );
return '';
});
});
};
function generateContentHash(input: string, length: number = 10): string {
// 使用 SHA-256 生成哈希值
const hash = crypto.createHash('sha256').update(input).digest('hex');
// 将哈希值转换为 Base36 编码,并取指定长度的字符作为结果
return Number.parseInt(hash, 16).toString(36).slice(0, length);
}

View File

@ -1,170 +0,0 @@
import type { PwaOptions } from '@vite-pwa/vitepress';
import type { HeadConfig } from 'vitepress';
import { resolve } from 'node:path';
import {
viteArchiverPlugin,
viteVxeTableImportsPlugin,
} from '@vben/vite-config';
import {
GitChangelog,
GitChangelogMarkdownSection,
} from '@nolebase/vitepress-plugin-git-changelog/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig, postcssIsolateStyles } from 'vitepress';
import {
groupIconMdPlugin,
groupIconVitePlugin,
} from 'vitepress-plugin-group-icons';
import { demoPreviewPlugin } from './plugins/demo-preview';
import { search as zhSearch } from './zh.mts';
export const shared = defineConfig({
appearance: 'dark',
head: head(),
markdown: {
preConfig(md) {
md.use(demoPreviewPlugin);
md.use(groupIconMdPlugin);
},
},
pwa: pwa(),
srcDir: 'src',
themeConfig: {
i18nRouting: true,
logo: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
search: {
options: {
locales: {
...zhSearch,
},
},
provider: 'local',
},
siteTitle: 'Vben Admin',
socialLinks: [
{ icon: 'github', link: 'https://github.com/vbenjs/vue-vben-admin' },
],
},
title: 'Vben Admin',
vite: {
build: {
chunkSizeWarningLimit: Infinity,
minify: 'terser',
},
css: {
postcss: {
plugins: [postcssIsolateStyles({ includeFiles: [/vp-doc\.css/] })],
},
preprocessorOptions: {
scss: {
api: 'modern',
},
},
},
json: {
stringify: true,
},
plugins: [
tailwindcss(),
GitChangelog({
mapAuthors: [
{
mapByNameAliases: ['Vben'],
name: 'vben',
username: 'anncwb',
},
{
name: 'vince',
username: 'vince292007',
},
{
name: 'Li Kui',
username: 'likui628',
},
],
repoURL: () => 'https://github.com/vbenjs/vue-vben-admin',
}),
GitChangelogMarkdownSection(),
viteArchiverPlugin({ outputDir: '.vitepress' }),
groupIconVitePlugin(),
await viteVxeTableImportsPlugin(),
],
server: {
fs: {
allow: ['../..'],
},
host: true,
port: 6173,
},
ssr: {
external: ['@vue/repl'],
},
},
});
function head(): HeadConfig[] {
return [
['meta', { content: 'Vbenjs Team', name: 'author' }],
[
'meta',
{
content: 'vben, vitejs, vite, shacdn-ui, vue',
name: 'keywords',
},
],
['link', { href: '/favicon.ico', rel: 'icon', type: 'image/svg+xml' }],
[
'meta',
{
content:
'width=device-width,initial-scale=1,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no',
name: 'viewport',
},
],
['meta', { content: 'vben admin docs', name: 'keywords' }],
['link', { href: '/favicon.ico', rel: 'icon' }],
// [
// 'script',
// {
// src: 'https://cdn.tailwindcss.com',
// },
// ],
];
}
function pwa(): PwaOptions {
return {
includeManifestIcons: false,
manifest: {
description:
'Vben Admin is a modern admin dashboard template based on Vue 3. ',
icons: [
{
sizes: '192x192',
src: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/pwa-icon-192.png',
type: 'image/png',
},
{
sizes: '512x512',
src: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/pwa-icon-512.png',
type: 'image/png',
},
],
id: '/',
name: 'Vben Admin Doc',
short_name: 'vben_admin_doc',
theme_color: '#ffffff',
},
outDir: resolve(process.cwd(), '.vitepress/dist'),
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{css,js,html,svg,png,ico,txt,woff2}'],
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
},
};
}

View File

@ -1,358 +0,0 @@
import type { DefaultTheme } from 'vitepress';
import { defineConfig } from 'vitepress';
import { version } from '../../../package.json';
export const zh = defineConfig({
description: 'Vben Admin & 企业级管理系统框架',
lang: 'zh-Hans',
themeConfig: {
darkModeSwitchLabel: '主题',
darkModeSwitchTitle: '切换到深色模式',
docFooter: {
next: '下一页',
prev: '上一页',
},
editLink: {
pattern:
'https://github.com/vbenjs/vue-vben-admin/edit/main/docs/src/:path',
text: '在 GitHub 上编辑此页面',
},
footer: {
copyright: `Copyright © 2020-${new Date().getFullYear()} Vben`,
message: '基于 MIT 许可发布.',
},
langMenuLabel: '多语言',
lastUpdated: {
formatOptions: {
dateStyle: 'short',
timeStyle: 'medium',
},
text: '最后更新于',
},
lightModeSwitchTitle: '切换到浅色模式',
nav: nav(),
outline: {
label: '页面导航',
},
returnToTopLabel: '回到顶部',
sidebar: {
'/commercial/': { base: '/commercial/', items: sidebarCommercial() },
'/components/': { base: '/components/', items: sidebarComponents() },
'/guide/': { base: '/guide/', items: sidebarGuide() },
},
sidebarMenuLabel: '菜单',
},
});
function sidebarGuide(): DefaultTheme.SidebarItem[] {
return [
{
collapsed: false,
text: '简介',
items: [
{
link: 'introduction/vben',
text: '关于 Vben Admin',
},
{
link: 'introduction/why',
text: '为什么选择我们?',
},
{ link: 'introduction/quick-start', text: '快速开始' },
{ link: 'introduction/thin', text: '精简版本' },
{
base: '/',
link: 'components/introduction',
text: '组件文档',
},
],
},
{
text: '基础',
items: [
{ link: 'essentials/concept', text: '基础概念' },
{ link: 'essentials/development', text: '本地开发' },
{ link: 'essentials/route', text: '路由和菜单' },
{ link: 'essentials/settings', text: '配置' },
{ link: 'essentials/icons', text: '图标' },
{ link: 'essentials/styles', text: '样式' },
{ link: 'essentials/external-module', text: '外部模块' },
{ link: 'essentials/build', text: '构建与部署' },
{ link: 'essentials/server', text: '服务端交互与数据Mock' },
],
},
{
text: '深入',
items: [
{ link: 'in-depth/login', text: '登录' },
// { link: 'in-depth/layout', text: '布局' },
{ link: 'in-depth/theme', text: '主题' },
{ link: 'in-depth/access', text: '权限' },
{ link: 'in-depth/locale', text: '国际化' },
{ link: 'in-depth/features', text: '常用功能' },
{ link: 'in-depth/check-updates', text: '检查更新' },
{ link: 'in-depth/loading', text: '全局loading' },
{ link: 'in-depth/ui-framework', text: '组件库切换' },
],
},
{
text: '工程',
items: [
{ link: 'project/standard', text: '规范' },
{ link: 'project/cli', text: 'CLI' },
{ link: 'project/dir', text: '目录说明' },
{ link: 'project/test', text: '单元测试' },
{ link: 'project/tailwindcss', text: 'Tailwind CSS' },
{ link: 'project/changeset', text: 'Changeset' },
{ link: 'project/vite', text: 'Vite Config' },
],
},
{
text: '其他',
items: [
{ link: 'other/project-update', text: '项目更新' },
{ link: 'other/remove-code', text: '移除代码' },
{ link: 'other/faq', text: '常见问题' },
],
},
];
}
function sidebarCommercial(): DefaultTheme.SidebarItem[] {
return [
{
link: 'community',
text: '交流群',
},
{
link: 'technical-support',
text: '技术支持',
},
{
link: 'customized',
text: '定制开发',
},
];
}
function sidebarComponents(): DefaultTheme.SidebarItem[] {
return [
{
text: '组件',
items: [
{
link: 'introduction',
text: '介绍',
},
],
},
{
collapsed: false,
text: '布局组件',
items: [
{
link: 'layout-ui/page',
text: 'Page 页面',
},
],
},
{
collapsed: false,
text: '通用组件',
items: [
{
link: 'common-ui/vben-api-component',
text: 'ApiComponent Api组件包装器',
},
{
link: 'common-ui/vben-alert',
text: 'Alert 轻量提示框',
},
{
link: 'common-ui/vben-modal',
text: 'Modal 模态框',
},
{
link: 'common-ui/vben-drawer',
text: 'Drawer 抽屉',
},
{
link: 'common-ui/vben-form',
text: 'Form 表单',
},
{
link: 'common-ui/vben-vxe-table',
text: 'Vxe Table 表格',
},
{
link: 'common-ui/vben-count-to-animator',
text: 'CountToAnimator 数字动画',
},
{
link: 'common-ui/vben-ellipsis-text',
text: 'EllipsisText 省略文本',
},
],
},
];
}
function nav(): DefaultTheme.NavItem[] {
return [
{
activeMatch: '^/(guide|components)/',
text: '文档',
items: [
{
activeMatch: '^/guide/',
link: '/guide/introduction/vben',
text: '指南',
},
{
activeMatch: '^/components/',
link: '/components/introduction',
text: '组件',
},
{
text: '历史版本',
items: [
{
link: 'https://doc.vvbin.cn',
text: '2.x版本文档',
},
],
},
],
},
{
text: '演示',
items: [
{
text: 'Vben Admin',
items: [
{
link: 'https://www.vben.pro',
text: '演示版本',
},
{
link: 'https://ant.vben.pro',
text: 'Ant Design Vue 版本',
},
{
link: 'https://naive.vben.pro',
text: 'Naive 版本',
},
{
link: 'https://ele.vben.pro',
text: 'Element Plus版本',
},
],
},
{
text: '其他',
items: [
{
link: 'https://vben.vvbin.cn',
text: 'Vben Admin 2.x',
},
],
},
],
},
{
text: version,
items: [
{
link: 'https://github.com/vbenjs/vue-vben-admin/releases',
text: '更新日志',
},
{
link: 'https://github.com/orgs/vbenjs/projects/5',
text: '路线图',
},
{
link: 'https://github.com/vbenjs/vue-vben-admin/blob/main/.github/contributing.md',
text: '贡献',
},
],
},
{
link: '/commercial/technical-support',
text: '🦄 技术支持',
},
{
link: '/sponsor/personal',
text: '✨ 赞助',
},
{
link: '/commercial/community',
text: '👨‍👦‍👦 交流群',
// items: [
// {
// link: 'https://qun.qq.com/qqweb/qunpro/share?_wv=3&_wwv=128&appChannel=share&inviteCode=22ySzj7pKiw&businessType=9&from=246610&biz=ka&mainSourceId=share&subSourceId=others&jumpsource=shorturl#/pc',
// text: 'QQ频道',
// },
// {
// link: 'https://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=mjZmlhgVzzUxvdxllB6C1vHpX8O8QRL0&authKey=DBdFbBwERmfaKY95JvRWqLCJIRGJAmKyZbrpzZ41EKDMZ5SR6MfbjOBaaNRN73fr&noverify=0&group_code=4286109',
// text: 'QQ群',
// },
// {
// link: 'https://discord.gg/VU62jTecad',
// text: 'Discord',
// },
// ],
},
// {
// link: '/friend-links/',
// text: '🤝 友情链接',
// },
];
}
export const search: DefaultTheme.AlgoliaSearchOptions['locales'] = {
root: {
placeholder: '搜索文档',
translations: {
button: {
buttonAriaLabel: '搜索文档',
buttonText: '搜索文档',
},
modal: {
errorScreen: {
helpText: '你可能需要检查你的网络连接',
titleText: '无法获取结果',
},
footer: {
closeText: '关闭',
navigateText: '切换',
searchByText: '搜索提供者',
selectText: '选择',
},
noResultsScreen: {
noResultsText: '无法找到相关结果',
reportMissingResultsLinkText: '点击反馈',
reportMissingResultsText: '你认为该查询应该有结果?',
suggestedQueryText: '你可以尝试查询',
},
searchBox: {
cancelButtonAriaLabel: '取消',
cancelButtonText: '取消',
resetButtonAriaLabel: '清除查询条件',
resetButtonTitle: '清除查询条件',
},
startScreen: {
favoriteSearchesTitle: '收藏',
noRecentSearchesText: '没有搜索历史',
recentSearchesTitle: '搜索历史',
removeFavoriteSearchButtonTitle: '从收藏中移除',
removeRecentSearchButtonTitle: '从搜索历史中移除',
saveRecentSearchButtonTitle: '保存至搜索历史',
},
},
},
},
};

View File

@ -1,96 +0,0 @@
<script lang="ts" setup>
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
// import { useAntdDesignTokens } from '@vben/hooks';
// import { initPreferences } from '@vben/preferences';
import { ConfigProvider, theme } from 'ant-design-vue';
import mediumZoom from 'medium-zoom';
import { useRoute } from 'vitepress';
import DefaultTheme from 'vitepress/theme';
const { Layout } = DefaultTheme;
const route = useRoute();
// const { tokens } = useAntdDesignTokens();
const initZoom = () => {
// mediumZoom('[data-zoomable]', { background: 'var(--vp-c-bg)' });
mediumZoom('.VPContent img', { background: 'var(--vp-c-bg)' });
};
const isDark = ref(true);
watch(
() => route.path,
() => nextTick(() => initZoom()),
);
// initPreferences({
// namespace: 'docs',
// });
onMounted(() => {
initZoom();
});
// 使
const observer = watchDarkModeChange((dark) => {
isDark.value = dark;
});
onBeforeUnmount(() => {
observer?.disconnect();
});
function watchDarkModeChange(callback: (isDark: boolean) => void) {
if (typeof window === 'undefined') {
return;
}
const htmlElement = document.documentElement;
const observer = new MutationObserver(() => {
const isDark = htmlElement.classList.contains('dark');
callback(isDark);
});
observer.observe(htmlElement, {
attributeFilter: ['class'],
attributes: true,
});
const initialIsDark = htmlElement.classList.contains('dark');
callback(initialIsDark);
return observer;
}
const tokenTheme = computed(() => {
const algorithm = isDark.value
? [theme.darkAlgorithm]
: [theme.defaultAlgorithm];
return {
algorithm,
// token: tokens,
};
});
</script>
<template>
<ConfigProvider :theme="tokenTheme">
<Layout />
</ConfigProvider>
</template>
<style>
.medium-zoom-overlay,
.medium-zoom-image--opened {
z-index: 2147483647;
}
</style>

View File

@ -1,29 +0,0 @@
<script setup lang="ts"></script>
<template>
<div class="vben-contributors vp-doc">
<p>Contributors</p>
<a href="https://github.com/vbenjs/vue-vben-admin/graphs/contributors">
<img
alt="Contributors"
src="https://opencollective.com/vbenjs/contributors.svg?button=false"
/>
</a>
</div>
</template>
<style scoped>
.vben-contributors {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 60px;
p {
margin-bottom: 50px;
font-size: 30px;
font-weight: 700;
}
}
</style>

View File

@ -1,29 +0,0 @@
// https://vitepress.dev/guide/custom-theme
import type { EnhanceAppContext, Theme } from 'vitepress';
import { NolebaseGitChangelogPlugin } from '@nolebase/vitepress-plugin-git-changelog/client';
import DefaultTheme from 'vitepress/theme';
import { DemoPreview } from '../components';
import SiteLayout from './components/site-layout.vue';
import VbenContributors from './components/vben-contributors.vue';
import { initHmPlugin } from './plugins/hm';
import './styles';
import 'virtual:group-icons.css';
import '@nolebase/vitepress-plugin-git-changelog/client/style.css';
export default {
async enhanceApp(ctx: EnhanceAppContext) {
const { app } = ctx;
app.component('VbenContributors', VbenContributors);
app.component('DemoPreview', DemoPreview);
app.use(NolebaseGitChangelogPlugin);
// 百度统计
initHmPlugin();
},
extends: DefaultTheme,
Layout: SiteLayout,
} satisfies Theme;

View File

@ -1,28 +0,0 @@
import { inBrowser } from 'vitepress';
const SITE_ID = '2e443a834727c065877c01d89921545e';
declare global {
interface Window {
_hmt: any;
}
}
function registerAnalytics() {
window._hmt = window._hmt || [];
const script = document.createElement('script');
script.innerHTML = `var _hmt = _hmt || [];
(function() {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?${SITE_ID}";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})()`;
document.querySelector('head')?.append(script);
}
export function initHmPlugin() {
if (inBrowser && import.meta.env.PROD) {
registerAnalytics();
}
}

View File

@ -1,22 +0,0 @@
html.dark {
color-scheme: dark;
}
.dark .VPContent {
/* background-color: #14161a; */
}
.form-valid-error p {
margin: 0;
}
/* 顶部导航栏选中项样式 */
.VPNavBarMenuLink,
.VPNavBarMenuGroup {
border-bottom: 1px solid transparent;
}
.VPNavBarMenuLink.active,
.VPNavBarMenuGroup.active {
border-bottom-color: var(--vp-c-brand-1);
}

View File

@ -1,4 +0,0 @@
import '@vben/styles';
import './variables.css';
import './base.css';

View File

@ -1,132 +0,0 @@
/**
* Customize default theme styling by overriding CSS variables:
* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
*/
/**
* Colors
*
* Each colors have exact same color scale system with 3 levels of solid
* colors with different brightness, and 1 soft color.
*
* - `XXX-1`: The most solid color used mainly for colored text. It must
* satisfy the contrast ratio against when used on top of `XXX-soft`.
*
* - `XXX-2`: The color used mainly for hover state of the button.
*
* - `XXX-3`: The color for solid background, such as bg color of the button.
* It must satisfy the contrast ratio with pure white (#ffffff) text on
* top of it.
*
* - `XXX-soft`: The color used for subtle background such as custom container
* or badges. It must satisfy the contrast ratio when putting `XXX-1` colors
* on top of it.
*
* The soft color must be semi transparent alpha channel. This is crucial
* because it allows adding multiple "soft" colors on top of each other
* to create a accent, such as when having inline code block inside
* custom containers.
*
* - `default`: The color used purely for subtle indication without any
* special meanings attched to it such as bg color for menu hover state.
*
* - `brand`: Used for primary brand colors, such as link text, button with
* brand theme, etc.
*
* - `tip`: Used to indicate useful information. The default theme uses the
* brand color for this by default.
*
* - `warning`: Used to indicate warning to the users. Used in custom
* container, badges, etc.
*
* - `danger`: Used to show error, or dangerous message to the users. Used
* in custom container, badges, etc.
* -------------------------------------------------------------------------- */
:root {
/* --vp-c-indigo-1: #4f69fd; */
--vp-c-default-1: var(--vp-c-gray-1);
--vp-c-default-2: var(--vp-c-gray-2);
--vp-c-default-3: var(--vp-c-gray-3);
--vp-c-default-soft: var(--vp-c-gray-soft);
--vp-c-brand-1: var(--vp-c-indigo-1);
--vp-c-brand-2: var(--vp-c-indigo-2);
--vp-c-brand-3: var(--vp-c-indigo-3);
--vp-c-brand-soft: var(--vp-c-indigo-soft);
--vp-c-tip-1: var(--vp-c-brand-1);
--vp-c-tip-2: var(--vp-c-brand-2);
--vp-c-tip-3: var(--vp-c-brand-3);
--vp-c-tip-soft: var(--vp-c-brand-soft);
--vp-c-warning-1: var(--vp-c-yellow-1);
--vp-c-warning-2: var(--vp-c-yellow-2);
--vp-c-warning-3: var(--vp-c-yellow-3);
--vp-c-warning-soft: var(--vp-c-yellow-soft);
--vp-c-danger-1: var(--vp-c-red-1);
--vp-c-danger-2: var(--vp-c-red-2);
--vp-c-danger-3: var(--vp-c-red-3);
--vp-c-danger-soft: var(--vp-c-red-soft);
/**
* Component: Button
* -------------------------------------------------------------------------- */
--vp-button-brand-border: transparent;
--vp-button-brand-text: var(--vp-c-white);
--vp-button-brand-bg: var(--vp-c-brand-3);
--vp-button-brand-hover-border: transparent;
--vp-button-brand-hover-text: var(--vp-c-white);
--vp-button-brand-hover-bg: var(--vp-c-brand-2);
--vp-button-brand-active-border: transparent;
--vp-button-brand-active-text: var(--vp-c-white);
--vp-button-brand-active-bg: var(--vp-c-brand-1);
/**
* Component: Home
* -------------------------------------------------------------------------- */
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: linear-gradient(
120deg,
var(--vp-c-indigo-1) 30%,
#18cefe
);
--vp-home-hero-image-background-image: linear-gradient(
-45deg,
#18cefe 50%,
#c279ed 50%
);
--vp-home-hero-image-filter: blur(44px);
/**
* Component: Custom Block
* -------------------------------------------------------------------------- */
--vp-custom-block-tip-border: transparent;
--vp-custom-block-tip-text: var(--vp-c-text-1);
--vp-custom-block-tip-bg: var(--vp-c-brand-soft);
--vp-custom-block-tip-code-bg: var(--vp-c-brand-soft);
/**
* modal zIndex
*/
--popup-z-index: 1000;
}
@media (min-width: 640px) {
:root {
--vp-home-hero-image-filter: blur(56px);
}
}
@media (min-width: 960px) {
:root {
--vp-home-hero-image-filter: blur(68px);
}
}
/**
* Component: Algolia
* -------------------------------------------------------------------------- */
.DocSearch {
--docsearch-primary-color: var(--vp-c-brand-1) !important;
}

View File

@ -1,37 +0,0 @@
{
"name": "@vben/docs",
"version": "5.7.0",
"private": true,
"scripts": {
"build": "vitepress build",
"dev": "vitepress dev",
"docs:preview": "vitepress preview"
},
"imports": {
"#/*": {
"node": "./src/_env/node/*",
"default": "./src/_env/*"
}
},
"dependencies": {
"@vben-core/shadcn-ui": "workspace:*",
"@vben/common-ui": "workspace:*",
"@vben/locales": "workspace:*",
"@vben/plugins": "workspace:*",
"@vben/styles": "workspace:*",
"ant-design-vue": "catalog:",
"lucide-vue-next": "catalog:",
"medium-zoom": "catalog:",
"reka-ui": "catalog:",
"vitepress-plugin-group-icons": "catalog:"
},
"devDependencies": {
"@nolebase/vitepress-plugin-git-changelog": "catalog:",
"@tailwindcss/vite": "catalog:",
"@vben/tailwind-config": "workspace:*",
"@vben/vite-config": "workspace:*",
"@vite-pwa/vitepress": "catalog:",
"vitepress": "catalog:",
"vue": "catalog:"
}
}

View File

@ -1,128 +0,0 @@
/**
* 使 adapter/form 使便使
* vben-formvben-modalvben-drawer 使,
*/
import type { Component, SetupContext } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import { h } from 'vue';
import { globalShareState } from '@vben/common-ui';
import { $t } from '@vben/locales';
import {
AutoComplete,
Button,
Checkbox,
CheckboxGroup,
DatePicker,
Divider,
Input,
InputNumber,
InputPassword,
Mentions,
notification,
Radio,
RadioGroup,
RangePicker,
Rate,
Select,
Space,
Switch,
Textarea,
TimePicker,
TreeSelect,
Upload,
} from 'ant-design-vue';
const withDefaultPlaceholder = <T extends Component>(
component: T,
type: 'input' | 'select',
) => {
return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
const placeholder = props?.placeholder || $t(`ui.placeholder.${type}`);
return h(component, { ...props, ...attrs, placeholder }, slots);
};
};
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType =
| 'AutoComplete'
| 'Checkbox'
| 'CheckboxGroup'
| 'DatePicker'
| 'DefaultButton'
| 'Divider'
| 'Input'
| 'InputNumber'
| 'InputPassword'
| 'Mentions'
| 'PrimaryButton'
| 'Radio'
| 'RadioGroup'
| 'RangePicker'
| 'Rate'
| 'Select'
| 'Space'
| 'Switch'
| 'Textarea'
| 'TimePicker'
| 'TreeSelect'
| 'Upload'
| BaseFormComponentType;
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载
// Button: () =>
// import('xxx').then((res) => res.Button),
AutoComplete,
Checkbox,
CheckboxGroup,
DatePicker,
// 自定义默认按钮
DefaultButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'default' }, slots);
},
Divider,
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
Mentions: withDefaultPlaceholder(Mentions, 'input'),
// 自定义主要按钮
PrimaryButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'primary' }, slots);
},
Radio,
RadioGroup,
RangePicker,
Rate,
Select: withDefaultPlaceholder(Select, 'select'),
Space,
Switch,
Textarea: withDefaultPlaceholder(Textarea, 'input'),
TimePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload,
};
// 将组件注册到全局共享状态中
globalShareState.setComponents(components);
// 定义全局共享状态中的消息提示
globalShareState.defineMessage({
// 复制成功消息提示
copyPreferencesSuccess: (title, content) => {
notification.success({
description: content,
message: title,
placement: 'bottomRight',
});
},
});
}
export { initComponentAdapter };

View File

@ -1,48 +0,0 @@
import type {
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui';
import type { ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { initComponentAdapter } from './component';
initComponentAdapter();
setupVbenForm<ComponentType>({
config: {
baseModelPropName: 'value',
// naive-ui组件的空值为null,不能是undefined否则重置表单时不生效
emptyStateValue: null,
modelPropNameMap: {
Checkbox: 'checked',
Radio: 'checked',
Switch: 'checked',
Upload: 'fileList',
},
},
defineRules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
},
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
});
const useVbenForm = useForm<ComponentType>;
export { useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };

View File

@ -1,71 +0,0 @@
import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { Button, Image } from 'ant-design-vue';
import { useVbenForm } from './form';
if (!import.meta.env.SSR) {
setupVbenVxeTable({
configVxeTable: (vxeUI) => {
vxeUI.setConfig({
grid: {
align: 'center',
border: false,
columnConfig: {
resizable: true,
},
formConfig: {
// 全局禁用vxe-table的表单配置使用formOptions
enabled: false,
},
minHeight: 180,
proxyConfig: {
autoLoad: true,
response: {
result: 'items',
total: 'total',
list: 'items',
},
showActiveMsg: true,
showResponseMsg: false,
},
round: true,
showOverflow: true,
size: 'small',
},
});
// 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', {
renderTableDefault(renderOpts, params) {
const { props } = renderOpts;
const { column, row } = params;
return h(Image, { src: row[column.field], ...props });
},
});
// 表格配置项可以用 cellRender: { name: 'CellLink' },
vxeUI.renderer.add('CellLink', {
renderTableDefault(renderOpts) {
const { props } = renderOpts;
return h(
Button,
{ size: 'small', type: 'link' },
{ default: () => props?.text },
);
},
});
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
// vxeUI.formats.add
},
useVbenForm,
});
}
export { useVbenVxeGrid };
export type * from '@vben/plugins/vxe-table';

View File

@ -1,4 +0,0 @@
export const useVbenForm = () => {};
export const z = {};
export type VbenFormSchema = any;
export type VbenFormProps = any;

View File

@ -1,3 +0,0 @@
export type * from '@vben/plugins/vxe-table';
export const useVbenVxeGrid = () => {};

View File

@ -1,30 +0,0 @@
# 社区交流
社区交流群主要是为了方便大家交流,提问,解答问题,分享经验等。偏自助方式,如果你有问题,可以通过以下方式加入社区交流群:
- [QQ频道](https://pd.qq.com/s/16p8lvvob):推荐!!!主要提供问题解答,分享经验等。
- QQ群[大群](https://qm.qq.com/q/MEmHoCLbG0)[1群](https://qm.qq.com/q/YacMHPYAMu)、[2群](https://qm.qq.com/q/ajVKZvFICk)、[3群](https://qm.qq.com/q/36zdwThP2E)[4群](https://qm.qq.com/q/sCzSlm3504)[5群](https://qm.qq.com/q/ya9XrtbS6s),主要的使用者交流群。
- [Discord](https://discord.com/invite/VU62jTecad): 主要提供问题解答,分享经验等。
::: tip
免费QQ群人数上限200将会不定期清理。推荐加入QQ频道进行交流
:::
## 微信群
作者主要通过微信群提供帮助,如果你有问题,可以通过以下方式加入微信群。
通过微信联系作者,注明加群来意:
::: tip
因为微信群人数有限制,加微信群要求:
- 通过[赞助](../sponsor/personal.md)任意金额。
- 发送赞助`截图`,备注`加入微信群`即可。
:::
<img src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/wechat.jpg" style="width: 300px;"/>

View File

@ -1,12 +0,0 @@
# 定制开发
我们提供基于 Vben Admin 的技术支持服务及定制开发,基本需求我们都可以满足。
详细需求可添加作者了解,并注明来意:
- 通过邮箱联系开发者: [ann.vben@gmail.com](mailto:ann.vben@gmail.com)
- 通过微信联系开发者:
<img src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/wechat.jpg" style="width: 300px;"/>
我们会在第一时间回复您,定制费用根据需求而定。

View File

@ -1,8 +0,0 @@
# 技术支持
## 问题反馈
在使用项目的过程中,如果遇到问题,你可以先详细阅读本文档,未找到解决方案时,可以通过以下方式获取技术支持:
- 通过 [GitHub Issues](https://github.com/vbenjs/vue-vben-admin/issues)
- 通过 [GitHub Discussions](https://github.com/vbenjs/vue-vben-admin/discussions)

View File

@ -1,115 +0,0 @@
---
outline: deep
---
# Vben Alert 轻量提示框
`Alert` 提供了一组纯 JavaScript 调用的轻量提示框能力,适合快速创建 `alert``confirm``prompt` 这类简单交互。
::: info 适用场景
`Alert``Modal` 的能力有部分重叠,但更适合临时确认、简单提示和轻量输入场景。复杂弹窗仍然建议使用 `Vben Modal`。:::
::: tip 注意
通过 `alert``confirm``prompt` 动态创建的弹窗,在已经打开的情况下不支持 HMR 热更新。修改相关代码后,需要关闭后重新打开。:::
## 基础用法
使用 `alert` 创建只有确认按钮的提示框。
<DemoPreview dir="demos/vben-alert/alert" />
使用 `confirm` 创建带确认和取消按钮的提示框。
<DemoPreview dir="demos/vben-alert/confirm" />
使用 `prompt` 创建可接收用户输入的提示框。
<DemoPreview dir="demos/vben-alert/prompt" />
## useAlertContext
`content``footer``icon` 使用的是自定义组件时,可以在这些组件内部通过 `useAlertContext()` 获取当前弹窗上下文,并主动触发确认或取消。
::: tip 注意
`useAlertContext` 只能在 `setup` 或函数式组件中使用。:::
### Methods
| 方法 | 描述 | 类型 | 版本要求 |
| --------- | ---------------------- | ------------ | -------- |
| doConfirm | 触发当前弹窗的确认操作 | `() => void` | `>5.5.4` |
| doCancel | 触发当前弹窗的取消操作 | `() => void` | `>5.5.4` |
## 类型说明
```ts
export type IconType = 'error' | 'info' | 'question' | 'success' | 'warning';
export type BeforeCloseScope = {
isConfirm: boolean;
};
export type AlertProps = {
beforeClose?: (
scope: BeforeCloseScope,
) => boolean | Promise<boolean | undefined> | undefined;
bordered?: boolean;
buttonAlign?: 'center' | 'end' | 'start';
cancelText?: string;
centered?: boolean;
confirmText?: string;
containerClass?: string;
content: Component | string;
contentClass?: string;
contentMasking?: boolean;
footer?: Component | string;
icon?: Component | IconType;
overlayBlur?: number;
showCancel?: boolean;
title?: string;
};
export type PromptProps<T = any> = {
beforeClose?: (scope: {
isConfirm: boolean;
value: T | undefined;
}) => boolean | Promise<boolean | undefined> | undefined;
component?: Component;
componentProps?: Recordable<any>;
componentSlots?:
| (() => any)
| Recordable<unknown>
| VNode
| VNodeArrayChildren;
defaultValue?: T;
modelPropName?: string;
} & Omit<AlertProps, 'beforeClose'>;
export function alert(options: AlertProps): Promise<void>;
export function alert(
message: string,
options?: Partial<AlertProps>,
): Promise<void>;
export function alert(
message: string,
title?: string,
options?: Partial<AlertProps>,
): Promise<void>;
export async function prompt<T = any>(
options: Omit<AlertProps, 'beforeClose'> & {
beforeClose?: (
scope: BeforeCloseScope & {
value: T;
},
) => boolean | Promise<boolean | undefined> | undefined;
component?: Component;
componentProps?: Recordable<any>;
defaultValue?: T;
modelPropName?: string;
},
): Promise<T | undefined>;
```

View File

@ -1,173 +0,0 @@
---
outline: deep
---
# Vben ApiComponent Api组件包装器
框架提供的API“包装器”它一般不独立使用主要用于包装其它组件为目标组件提供自动获取远程数据的能力但仍然保持了目标组件的原始用法。
::: info 写在前面
我们在各个应用的组件适配器中使用ApiComponent包装了Select、TreeSelect组件使得这些组件可以自动获取远程数据并生成选项。其它类似的组件比如Cascader如有需要也可以参考示例代码自行进行包装。
:::
## 基础用法
通过 `component` 传入其它组件的定义,并配置相关的其它属性(主要是一些名称映射)。包装组件将通过 `api` 获取数据(`beforeFetch``afterFetch` 将分别在 `api` 运行前、运行后被调用),使用 `resultField` 从中提取数组,使用 `valueField``labelField` 等来从数据中提取 value 和 label如果提供了 `childrenField`,会将其作为树形结构递归处理每一级数据),之后将处理好的数据通过 `optionsPropName` 指定的属性传递给目标组件。
::: details 包装级联选择器,点击下拉时开始加载远程数据
```vue
<script lang="ts" setup>
import { ApiComponent } from '@vben/common-ui';
import { Cascader } from 'ant-design-vue';
const treeData: Record<string, any> = [
{
label: '浙江',
value: 'zhejiang',
children: [
{
value: 'hangzhou',
label: '杭州',
children: [
{
value: 'xihu',
label: '西湖',
},
{
value: 'sudi',
label: '苏堤',
},
],
},
{
value: 'jiaxing',
label: '嘉兴',
children: [
{
value: 'wuzhen',
label: '乌镇',
},
{
value: 'meihuazhou',
label: '梅花洲',
},
],
},
{
value: 'zhoushan',
label: '舟山',
children: [
{
value: 'putuoshan',
label: '普陀山',
},
{
value: 'taohuadao',
label: '桃花岛',
},
],
},
],
},
{
label: '江苏',
value: 'jiangsu',
children: [
{
value: 'nanjing',
label: '南京',
children: [
{
value: 'zhonghuamen',
label: '中华门',
},
{
value: 'zijinshan',
label: '紫金山',
},
{
value: 'yuhuatai',
label: '雨花台',
},
],
},
],
},
];
/**
* 模拟请求接口
*/
function fetchApi(): Promise<Record<string, any>> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(treeData);
}, 1000);
});
}
</script>
<template>
<ApiComponent
:api="fetchApi"
:component="Cascader"
:immediate="false"
children-field="children"
loading-slot="suffixIcon"
visible-event="onDropdownVisibleChange"
/>
</template>
```
:::
## 并发和缓存
有些场景下可能需要使用多个ApiComponent它们使用了相同的远程数据源例如用在可编辑的表格中。如果直接将请求后端接口的函数传递给api属性则每一个实例都会访问一次接口这会造成资源浪费是完全没有必要的。Tanstack Query提供了并发控制、缓存、重试等诸多特性我们可以将接口请求函数用useQuery包装一下再传递给ApiComponent这样的话无论页面有多少个使用相同数据源的ApiComponent实例都只会发起一次远程请求。
## API
### Props
| 属性名 | 描述 | 类型 | 默认值 | 版本要求 |
| --- | --- | --- | --- | --- |
| modelValue(v-model) | 当前值 | `any` | - | - |
| component | 欲包装的组件(以下称为目标组件) | `Component` | - | - |
| numberToString | 是否将value从数字转为string | `boolean` | `false` | - |
| api | 获取数据的函数 | `(arg?: any) => Promise<OptionsItem[] \| Record<string, any>>` | - | - |
| params | 传递给api的参数 | `Record<string, any>` | - | - |
| resultField | 从api返回的结果中提取options数组的字段名 | `string` | - | - |
| labelField | label字段名 | `string` | `label` | - |
| childrenField | 子级数据字段名,需要层级数据的组件可用 | `string` | `` | - |
| valueField | value字段名 | `string` | `value` | - |
| optionsPropName | 目标组件接收options数据的属性名称 | `string` | `options` | - |
| modelPropName | 目标组件的双向绑定属性名默认为modelValue。部分组件可能为value | `string` | `modelValue` | - |
| immediate | 是否立即调用api | `boolean` | `true` | - |
| alwaysLoad | 每次`visibleEvent`事件发生时都重新请求数据 | `boolean` | `false` | - |
| beforeFetch | 在api请求之前的回调函数 | `AnyPromiseFunction<any, any>` | - | - |
| afterFetch | 在api请求之后的回调函数 | `AnyPromiseFunction<any, any>` | - | - |
| options | 直接传入选项数据也作为api返回空数据时的后备数据 | `OptionsItem[]` | - | - |
| visibleEvent | 触发重新请求数据的事件名 | `string` | - | - |
| loadingSlot | 目标组件的插槽名称,用来显示一个"加载中"的图标 | `string` | - | - |
| autoSelect | 自动设置选项 | `'first' \| 'last' \| 'one'\| ((item: OptionsItem[]) => OptionsItem) \| false` | `false` | >5.5.4 |
#### autoSelect 自动设置选项
如果当前值为undefined在选项数据成功加载之后自动从备选项中选择一个作为当前值。默认值为`false`,即不自动选择选项。注意:该属性不应用于多选组件。可选值有:
- `"first"`:自动选择第一个选项
- `"last"`:自动选择最后一个选项
- `"one"`:有且仅有一个选项时,自动选择它
- `自定义函数`自定义选择逻辑函数的参数为options返回值为选择的选项
- `false`:不自动选择选项
### Methods
| 方法 | 描述 | 类型 | 版本要求 |
| --- | --- | --- | --- |
| getComponentRef | 获取被包装的组件的实例 | ()=>T | >5.5.4 |
| updateParam | 设置接口请求参数将与params属性合并 | (newParams: Record<string, any>)=>void | >5.5.4 |
| getOptions | 获取已加载的选项数据 | ()=>OptionsItem[] | >5.5.4 |
| getValue | 获取当前值 | ()=>any | >5.5.4 |

View File

@ -1,57 +0,0 @@
---
outline: deep
---
# Vben CountToAnimator 数字动画
`CountToAnimator` 用于展示数字滚动动画效果。
> 如果文档内没有覆盖到你需要的细节,可以结合在线示例一起查看。
::: info 写在前面
这是一个轻量数字动画组件。如果你需要完全不同的过渡控制方式,也可以直接使用原生动画方案或自行封装。:::
## 基础用法
通过 `start-val``end-val` 设置数字动画的起始值和结束值,配合 `duration` 控制动画时长。
<DemoPreview dir="demos/vben-count-to-animator/basic" />
## 自定义前缀与分隔符
通过 `prefix``suffix``separator``decimal` 可以控制展示格式。
<DemoPreview dir="demos/vben-count-to-animator/custom" />
### Props
| 属性名 | 描述 | 类型 | 默认值 |
| --- | --- | --- | --- |
| startVal | 起始值 | `number` | `0` |
| endVal | 结束值 | `number` | `2021` |
| duration | 动画持续时间 | `number` | `1500` |
| autoplay | 是否自动播放 | `boolean` | `true` |
| prefix | 前缀 | `string` | `''` |
| suffix | 后缀 | `string` | `''` |
| separator | 千分位分隔符 | `string` | `','` |
| decimal | 小数点分隔符 | `string` | `'.'` |
| color | 文本颜色 | `string` | `''` |
| useEasing | 是否启用过渡预设 | `boolean` | `true` |
| transition | 过渡预设名称 | `keyof typeof TransitionPresets` | `'linear'` |
| decimals | 保留小数位数 | `number` | `0` |
### Events
| 事件名 | 描述 | 类型 |
| -------------- | ----------------------------- | ---------------- |
| started | 动画开始时触发 | `() => void` |
| finished | 动画结束时触发 | `() => void` |
| ~~onStarted~~ | ~~已废弃,请改用 `started`~~ | ~~`() => void`~~ |
| ~~onFinished~~ | ~~已废弃,请改用 `finished`~~ | ~~`() => void`~~ |
### Methods
| 方法名 | 描述 | 类型 |
| ------ | -------------------------------- | ------------ |
| reset | 重置为 `startVal` 并重新执行动画 | `() => void` |

View File

@ -1,156 +0,0 @@
---
outline: deep
---
# Vben Drawer 抽屉
框架提供的抽屉组件,支持`自动高度``loading`等功能。
> 如果文档内没有参数说明,可以尝试在在线示例内寻找
::: info 写在前面
如果你觉得现有组件的封装不够理想,或者不完全符合你的需求,大可以直接使用原生组件,亦或亲手封装一个适合的组件。框架提供的组件并非束缚,使用与否,完全取决于你的需求与自由。
:::
::: tip README
下方示例代码中的,存在一些国际化、主题色未适配问题,这些问题只在文档内会出现,实际使用并不会有这些问题,可忽略,不必纠结。
:::
## 基础用法
使用 `useVbenDrawer` 创建最基础的抽屉。
<DemoPreview dir="demos/vben-drawer/basic" />
## 组件抽离
Drawer 内的内容一般业务中,会比较复杂,所以我们可以将 drawer 内的内容抽离出来,也方便复用。通过 `connectedComponent` 参数,可以将内外组件进行连接,而不用其他任何操作。
<DemoPreview dir="demos/vben-drawer/extra" />
## 自动计算高度
弹窗会自动计算内容高度,超过一定高度会出现滚动条,同时结合 `loading` 效果以及使用 `prepend-footer` 插槽。
<DemoPreview dir="demos/vben-drawer/auto-height" />
## 使用 Api
通过 `drawerApi` 可以调用 drawer 的方法以及使用 `setState` 更新 drawer 的状态。
<DemoPreview dir="demos/vben-drawer/dynamic" />
## 数据共享
如果你使用了 `connectedComponent` 参数,那么内外组件会共享数据,比如一些表单回填等操作。可以用 `drawerApi` 来获取数据和设置数据,配合 `onOpenChange`,可以满足大部分的需求。
<DemoPreview dir="demos/vben-drawer/shared-data" />
::: info 注意
- `VbenDrawer` 组件对于参数的处理优先级是 `slot` > `props` > `state`(通过api更新的状态以及useVbenDrawer参数)。如果你已经传入了 `slot` 或者 `props`,那么 `setState` 将不会生效,这种情况下你可以通过 `slot` 或者 `props` 来更新状态。
- 如果你使用到了 `connectedComponent` 参数,那么会存在 2 个`useVbenDrawer`, 此时,如果同时设置了相同的参数,那么以内部为准(也就是没有设置 connectedComponent 的代码)。比如 同时设置了 `onConfirm`,那么以内部的 `onConfirm` 为准。`onOpenChange`事件除外,内外都会触发。
- 使用了`connectedComponent`参数时,可以配置`destroyOnClose`属性来决定当关闭弹窗时,是否要销毁`connectedComponent`组件(重新创建`connectedComponent`组件,这将会把其内部所有的变量、状态、数据等恢复到初始状态。)。
- 如果抽屉的默认行为不符合你的预期,可以在对应应用的 `apps/<app>/src/bootstrap.ts` 中修改 `setDefaultDrawerProps` 的参数来设置默认属性,例如修改默认 `zIndex` 等。
:::
## API
```ts
// Drawer 为弹窗组件
// drawerApi 为弹窗的方法
const [Drawer, drawerApi] = useVbenDrawer({
// 属性
// 事件
});
```
### Props
所有属性都可以传入 `useVbenDrawer` 的第一个参数中。
| 属性名 | 描述 | 类型 | 默认值 |
| --- | --- | --- | --- |
| appendToMain | 是否挂载到内容区域默认挂载到body | `boolean` | `false` |
| connectedComponent | 连接另一个Drawer组件 | `Component` | - |
| destroyOnClose | 关闭时销毁 | `boolean` | `false` |
| title | 标题 | `string\|slot` | - |
| titleTooltip | 标题提示信息 | `string\|slot` | - |
| description | 描述信息 | `string\|slot` | - |
| isOpen | 弹窗打开状态 | `boolean` | `false` |
| loading | 弹窗加载状态 | `boolean` | `false` |
| closable | 显示关闭按钮 | `boolean` | `true` |
| closeIconPlacement | 关闭按钮位置 | `'left'\|'right'` | `right` |
| modal | 显示遮罩 | `boolean` | `true` |
| header | 显示header | `boolean` | `true` |
| footer | 显示footer | `boolean\|slot` | `true` |
| confirmLoading | 确认按钮loading状态 | `boolean` | `false` |
| closeOnClickModal | 点击遮罩关闭弹窗 | `boolean` | `true` |
| closeOnPressEscape | esc 关闭弹窗 | `boolean` | `true` |
| confirmText | 确认按钮文本 | `string\|slot` | `确认` |
| cancelText | 取消按钮文本 | `string\|slot` | `取消` |
| placement | 抽屉弹出位置 | `'left'\|'right'\|'top'\|'bottom'` | `right` |
| showCancelButton | 显示取消按钮 | `boolean` | `true` |
| showConfirmButton | 显示确认按钮 | `boolean` | `true` |
| class | modal的class宽度通过这个配置 | `string` | - |
| contentClass | modal内容区域的class | `string` | - |
| footerClass | modal底部区域的class | `string` | - |
| headerClass | modal顶部区域的class | `string` | - |
| zIndex | 抽屉的ZIndex层级 | `number` | `1000` |
| overlayBlur | 遮罩模糊度 | `number` | - |
::: info appendToMain
`appendToMain`可以指定将抽屉挂载到内容区域打开抽屉时内容区域以外的部分标签栏、导航菜单等等不会被遮挡。默认情况下抽屉会挂载到body上。但是挂载到内容区域时作为页面根容器的`Page`组件,需要设置`auto-content-height`属性,以便抽屉能够正确计算高度。
:::
### Event
以下事件,只有在 `useVbenDrawer({onCancel:()=>{}})` 中传入才会生效。
| 事件名 | 描述 | 类型 | 版本限制 |
| --- | --- | --- | --- |
| onBeforeClose | 关闭前触发,返回 `false` 或 Promise reject 则禁止关闭 | `()=>Promise<boolean \| undefined>\|boolean\|undefined` | >5.5.2 支持 Promise |
| onCancel | 点击取消按钮触发 | `()=>void` | --- |
| onClosed | 关闭动画播放完毕时触发 | `()=>void` | >5.5.2 |
| onConfirm | 点击确认按钮触发 | `()=>void` | --- |
| onOpenChange | 关闭或者打开弹窗时触发 | `(isOpen:boolean)=>void` | --- |
| onOpened | 打开动画播放完毕时触发 | `()=>void` | >5.5.2 |
### Slots
除了上面的属性类型包含`slot`,还可以通过插槽来自定义弹窗的内容。
| 插槽名 | 描述 |
| -------------- | -------------------------------------------------- |
| default | 默认插槽 - 弹窗内容 |
| prepend-footer | 取消按钮左侧 |
| center-footer | 取消按钮和确认按钮中间(不使用 footer 插槽时有效) |
| append-footer | 确认按钮右侧 |
| close-icon | 关闭按钮图标 |
| extra | 额外内容(标题右侧) |
### drawerApi
| 方法 | 描述 | 类型 | 版本限制 |
| --- | --- | --- | --- |
| setState | 动态设置抽屉状态属性 | `(((prev: DrawerState) => Partial<DrawerState>)\| Partial<DrawerState>)=>drawerApi` |
| open | 打开弹窗 | `()=>void` | --- |
| close | 关闭弹窗 | `()=>void` | --- |
| setData | 设置共享数据 | `<T>(data:T)=>drawerApi` | --- |
| getData | 获取共享数据 | `<T>()=>T` | --- |
| useStore | 获取可响应式状态 | - | --- |
| lock | 将抽屉标记为提交中,锁定当前状态 | `(isLock:boolean)=>drawerApi` | >5.5.3 |
| unlock | lock方法的反操作解除抽屉的锁定状态也是lock(false)的别名 | `()=>drawerApi` | >5.5.3 |
::: info lock
`lock`方法用于锁定抽屉的状态一般用于提交数据的过程中防止用户重复提交或者抽屉被意外关闭、表单数据被改变等等。当处于锁定状态时抽屉的确认按钮会变为loading状态同时禁用取消按钮和关闭按钮、禁止ESC或者点击遮罩等方式关闭抽屉、开启抽屉的spinner动画以遮挡弹窗内容。调用`close`方法关闭处于锁定状态的抽屉时,会自动解锁。要主动解除这种状态,可以调用`unlock`方法或者再次调用lock方法并传入false参数。
:::

View File

@ -1,64 +0,0 @@
---
outline: deep
---
# Vben EllipsisText 省略文本
`EllipsisText` 用于展示超长文本支持省略、Tooltip 提示以及点击展开收起。
> 如果文档内没有覆盖到你需要的细节,可以结合在线示例一起查看。
## 基础用法
通过默认插槽提供文本内容,`maxWidth` 用于限制文本区域宽度。
<DemoPreview dir="demos/vben-ellipsis-text/line" />
## 可折叠文本
通过 `line` 设置折叠后的最大行数,通过 `expand` 开启点击展开与收起。
<DemoPreview dir="demos/vben-ellipsis-text/expand" />
## 自定义提示浮层
通过 `tooltip` 插槽自定义提示内容。
<DemoPreview dir="demos/vben-ellipsis-text/tooltip" />
## 仅在省略时显示 Tooltip
通过 `tooltip-when-ellipsis` 控制仅在文本被截断时显示 Tooltip。
<DemoPreview dir="demos/vben-ellipsis-text/auto-display" />
## API
### Props
| 属性名 | 描述 | 类型 | 默认值 |
| --- | --- | --- | --- |
| expand | 是否支持点击展开或收起 | `boolean` | `false` |
| line | 文本最大显示行数 | `number` | `1` |
| maxWidth | 文本区域最大宽度 | `number \| string` | `'100%'` |
| placement | 提示浮层位置 | `'bottom' \| 'left' \| 'right' \| 'top'` | `'top'` |
| tooltip | 是否启用文本提示 | `boolean` | `true` |
| tooltipWhenEllipsis | 是否仅在文本被截断时显示提示 | `boolean` | `false` |
| ellipsisThreshold | 文本截断检测阈值,值越大判定越严格 | `number` | `3` |
| tooltipBackgroundColor | 提示背景色 | `string` | `''` |
| tooltipColor | 提示文字颜色 | `string` | `''` |
| tooltipFontSize | 提示文字大小,单位 `px` | `number` | `14` |
| tooltipMaxWidth | 提示内容最大宽度,单位 `px` | `number` | - |
| tooltipOverlayStyle | 提示内容区域样式 | `CSSProperties` | `{ textAlign: 'justify' }` |
### Events
| 事件名 | 描述 | 类型 |
| ------------ | ------------------ | ----------------------------- |
| expandChange | 展开状态变化时触发 | `(isExpand: boolean) => void` |
### Slots
| 插槽名 | 描述 |
| ------- | ---------------------------------- |
| tooltip | 开启文本提示时,用于自定义提示内容 |

View File

@ -1,568 +0,0 @@
---
outline: deep
---
# Vben Form 表单
框架提供的表单组件,可适配 `Element Plus``Ant Design Vue``Naive UI` 等框架。
> 如果文档内没有参数说明,可以尝试在在线示例内寻找
::: info 写在前面
如果你觉得现有组件的封装不够理想,或者不完全符合你的需求,大可以直接使用原生组件,亦或亲手封装一个适合的组件。框架提供的组件并非束缚,使用与否,完全取决于你的需求与自由。
:::
## 适配器
表单底层使用 [vee-validate](https://vee-validate.logaretm.com/v4/) 进行表单验证,所以你可以使用 `vee-validate` 的所有功能。对于不同的 UI 框架,我们提供了适配器,以便更好的适配不同的 UI 框架。
### 适配器说明
每个应用都有不同的 UI 框架,所以在应用的 `src/adapter/form``src/adapter/component` 内部,你可以根据自己的需求,进行组件适配。下面是 `Ant Design Vue` 的适配器示例代码,可根据注释查看说明:
::: details ant design vue 表单适配器
```ts
import type {
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui';
import type { ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { initComponentAdapter } from './component';
initComponentAdapter();
setupVbenForm<ComponentType>({
config: {
// ant design vue组件库默认都是 v-model:value
baseModelPropName: 'value',
// 一些组件库空值为 null重置表单时需要和实际组件行为保持一致
emptyStateValue: null,
// 一些组件是 v-model:checked 或者 v-model:fileList
modelPropNameMap: {
Checkbox: 'checked',
Radio: 'checked',
Switch: 'checked',
Upload: 'fileList',
},
},
defineRules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
},
// 选择项目必填国际化适配
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
});
const useVbenForm = useForm<ComponentType>;
export { useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };
```
:::
::: details ant design vue 组件适配器
```ts
/**
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
*/
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Component, SetupContext } from 'vue';
import { h } from 'vue';
import { globalShareState } from '@vben/common-ui';
import { $t } from '@vben/locales';
import {
AutoComplete,
Button,
Checkbox,
CheckboxGroup,
DatePicker,
Divider,
Input,
InputNumber,
InputPassword,
Mentions,
notification,
Radio,
RadioGroup,
RangePicker,
Rate,
Select,
Space,
Switch,
Textarea,
TimePicker,
TreeSelect,
Upload,
} from 'ant-design-vue';
const withDefaultPlaceholder = <T extends Component>(
component: T,
type: 'input' | 'select',
) => {
return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
const placeholder = props?.placeholder || $t(`ui.placeholder.${type}`);
return h(component, { ...props, ...attrs, placeholder }, slots);
};
};
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType =
| 'AutoComplete'
| 'Checkbox'
| 'CheckboxGroup'
| 'DatePicker'
| 'DefaultButton'
| 'Divider'
| 'Input'
| 'InputNumber'
| 'InputPassword'
| 'Mentions'
| 'PrimaryButton'
| 'Radio'
| 'RadioGroup'
| 'RangePicker'
| 'Rate'
| 'Select'
| 'Space'
| 'Switch'
| 'Textarea'
| 'TimePicker'
| 'TreeSelect'
| 'Upload'
| BaseFormComponentType;
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载
// Button: () =>
// import('xxx').then((res) => res.Button),
AutoComplete,
Checkbox,
CheckboxGroup,
DatePicker,
// 自定义默认按钮
DefaultButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'default' }, slots);
},
Divider,
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
Mentions: withDefaultPlaceholder(Mentions, 'input'),
// 自定义主要按钮
PrimaryButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'primary' }, slots);
},
Radio,
RadioGroup,
RangePicker,
Rate,
Select: withDefaultPlaceholder(Select, 'select'),
Space,
Switch,
Textarea: withDefaultPlaceholder(Textarea, 'input'),
TimePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload,
};
// 将组件注册到全局共享状态中
globalShareState.setComponents(components);
// 定义全局共享状态中的消息提示
globalShareState.defineMessage({
// 复制成功消息提示
copyPreferencesSuccess: (title, content) => {
notification.success({
description: content,
message: title,
placement: 'bottomRight',
});
},
});
}
export { initComponentAdapter };
```
:::
## 基础用法
::: tip README
下方示例代码中的,存在一些国际化、主题色未适配问题,这些问题只在文档内会出现,实际使用并不会有这些问题,可忽略,不必纠结。
:::
使用 `useVbenForm` 创建最基础的表单。
<DemoPreview dir="demos/vben-form/basic" />
## 查询表单
查询表单是一种特殊的表单,用于查询数据。查询表单不会触发表单验证,只会触发查询事件。
<DemoPreview dir="demos/vben-form/query" />
## 表单校验
表单校验是一个非常重要的功能,可以通过 `rules` 属性进行校验。
<DemoPreview dir="demos/vben-form/rules" />
## 表单联动
表单联动是一个非常常见的功能,可以通过 `dependencies` 属性进行联动。
_注意_ 需要指定 `dependencies``triggerFields` 属性,设置由谁的改动来触发,以便表单组件能够正确的联动。
<DemoPreview dir="demos/vben-form/dynamic" />
## 自定义组件
如果你的业务组件库没有提供某个组件,你可以自行封装一个组件,然后加到表单内部。
<DemoPreview dir="demos/vben-form/custom" />
## 操作
一些常见的表单操作。
<DemoPreview dir="demos/vben-form/api" />
## API
`useVbenForm` 返回一个数组,第一个元素是表单组件,第二个元素是表单的方法。
```vue
<script setup lang="ts">
import { useVbenForm } from '#/adapter/form';
// Form 为弹窗组件
// formApi 为弹窗的方法
const [Form, formApi] = useVbenForm({
// 属性
// 事件
});
</script>
<template>
<Form />
</template>
```
### FormApi
useVbenForm 返回的第二个参数,是一个对象,包含了一些表单的方法。
| 方法名 | 描述 | 类型 | 版本号 |
| --- | --- | --- | --- |
| submitForm | 提交表单 | `(e:Event)=>Promise<Record<string,any>>` | - |
| validateAndSubmitForm | 提交并校验表单 | `(e:Event)=>Promise<Record<string,any>>` | - |
| resetForm | 重置表单 | `()=>Promise<void>` | - |
| setValues | 设置表单值, 默认会过滤不在schema中定义的field, 可通过filterFields形参关闭过滤 | `(fields: Record<string, any>, filterFields?: boolean, shouldValidate?: boolean) => Promise<void>` | - |
| getValues | 获取表单值 | `(fields:Record<string, any>,shouldValidate: boolean = false)=>Promise<void>` | - |
| validate | 表单校验 | `()=>Promise<void>` | - |
| validateField | 校验指定字段 | `(fieldName: string)=>Promise<ValidationResult<unknown>>` | - |
| isFieldValid | 检查某个字段是否已通过校验 | `(fieldName: string)=>Promise<boolean>` | - |
| resetValidate | 重置表单校验 | `()=>Promise<void>` | - |
| updateSchema | 更新formSchema | `(schema:FormSchema[])=>void` | - |
| setFieldValue | 设置字段值 | `(field: string, value: any, shouldValidate?: boolean)=>Promise<void>` | - |
| setState | 设置组件状态props | `(stateOrFn:\| ((prev: VbenFormProps) => Partial<VbenFormProps>)\| Partial<VbenFormProps>)=>Promise<void>` | - |
| getState | 获取组件状态props | `()=>Promise<VbenFormProps>` | - |
| form | 表单对象实例,可以操作表单,见 [useForm](https://vee-validate.logaretm.com/v4/api/use-form/) | - | - |
| getFieldComponentRef | 获取指定字段的组件实例 | `<T=unknown>(fieldName: string)=>T` | >5.5.3 |
| getFocusedField | 获取当前已获得焦点的字段 | `()=>string\|undefined` | >5.5.3 |
## Props
所有属性都可以传入 `useVbenForm` 的第一个参数中。
| 属性名 | 描述 | 类型 | 默认值 |
| --- | --- | --- | --- |
| layout | 表单项布局 | `'horizontal' \| 'vertical'\| 'inline'` | `horizontal` |
| showCollapseButton | 是否显示折叠按钮 | `boolean` | `false` |
| wrapperClass | 表单的布局基于tailwindcss | `any` | - |
| actionWrapperClass | 表单操作区域class | `any` | - |
| actionLayout | 表单操作按钮位置 | `'newLine' \| 'rowEnd' \| 'inline'` | `rowEnd` |
| actionPosition | 表单操作按钮对齐方式 | `'left' \| 'center' \| 'right'` | `right` |
| handleReset | 表单重置回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - |
| handleSubmit | 表单提交回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - |
| handleValuesChange | 表单值变化回调 | `(values: Record<string, any>, fieldsChanged: string[]) => void` | - |
| handleCollapsedChange | 表单收起展开状态变化回调 | `(collapsed: boolean) => void` | - |
| actionButtonsReverse | 调换操作按钮位置 | `boolean` | `false` |
| resetButtonOptions | 重置按钮组件参数 | `ActionButtonOptions` | - |
| submitButtonOptions | 提交按钮组件参数 | `ActionButtonOptions` | - |
| showDefaultActions | 是否显示默认操作按钮 | `boolean` | `true` |
| collapsed | 是否折叠,在`showCollapseButton``true`时生效 | `boolean` | `false` |
| collapseTriggerResize | 折叠时,触发`resize`事件 | `boolean` | `false` |
| collapsedRows | 折叠时保持的行数 | `number` | `1` |
| fieldMappingTime | 用于将表单内的数组值映射成 2 个字段 | `[string, [string, string],Nullable<string>\|[string,string]\|((any,string)=>any)?][]` | - |
| commonConfig | 表单项的通用配置,每个配置都会传递到每个表单项,表单项可覆盖 | `FormCommonConfig` | - |
| schema | 表单项的每一项配置 | `FormSchema[]` | - |
| submitOnEnter | 按下回车健时提交表单 | `boolean` | false |
| submitOnChange | 字段值改变时提交表单(内部防抖,这个属性一般用于表格的搜索表单) | `boolean` | false |
| compact | 是否紧凑模式(忽略为校验信息所预留的空间) | `boolean` | false |
| scrollToFirstError | 表单验证失败时是否自动滚动到第一个错误字段 | `boolean` | false |
::: tip handleValuesChange
`handleValuesChange` 回调函数的第一个参数`values`装载了表单改变后的当前值对象,第二个参数`fieldsChanged`是一个数组包含了所有被改变的字段名。注意第二个参数仅在v5.5.4(不含)以上版本可用并且传递的是已在schema中定义的字段名。如果你使用了字段映射并且需要检查是哪些字段发生了变化的话请注意该参数并不会包含映射后的字段名。
:::
::: tip fieldMappingTime
此属性用于将表单内的数组值映射成 2 个字段它应当传入一个数组数组的每一项是一个映射规则规则的第一个成员是一个字符串表示需要映射的字段名第二个成员是一个数组表示映射后的字段名第三个成员是一个可选的格式掩码用于格式化日期时间字段也可以提供一个格式化函数参数分别为当前值和当前字段名返回格式化后的值。如果明确地将格式掩码设为null则原值映射而不进行格式化适用于非日期时间字段。例如`[['timeRange', ['startTime', 'endTime'], 'YYYY-MM-DD']]``timeRange`应当是一个至少具有2个成员的数组类型的值。Form会将`timeRange`的值前两个值分别按照格式掩码`YYYY-MM-DD`格式化后映射到`startTime``endTime`字段上。每一项的第三个参数是一个可选的格式掩码,
:::
### TS 类型说明
::: details ActionButtonOptions
```ts
export interface ActionButtonOptions {
/** 样式 */
class?: ClassType;
/** 是否禁用 */
disabled?: boolean;
/** 是否加载中 */
loading?: boolean;
/** 按钮大小 */
size?: ButtonVariantSize;
/** 按钮类型 */
variant?: ButtonVariants;
/** 是否显示 */
show?: boolean;
/** 按钮文本 */
content?: string;
/** 任意属性 */
[key: string]: any;
}
```
:::
::: details FormCommonConfig
```ts
export interface FormCommonConfig {
/**
* 所有表单项的props
*/
componentProps?: ComponentProps;
/**
* 所有表单项的控件样式
*/
controlClass?: string;
/**
* 在表单项的Label后显示一个冒号
*/
colon?: boolean;
/**
* 所有表单项的禁用状态
* @default false
*/
disabled?: boolean;
/**
* 所有表单项的控件样式
* @default {}
*/
formFieldProps?: Partial<typeof Field>;
/**
* 所有表单项的栅格布局
* @default ""
*/
formItemClass?: (() => string) | string;
/**
* 隐藏所有表单项label
* @default false
*/
hideLabel?: boolean;
/**
* 是否隐藏必填标记
* @default false
*/
hideRequiredMark?: boolean;
/**
* 所有表单项的label样式
* @default ""
*/
labelClass?: string;
/**
* 所有表单项的label宽度
*/
labelWidth?: number;
/**
* 所有表单项的model属性名。使用自定义组件时可通过此配置指定组件的model属性名。已经在modelPropNameMap中注册的组件不受此配置影响
* @default "modelValue"
*/
modelPropName?: string;
/**
* 所有表单项的wrapper样式
*/
wrapperClass?: string;
}
```
:::
::: details FormSchema
```ts
export interface FormSchema<
T extends BaseFormComponentType = BaseFormComponentType,
> extends FormCommonConfig {
/** 组件 */
component: Component | T;
/** 组件参数 */
componentProps?: ComponentProps;
/** 默认值 */
defaultValue?: any;
/** 依赖 */
dependencies?: FormItemDependencies;
/** 描述 */
description?: string;
/** 字段名,也作为自定义插槽的名称 */
fieldName: string;
/** 帮助信息 */
help?: CustomRenderType;
/** 是否隐藏表单项 */
hide?: boolean;
/** 表单的标签如果是一个string会用于默认必选规则的消息提示 */
label?: CustomRenderType;
/** 自定义组件内部渲染 */
renderComponentContent?: RenderComponentContentType;
/** 字段规则 */
rules?: FormSchemaRuleType;
/** 后缀 */
suffix?: CustomRenderType;
}
```
:::
### 表单联动
表单联动需要通过 schema 内的 `dependencies` 属性进行联动,允许您添加字段之间的依赖项,以根据其他字段的值控制字段。
```ts
dependencies: {
// 触发字段。只有这些字段值变动时,联动才会触发
triggerFields: ['name'],
// 动态判断当前字段是否需要显示,不显示则直接销毁
if(values,formApi){},
// 动态判断当前字段是否需要显示不显示用css隐藏
show(values,formApi){},
// 动态判断当前字段是否需要禁用
disabled(values,formApi){},
// 字段变更时,都会触发该函数
trigger(values,formApi){},
// 动态rules
rules(values,formApi){},
// 动态必填
required(values,formApi){},
// 动态组件参数
componentProps(values,formApi){},
}
```
### 表单校验
表单校验需要通过 schema 内的 `rules` 属性进行配置。
rules的值可以是字符串预定义的校验规则名称也可以是一个zod的schema。
#### 预定义的校验规则
```ts
// 表示字段必填默认会根据适配器的required进行国际化
{
rules: 'required';
}
// 表示字段必填默认会根据适配器的required进行国际化用于下拉选择之类
{
rules: 'selectRequired';
}
```
#### zod
rules也支持 zod 的 schema可以进行更复杂的校验zod 的使用请查看 [zod文档](https://zod.dev/)。
```ts
import { z } from '#/adapter/form';
// 基础类型
{
rules: z.string().min(1, { message: '请输入字符串' });
}
// 可选(可以是undefined)并且携带默认值。注意zod的optional不包括空字符串''
{
rules: z.string().default('默认值').optional();
}
// 可以是空字符串、undefined或者一个邮箱地址(两种不同的用法)
{
rules: z.union([z.string().email().optional(), z.literal('')]);
}
{
rules: z.string().email().or(z.literal('')).optional();
}
// 复杂校验
{
z.string()
.min(1, { message: '请输入' })
.refine((value) => value === '123', {
message: '值必须为123',
});
}
```
## Slots
可以使用以下插槽在表单中插入自定义的内容
| 插槽名 | 描述 |
| ------------- | ------------------ |
| reset-before | 重置按钮之前的位置 |
| submit-before | 提交按钮之前的位置 |
| expand-before | 展开按钮之前的位置 |
| expand-after | 展开按钮之后的位置 |
::: tip 字段插槽
除了以上内置插槽之外,`schema`属性中每个字段的`fieldName`都可以作为插槽名称,这些字段插槽的优先级高于`component`定义的组件。也就是说,当提供了与`fieldName`同名的插槽时,这些插槽的内容将会作为这些字段的组件,此时`component`的值将会被忽略。
:::

View File

@ -1,174 +0,0 @@
---
outline: deep
---
# Vben Modal 模态框
框架提供的模态框组件,支持`拖拽``全屏``自动高度``loading`等功能。
> 如果文档内没有参数说明,可以尝试在在线示例内寻找
::: info 写在前面
如果你觉得现有组件的封装不够理想,或者不完全符合你的需求,大可以直接使用原生组件,亦或亲手封装一个适合的组件。框架提供的组件并非束缚,使用与否,完全取决于你的需求与自由。
:::
::: tip README
下方示例代码中的,存在一些国际化、主题色未适配问题,这些问题只在文档内会出现,实际使用并不会有这些问题,可忽略,不必纠结。
:::
## 基础用法
使用 `useVbenModal` 创建最基础的模态框。
<DemoPreview dir="demos/vben-modal/basic" />
## 组件抽离
Modal 内的内容一般业务中,会比较复杂,所以我们可以将 modal 内的内容抽离出来,也方便复用。通过 `connectedComponent` 参数,可以将内外组件进行连接,而不用其他任何操作。
<DemoPreview dir="demos/vben-modal/extra" />
## 开启拖拽
通过 `draggable` 参数,可开启拖拽功能。
<DemoPreview dir="demos/vben-modal/draggable" />
## 自动计算高度
弹窗会自动计算内容高度,超过一定高度会出现滚动条,同时结合 `loading` 效果以及使用 `prepend-footer` 插槽。
<DemoPreview dir="demos/vben-modal/auto-height" />
## 使用 Api
通过 `modalApi` 可以调用 modal 的方法以及使用 `setState` 更新 modal 的状态。
<DemoPreview dir="demos/vben-modal/dynamic" />
## 数据共享
如果你使用了 `connectedComponent` 参数,那么内外组件会共享数据,比如一些表单回填等操作。可以用 `modalApi` 来获取数据和设置数据,配合 `onOpenChange`,可以满足大部分的需求。
<DemoPreview dir="demos/vben-modal/shared-data" />
## 动画类型
通过 `animationType` 属性可以控制弹窗的动画效果:
- `slide`(默认):从顶部向下滑动进入/退出
- `scale`:缩放淡入/淡出效果
<DemoPreview dir="demos/vben-modal/animation-type" />
::: info 注意
- `VbenModal` 组件对与参数的处理优先级是 `slot` > `props` > `state`(通过api更新的状态以及useVbenModal参数)。如果你已经传入了 `slot` 或者 `props`,那么 `setState` 将不会生效,这种情况下你可以通过 `slot` 或者 `props` 来更新状态。
- 如果你使用到了 `connectedComponent` 参数,那么会存在 2 个`useVbenModal`, 此时,如果同时设置了相同的参数,那么以内部为准(也就是没有设置 connectedComponent 的代码)。比如 同时设置了 `onConfirm`,那么以内部的 `onConfirm` 为准。`onOpenChange`事件除外,内外都会触发。另外,如果设置了`destroyOnClose`内部Modal及其子组件会在被关闭后<b>完全销毁</b>
- 如果弹窗的默认行为不符合你的预期,可以在对应应用的 `apps/<app>/src/bootstrap.ts` 中修改 `setDefaultModalProps` 的参数来设置默认属性,例如默认隐藏全屏按钮、修改默认 `zIndex` 等。
:::
## API
```ts
// Modal 为弹窗组件
// modalApi 为弹窗的方法
const [Modal, modalApi] = useVbenModal({
// 属性
// 事件
});
```
### Props
所有属性都可以传入 `useVbenModal` 的第一个参数中。
| 属性名 | 描述 | 类型 | 默认值 |
| --- | --- | --- | --- |
| appendToMain | 是否挂载到内容区域默认挂载到body | `boolean` | `false` |
| connectedComponent | 连接另一个Modal组件 | `Component` | - |
| destroyOnClose | 关闭时销毁 | `boolean` | `false` |
| title | 标题 | `string\|slot` | - |
| titleTooltip | 标题提示信息 | `string\|slot` | - |
| description | 描述信息 | `string\|slot` | - |
| isOpen | 弹窗打开状态 | `boolean` | `false` |
| loading | 弹窗加载状态 | `boolean` | `false` |
| fullscreen | 全屏显示 | `boolean` | `false` |
| fullscreenButton | 显示全屏按钮 | `boolean` | `true` |
| draggable | 可拖拽 | `boolean` | `false` |
| closable | 显示关闭按钮 | `boolean` | `true` |
| centered | 居中显示 | `boolean` | `false` |
| modal | 显示遮罩 | `boolean` | `true` |
| header | 显示header | `boolean` | `true` |
| footer | 显示footer | `boolean\|slot` | `true` |
| confirmDisabled | 禁用确认按钮 | `boolean` | `false` |
| confirmLoading | 确认按钮loading状态 | `boolean` | `false` |
| closeOnClickModal | 点击遮罩关闭弹窗 | `boolean` | `true` |
| closeOnPressEscape | esc 关闭弹窗 | `boolean` | `true` |
| confirmText | 确认按钮文本 | `string\|slot` | `确认` |
| cancelText | 取消按钮文本 | `string\|slot` | `取消` |
| showCancelButton | 显示取消按钮 | `boolean` | `true` |
| showConfirmButton | 显示确认按钮 | `boolean` | `true` |
| class | modal的class宽度通过这个配置 | `string` | - |
| contentClass | modal内容区域的class | `string` | - |
| footerClass | modal底部区域的class | `string` | - |
| headerClass | modal顶部区域的class | `string` | - |
| bordered | 是否显示border | `boolean` | `false` |
| zIndex | 弹窗的ZIndex层级 | `number` | `1000` |
| overlayBlur | 遮罩模糊度 | `number` | - |
| animationType | 动画类型 | `'slide' \| 'scale'` | `'slide'` |
| submitting | 标记为提交中,锁定弹窗当前状态 | `boolean` | `false` |
::: info appendToMain
`appendToMain`可以指定将弹窗挂载到内容区域打开这种弹窗时内容区域以外的部分标签栏、导航菜单等等不会被遮挡。默认情况下弹窗会挂载到body上。但是挂载到内容区域时作为页面根容器的`Page`组件,需要设置`auto-content-height`属性,以便弹窗能够正确计算高度。
:::
### Event
以下事件,只有在 `useVbenModal({onCancel:()=>{}})` 中传入才会生效。
| 事件名 | 描述 | 类型 | 版本号 |
| --- | --- | --- | --- |
| onBeforeClose | 关闭前触发,返回 `false`或者被`reject`则禁止关闭 | `()=>Promise<boolean>\|boolean` | >5.5.2支持Promise |
| onCancel | 点击取消按钮触发 | `()=>void` | |
| onClosed | 关闭动画播放完毕时触发 | `()=>void` | >5.4.3 |
| onConfirm | 点击确认按钮触发 | `()=>void` | |
| onOpenChange | 关闭或者打开弹窗时触发 | `(isOpen:boolean)=>void` | |
| onOpened | 打开动画播放完毕时触发 | `()=>void` | >5.4.3 |
### Slots
除了上面的属性类型包含`slot`,还可以通过插槽来自定义弹窗的内容。
| 插槽名 | 描述 |
| -------------- | -------------------------------------------------- |
| default | 默认插槽 - 弹窗内容 |
| prepend-footer | 取消按钮左侧 |
| center-footer | 取消按钮和确认按钮中间(不使用 footer 插槽时有效) |
| append-footer | 确认按钮右侧 |
### modalApi
| 方法 | 描述 | 类型 | 版本 |
| --- | --- | --- | --- |
| setState | 动态设置弹窗状态属性 | `(((prev: ModalState) => Partial<ModalState>)\| Partial<ModalState>)=>modalApi` | - |
| open | 打开弹窗 | `()=>void` | - |
| close | 关闭弹窗 | `()=>void` | - |
| setData | 设置共享数据 | `<T>(data:T)=>modalApi` | - |
| getData | 获取共享数据 | `<T>()=>T` | - |
| useStore | 获取可响应式状态 | - | - |
| lock | 将弹窗标记为提交中,锁定当前状态 | `(isLock:boolean)=>modalApi` | >5.5.2 |
| unlock | lock方法的反操作解除弹窗的锁定状态也是lock(false)的别名 | `()=>modalApi` | >5.5.3 |
::: info lock
`lock`方法用于锁定当前弹窗的状态一般用于提交数据的过程中防止用户重复提交或者弹窗被意外关闭、表单数据被改变等等。当处于锁定状态时弹窗的确认按钮会变为loading状态同时禁用取消按钮和关闭按钮、禁止ESC或者点击遮罩等方式关闭弹窗、开启弹窗的spinner动画以遮挡弹窗内容。调用`close`方法关闭处于锁定状态的弹窗时,会自动解锁。要主动解除这种状态,可以调用`unlock`方法或者再次调用lock方法并传入false参数。
:::

View File

@ -1,249 +0,0 @@
---
outline: deep
---
# Vben Vxe Table 表格
`Vben Vxe Table` 基于 [vxe-table](https://vxetable.cn/v4/#/grid/api?apiKey=grid) 和 `Vben Form` 做了二次封装,用于构建带搜索表单的列表页面。
> 如果文档内没有覆盖到你需要的细节,可以结合在线示例和 [vxe-grid 官方 API](https://vxetable.cn/v4/#/grid/api?apiKey=grid) 一起查看。
::: info 写在前面
如果现有封装不满足你的场景,可以直接使用原生 `vxe-table` 能力,或者在适配层中继续扩展。:::
## 适配器
底层表格基于 `vxe-table`,每个应用都可以在自己的适配层中配置默认行为、自定义渲染器以及与 UI 组件库的集成。
### 适配器示例
::: details vxe-table 表格适配器
```ts
import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { Button, Image } from 'ant-design-vue';
import { useVbenForm } from './form';
setupVbenVxeTable({
configVxeTable: (vxeUI) => {
vxeUI.setConfig({
grid: {
align: 'center',
border: false,
columnConfig: {
resizable: true,
},
minHeight: 180,
formConfig: {
enabled: false,
},
proxyConfig: {
autoLoad: true,
response: {
result: 'items',
total: 'total',
list: 'items',
},
showActiveMsg: true,
showResponseMsg: false,
},
round: true,
showOverflow: true,
size: 'small',
},
});
vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) {
const { column, row } = params;
return h(Image, { src: row[column.field] });
},
});
vxeUI.renderer.add('CellLink', {
renderTableDefault(renderOpts) {
const { props } = renderOpts;
return h(
Button,
{ size: 'small', type: 'link' },
{ default: () => props?.text },
);
},
});
},
useVbenForm,
});
export { useVbenVxeGrid };
export type * from '@vben/plugins/vxe-table';
```
:::
## 基础表格
通过 `useVbenVxeGrid` 创建一个基础表格。
<DemoPreview dir="demos/vben-vxe-table/basic" />
## 远程加载
通过配置 `proxyConfig.ajax.query` 实现远程数据加载。
<DemoPreview dir="demos/vben-vxe-table/remote" />
## 树形表格
树形表格的数据源通常是扁平结构,可以通过 `treeConfig` 转换为树形展示。
```ts
treeConfig: {
transform: true,
parentField: 'parentId',
rowField: 'id',
},
```
<DemoPreview dir="demos/vben-vxe-table/tree" />
## 固定列
固定列可选值为 `'left' | 'right' | '' | null`
<DemoPreview dir="demos/vben-vxe-table/fixed" />
## 自定义单元格
可以通过插槽或自定义渲染器实现单元格定制。
```ts
vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) {
const { column, row } = params;
return h(Image, { src: row[column.field] } as any);
},
});
vxeUI.renderer.add('CellLink', {
renderTableDefault(renderOpts) {
const { props } = renderOpts;
return h(
Button,
{ size: 'small', type: 'link' },
{ default: () => props?.text },
);
},
});
```
<DemoPreview dir="demos/vben-vxe-table/custom-cell" />
## 搜索表单
搜索区域底层使用的是 `Vben Form`。启用搜索表单后,可以通过 `gridOptions.toolbarConfig.search = true` 在工具栏中显示搜索面板开关按钮。
所有以 `form-` 开头的具名插槽都会自动转发到搜索表单。
### 自定义分隔条
启用搜索表单时,表单和表格主体之间默认会显示一个分隔条。可以通过 `separator` 调整或关闭它。
```ts
const [Grid] = useVbenVxeGrid({
formOptions: {},
gridOptions: {},
separator: false,
// separator: { show: false },
// separator: { backgroundColor: 'rgba(100,100,0,0.5)' },
});
```
<DemoPreview dir="demos/vben-vxe-table/form" />
## 单元格编辑
通过设置 `editConfig.mode = 'cell'` 开启单元格编辑。
<DemoPreview dir="demos/vben-vxe-table/edit-cell" />
## 行编辑
通过设置 `editConfig.mode = 'row'` 开启整行编辑。
<DemoPreview dir="demos/vben-vxe-table/edit-row" />
## 虚拟滚动
通过 `scroll-y.enabled``scroll-y.gt` 组合开启纵向虚拟滚动。
> 参考 [vxe-table 官方文档 - 虚拟滚动](https://vxetable.cn/v4/#/component/grid/scroll/vertical)
<DemoPreview dir="demos/vben-vxe-table/virtual" />
## API
`useVbenVxeGrid` 返回一个数组,第一个元素是表格组件,第二个元素是表格 API。
```vue
<script setup lang="ts">
import { useVbenVxeGrid } from '#/adapter/vxe-table';
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {},
formOptions: {},
gridEvents: {},
});
</script>
<template>
<Grid />
</template>
```
### GridApi
| 方法名 | 描述 | 类型 | 说明 |
| --- | --- | --- | --- |
| setLoading | 设置 loading 状态 | `(loading: boolean) => void` | - |
| setGridOptions | 更新 `gridOptions` | `(options: Partial<VxeGridProps['gridOptions']>) => void` | - |
| reload | 重新加载表格,并重置到初始分页 | `(params?: Record<string, any>) => void` | - |
| query | 重新查询表格,保留当前分页 | `(params?: Record<string, any>) => void` | - |
| grid | `vxe-grid` 实例 | `VxeGridInstance` | - |
| formApi | 搜索表单 API 实例 | `FormApi` | - |
| toggleSearchForm | 切换或指定搜索表单显示状态 | `(show?: boolean) => boolean` | 传入参数时强制设置;不传参数时在显示和隐藏之间切换,并返回当前状态 |
## Props
所有属性都通过 `useVbenVxeGrid` 的第一个参数传入。
| 属性名 | 描述 | 类型 | 版本要求 |
| --- | --- | --- | --- |
| tableTitle | 表格标题 | `string` | - |
| tableTitleHelp | 表格标题帮助信息 | `string` | - |
| class | 外层容器的 class | `string` | - |
| gridClass | `vxe-grid` 的 class | `string` | - |
| gridOptions | `vxe-grid` 配置 | `DeepPartial<VxeTableGridOptions>` | - |
| gridEvents | `vxe-grid` 事件 | `DeepPartial<VxeGridListeners>` | - |
| formOptions | 搜索表单配置 | `VbenFormProps` | - |
| showSearchForm | 是否显示搜索表单 | `boolean` | - |
| separator | 搜索表单与表格主体之间的分隔条 | `boolean \| SeparatorOptions` | `>5.5.4` |
## Slots
大部分插槽说明可参考 [vxe-table 官方文档](https://vxetable.cn/v4/#/grid/api),这里列出封装层新增或约定的部分。
| 插槽名 | 描述 |
| --------------- | ------------------------------------ |
| toolbar-actions | 工具栏左侧区域,位于标题附近 |
| toolbar-tools | 工具栏右侧区域,位于内置工具按钮左侧 |
| table-title | 自定义表格标题 |
::: info 搜索表单插槽
当启用了搜索表单时,所有以 `form-` 开头的具名插槽都会被转发给表单。:::

View File

@ -1,15 +0,0 @@
# 介绍
::: info README
该文档介绍的是框架组件的使用方法、属性、事件等。如果你觉得现有组件的封装不够理想,或者不完全符合你的需求,大可以直接使用原生组件,亦或亲手封装一个适合的组件。框架提供的组件并非束缚,使用与否,完全取决于你的需求与自由。
:::
## 布局组件
布局组件一般在页面内容区域用作顶层容器组件,提供一些统一的布局样式和基本功能。
## 通用组件
通用组件是一些常用的组件,比如弹窗、抽屉、表单等。大部分基于 `Tailwind CSS` 实现,可适用于不同 UI 组件库的应用。

View File

@ -1,41 +0,0 @@
---
outline: deep
---
# Page 常规页面组件
`Page` 是页面内容区最常用的顶层布局容器,内置了标题区、内容区和底部区三部分结构。
::: info 写在前面
这是一个基础页面容器。如果你的业务页面需要更复杂的布局,例如双列、侧边操作区或自定义滚动区域,建议在 `Page` 之上继续封装。:::
## 基础用法
直接将 `Page` 作为业务页面的根组件使用即可。
### Props
| 属性名 | 描述 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| title | 页面标题 | `string \| slot` | - | - |
| description | 页面描述 | `string \| slot` | - | - |
| contentClass | 内容区域的 class | `string` | - | - |
| headerClass | 头部区域的 class | `string` | - | - |
| footerClass | 底部区域的 class | `string` | - | - |
| autoContentHeight | 根据可视内容高度自动计算内容区高度 | `boolean` | `false` | 开启后内容区会根据布局可视高度自动扣减头部和底部高度 |
| heightOffset | 额外扣减的内容区高度偏移量 | `number` | `0` | 仅在 `autoContentHeight` 开启时生效,单位为 `px` |
::: tip 注意
如果 `title``description``extra` 三者都没有提供有效内容,无论是通过 `props` 还是 `slots`,头部区域都不会渲染。:::
### Slots
| 插槽名称 | 描述 |
| ----------- | ---------------- |
| default | 页面内容 |
| title | 页面标题 |
| description | 页面描述 |
| extra | 页面头部右侧内容 |
| footer | 页面底部内容 |

View File

@ -1,36 +0,0 @@
<script lang="ts" setup>
import { h } from 'vue';
import { alert, VbenButton } from '@vben/common-ui';
import { Result } from 'ant-design-vue';
function showAlert() {
alert('This is an alert message');
}
function showIconAlert() {
alert({
content: 'This is an alert message with icon',
icon: 'success',
});
}
function showCustomAlert() {
alert({
buttonAlign: 'center',
content: h(Result, {
status: 'success',
subTitle: '已成功创建订单。订单ID2017182818828182881',
title: '操作成功',
}),
});
}
</script>
<template>
<div class="flex gap-4">
<VbenButton @click="showAlert">Alert</VbenButton>
<VbenButton @click="showIconAlert">Alert With Icon</VbenButton>
<VbenButton @click="showCustomAlert">Alert With Custom Content</VbenButton>
</div>
</template>

View File

@ -1,75 +0,0 @@
<script lang="ts" setup>
import { h, ref } from 'vue';
import { alert, confirm, VbenButton } from '@vben/common-ui';
import { Checkbox, message } from 'ant-design-vue';
function showConfirm() {
confirm('This is an alert message')
.then(() => {
alert('Confirmed');
})
.catch(() => {
alert('Canceled');
});
}
function showIconConfirm() {
confirm({
content: 'This is an alert message with icon',
icon: 'success',
});
}
function showfooterConfirm() {
const checked = ref(false);
confirm({
cancelText: '不要虾扯蛋',
confirmText: '是的我们都是NPC',
content:
'刚才发生的事情,为什么我似乎早就经历过一般?\n我甚至能在事情发生过程中潜意识里预知到接下来会发生什么。\n\n听起来挺玄乎的你有过这种感觉吗',
footer: () =>
h(
Checkbox,
{
checked: checked.value,
class: 'flex-1',
'onUpdate:checked': (v) => (checked.value = v),
},
'不再提示',
),
icon: 'question',
title: '未解之谜',
}).then(() => {
if (checked.value) {
message.success('我不会再拿这个问题烦你了');
} else {
message.info('下次还要继续问你哟');
}
});
}
function showAsyncConfirm() {
confirm({
beforeClose({ isConfirm }) {
if (isConfirm) {
// false
return new Promise((resolve) => setTimeout(resolve, 2000));
}
},
content: 'This is an alert message with async confirm',
icon: 'success',
}).then(() => {
alert('Confirmed');
});
}
</script>
<template>
<div class="flex gap-4">
<VbenButton @click="showConfirm">Confirm</VbenButton>
<VbenButton @click="showIconConfirm">Confirm With Icon</VbenButton>
<VbenButton @click="showfooterConfirm">Confirm With Footer</VbenButton>
<VbenButton @click="showAsyncConfirm">Async Confirm</VbenButton>
</div>
</template>

View File

@ -1,118 +0,0 @@
<script lang="ts" setup>
import { h } from 'vue';
import { alert, prompt, useAlertContext, VbenButton } from '@vben/common-ui';
import { Input, RadioGroup, Select } from 'ant-design-vue';
import { BadgeJapaneseYen } from 'lucide-vue-next';
function showPrompt() {
prompt({
content: '请输入一些东西',
})
.then((val) => {
alert(`已收到你的输入:${val}`);
})
.catch(() => {
alert('Canceled');
});
}
function showSlotsPrompt() {
prompt({
component: () => {
// setup
const { doConfirm } = useAlertContext();
return h(
Input,
{
onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
//
doConfirm();
}
},
placeholder: '请输入',
prefix: '充值金额:',
type: 'number',
},
{
addonAfter: () => h(BadgeJapaneseYen),
},
);
},
content:
'此弹窗演示了如何使用自定义插槽并且可以使用useAlertContext获取到弹窗的上下文。\n在输入框中按下回车键会触发确认操作。',
icon: 'question',
modelPropName: 'value',
}).then((val) => {
if (val) alert(`你输入的是${val}`);
});
}
function showSelectPrompt() {
prompt({
component: Select,
componentProps: {
options: [
{ label: 'Option A', value: 'Option A' },
{ label: 'Option B', value: 'Option B' },
{ label: 'Option C', value: 'Option C' },
],
placeholder: '请选择',
// bodypointer-eventsnone
popupClassName: 'pointer-events-auto',
},
content: '此弹窗演示了如何使用component传递自定义组件',
icon: 'question',
modelPropName: 'value',
}).then((val) => {
if (val) {
alert(`你选择了${val}`);
}
});
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function showAsyncPrompt() {
prompt({
async beforeClose(scope) {
if (scope.isConfirm) {
if (scope.value) {
// false
await sleep(2000);
} else {
alert('请选择一个选项');
return false;
}
}
},
component: RadioGroup,
componentProps: {
class: 'flex flex-col',
options: [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
],
},
content: '选择一个选项后再点击[确认]',
icon: 'question',
modelPropName: 'value',
}).then((val) => {
alert(`${val} 已设置。`);
});
}
</script>
<template>
<div class="flex gap-4">
<VbenButton @click="showPrompt">Prompt</VbenButton>
<VbenButton @click="showSlotsPrompt"> Prompt With slots </VbenButton>
<VbenButton @click="showSelectPrompt">Prompt With Select</VbenButton>
<VbenButton @click="showAsyncPrompt">Prompt With Async</VbenButton>
</div>
</template>

View File

@ -1,100 +0,0 @@
<script lang="ts" setup>
import { ApiComponent } from '@vben/common-ui';
import { Cascader } from 'ant-design-vue';
const treeData: Record<string, any> = [
{
label: '浙江',
value: 'zhejiang',
children: [
{
value: 'hangzhou',
label: '杭州',
children: [
{
value: 'xihu',
label: '西湖',
},
{
value: 'sudi',
label: '苏堤',
},
],
},
{
value: 'jiaxing',
label: '嘉兴',
children: [
{
value: 'wuzhen',
label: '乌镇',
},
{
value: 'meihuazhou',
label: '梅花洲',
},
],
},
{
value: 'zhoushan',
label: '舟山',
children: [
{
value: 'putuoshan',
label: '普陀山',
},
{
value: 'taohuadao',
label: '桃花岛',
},
],
},
],
},
{
label: '江苏',
value: 'jiangsu',
children: [
{
value: 'nanjing',
label: '南京',
children: [
{
value: 'zhonghuamen',
label: '中华门',
},
{
value: 'zijinshan',
label: '紫金山',
},
{
value: 'yuhuatai',
label: '雨花台',
},
],
},
],
},
];
/**
* 模拟请求接口
*/
function fetchApi(): Promise<Record<string, any>> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(treeData);
}, 1000);
});
}
</script>
<template>
<ApiComponent
:api="fetchApi"
:component="Cascader"
:immediate="false"
children-field="children"
loading-slot="suffixIcon"
visible-event="onDropdownVisibleChange"
/>
</template>

View File

@ -1,6 +0,0 @@
<script lang="ts" setup>
import { VbenCountToAnimator } from '@vben/common-ui';
</script>
<template>
<VbenCountToAnimator :duration="3000" :end-val="30000" :start-val="1" />
</template>

View File

@ -1,12 +0,0 @@
<script lang="ts" setup>
import { VbenCountToAnimator } from '@vben/common-ui';
</script>
<template>
<VbenCountToAnimator
:duration="3000"
:end-val="2000000"
:start-val="1"
prefix="$"
separator="/"
/>
</template>

View File

@ -1,45 +0,0 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenDrawer, VbenButton } from '@vben/common-ui';
const list = ref<number[]>([]);
const [Drawer, drawerApi] = useVbenDrawer({
onCancel() {
drawerApi.close();
},
onConfirm() {
console.log('onConfirm');
},
onOpenChange(isOpen) {
if (isOpen) {
handleUpdate(10);
}
},
});
function handleUpdate(len: number) {
drawerApi.setState({ loading: true });
setTimeout(() => {
list.value = Array.from({ length: len }, (_v, k) => k + 1);
drawerApi.setState({ loading: false });
}, 2000);
}
</script>
<template>
<Drawer title="自动计算高度">
<div
v-for="item in list"
:key="item"
class="flex-center h-55 w-full bg-muted even:bg-heavy"
>
{{ item }}
</div>
<template #prepend-footer>
<VbenButton type="link" @click="handleUpdate(6)">
点击更新数据
</VbenButton>
</template>
</Drawer>
</template>

View File

@ -1,21 +0,0 @@
<script lang="ts" setup>
import { useVbenDrawer, VbenButton } from '@vben/common-ui';
import ExtraDrawer from './drawer.vue';
const [Drawer, drawerApi] = useVbenDrawer({
//
connectedComponent: ExtraDrawer,
});
function open() {
drawerApi.open();
}
</script>
<template>
<div>
<Drawer />
<VbenButton @click="open">Open</VbenButton>
</div>
</template>

View File

@ -1,11 +0,0 @@
<script lang="ts" setup>
import { useVbenDrawer, VbenButton } from '@vben/common-ui';
const [Drawer, drawerApi] = useVbenDrawer();
</script>
<template>
<div>
<VbenButton @click="() => drawerApi.open()">Open</VbenButton>
<Drawer class="w-150" title="基础示例"> drawer content </Drawer>
</div>
</template>

View File

@ -1,26 +0,0 @@
<script lang="ts" setup>
import { useVbenDrawer, VbenButton } from '@vben/common-ui';
const [Drawer, drawerApi] = useVbenDrawer({
onCancel() {
drawerApi.close();
},
onConfirm() {
console.info('onConfirm');
},
title: '动态修改配置示例',
});
function handleUpdateTitle() {
drawerApi.setState({ title: '内部动态标题' });
}
</script>
<template>
<Drawer>
<div class="flex-col-center">
<VbenButton class="mb-3" type="primary" @click="handleUpdateTitle()">
内部动态修改标题
</VbenButton>
</div>
</Drawer>
</template>

View File

@ -1,29 +0,0 @@
<script lang="ts" setup>
import { useVbenDrawer, VbenButton } from '@vben/common-ui';
import ExtraDrawer from './drawer.vue';
const [Drawer, drawerApi] = useVbenDrawer({
//
connectedComponent: ExtraDrawer,
});
function open() {
drawerApi.open();
}
function handleUpdateTitle() {
drawerApi.setState({ title: '外部动态标题' }).open();
}
</script>
<template>
<div>
<Drawer />
<VbenButton @click="open">Open</VbenButton>
<VbenButton class="ml-2" type="primary" @click="handleUpdateTitle">
从外部修改标题并打开
</VbenButton>
</div>
</template>

View File

@ -1,8 +0,0 @@
<script lang="ts" setup>
import { useVbenDrawer } from '@vben/common-ui';
const [Drawer] = useVbenDrawer();
</script>
<template>
<Drawer title="组件抽离示例"> extra drawer content </Drawer>
</template>

View File

@ -1,21 +0,0 @@
<script lang="ts" setup>
import { useVbenDrawer, VbenButton } from '@vben/common-ui';
import ExtraDrawer from './drawer.vue';
const [Drawer, drawerApi] = useVbenDrawer({
//
connectedComponent: ExtraDrawer,
});
function open() {
drawerApi.open();
}
</script>
<template>
<div>
<Drawer />
<VbenButton @click="open">Open</VbenButton>
</div>
</template>

View File

@ -1,26 +0,0 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
const data = ref();
const [Drawer, drawerApi] = useVbenDrawer({
onCancel() {
drawerApi.close();
},
onConfirm() {
console.info('onConfirm');
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
data.value = drawerApi.getData<Record<string, any>>();
}
},
});
</script>
<template>
<Drawer title="数据共享示例">
<div class="flex-col-center">外部传递数据 {{ data }}</div>
</Drawer>
</template>

View File

@ -1,27 +0,0 @@
<script lang="ts" setup>
import { useVbenDrawer, VbenButton } from '@vben/common-ui';
import ExtraDrawer from './drawer.vue';
const [Drawer, drawerApi] = useVbenDrawer({
//
connectedComponent: ExtraDrawer,
});
function open() {
drawerApi
.setData({
content: '外部传递的数据 content',
payload: '外部传递的数据 payload',
})
.open();
}
</script>
<template>
<div>
<Drawer />
<VbenButton @click="open">Open</VbenButton>
</div>
</template>

View File

@ -1,16 +0,0 @@
<script lang="ts" setup>
import { EllipsisText } from '@vben/common-ui';
const text = `
Vben Admin 是一个基于 Vue3.0Vite TypeScript 的后台解决方案目标是为开发中大型项目提供开箱即用的解决方案
`;
</script>
<template>
<EllipsisText :line="2" :tooltip-when-ellipsis="true">
{{ text }}
</EllipsisText>
<EllipsisText :line="3" :tooltip-when-ellipsis="true">
{{ text }}
</EllipsisText>
</template>

View File

@ -1,10 +0,0 @@
<script lang="ts" setup>
import { EllipsisText } from '@vben/common-ui';
const text = `
Vben Admin 是一个基于 Vue3.0Vite TypeScript 的后台解决方案目标是为开发中大型项目提供开箱即用的解决方案包括二次封装组件utilshooks动态菜单权限校验多主题配置按钮级别权限控制等功能项目会使用前端较新的技术栈可以作为项目的启动模版以帮助你快速搭建企业级中后台产品原型也可以作为一个示例用于学习 vue3vitets 等主流技术该项目会持续跟进最新技术并将其应用在项目中Vben Admin 是一个基于 Vue3.0Vite TypeScript 的后台解决方案目标是为开发中大型项目提供开箱即用的解决方案包括二次封装组件utilshooks动态菜单权限校验多主题配置按钮级别权限控制等功能项目会使用前端较新的技术栈可以作为项目的启动模版以帮助你快速搭建企业级中后台产品原型也可以作为一个示例用于学习 vue3vitets 等主流技术该项目会持续跟进最新技术并将其应用在项目中Vben Admin 是一个基于 Vue3.0Vite TypeScript 的后台解决方案目标是为开发中大型项目提供开箱即用的解决方案包括二次封装组件utilshooks动态菜单权限校验多主题配置按钮级别权限控制等功能项目会使用前端较新的技术栈可以作为项目的启动模版以帮助你快速搭建企业级中后台产品原型也可以作为一个示例用于学习 vue3vitets 等主流技术该项目会持续跟进最新技术并将其应用在项目中Vben Admin 是一个基于 Vue3.0Vite TypeScript 的后台解决方案目标是为开发中大型项目提供开箱即用的解决方案包括二次封装组件utilshooks动态菜单权限校验多主题配置按钮级别权限控制等功能项目会使用前端较新的技术栈可以作为项目的启动模版以帮助你快速搭建企业级中后台产品原型也可以作为一个示例用于学习 vue3vitets 等主流技术该项目会持续跟进最新技术并将其应用在项目中
`;
</script>
<template>
<EllipsisText :line="3" expand>{{ text }}</EllipsisText>
</template>

Some files were not shown because too many files have changed in this diff Show More