Compare commits
9 Commits
1b835ba52d
...
cf291546de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf291546de | ||
|
|
2dcd19a3ff | ||
|
|
15fbfa95de | ||
|
|
9ad05f8aef | ||
|
|
02cbe30117 | ||
|
|
0788ddfb8a | ||
|
|
3dcf7bbd3e | ||
|
|
5ddd79d116 | ||
|
|
fa5663bdbf |
26
README.md
26
README.md
@ -1,6 +1,6 @@
|
||||
# Frontend
|
||||
|
||||
`Frontend/` 是 AppPlatform 的前端工作区,采用 `pnpm workspace + Turbo` 的 monorepo 组织方式。当前目录主要承载主业务应用、文档站、一组共享包和工程脚本。
|
||||
`Frontend/` 是 AppPlatform 的前端工作区,采用 `pnpm workspace + Turbo` 的 monorepo 组织方式。当前目录主要承载主业务应用、一组共享包和工程脚本。
|
||||
|
||||
项目整体基于 Vue 3 技术栈,并延续 `Vue Vben Admin v5` 的分层方式做业务化扩展。
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
- Vue Router
|
||||
- Turbo
|
||||
- pnpm workspace
|
||||
- VitePress
|
||||
|
||||
- Ant Design Vue / antdv-next
|
||||
|
||||
## 2. 工作区结构
|
||||
@ -21,7 +21,6 @@
|
||||
```text
|
||||
Frontend/
|
||||
├── apps/ 主业务应用(数据平台)
|
||||
├── docs/ VitePress 文档站
|
||||
├── packages/ 业务与框架共享包
|
||||
├── internal/ 内部工程配置与构建基础设施
|
||||
├── scripts/ CLI 与部署脚本
|
||||
@ -54,15 +53,9 @@ Frontend/
|
||||
- `adapter`:表单、组件等适配层
|
||||
- `locales`:国际化资源
|
||||
|
||||
### 3.2 docs
|
||||
|
||||
文档站应用,包名为 `@vben/docs`,基于 VitePress。
|
||||
|
||||
- 配置入口在 `docs/.vitepress/config/index.mts`
|
||||
- 同时提供中文与英文站点
|
||||
- 首页内容位于 `docs/src/index.md` 和 `docs/src/en/index.md`
|
||||
|
||||
### 3.3 packages
|
||||
### 3.2 packages
|
||||
|
||||
共享包分为几层:
|
||||
|
||||
@ -80,7 +73,7 @@ Frontend/
|
||||
- `packages/constants|icons|locales|preferences|stores|styles|types|utils`
|
||||
- 面向应用的共享常量、样式、类型、工具与状态能力
|
||||
|
||||
### 3.4 internal
|
||||
### 3.3 internal
|
||||
|
||||
内部工程基础设施,不直接承载业务页面:
|
||||
|
||||
@ -90,7 +83,7 @@ Frontend/
|
||||
- `internal/tailwind-config`:Tailwind 配置
|
||||
- `internal/node-utils`:Node 侧工具能力
|
||||
|
||||
### 3.5 scripts
|
||||
### 3.4 scripts
|
||||
|
||||
工程脚本目录:
|
||||
|
||||
@ -127,9 +120,6 @@ pnpm dev
|
||||
|
||||
# 启动主业务应用
|
||||
pnpm dev:antdv-next
|
||||
|
||||
# 启动文档站
|
||||
pnpm dev:docs
|
||||
```
|
||||
|
||||
### 4.3 构建命令
|
||||
@ -141,9 +131,6 @@ pnpm build
|
||||
# 构建主应用
|
||||
pnpm build:antdv-next
|
||||
|
||||
# 构建文档站
|
||||
pnpm build:docs
|
||||
|
||||
# 本地构建 Docker 镜像
|
||||
pnpm build:docker
|
||||
```
|
||||
@ -200,8 +187,7 @@ pnpm test:unit
|
||||
4. `apps/package.json`
|
||||
5. `apps/src/main.ts`
|
||||
6. `apps/src/bootstrap.ts`
|
||||
7. `docs/.vitepress/config/index.mts`
|
||||
8. `internal/vite-config/src/config/application.ts`
|
||||
7. `internal/vite-config/src/config/application.ts`
|
||||
|
||||
## 8. 补充说明
|
||||
|
||||
|
||||
@ -14,19 +14,6 @@
|
||||
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
|
||||
<title><%= VITE_APP_TITLE %></title>
|
||||
<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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@ -23,14 +23,14 @@ async function initSetupVbenForm() {
|
||||
},
|
||||
},
|
||||
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]);
|
||||
|
||||
@ -145,12 +145,6 @@ export async function approveReportedNotPass(data: Record<string, any>[]) {
|
||||
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(
|
||||
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>) {
|
||||
return requestClient.get<LegacyPageResult>('/approval/history/page', {
|
||||
params,
|
||||
|
||||
@ -37,6 +37,7 @@ export interface CountryDashboardMetric {
|
||||
periodKey: string;
|
||||
periodName: string;
|
||||
salaryExchange: number | string;
|
||||
salaryTransfer: number | string;
|
||||
totalRecharge: number | string;
|
||||
}
|
||||
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
@ -78,23 +78,6 @@ export async function offPropsActivityRewardGroup(
|
||||
return requestClient.get(`/props/activity/reward/group/off/shelf/${id}/${offShelf}`);
|
||||
}
|
||||
|
||||
export async function pagePropsActivityRuleConfig(params: Record<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() {
|
||||
return requestClient.get<Array<Record<string, any>>>(
|
||||
'/props/activity/reward/group/get/activity/list',
|
||||
@ -102,11 +85,11 @@ export async function getGroupActivityList() {
|
||||
}
|
||||
|
||||
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>) {
|
||||
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>) {
|
||||
@ -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>) {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
} else {
|
||||
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-Version'] = 'v2';
|
||||
return config;
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useAntdDesignTokens } from '@vben/hooks';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
import { App, ConfigProvider, theme } from 'antdv-next';
|
||||
|
||||
@ -10,13 +10,10 @@ import { antdLocale } from '#/locales';
|
||||
|
||||
defineOptions({ name: 'App' });
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
const { tokens } = useAntdDesignTokens();
|
||||
|
||||
const tokenTheme = computed(() => {
|
||||
const algorithm = isDark.value
|
||||
? [theme.darkAlgorithm]
|
||||
: [theme.defaultAlgorithm];
|
||||
const algorithm = [theme.defaultAlgorithm];
|
||||
|
||||
// antd 紧凑模式算法
|
||||
if (preferences.app.compact) {
|
||||
|
||||
BIN
apps/src/assets/auth/yumi-logo.png
Normal file
BIN
apps/src/assets/auth/yumi-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
@ -9,14 +9,14 @@ import '@vben/styles/antdv-next';
|
||||
|
||||
import { useTitle } from '@vueuse/core';
|
||||
|
||||
import { $t, setupI18n } from '#/locales';
|
||||
import SysOriginSelect from '#/components/sys-origin-select.vue';
|
||||
import { $t, setupLocale } from '#/locales';
|
||||
|
||||
import { initComponentAdapter } from './adapter/component';
|
||||
import { initSetupVbenForm } from './adapter/form';
|
||||
import App from './app.vue';
|
||||
import { setupRuntimeLocaleSync } from './locales/runtime';
|
||||
import { router } from './router';
|
||||
|
||||
import './styles/table-filters.css';
|
||||
|
||||
async function bootstrap(namespace: string) {
|
||||
@ -44,8 +44,8 @@ async function bootstrap(namespace: string) {
|
||||
spinning: 'spinning',
|
||||
});
|
||||
|
||||
// 国际化 i18n 配置
|
||||
await setupI18n(app);
|
||||
// 固定中文基础文案
|
||||
await setupLocale(app);
|
||||
|
||||
// 配置 pinia-tore
|
||||
await initStores(app, { namespace });
|
||||
@ -75,7 +75,6 @@ async function bootstrap(namespace: string) {
|
||||
});
|
||||
|
||||
app.mount('#app');
|
||||
setupRuntimeLocaleSync();
|
||||
}
|
||||
|
||||
export { bootstrap };
|
||||
|
||||
@ -1,25 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<AuthPageLayout
|
||||
:app-name="appName"
|
||||
:logo="logo"
|
||||
:logo-dark="logoDark"
|
||||
:page-description="$t('authentication.pageDesc')"
|
||||
:page-title="$t('authentication.pageTitle')"
|
||||
>
|
||||
<!-- 自定义工具栏 -->
|
||||
<!-- <template #toolbar></template> -->
|
||||
</AuthPageLayout>
|
||||
:copyright="false"
|
||||
:toolbar="false"
|
||||
minimal
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -1,17 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import type { NotificationItem } from '@vben/layouts';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
|
||||
import { useWatermark } from '@vben/hooks';
|
||||
import {
|
||||
BasicLayout,
|
||||
LockScreen,
|
||||
Notification,
|
||||
UserDropdown,
|
||||
} from '@vben/layouts';
|
||||
import { BasicLayout, LockScreen, UserDropdown } from '@vben/layouts';
|
||||
import { preferences } from '@vben/preferences';
|
||||
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';
|
||||
|
||||
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 userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const accessStore = useAccessStore();
|
||||
const { destroyWatermark, updateWatermark } = useWatermark();
|
||||
const showDot = computed(() =>
|
||||
notifications.value.some((item) => !item.isRead),
|
||||
);
|
||||
|
||||
const menus = computed(() => [
|
||||
{
|
||||
@ -100,25 +37,6 @@ const avatar = computed(() => {
|
||||
async function handleLogout() {
|
||||
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(
|
||||
() => ({
|
||||
enable: preferences.app.watermark,
|
||||
@ -156,16 +74,6 @@ watch(
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
</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>
|
||||
<AuthenticationLoginExpiredModal
|
||||
v-model:open="accessStore.loginExpired"
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
# locale
|
||||
|
||||
每个app使用的国际化可能不同,这里用于扩展国际化的功能,例如扩展 dayjs、antd组件库的多语言切换,以及app本身的国际化文件。
|
||||
后台固定使用中文,这里只保留 dayjs、antd 组件库和应用基础文案的中文配置。
|
||||
|
||||
@ -2,117 +2,71 @@ import type { Locale } from 'antdv-next/dist/locale/index';
|
||||
|
||||
import type { App } from 'vue';
|
||||
|
||||
import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
$t,
|
||||
setupI18n as coreSetup,
|
||||
loadLocalesMapFromDir,
|
||||
setupLocale as coreSetup,
|
||||
mergeLocaleMessages,
|
||||
} 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 dayjs from 'dayjs';
|
||||
|
||||
const antdLocale = ref<Locale>(antdDefaultLocale);
|
||||
|
||||
const modules = import.meta.glob('./langs/**/*.json');
|
||||
const runtimeModules = import.meta.glob('./langs/*/runtime.json');
|
||||
const modules = import.meta.glob('./langs/zh-CN/*.json', { eager: true });
|
||||
|
||||
const localesMap = loadLocalesMapFromDir(
|
||||
/\.\/langs\/([^/]+)\/(.*)\.json$/,
|
||||
modules,
|
||||
);
|
||||
/**
|
||||
* 加载应用特有的语言包
|
||||
* 这里也可以改造为从服务端获取翻译数据
|
||||
* @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,
|
||||
};
|
||||
function normalizeModule(module: unknown) {
|
||||
return module && typeof module === 'object' && 'default' in module
|
||||
? (module as { default: Record<string, any> }).default
|
||||
: module;
|
||||
}
|
||||
|
||||
async function loadRuntimeMessages(lang: SupportedLanguagesType) {
|
||||
const runtimeLoader = runtimeModules[`./langs/${lang}/runtime.json`];
|
||||
const runtimeMessages = runtimeLoader
|
||||
? ((await runtimeLoader()) as { default?: Record<string, string> })
|
||||
: undefined;
|
||||
return runtimeMessages?.default;
|
||||
function loadAppMessages() {
|
||||
const messages: Record<string, any> = {};
|
||||
for (const [path, module] of Object.entries(modules)) {
|
||||
const fileName = path.match(/\.\/langs\/zh-CN\/(.*)\.json$/)?.[1];
|
||||
if (!fileName) {
|
||||
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) {
|
||||
await Promise.all([loadAntdLocale(lang), loadDayjsLocale(lang)]);
|
||||
async function loadThirdPartyMessage() {
|
||||
await Promise.all([loadAntdLocale(), loadDayjsLocale()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载dayjs的语言包
|
||||
* @param lang
|
||||
*/
|
||||
async function loadDayjsLocale(lang: SupportedLanguagesType) {
|
||||
let locale;
|
||||
switch (lang) {
|
||||
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}`);
|
||||
}
|
||||
async function loadDayjsLocale() {
|
||||
await import('dayjs/locale/zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载antd的语言包
|
||||
* @param lang
|
||||
*/
|
||||
async function loadAntdLocale(lang: SupportedLanguagesType) {
|
||||
switch (lang) {
|
||||
case 'en-US': {
|
||||
antdLocale.value = antdEnLocale;
|
||||
break;
|
||||
}
|
||||
case 'zh-CN': {
|
||||
antdLocale.value = antdDefaultLocale;
|
||||
break;
|
||||
}
|
||||
}
|
||||
async function loadAntdLocale() {
|
||||
antdLocale.value = antdDefaultLocale;
|
||||
}
|
||||
|
||||
async function setupI18n(app: App, options: LocaleSetupOptions = {}) {
|
||||
await coreSetup(app, {
|
||||
defaultLocale: preferences.app.locale,
|
||||
loadMessages,
|
||||
missingWarn: !import.meta.env.PROD,
|
||||
...options,
|
||||
});
|
||||
async function setupLocale(app: App) {
|
||||
await coreSetup(app);
|
||||
await loadThirdPartyMessage();
|
||||
}
|
||||
|
||||
export { $t, antdLocale, setupI18n };
|
||||
export { $t, antdLocale, setupLocale };
|
||||
|
||||
@ -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
@ -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",
|
||||
"继续发送每条动态支付多少金币": "继续发送每条动态支付多少金币",
|
||||
"加入": "加入",
|
||||
"加入成员金币": "加入成员金币",
|
||||
"加入房间": "加入房间",
|
||||
@ -1199,7 +1187,6 @@
|
||||
"美元": "美元",
|
||||
"美元汇率": "美元汇率",
|
||||
"魅力等级": "魅力等级",
|
||||
"魅力等级多少级才能发布动态": "魅力等级多少级才能发布动态",
|
||||
"门票": "门票",
|
||||
"密码": "密码",
|
||||
"密码不能少于4位": "密码不能少于4位",
|
||||
@ -1701,7 +1688,6 @@
|
||||
"确认审核选中记录吗": "确认审核选中记录吗",
|
||||
"确认选择": "确认选择",
|
||||
"热门": "热门",
|
||||
"热门动态权重计分设置": "热门动态权重计分设置",
|
||||
"热门房间": "热门房间",
|
||||
"人": "人",
|
||||
"人身攻击": "人身攻击",
|
||||
@ -2348,10 +2334,7 @@
|
||||
"用户:数据分配图": "用户:数据分配图",
|
||||
"用户1V1": "用户1V1",
|
||||
"用户充值抽奖活动奖励": "用户充值抽奖活动奖励",
|
||||
"用户当天发送动态数量达到限制后": "用户当天发送动态数量达到限制后",
|
||||
"用户当天发送动态数量达到限制后,继续发送每条动态支付多少金币": "用户当天发送动态数量达到限制后,继续发送每条动态支付多少金币",
|
||||
"用户道具流水": "用户道具流水",
|
||||
"用户动态": "用户动态",
|
||||
"用户房间区域变动": "用户房间区域变动",
|
||||
"用户个性签名审批": "用户个性签名审批",
|
||||
"用户观看广告配置": "用户观看广告配置",
|
||||
@ -2487,7 +2470,6 @@
|
||||
"暂无备注": "暂无备注",
|
||||
"暂无查看权限": "暂无查看权限",
|
||||
"暂无道具配置": "暂无道具配置",
|
||||
"暂无动态": "暂无动态",
|
||||
"暂无购买记录": "暂无购买记录",
|
||||
"暂无可复制内容": "暂无可复制内容",
|
||||
"暂无可选资源": "暂无可选资源",
|
||||
|
||||
@ -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 };
|
||||
@ -1,8 +1,9 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import type {
|
||||
ComponentRecordType,
|
||||
GenerateMenuAndRoutesOptions,
|
||||
} from '@vben/types';
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { generateAccessible } from '@vben/access';
|
||||
import { preferences } from '@vben/preferences';
|
||||
@ -24,8 +25,6 @@ const ROUTE_MENU_OVERRIDES: Record<
|
||||
}
|
||||
> = {
|
||||
BdLead: { aliases: ['BdLead'], routers: ['team/bd-lead'] },
|
||||
DynamicTagList: { aliases: ['DynamicTag'], routers: ['dynamic/tag'] },
|
||||
DynamicUserList: { aliases: ['UserDynamicList'], routers: ['user/dynamic/list'] },
|
||||
OperateBusinessDevelopment: {
|
||||
aliases: ['BusinessDevelopment'],
|
||||
routers: ['room/business-development'],
|
||||
@ -63,16 +62,16 @@ const ROUTE_MENU_OVERRIDES: Record<
|
||||
};
|
||||
|
||||
const LOCAL_RESIDENT_ACTIVITY_FALLBACK_ROUTES = new Set([
|
||||
'ResidentWheelConfig',
|
||||
'ResidentSmashGoldenEggConfig',
|
||||
'ResidentVoiceRoomRocket',
|
||||
'ResidentWheelConfig',
|
||||
]);
|
||||
|
||||
const LOCAL_APP_SYSTEM_FALLBACK_ROUTES = new Set([
|
||||
'AppSystemAgoraErrorLog',
|
||||
]);
|
||||
|
||||
function normalizeRoutePath(path?: string | null) {
|
||||
function normalizeRoutePath(path?: null | string) {
|
||||
return String(path || '')
|
||||
.trim()
|
||||
.replace(/^\/+/, '')
|
||||
@ -95,9 +94,9 @@ function collectRoutePaths(route: RouteRecordRaw) {
|
||||
const values = [normalizeRoutePath(route.path)];
|
||||
const aliases = Array.isArray(route.alias)
|
||||
? route.alias
|
||||
: route.alias
|
||||
: (route.alias
|
||||
? [route.alias]
|
||||
: [];
|
||||
: []);
|
||||
values.push(...aliases.map((item) => normalizeRoutePath(String(item))));
|
||||
const override = ROUTE_MENU_OVERRIDES[String(route.name || '')];
|
||||
if (override?.routers) {
|
||||
|
||||
@ -11,7 +11,6 @@ const AuthPageLayout = () => import('#/layouts/auth.vue');
|
||||
const fallbackNotFoundRoute: RouteRecordRaw = {
|
||||
component: () => import('#/views/_core/fallback/not-found.vue'),
|
||||
meta: {
|
||||
hideInBreadcrumb: true,
|
||||
hideInMenu: true,
|
||||
hideInTab: true,
|
||||
title: '404',
|
||||
@ -30,7 +29,6 @@ const coreRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
component: BasicLayout,
|
||||
meta: {
|
||||
hideInBreadcrumb: true,
|
||||
title: 'Root',
|
||||
},
|
||||
name: 'Root',
|
||||
|
||||
@ -8,7 +8,6 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'LegacyCommonRoot',
|
||||
component: BasicLayout,
|
||||
meta: {
|
||||
hideInBreadcrumb: true,
|
||||
hideInMenu: true,
|
||||
hideInTab: true,
|
||||
title: 'Common',
|
||||
@ -20,7 +19,6 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'LegacyUserDetails',
|
||||
component: () => import('#/views/legacy/common/user-details.vue'),
|
||||
meta: {
|
||||
hideInBreadcrumb: true,
|
||||
hideInMenu: true,
|
||||
keepAlive: true,
|
||||
title: '用户详情',
|
||||
|
||||
@ -82,18 +82,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('#/views/approval/index.vue'),
|
||||
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',
|
||||
path: 'user/bank-card/approval',
|
||||
|
||||
@ -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;
|
||||
@ -8,7 +8,6 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'LegacyLogin',
|
||||
redirect: LOGIN_PATH,
|
||||
meta: {
|
||||
hideInBreadcrumb: true,
|
||||
hideInMenu: true,
|
||||
hideInTab: true,
|
||||
ignoreAccess: true,
|
||||
@ -20,7 +19,6 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'LegacyForbidden',
|
||||
component: () => import('#/views/_core/fallback/forbidden.vue'),
|
||||
meta: {
|
||||
hideInBreadcrumb: true,
|
||||
hideInMenu: true,
|
||||
hideInTab: true,
|
||||
ignoreAccess: true,
|
||||
@ -32,7 +30,6 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'LegacyNotFound',
|
||||
component: () => import('#/views/_core/fallback/not-found.vue'),
|
||||
meta: {
|
||||
hideInBreadcrumb: true,
|
||||
hideInMenu: true,
|
||||
hideInTab: true,
|
||||
ignoreAccess: true,
|
||||
|
||||
@ -28,12 +28,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('#/views/props/source-group.vue'),
|
||||
meta: { title: '道具资源组配置' },
|
||||
},
|
||||
{
|
||||
name: 'PropsActivityRuleConfig',
|
||||
path: 'props-activity-rule-config',
|
||||
component: () => import('#/views/props/activity-rule-config.vue'),
|
||||
meta: { title: '活动道具规则配置' },
|
||||
},
|
||||
{
|
||||
name: 'PropsStore',
|
||||
path: 'props_store',
|
||||
|
||||
@ -5,6 +5,7 @@ import { computed } from 'vue';
|
||||
|
||||
import { AuthenticationLogin, z } from '@vben/common-ui';
|
||||
|
||||
import yumiLogo from '#/assets/auth/yumi-logo.png';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
defineOptions({ name: 'Login' });
|
||||
@ -16,6 +17,9 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
autocomplete: 'username',
|
||||
autofocus: true,
|
||||
class: 'h-11',
|
||||
placeholder: '请输入账号',
|
||||
},
|
||||
fieldName: 'username',
|
||||
@ -25,6 +29,8 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
autocomplete: 'current-password',
|
||||
class: 'h-11',
|
||||
placeholder: '请输入密码',
|
||||
},
|
||||
fieldName: 'password',
|
||||
@ -42,11 +48,23 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
:show-code-login="false"
|
||||
:show-forget-password="false"
|
||||
:show-qrcode-login="false"
|
||||
:show-remember-me="false"
|
||||
:show-register="false"
|
||||
:show-third-party-login="false"
|
||||
sub-title="用于处理审核、运营、配置和数据管理任务。"
|
||||
submit-button-text="进入系统"
|
||||
title="内部运营后台"
|
||||
submit-button-text="登录"
|
||||
@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>
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import {
|
||||
approveData,
|
||||
approveDynamicContentNotPass,
|
||||
approveDynamicContentPass,
|
||||
approvePhotoWallNotPass,
|
||||
approvePhotoWallPass,
|
||||
approveReportedNotPass,
|
||||
@ -10,8 +8,6 @@ import {
|
||||
approveUserBankCardNotPass,
|
||||
approveUserBankCardPass,
|
||||
batchProcessFeedback,
|
||||
getDynamicContentPage,
|
||||
getDynamicReportPage,
|
||||
getFamilyApprovalPage,
|
||||
getFeedbackPage,
|
||||
getPhotoWallApprovalPage,
|
||||
@ -23,7 +19,6 @@ import {
|
||||
getUserProfileDescApprovalPage,
|
||||
getViolationHistoryPage,
|
||||
markFamilyNotPass,
|
||||
processDynamicReport,
|
||||
} from '#/api/legacy/approval';
|
||||
|
||||
type ApprovalActionType = 'danger' | 'primary';
|
||||
@ -181,12 +176,6 @@ const REPORT_STATUS_OPTIONS: ApprovalFilterOption[] = [
|
||||
{ label: '无效', value: 2 },
|
||||
];
|
||||
|
||||
const DYNAMIC_REPORT_STATUS_OPTIONS: ApprovalFilterOption[] = [
|
||||
{ label: '待审核', value: 0 },
|
||||
{ label: '违规', value: 1 },
|
||||
{ label: '正常', value: 2 },
|
||||
];
|
||||
|
||||
const REPORT_TYPE_OPTIONS: ApprovalFilterOption[] = [
|
||||
{ label: '非法信息', value: 0 },
|
||||
{ label: '人身攻击', value: 1 },
|
||||
@ -230,7 +219,7 @@ function splitCsv(value: any) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(Boolean).map((item) => String(item));
|
||||
return value.filter(Boolean).map(String);
|
||||
}
|
||||
return String(value)
|
||||
.split(',')
|
||||
@ -248,7 +237,7 @@ function buildApprovalDataAction(
|
||||
await approveData({
|
||||
approvalStatus,
|
||||
approvalType,
|
||||
waitApprovalUser: records.map(mapRecord),
|
||||
waitApprovalUser: records.map((record) => mapRecord(record)),
|
||||
});
|
||||
},
|
||||
label: approvalStatus === 'PASS' ? '鉴定通过' : '鉴定违规',
|
||||
@ -262,7 +251,7 @@ function familyNotPassAction(
|
||||
): ApprovalBulkAction {
|
||||
return {
|
||||
execute: async (records) => {
|
||||
const approvalRecords = records.map(mapRecord);
|
||||
const approvalRecords = records.map((record) => mapRecord(record));
|
||||
await approveData({
|
||||
approvalStatus: 'NOT_PASS',
|
||||
approvalType,
|
||||
@ -867,9 +856,9 @@ const approvalPages: Record<string, ApprovalPage> = {
|
||||
`年龄:${record.userProfile?.age || '-'}`,
|
||||
`创建时间:${record.createTime || '-'}`,
|
||||
`修改时间:${record.updateTime || '-'}`,
|
||||
...(query.themeStatus !== 'PENDING'
|
||||
? [`审核人:${record.optUserNickname || '-'}`]
|
||||
: []),
|
||||
...(query.themeStatus === 'PENDING'
|
||||
? []
|
||||
: [`审核人:${record.optUserNickname || '-'}`]),
|
||||
],
|
||||
title: record.userProfile?.userNickname || '-',
|
||||
titleUserId: record.userProfile?.id,
|
||||
@ -1183,10 +1172,10 @@ const approvalPages: Record<string, ApprovalPage> = {
|
||||
record.violationType === 'PHOTO_WALL' ||
|
||||
record.violationType === 'ROOM_AVATAR'
|
||||
? 'images'
|
||||
: record.violationType === 'LIVE' ||
|
||||
: (record.violationType === 'LIVE' ||
|
||||
record.violationType === 'SHORT_VIDEO'
|
||||
? 'videos'
|
||||
: 'text';
|
||||
: 'text');
|
||||
return {
|
||||
content:
|
||||
contentType === 'text' ? record.content || '-' : splitCsv(record.content),
|
||||
@ -1471,238 +1460,6 @@ const approvalPages: Record<string, ApprovalPage> = {
|
||||
},
|
||||
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': {
|
||||
actions: [
|
||||
{
|
||||
@ -1760,15 +1517,15 @@ const approvalPages: Record<string, ApprovalPage> = {
|
||||
color:
|
||||
record.status === 'PENDING'
|
||||
? 'default'
|
||||
: record.status === 'PASS'
|
||||
: (record.status === 'PASS'
|
||||
? 'success'
|
||||
: 'error',
|
||||
: 'error'),
|
||||
text:
|
||||
record.status === 'PENDING'
|
||||
? '待审核'
|
||||
: record.status === 'PASS'
|
||||
: (record.status === 'PASS'
|
||||
? '通过'
|
||||
: '驳回',
|
||||
: '驳回'),
|
||||
type: 'tag',
|
||||
}),
|
||||
title: '状态',
|
||||
@ -1821,12 +1578,11 @@ const approvalPages: Record<string, ApprovalPage> = {
|
||||
|
||||
export {
|
||||
APPROVAL_TYPE_OPTIONS,
|
||||
approvalPages,
|
||||
BANK_CARD_OPTIONS,
|
||||
DYNAMIC_REPORT_STATUS_OPTIONS,
|
||||
FEEDBACK_STATUS_OPTIONS,
|
||||
PHOTO_WALL_STATUS_OPTIONS,
|
||||
REPORT_STATUS_OPTIONS,
|
||||
REPORT_TYPE_OPTIONS,
|
||||
SYS_ORIGIN_OPTIONS,
|
||||
approvalPages,
|
||||
};
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -232,7 +232,6 @@ export const CURRENCY_ORIGIN_OPTIONS = [
|
||||
{ value: 'USER_REFUND', name: '用户退款' },
|
||||
{ value: 'ACTIVITY_FRIENDSHIP_CARD_REWARDS', name: '每周特殊关系卡片奖励' },
|
||||
{ value: 'WEEKLY_GAME_TASKS', name: '每周游戏任务奖励' },
|
||||
{ value: 'SEND_DYNAMIC_PAY_GOLD', name: '超限制发动态支付金币' },
|
||||
{ value: 'BUY_EMOJI', name: '购买表情包' },
|
||||
{ value: 'BET_TEEN_PATTI', name: '炸金花' },
|
||||
{ value: 'TEEN_PATTI', name: '炸金花游戏 ' },
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
1390
apps/src/views/props/components/resource-batch-drawer.vue
Normal file
1390
apps/src/views/props/components/resource-batch-drawer.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -7,8 +7,8 @@ import {
|
||||
FormItem,
|
||||
Image,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Switch,
|
||||
} from 'antdv-next';
|
||||
@ -241,6 +241,18 @@ function handleNotSelectInput(event: Event) {
|
||||
void uploadFile(event, 'notSelectUrl', notSelectUploading, true);
|
||||
}
|
||||
|
||||
function clearUpload(targetKey: 'cover' | 'expand' | 'notSelectUrl' | 'sourceUrl') {
|
||||
form[targetKey] = '';
|
||||
}
|
||||
|
||||
function getAssetName(value: unknown) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
return decodeURIComponent(text.split('/').pop() || text);
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
event: Event,
|
||||
targetKey: 'cover' | 'expand' | 'notSelectUrl' | 'sourceUrl',
|
||||
@ -299,7 +311,13 @@ function validateForm() {
|
||||
message.warning('请输入编码');
|
||||
return false;
|
||||
}
|
||||
if (isAmountRequired.value && (form.amount === '' || Number.isNaN(Number(form.amount)))) {
|
||||
if (
|
||||
isAmountRequired.value &&
|
||||
(form.amount === '' ||
|
||||
form.amount === null ||
|
||||
form.amount === undefined ||
|
||||
Number.isNaN(Number(form.amount)))
|
||||
) {
|
||||
message.warning('请输入底价');
|
||||
return false;
|
||||
}
|
||||
@ -335,141 +353,259 @@ async function handleSubmit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:confirm-loading="saving"
|
||||
:open="open"
|
||||
destroy-on-close
|
||||
:title="title"
|
||||
width="720"
|
||||
@cancel="emit('close')"
|
||||
@ok="handleSubmit"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<FormItem label="系统">
|
||||
<SysOriginSelect
|
||||
v-model:value="form.sysOrigin"
|
||||
:options="sysOriginOptions"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="类型">
|
||||
<Select
|
||||
option-label-prop="label"
|
||||
v-model:value="form.type"
|
||||
:options="propsTypeOptions"
|
||||
/>
|
||||
</FormItem>
|
||||
<template v-if="form.type === 'BADGE'">
|
||||
<FormItem label="展示形态">
|
||||
<Select
|
||||
v-model:value="form.badgeDisplayType"
|
||||
:options="badgeDisplayTypeOptions"
|
||||
option-label-prop="label"
|
||||
/>
|
||||
</FormItem>
|
||||
</template>
|
||||
<FormItem label="封面">
|
||||
<input
|
||||
ref="coverInputRef"
|
||||
accept="image/*"
|
||||
class="hidden-input"
|
||||
type="file"
|
||||
@change="handleCoverInput"
|
||||
/>
|
||||
<div class="upload-field">
|
||||
<div v-if="form.cover" class="upload-preview">
|
||||
<Image class="upload-preview__image" :src="form.cover" />
|
||||
</div>
|
||||
<div v-else class="upload-empty">请上传封面</div>
|
||||
<Button :loading="coverUploading" @click="openCoverPicker">
|
||||
{{ form.cover ? '重新上传' : '上传封面' }}
|
||||
</Button>
|
||||
</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"
|
||||
<section v-if="open" class="quick-editor">
|
||||
<input
|
||||
ref="coverInputRef"
|
||||
accept="image/*"
|
||||
class="hidden-input"
|
||||
type="file"
|
||||
@change="handleCoverInput"
|
||||
/>
|
||||
<input
|
||||
ref="notSelectInputRef"
|
||||
accept="image/*"
|
||||
class="hidden-input"
|
||||
type="file"
|
||||
@change="handleNotSelectInput"
|
||||
/>
|
||||
<input
|
||||
ref="sourceInputRef"
|
||||
:accept="isImageSourceType ? 'image/*' : '.svga,.pag,.mp4'"
|
||||
class="hidden-input"
|
||||
type="file"
|
||||
@change="handleSourceInput"
|
||||
/>
|
||||
<input
|
||||
ref="expandInputRef"
|
||||
accept="image/*"
|
||||
class="hidden-input"
|
||||
type="file"
|
||||
@change="handleExpandInput"
|
||||
/>
|
||||
|
||||
<div class="quick-editor__bar">
|
||||
<div class="quick-editor__title">{{ title }}</div>
|
||||
<div class="quick-editor__actions">
|
||||
<Button @click="emit('close')">取消</Button>
|
||||
<Button :loading="saving" type="primary" @click="handleSubmit">
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form class="resource-form" layout="vertical">
|
||||
<section class="compact-section">
|
||||
<div class="section-title">基础配置</div>
|
||||
<div class="form-grid">
|
||||
<FormItem label="系统">
|
||||
<SysOriginSelect
|
||||
v-model:value="form.sysOrigin"
|
||||
:options="sysOriginOptions"
|
||||
/>
|
||||
<div v-else class="upload-file">
|
||||
{{ form.sourceUrl }}
|
||||
</FormItem>
|
||||
<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 v-else class="upload-empty">请上传资源</div>
|
||||
<Button :loading="sourceUploading" @click="openSourcePicker">
|
||||
{{ form.sourceUrl ? '重新上传' : '上传资源' }}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem v-if="showExpandUpload" label="Android资源">
|
||||
<input
|
||||
ref="expandInputRef"
|
||||
accept="image/*"
|
||||
class="hidden-input"
|
||||
type="file"
|
||||
@change="handleExpandInput"
|
||||
/>
|
||||
<div class="upload-field">
|
||||
<div v-if="form.expand" class="upload-preview">
|
||||
<Image class="upload-preview__image" :src="form.expand" />
|
||||
|
||||
<div v-if="showSourceUpload" class="asset-card">
|
||||
<div class="asset-preview">
|
||||
<Image
|
||||
v-if="form.sourceUrl && isImageSourceType"
|
||||
class="asset-preview__image"
|
||||
:src="form.sourceUrl"
|
||||
/>
|
||||
<div v-else-if="form.sourceUrl" class="asset-file">
|
||||
{{ getAssetName(form.sourceUrl) }}
|
||||
</div>
|
||||
<div v-else class="asset-empty">未上传</div>
|
||||
</div>
|
||||
<div class="asset-detail">
|
||||
<div class="asset-card__head">
|
||||
<span>{{ sourceUploadLabel }}</span>
|
||||
<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 v-else class="upload-empty">请上传 Android 资源</div>
|
||||
<Button :loading="expandUploading" @click="openExpandPicker">
|
||||
{{ form.expand ? '重新上传' : '上传资源' }}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
<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>
|
||||
</section>
|
||||
</Form>
|
||||
</Modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@ -477,37 +613,221 @@ async function handleSubmit() {
|
||||
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;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upload-preview,
|
||||
.upload-empty {
|
||||
.resource-form :deep(.ant-form-item) {
|
||||
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;
|
||||
background: rgb(248 250 252);
|
||||
border: 1px dashed rgb(148 163 184 / 35%);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 180px;
|
||||
overflow: hidden;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.upload-empty {
|
||||
color: rgb(148 163 184);
|
||||
.asset-card__head,
|
||||
.asset-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.upload-preview__image {
|
||||
height: 180px;
|
||||
.asset-card__head {
|
||||
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;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-file {
|
||||
color: rgb(51 65 85);
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
.asset-empty {
|
||||
color: #98a2b3;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@ -77,10 +77,20 @@ const tokenClassName = computed(() => {
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 1px solid rgb(226 232 240);
|
||||
border-radius: 14px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.reward-icon:hover {
|
||||
border-color: rgb(191 219 254);
|
||||
box-shadow: 0 6px 18px rgb(15 23 42 / 10%);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.reward-icon__image {
|
||||
|
||||
@ -1,28 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
addOrUpdatePropsStore,
|
||||
listSysOriginTypeList,
|
||||
} from '#/api/legacy/props';
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormItem,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Switch,
|
||||
message,
|
||||
} from 'antdv-next';
|
||||
|
||||
import RewardIcon from './reward-icon.vue';
|
||||
import {
|
||||
addOrUpdatePropsStoreLegacy,
|
||||
listSysOriginTypeList,
|
||||
} from '#/api/legacy/props';
|
||||
|
||||
import {
|
||||
NOBLE_VIP_OPTIONS,
|
||||
PROPS_CURRENCY_TYPES,
|
||||
PROPS_STORE_TYPES,
|
||||
PROPS_VALID_DAYS,
|
||||
} from '../shared';
|
||||
import RewardIcon from './reward-icon.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
@ -42,14 +42,15 @@ const sourceOptionsMap = reactive<
|
||||
Record<string, { list: Array<Record<string, any>>; loaded: boolean; loading: boolean }>
|
||||
>({});
|
||||
|
||||
const mainSource = ref<Record<string, any> | null>(null);
|
||||
const rideSource = ref<Record<string, any> | null>(null);
|
||||
const avatarSource = ref<Record<string, any> | null>(null);
|
||||
const dataCardSource = ref<Record<string, any> | null>(null);
|
||||
const chatBubbleSource = ref<Record<string, any> | null>(null);
|
||||
const mainSource = ref<null | Record<string, any>>(null);
|
||||
const rideSource = ref<null | Record<string, any>>(null);
|
||||
const avatarSource = ref<null | Record<string, any>>(null);
|
||||
const dataCardSource = ref<null | Record<string, any>>(null);
|
||||
const chatBubbleSource = ref<null | Record<string, any>>(null);
|
||||
|
||||
const form = reactive<Record<string, any>>({
|
||||
currencyTypes: '',
|
||||
del: false,
|
||||
discount: 0,
|
||||
id: '',
|
||||
label: '',
|
||||
@ -161,6 +162,7 @@ watch(
|
||||
const propsAbility = record.propsAbility || {};
|
||||
const nobleVipAbility = propsAbility.nobleVipAbility || {};
|
||||
form.currencyTypes = commodity.currencyTypes || '';
|
||||
form.del = commodity.del ?? false;
|
||||
form.discount = commodity.discount || 0;
|
||||
form.id = commodity.id || '';
|
||||
form.label = commodity.label || '';
|
||||
@ -203,7 +205,7 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function updateSelectedSource(type: string, id: string | number) {
|
||||
function updateSelectedSource(type: string, id: number | string) {
|
||||
const item = sourceOptionsMap[type]?.list.find(
|
||||
(option) => String(option.id) === String(id),
|
||||
);
|
||||
@ -295,7 +297,7 @@ async function handleSubmit() {
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await addOrUpdatePropsStore({
|
||||
await addOrUpdatePropsStoreLegacy({
|
||||
...form,
|
||||
currencyTypes: form.tmpCurrencyTypes.join(','),
|
||||
discount: Number(form.discount) >= 0 && Number(form.discount) <= 1 ? Number(form.discount) : 1,
|
||||
@ -324,9 +326,11 @@ async function handleSubmit() {
|
||||
<Form layout="vertical">
|
||||
<div class="form-grid">
|
||||
<FormItem label="归属系统">
|
||||
<SysOriginSelect v-model:value="form.sysOrigin" disabled
|
||||
<SysOriginSelect
|
||||
v-model:value="form.sysOrigin"
|
||||
disabled
|
||||
:options="sysOriginOptions"
|
||||
></SysOriginSelect>
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="道具类型">
|
||||
<Select
|
||||
@ -339,7 +343,8 @@ async function handleSubmit() {
|
||||
</div>
|
||||
|
||||
<FormItem label="道具资源">
|
||||
<Select option-label-prop="label"
|
||||
<Select
|
||||
option-label-prop="label"
|
||||
v-model:value="form.sourceId"
|
||||
:loading="sourceOptionsMap[form.propsType]?.loading"
|
||||
:options="getSourceSelectOptions(form.propsType)"
|
||||
@ -429,7 +434,8 @@ async function handleSubmit() {
|
||||
<div class="ability-grid">
|
||||
<div class="ability-item">
|
||||
<div class="ability-item__label">车辆</div>
|
||||
<Select option-label-prop="label"
|
||||
<Select
|
||||
option-label-prop="label"
|
||||
v-model:value="form.propsAbility.carId"
|
||||
:loading="sourceOptionsMap.RIDE?.loading"
|
||||
:options="getSourceSelectOptions('RIDE')"
|
||||
@ -447,7 +453,8 @@ async function handleSubmit() {
|
||||
|
||||
<div class="ability-item">
|
||||
<div class="ability-item__label">头像框</div>
|
||||
<Select option-label-prop="label"
|
||||
<Select
|
||||
option-label-prop="label"
|
||||
v-model:value="form.propsAbility.avatarFrameId"
|
||||
:loading="sourceOptionsMap.AVATAR_FRAME?.loading"
|
||||
:options="getSourceSelectOptions('AVATAR_FRAME')"
|
||||
@ -465,7 +472,8 @@ async function handleSubmit() {
|
||||
|
||||
<div class="ability-item">
|
||||
<div class="ability-item__label">资料卡</div>
|
||||
<Select option-label-prop="label"
|
||||
<Select
|
||||
option-label-prop="label"
|
||||
v-model:value="form.propsAbility.dataCardId"
|
||||
:loading="sourceOptionsMap.DATA_CARD?.loading"
|
||||
:options="getSourceSelectOptions('DATA_CARD')"
|
||||
@ -483,7 +491,8 @@ async function handleSubmit() {
|
||||
|
||||
<div class="ability-item">
|
||||
<div class="ability-item__label">聊天气泡</div>
|
||||
<Select option-label-prop="label"
|
||||
<Select
|
||||
option-label-prop="label"
|
||||
v-model:value="form.propsAbility.chatBubbleId"
|
||||
:loading="sourceOptionsMap.CHAT_BUBBLE?.loading"
|
||||
:options="getSourceSelectOptions('CHAT_BUBBLE')"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -328,40 +328,3 @@ export function formatRewardText(item: Record<string, any>) {
|
||||
const quantity = Number(item.quantity || 0);
|
||||
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: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,35 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
computed,
|
||||
reactive,
|
||||
ref } from 'vue';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
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 {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
message,
|
||||
Pagination,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
message
|
||||
} 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 StoreEditModal from './components/store-edit-modal.vue';
|
||||
import {
|
||||
@ -50,7 +46,7 @@ const loading = ref(false);
|
||||
const total = ref(0);
|
||||
const list = ref<Array<Record<string, any>>>([]);
|
||||
const formOpen = ref(false);
|
||||
const activeRow = ref<Record<string, any> | null>(null);
|
||||
const activeRow = ref<null | Record<string, any>>(null);
|
||||
|
||||
const query = reactive({
|
||||
cursor: 1,
|
||||
@ -145,34 +141,34 @@ loadData(true);
|
||||
@change="loadData(true)"
|
||||
|
||||
:options="sysOriginOptions"
|
||||
></SysOriginSelect>
|
||||
/>
|
||||
</InlineFilterField>
|
||||
<InlineFilterField label="道具类型">
|
||||
<Select option-label-prop="label"
|
||||
<Select
|
||||
option-label-prop="label"
|
||||
v-model:value="query.propsType"
|
||||
style="width: 140px"
|
||||
@change="loadData(true)"
|
||||
|
||||
:options="PROPS_STORE_TYPES.map((item) => ({ label: `${item.name}`, value: item.value as any }))"
|
||||
/>
|
||||
</InlineFilterField>
|
||||
<InlineFilterField label="状态">
|
||||
<Select option-label-prop="label"
|
||||
<Select
|
||||
option-label-prop="label"
|
||||
v-model:value="query.shelfStatus"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
@change="loadData(true)"
|
||||
|
||||
:options="PROPS_SHELF_STATUS_OPTIONS.map((item) => ({ label: `${item.name}`, value: item.value as any }))"
|
||||
/>
|
||||
</InlineFilterField>
|
||||
<InlineFilterField label="付费类型" :label-width="72">
|
||||
<Select option-label-prop="label"
|
||||
<Select
|
||||
option-label-prop="label"
|
||||
v-model:value="query.currencyTypes"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
@change="loadData(true)"
|
||||
|
||||
:options="PROPS_CURRENCY_TYPES.map((item) => ({ label: `${item.name}`, value: item.value as any }))"
|
||||
/>
|
||||
</InlineFilterField>
|
||||
@ -262,7 +258,7 @@ loadData(true);
|
||||
:total="total"
|
||||
show-size-changer
|
||||
@change="handlePageChange"
|
||||
@showSizeChange="handlePageChange"
|
||||
@show-size-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -35,7 +35,7 @@ function formatPrice(value: any) {
|
||||
if (!Number.isFinite(amount)) {
|
||||
return String(value || '-');
|
||||
}
|
||||
return amount.toLocaleString('en-US', {
|
||||
return amount.toLocaleString('zh-CN', {
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
@ -123,7 +123,7 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
alt=""
|
||||
class="latest-purchase__avatar"
|
||||
>
|
||||
/>
|
||||
<div class="latest-purchase__content">
|
||||
<div class="latest-purchase__header">
|
||||
<span
|
||||
|
||||
@ -26,7 +26,7 @@ function normalizeNumber(value: any) {
|
||||
}
|
||||
|
||||
function formatValue(value: number) {
|
||||
return value.toLocaleString('en-US', {
|
||||
return value.toLocaleString('zh-CN', {
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
@ -198,6 +198,7 @@ const metricColumns = new Set([
|
||||
'mifapayRecharge',
|
||||
'newUserRecharge',
|
||||
'salaryExchange',
|
||||
'salaryTransfer',
|
||||
'totalRecharge',
|
||||
]);
|
||||
|
||||
@ -227,6 +228,7 @@ const summaryMetricKeys = [
|
||||
'mifapayRecharge',
|
||||
'googleRecharge',
|
||||
'salaryExchange',
|
||||
'salaryTransfer',
|
||||
'totalRecharge',
|
||||
'giftConsume',
|
||||
'luckyGiftTotalFlow',
|
||||
@ -253,6 +255,7 @@ const summaryMetricDefinitions = [
|
||||
{ key: 'mifapayRecharge', label: 'MifaPay 充值' },
|
||||
{ key: 'googleRecharge', label: 'Google 充值' },
|
||||
{ key: 'salaryExchange', label: '工资兑换' },
|
||||
{ key: 'salaryTransfer', label: '工资转移' },
|
||||
{ key: 'totalRecharge', label: '总充值' },
|
||||
{ key: 'giftConsume', label: '礼物消耗' },
|
||||
{ key: 'luckyGiftTotalFlow', label: '幸运礼物流水' },
|
||||
@ -301,6 +304,7 @@ const overviewColumns: any[] = [
|
||||
{ align: 'right', dataIndex: 'mifapayRecharge', key: 'mifapayRecharge', title: 'MifaPay 充值', width: 150 },
|
||||
{ align: 'right', dataIndex: 'googleRecharge', key: 'googleRecharge', title: 'Google 充值', width: 140 },
|
||||
{ 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: 'giftConsume', key: 'giftConsume', title: '礼物消耗', width: 130 },
|
||||
{ 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) {
|
||||
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) {
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number)
|
||||
? number.toLocaleString('en-US', {
|
||||
? number.toLocaleString('zh-CN', {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 0,
|
||||
})
|
||||
@ -784,7 +788,7 @@ function formatAmount(value?: null | number | string) {
|
||||
function formatPercent(value?: null | number | string) {
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number)
|
||||
? `${number.toLocaleString('en-US', {
|
||||
? `${number.toLocaleString('zh-CN', {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 2,
|
||||
})}%`
|
||||
@ -893,6 +897,7 @@ function emptySummary(): SummaryMetric {
|
||||
mifapayRecharge: 0,
|
||||
newUserRecharge: 0,
|
||||
salaryExchange: 0,
|
||||
salaryTransfer: 0,
|
||||
totalRecharge: 0,
|
||||
};
|
||||
}
|
||||
@ -1109,6 +1114,7 @@ async function renderVisualCharts() {
|
||||
amountSeries('MifaPay 充值', 'mifapayRecharge', list),
|
||||
amountSeries('Google 充值', 'googleRecharge', list),
|
||||
amountSeries('工资兑换', 'salaryExchange', list),
|
||||
amountSeries('工资转移', 'salaryTransfer', list),
|
||||
amountSeries('总充值', 'totalRecharge', list),
|
||||
]));
|
||||
renderFlowChart(baseChartOption('核心流水', list, [
|
||||
|
||||
@ -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_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) {
|
||||
return new RegExp(`^${prefix.replaceAll('/', '\\/')}`);
|
||||
}
|
||||
@ -16,6 +47,19 @@ export default defineConfig(async () => {
|
||||
return {
|
||||
application: {},
|
||||
vite: {
|
||||
build: {
|
||||
cssCodeSplit: false,
|
||||
modulePreload: false,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
assetFileNames: getBundledAssetFileName,
|
||||
chunkFileNames: 'js/[name]-[hash].js',
|
||||
codeSplitting: false,
|
||||
entryFileNames: 'js/app.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [addBuildTimestampToBundledAssets()],
|
||||
server: {
|
||||
proxy: {
|
||||
[`${DEV_API_INTERNAL_PREFIX}/go`]: {
|
||||
|
||||
@ -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>
|
||||
@ -1 +0,0 @@
|
||||
export { default as DemoPreview } from './demo-preview.vue';
|
||||
@ -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>
|
||||
@ -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',
|
||||
// },
|
||||
];
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
@ -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);
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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: '保存至搜索历史',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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;
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
import '@vben/styles';
|
||||
|
||||
import './variables.css';
|
||||
import './base.css';
|
||||
@ -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;
|
||||
}
|
||||
@ -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:"
|
||||
}
|
||||
}
|
||||
@ -1,128 +0,0 @@
|
||||
/**
|
||||
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
|
||||
* 可用于 vben-form、vben-modal、vben-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 };
|
||||
@ -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 };
|
||||
@ -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';
|
||||
@ -1,4 +0,0 @@
|
||||
export const useVbenForm = () => {};
|
||||
export const z = {};
|
||||
export type VbenFormSchema = any;
|
||||
export type VbenFormProps = any;
|
||||
@ -1,3 +0,0 @@
|
||||
export type * from '@vben/plugins/vxe-table';
|
||||
|
||||
export const useVbenVxeGrid = () => {};
|
||||
@ -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;"/>
|
||||
@ -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;"/>
|
||||
|
||||
我们会在第一时间回复您,定制费用根据需求而定。
|
||||
@ -1,8 +0,0 @@
|
||||
# 技术支持
|
||||
|
||||
## 问题反馈
|
||||
|
||||
在使用项目的过程中,如果遇到问题,你可以先详细阅读本文档,未找到解决方案时,可以通过以下方式获取技术支持:
|
||||
|
||||
- 通过 [GitHub Issues](https://github.com/vbenjs/vue-vben-admin/issues)
|
||||
- 通过 [GitHub Discussions](https://github.com/vbenjs/vue-vben-admin/discussions)
|
||||
@ -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>;
|
||||
```
|
||||
@ -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 |
|
||||
@ -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` |
|
||||
@ -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参数。
|
||||
|
||||
:::
|
||||
@ -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 | 开启文本提示时,用于自定义提示内容 |
|
||||
@ -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`的值将会被忽略。
|
||||
|
||||
:::
|
||||
@ -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参数。
|
||||
|
||||
:::
|
||||
@ -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-` 开头的具名插槽都会被转发给表单。:::
|
||||
@ -1,15 +0,0 @@
|
||||
# 介绍
|
||||
|
||||
::: info README
|
||||
|
||||
该文档介绍的是框架组件的使用方法、属性、事件等。如果你觉得现有组件的封装不够理想,或者不完全符合你的需求,大可以直接使用原生组件,亦或亲手封装一个适合的组件。框架提供的组件并非束缚,使用与否,完全取决于你的需求与自由。
|
||||
|
||||
:::
|
||||
|
||||
## 布局组件
|
||||
|
||||
布局组件一般在页面内容区域用作顶层容器组件,提供一些统一的布局样式和基本功能。
|
||||
|
||||
## 通用组件
|
||||
|
||||
通用组件是一些常用的组件,比如弹窗、抽屉、表单等。大部分基于 `Tailwind CSS` 实现,可适用于不同 UI 组件库的应用。
|
||||
@ -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 | 页面底部内容 |
|
||||
@ -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: '已成功创建订单。订单ID:2017182818828182881',
|
||||
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>
|
||||
@ -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>
|
||||
@ -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: '请选择',
|
||||
// 弹窗会设置body的pointer-events为none,这回影响下拉框的点击事件
|
||||
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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -1,16 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { EllipsisText } from '@vben/common-ui';
|
||||
|
||||
const text = `
|
||||
Vben Admin 是一个基于 Vue3.0、Vite、 TypeScript 的后台解决方案,目标是为开发中大型项目提供开箱即用的解决方案。
|
||||
`;
|
||||
</script>
|
||||
<template>
|
||||
<EllipsisText :line="2" :tooltip-when-ellipsis="true">
|
||||
{{ text }}
|
||||
</EllipsisText>
|
||||
|
||||
<EllipsisText :line="3" :tooltip-when-ellipsis="true">
|
||||
{{ text }}
|
||||
</EllipsisText>
|
||||
</template>
|
||||
@ -1,10 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { EllipsisText } from '@vben/common-ui';
|
||||
|
||||
const text = `
|
||||
Vben Admin 是一个基于 Vue3.0、Vite、 TypeScript 的后台解决方案,目标是为开发中大型项目提供开箱即用的解决方案。包括二次封装组件、utils、hooks、动态菜单、权限校验、多主题配置、按钮级别权限控制等功能。项目会使用前端较新的技术栈,可以作为项目的启动模版,以帮助你快速搭建企业级中后台产品原型。也可以作为一个示例,用于学习 vue3、vite、ts 等主流技术。该项目会持续跟进最新技术,并将其应用在项目中。Vben Admin 是一个基于 Vue3.0、Vite、 TypeScript 的后台解决方案,目标是为开发中大型项目提供开箱即用的解决方案。包括二次封装组件、utils、hooks、动态菜单、权限校验、多主题配置、按钮级别权限控制等功能。项目会使用前端较新的技术栈,可以作为项目的启动模版,以帮助你快速搭建企业级中后台产品原型。也可以作为一个示例,用于学习 vue3、vite、ts 等主流技术。该项目会持续跟进最新技术,并将其应用在项目中。Vben Admin 是一个基于 Vue3.0、Vite、 TypeScript 的后台解决方案,目标是为开发中大型项目提供开箱即用的解决方案。包括二次封装组件、utils、hooks、动态菜单、权限校验、多主题配置、按钮级别权限控制等功能。项目会使用前端较新的技术栈,可以作为项目的启动模版,以帮助你快速搭建企业级中后台产品原型。也可以作为一个示例,用于学习 vue3、vite、ts 等主流技术。该项目会持续跟进最新技术,并将其应用在项目中。Vben Admin 是一个基于 Vue3.0、Vite、 TypeScript 的后台解决方案,目标是为开发中大型项目提供开箱即用的解决方案。包括二次封装组件、utils、hooks、动态菜单、权限校验、多主题配置、按钮级别权限控制等功能。项目会使用前端较新的技术栈,可以作为项目的启动模版,以帮助你快速搭建企业级中后台产品原型。也可以作为一个示例,用于学习 vue3、vite、ts 等主流技术。该项目会持续跟进最新技术,并将其应用在项目中。
|
||||
`;
|
||||
</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
Loading…
x
Reference in New Issue
Block a user