refactor(ui): 抽取密码/路由/通道商户/社交图标组件到 @daxpay/ui-biz

Batch 1+2: 将两端重复的展示组件与连带 type/utils 迁入共享包,两端改转发壳或 re-export:

- input-password: InputPassword + PasswordStrength + password-validator + PasswordPolicyConfig type 彻底迁入 ui-biz(两端实体删除,index 转发)
- route: RouteQueryMissingState 迁入 ui-biz(两端保留透传壳兼容 24 处直接 .vue 引用)
- channel: ChannelMerchantSelect 迁入 ui-biz(两端保留透传壳兼容 6 处直接引用)
- social: SocialLogo + assets/social(10 SVG) 迁入 ui-biz,assets 引用从绝对路径 /src/assets/social/ 改为 import.meta.glob 相对静态加载(两端保留透传壳)
- security.api 的 PasswordPolicyConfig type 与 password-validator 纯函数改 re-export ui-biz

两端 typecheck 零新增错误。
This commit is contained in:
DaxPay Dev
2026-07-25 09:54:16 +08:00
parent 81a3930b1e
commit eed70adc7a
54 changed files with 488 additions and 991 deletions

View File

@@ -98,36 +98,9 @@ export const SecurityApi = {
},
};
/**
* 密码策略配置
*/
export interface PasswordPolicyConfig {
/** 是否启用 */
enabled?: boolean;
/** 最小长度 */
minLength?: number;
/** 最大长度 */
maxLength?: number;
/** 是否要求大写字母 */
requireUppercase?: boolean;
/** 是否要求小写字母 */
requireLowercase?: boolean;
/** 是否要求数字 */
requireDigit?: boolean;
/** 是否要求特殊字符 */
requireSpecialChar?: boolean;
/** 特殊字符集合 */
specialChars?: string;
/** 密码轮换天数 */
rotationDays?: number;
/** 历史密码检查数量 */
historyCount?: number;
}
import type { PasswordPolicyConfig, PasswordPolicyValidateConfig } from '@daxpay/ui-biz/types/password-policy';
/**
* 密码策略校验配置(供前端校验使用)
*/
export type PasswordPolicyValidateConfig = PasswordPolicyConfig;
export type { PasswordPolicyConfig, PasswordPolicyValidateConfig };
/**
* 登录安全配置

View File

@@ -1,94 +1,13 @@
<script lang="ts" setup>
import type { ChannelMchOption } from '#/types/web';
import { computed } from 'vue';
import { ChannelLogo } from '@daxpay/ui-biz/channel';
/**
* 通道商户下拉选择
*
* - 选项数据由调用方通过 `options` 传入(类型 [ChannelMchOption], 含 product/channel 字段)
* - 下拉项与选中态都会渲染支付产品图标(通过 [ChannelLogo] 派生)
* - 当多个通道商户名重复时, 图标可辅助区分
*/
const props = withDefaults(
defineProps<{
allowClear?: boolean;
disabled?: boolean;
/** 通道商户候选, 携带 product/channel 用于显示图标 */
options?: ChannelMchOption[];
placeholder?: string;
/** 自定义下拉尺寸的 class */
rootClassName?: string;
/** 是否启用搜索(默认开启) */
showSearch?: boolean;
/** 当前选中的通道商户号 */
value?: string;
}>(),
{
value: '',
options: () => [],
placeholder: '',
disabled: false,
allowClear: true,
showSearch: true,
rootClassName: '',
},
);
const emit = defineEmits<{
(e: 'update:value', val: string | undefined): void;
(e: 'change', val: string | undefined, option?: ChannelMchOption): void;
}>();
const innerOptions = computed(() => props.options);
// 选中态根据 value 查回 option 拿 product/channel 渲染图标
const selectedOption = computed<ChannelMchOption | undefined>(() =>
innerOptions.value.find((o) => o.value === props.value),
);
function onChange(val: string | undefined, option: ChannelMchOption | undefined) {
emit('update:value', val);
emit('change', val, option);
}
// 兼容旧路径 `#/components/channel/ChannelMerchantSelect.vue`
import { ChannelMerchantSelect } from '@daxpay/ui-biz/channel';
defineOptions({ inheritAttrs: false });
</script>
<template>
<a-select
:value="value"
:options="innerOptions"
:placeholder="placeholder"
:disabled="disabled"
:allow-clear="allowClear"
:show-search="showSearch"
:class="rootClassName"
option-filter-prop="label"
option-label-prop="label"
@change="onChange"
>
<template #optionRender="{ option }">
<div class="flex items-center gap-2">
<!-- 支付产品图标(优先产品级, 回退到通道级) -->
<ChannelLogo
:product="(option.data as ChannelMchOption).product"
:channel="(option.data as ChannelMchOption).channel"
:size="18"
/>
<span>{{ option.data.label }}</span>
</div>
<ChannelMerchantSelect v-bind="$attrs">
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
<template #labelRender>
<div class="inline-flex items-center gap-1">
<ChannelLogo
v-if="selectedOption"
:product="selectedOption.product"
:channel="selectedOption.channel"
:size="14"
/>
<span>{{ selectedOption?.label ?? value }}</span>
</div>
</template>
</a-select>
</ChannelMerchantSelect>
</template>

View File

@@ -1 +1 @@
export { ChannelLogo, resolveChannelLogoUrl } from '@daxpay/ui-biz/channel';
export { ChannelLogo, ChannelMerchantSelect, resolveChannelLogoUrl } from '@daxpay/ui-biz/channel';

View File

@@ -1,121 +0,0 @@
<script setup lang="ts">
import type { PasswordPolicyValidateConfig } from '#/api/system/security.api';
import type { PasswordCondition } from '#/utils/password-validator';
import { computed } from 'vue';
import { IconifyIcon } from '@vben-core/icons';
import { calculateIntrinsicStrength, getPasswordConditions } from '#/utils/password-validator';
interface Props {
password?: string;
config?: PasswordPolicyValidateConfig;
}
const props = withDefaults(defineProps<Props>(), {
password: '',
config: () => ({}),
});
const strengthList: string[] = ['', '#e74242', '#ED6F6F', '#EFBD47', '#55D18780', '#55D187'];
const currentStrength = computed(() => {
return calculateIntrinsicStrength(props.password);
});
const currentColor = computed(() => {
return strengthList[currentStrength.value];
});
const conditions = computed<PasswordCondition[]>(() => {
return getPasswordConditions(props.password, props.config);
});
</script>
<template>
<div class="password-strength">
<!-- 强度条 -->
<div class="password-strength-bar">
<div
v-for="index in 5"
:key="index"
class="password-strength-item"
:style="{ backgroundColor: currentStrength >= index ? currentColor : undefined }"
/>
</div>
<!-- 条件列表 -->
<div v-if="conditions.length > 0" class="password-conditions">
<div
v-for="condition in conditions"
:key="condition.key"
class="condition-item"
:class="[{ satisfied: condition.satisfied }]"
>
<span class="condition-icon">
<IconifyIcon v-if="condition.satisfied" icon="ant-design:check-outlined" />
<IconifyIcon v-else icon="ant-design:close-outlined" />
</span>
<span class="condition-label">{{ condition.label }}</span>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.password-strength {
margin-top: 4px;
&-bar {
display: flex;
justify-content: space-between;
gap: 4px;
}
&-item {
flex: 1;
height: 4px;
background-color: rgba(0, 0, 0, 0.06);
border-radius: 2px;
transition: background-color 0.3s;
}
}
.password-conditions {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
}
.condition-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
transition: color 0.3s;
&.satisfied {
color: #52c41a;
}
}
.condition-icon {
font-size: 12px;
}
.dark {
.password-strength-item {
background-color: rgba(255, 255, 255, 0.12);
}
.condition-item {
color: rgba(255, 255, 255, 0.45);
&.satisfied {
color: #52c41a;
}
}
}
</style>

View File

@@ -1 +1 @@
export { default as InputPassword } from './InputPassword.vue';
export { InputPassword } from '@daxpay/ui-biz/components/input-password';

View File

@@ -1,24 +1,13 @@
<script lang="ts" setup>
defineOptions({ name: 'RouteQueryMissingState' });
defineProps<{
/** 空状态描述(已国际化) */
description: string;
/** 返回按钮文案(已国际化) */
backText: string;
}>();
const emits = defineEmits<{
back: [];
}>();
// 兼容旧路径 `#/components/route/RouteQueryMissingState.vue`
import { RouteQueryMissingState } from '@daxpay/ui-biz/components/route';
defineOptions({ inheritAttrs: false });
</script>
<template>
<div class="m-4 flex items-center justify-center" style="min-height: 400px">
<a-empty :description="description">
<a-button type="primary" @click="emits('back')">
{{ backText }}
</a-button>
</a-empty>
</div>
<RouteQueryMissingState v-bind="($attrs as any)">
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</RouteQueryMissingState>
</template>

View File

@@ -0,0 +1 @@
export { RouteQueryMissingState } from '@daxpay/ui-biz/components/route';

View File

@@ -1,95 +1,13 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { socialColorMap, socialLogoMap } from '#/enums/social';
const props = withDefaults(
defineProps<{
// 平台编码(weChat/weCom/qq/github/gitee/feishu/dingTalk/douyin/alipay)
source: string;
// 图标尺寸(px)
size?: number;
// 圆角, 默认圆形
radius?: string;
}>(),
{
size: 64,
radius: '50%',
},
);
// SVG 文件 URL, 找不到时为 undefined(显示占位块)
const logoSrc = computed(() => {
const fileName = socialLogoMap[props.source];
if (!fileName) {
return undefined;
}
try {
return new URL(`/src/assets/social/${fileName}.svg`, import.meta.url).href;
} catch {
return undefined;
}
});
// 品牌色(用于占位块背景)
const brandColor = computed(
() => socialColorMap[props.source] ?? '#8b8b8b',
);
// 占位块显示的平台首字母
const fallbackLetter = computed(() => {
return props.source?.charAt(0)?.toUpperCase() || '?';
});
// 兼容旧路径 `#/components/social/SocialLogo.vue`
import { SocialLogo } from '@daxpay/ui-biz/components/social';
defineOptions({ inheritAttrs: false });
</script>
<template>
<span
class="social-logo"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<img
v-if="logoSrc"
:src="logoSrc"
:width="size"
:height="size"
:alt="source"
class="logo-img"
/>
<span
v-else
class="fallback-icon"
:style="{
width: `${size}px`,
height: `${size}px`,
backgroundColor: brandColor,
borderRadius: radius,
fontSize: `${Math.max(size! * 0.4, 16)}px`,
}"
>
{{ fallbackLetter }}
</span>
</span>
<SocialLogo v-bind="($attrs as any)">
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</SocialLogo>
</template>
<style scoped>
.social-logo {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.logo-img {
display: block;
object-fit: contain;
}
.fallback-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
line-height: 1;
}
</style>

View File

@@ -1 +1 @@
export { default as SocialLogo } from './SocialLogo.vue';
export { SocialLogo } from '@daxpay/ui-biz/components/social';

View File

@@ -1,154 +1 @@
import type { PasswordPolicyValidateConfig } from '#/api/system/security.api';
import { $t } from '@vben/locales';
/**
* 密码条件项
*/
export interface PasswordCondition {
key: string;
label: string;
satisfied: boolean;
}
/**
* 获取密码条件列表(用于显示状态)
*/
export function getPasswordConditions(password: string, config: PasswordPolicyValidateConfig): PasswordCondition[] {
const conditions: PasswordCondition[] = [];
if (!config.enabled) {
return conditions;
}
// 长度条件
if (config.minLength && config.maxLength) {
conditions.push({
key: 'length',
label: $t('iam.user.passwordPolicy.hintLength', { min: config.minLength, max: config.maxLength }),
satisfied: password.length >= config.minLength && password.length <= config.maxLength,
});
}
// 大写字母
if (config.requireUppercase) {
conditions.push({
key: 'uppercase',
label: $t('iam.user.passwordPolicy.hintUppercase'),
satisfied: /[A-Z]/.test(password),
});
}
// 小写字母
if (config.requireLowercase) {
conditions.push({
key: 'lowercase',
label: $t('iam.user.passwordPolicy.hintLowercase'),
satisfied: /[a-z]/.test(password),
});
}
// 数字
if (config.requireDigit) {
conditions.push({
key: 'digit',
label: $t('iam.user.passwordPolicy.hintDigit'),
satisfied: /\d/.test(password),
});
}
// 特殊字符
if (config.requireSpecialChar && config.specialChars) {
conditions.push({
key: 'specialChar',
label: $t('iam.user.passwordPolicy.hintSpecialChar', { chars: config.specialChars }),
satisfied: new RegExp(`[${escapeRegExp(config.specialChars)}]`).test(password),
});
}
return conditions;
}
/**
* 计算密码强度(根据配置)
* 强度 = 满足的条件数 / 总条件数 * 5
*/
export function calculatePasswordStrength(password: string, config: PasswordPolicyValidateConfig): number {
if (!password) return 0;
const conditions = getPasswordConditions(password, config);
const satisfiedCount = conditions.filter((c) => c.satisfied).length;
const totalCount = conditions.length;
if (totalCount === 0) return 0;
// 按比例计算强度1-5
return Math.ceil((satisfiedCount / totalCount) * 5);
}
/**
* 生成简化的表单校验规则
*/
export function generatePasswordRules(config: PasswordPolicyValidateConfig) {
return [
{ required: true, message: $t('iam.user.validation.passwordRequired') },
{
validator: (_rule: any, value: string) => {
if (!value) return Promise.resolve();
// 密码不允许中文
if (/[\u4E00-\u9FA5]/.test(value)) {
return Promise.reject($t('iam.user.validation.passwordNoChinese'));
}
if (!config.enabled) return Promise.resolve();
const conditions = getPasswordConditions(value, config);
const allSatisfied = conditions.every((c) => c.satisfied);
if (!allSatisfied) {
return Promise.reject($t('iam.user.passwordPolicy.notMeetRequirements'));
}
return Promise.resolve();
},
trigger: 'change',
},
];
}
/**
* 账号验证规则(只允许字母、数字、下划线、中划线)
*/
export function generateAccountRules() {
return [
{ required: true, message: $t('common.pleaseInput') },
{ min: 6, max: 20, message: $t('iam.user.validation.accountLength') },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: $t('iam.user.validation.accountFormat') },
];
}
/**
* 计算密码内在强度(固定规则,不依赖后台配置)
* 用于强度条的显示
*/
export function calculateIntrinsicStrength(password: string): number {
if (!password) return 0;
let strength = 0;
// 长度 >= 8
if (password.length >= 8) strength++;
// 包含小写字母
if (/[a-z]/.test(password)) strength++;
// 包含大写字母
if (/[A-Z]/.test(password)) strength++;
// 包含数字
if (/\d/.test(password)) strength++;
// 包含特殊字符
if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) strength++;
return strength;
}
/**
* 转义正则表达式特殊字符
*/
function escapeRegExp(string: string): string {
return string.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
}
export * from '@daxpay/ui-biz/utils/password-validator';

View File

@@ -98,36 +98,9 @@ export const SecurityApi = {
},
};
/**
* 密码策略配置
*/
export interface PasswordPolicyConfig {
/** 是否启用 */
enabled?: boolean;
/** 最小长度 */
minLength?: number;
/** 最大长度 */
maxLength?: number;
/** 是否要求大写字母 */
requireUppercase?: boolean;
/** 是否要求小写字母 */
requireLowercase?: boolean;
/** 是否要求数字 */
requireDigit?: boolean;
/** 是否要求特殊字符 */
requireSpecialChar?: boolean;
/** 特殊字符集合 */
specialChars?: string;
/** 密码轮换天数 */
rotationDays?: number;
/** 历史密码检查数量 */
historyCount?: number;
}
import type { PasswordPolicyConfig, PasswordPolicyValidateConfig } from '@daxpay/ui-biz/types/password-policy';
/**
* 密码策略校验配置(供前端校验使用)
*/
export type PasswordPolicyValidateConfig = PasswordPolicyConfig;
export type { PasswordPolicyConfig, PasswordPolicyValidateConfig };
/**
* 登录安全配置

View File

@@ -1,34 +0,0 @@
# 社交平台 SVG 图标
本目录存放第三方社交登录平台的品牌 LogoSVG 矢量图),供 `SocialLogo.vue` 组件按 `source` 字段加载。
## 文件清单
| 文件名 | 平台 | source | 建议品牌色 |
|--------|------|--------|-----------|
| `weChat.svg` | 微信公众号 | `weChat` | #07c160 |
| `weCom.svg` | 企业微信 | `weCom` | #2f90ff |
| `qq.svg` | QQ | `qq` | #12b7f5 |
| `github.svg` | GitHub | `github` | #181717 |
| `gitee.svg` | Gitee | `gitee` | #c71d23 |
| `feishu.svg` | 飞书 | `feishu` | #3370ff |
| `dingTalk.svg` | 钉钉 | `dingTalk` | #1677ff |
| `douyin.svg` | 抖音 | `douyin` | #000000 |
| `alipay.svg` | 支付宝 | `alipay` | #1677FF |
> 当前 SVG 为品牌识别用简化标, 拿到官方品牌 logo 后直接覆盖同名文件即可。
## 命名规则
文件名 = 后端 `SocialSource` 枚举的 `name` + `.svg`(与平台 source 字段一致,驼峰命名)。
## 素材来源建议
- 微信/QQ/钉钉/抖音:各平台开放平台官方品牌资源
- GitHubgithub.com/logos官方品牌指南
- Gitee/飞书:官网品牌资源页
- 也可从 simple-icons.comCC0 协议品牌图标库)下载对应 SVG
## 缺失时的表现
`SocialLogo.vue` 组件在找不到对应 SVG 文件时会显示一个带品牌色背景的占位块(显示平台首字母),不影响页面功能。补充 SVG 文件后自动生效,无需改动代码。

View File

@@ -1 +0,0 @@
<svg t="1783580453512" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6683" width="200" height="200"><path d="M0 512c0 283.428571 228.571429 512 512 512s512-228.571429 512-512S795.428571 0 512 0 0 228.571429 0 512z" fill="#5A9EF7" p-id="6684"></path><path d="M637.696 558.701714s17.846857-25.892571 35.693714-77.677714c19.638857-51.821714 21.430857-81.408 21.430857-81.408l-142.738285-1.828571V347.794286l173.056-1.828572V308.955429h-174.848V227.547429h-85.650286v81.408H300.470857V345.965714l162.377143-1.828571v55.478857H334.372571v29.622857h267.666286c-1.828571 18.468571-7.168 35.108571-12.507428 51.785143-10.715429 27.721143-21.394286 53.650286-21.394286 53.650286s-124.928-46.262857-192.731429-46.262857c-66.011429 0-146.285714 27.757714-155.245714 107.300571-7.131429 79.542857 37.485714 123.977143 101.741714 138.752 62.427429 14.811429 121.307429 0 173.056-25.892571s101.705143-85.101714 101.705143-85.101715l265.874286 135.058286s16.091429-25.892571 28.525714-49.956571c8.96-16.64 16.091429-35.145143 23.222857-51.821715l-276.589714-98.011428zM362.934857 690.102857c-94.573714 0-112.420571-48.091429-112.420571-81.408 0-33.28 19.638857-72.155429 99.913143-77.677714 78.518857-5.558857 187.355429 59.172571 187.355428 59.172571S457.508571 690.102857 362.934857 690.102857z" fill="#FFFFFF" p-id="6685"></path></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg t="1781956601412" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5833" width="200" height="200"><path d="M512 960.15c-247.04 0-448-200.982-448-448s200.96-448 448-448c247.019 0 448 200.98 448 448s-200.981 448-448 448z" fill="#008DEF" p-id="5834"></path><path d="M576.341 852.864L723.52 655.659l-75.861-2.496 89.344-154.24s32.128-53.654-26.347-76.8L296.704 256.768s-23.147-14.208-19.285 26.283c0 0 9.002 80.96 44.992 114.368l207.616 73.258-197.334-43.072s-28.928-7.082-18.005 26.987c0 0 22.507 68.779 72 86.123l156.843 1.92-136.918 21.845s-24.426 3.861-2.581 31.488c0 0 35.99 50.133 88.704 53.995l77.781-16.982-21.226 92.352h62.442l-35.392 127.531z" fill="#FFFFFF" p-id="5835"></path></svg>

Before

Width:  |  Height:  |  Size: 739 B

View File

@@ -1 +0,0 @@
<svg t="1781958834251" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="19766" width="200" height="200"><path d="M512 512m-512 0a512 512 0 1 0 1024 0 512 512 0 1 0-1024 0Z" fill="#333333" p-id="19767"></path><path d="M759.296 448.512c-48.896 0-96.256-15.872-135.424-45.056v204.8c0 104.448-80.64 189.184-179.968 189.184s-179.968-84.736-179.968-189.184 80.64-189.184 179.968-189.184c9.984 0 19.712 0.768 28.928 2.56V529.92c-8.96-3.584-18.688-5.376-28.16-5.376-44.288 0-80.384 37.632-80.384 84.48s36.096 84.48 80.384 84.48 80.128-37.632 80.128-84.48V202.24h100.352c0 78.336 60.416 141.568 134.656 141.568v104.704h-0.512" fill="#FFFFFF" p-id="19768"></path></svg>

Before

Width:  |  Height:  |  Size: 703 B

View File

@@ -1 +0,0 @@
<svg t="1781956632439" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8786" width="200" height="200"><path d="M208.979592 1024h606.040816c115.461224 0 208.979592-93.518367 208.979592-208.979592V208.979592C1024 93.518367 930.481633 0 815.020408 0H208.979592C93.518367 0 0 93.518367 0 208.979592v606.040816c0 115.461224 93.518367 208.979592 208.979592 208.979592z" fill="#FFFFFF" p-id="8787"></path><path d="M530.73502 543.764898l187.036735-129.776327c0-142.837551-112.222041-246.804898-112.222041-246.804898h-399.25551c224.653061 142.837551 324.440816 376.581224 324.440816 376.581225z" fill="#00DAB8" p-id="8788"></path><path d="M929.938286 387.970612s-37.407347 26.017959-87.35347 155.794286c-56.006531 145.763265-212.114286 233.848163-212.114285 233.848163s-194.35102-121.417143-274.912653-169.795918c68.649796 20.897959 143.046531-56.52898 187.559183-102.922449 37.511837-38.974694 74.919184-90.906122 162.272653-129.880816 87.24898-38.974694 224.548571 12.956735 224.548572 12.956734z m-574.380408 219.846531c-8.254694-2.507755-16.404898-6.478367-24.450613-12.016327-20.897959-14.524082-6.791837-6.791837 24.450613 12.016327z" fill="#0C3AA0" p-id="8789"></path><path d="M94.040816 362.025796v0.10449c533.942857 505.208163 702.066939 260.91102 710.844082 247.222857-7.105306 11.284898-116.506122 182.543673-249.208163 220.16C231.549388 921.464163 94.040816 751.563755 94.040816 751.563755z m710.844082 247.327347l0.313469-0.626939-0.313469 0.626939z" fill="#296DFF" p-id="8790"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1 +0,0 @@
<svg t="1781956775290" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="17517" width="200" height="200"><path d="M512 1024C229.2224 1024 0 794.7776 0 512S229.2224 0 512 0s512 229.2224 512 512-229.2224 512-512 512z m259.1488-568.8832H480.4096a25.2928 25.2928 0 0 0-25.2928 25.2928l-0.0256 63.2064c0 13.952 11.3152 25.2928 25.2672 25.2928h177.024c13.9776 0 25.2928 11.3152 25.2928 25.2672v12.6464a75.8528 75.8528 0 0 1-75.8528 75.8528H366.592a25.2928 25.2928 0 0 1-25.2672-25.2928v-240.1792a75.8528 75.8528 0 0 1 75.8272-75.8528h353.9456a25.2928 25.2928 0 0 0 25.2672-25.2928l0.0768-63.2064a25.2928 25.2928 0 0 0-25.2672-25.2928H417.152a189.6192 189.6192 0 0 0-189.6192 189.6448v353.9456c0 13.9776 11.3152 25.2928 25.2928 25.2928h372.9408a170.6496 170.6496 0 0 0 170.6496-170.6496v-145.408a25.2928 25.2928 0 0 0-25.2928-25.2672z" fill="#C71D23" p-id="17518"></path></svg>

Before

Width:  |  Height:  |  Size: 913 B

View File

@@ -1 +0,0 @@
<svg t="1781956761194" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="16514" width="200" height="200"><path d="M0 0m0 0l1024 0q0 0 0 0l0 1024q0 0 0 0l-1024 0q0 0 0 0l0-1024q0 0 0 0Z" fill="#FFFFFF" p-id="16515"></path><path d="M85.333333 512a426.666667 426.666667 0 1 0 853.333334 0 426.666667 426.666667 0 1 0-853.333334 0Z" fill="#181717" p-id="16516"></path><path d="M145.365333 730.346667a427.882667 427.882667 0 0 0 231.744 186.517333c21.354667 3.904 29.141333-9.258667 29.141334-20.586667 0-4.693333-0.085333-12.949333-0.192-24.021333-0.128-12.842667-0.277333-29.418667-0.384-48.533333-118.677333 25.792-143.722667-57.194667-143.722667-57.194667-19.413333-49.28-47.381333-62.4-47.381333-62.4-38.741333-26.453333 2.922667-25.941333 2.922666-25.941333 42.837333 3.029333 65.365333 43.968 65.365334 43.968 38.058667 65.194667 99.861333 46.357333 124.16 35.477333 3.882667-27.584 14.890667-46.4 27.093333-57.066667-94.72-10.752-194.346667-47.36-194.346667-210.858666 0-46.570667 16.64-84.672 43.925334-114.496-4.394667-10.794667-19.050667-54.186667 4.16-112.917334 0 0 35.84-11.477333 117.333333 43.733334a408.661333 408.661333 0 0 1 106.837333-14.357334c36.224 0.170667 72.746667 4.906667 106.794667 14.357334 81.472-55.210667 117.226667-43.733333 117.226667-43.733334 23.274667 58.752 8.64 102.122667 4.245333 112.917334 27.349333 29.824 43.861333 67.925333 43.861333 114.496 0 163.904-99.754667 199.978667-194.816 210.517333 15.317333 13.184 28.970667 39.210667 28.970667 79.04 0 38.122667-0.234667 71.338667-0.384 93.354667-0.085333 10.88-0.149333 19.029333-0.149333 23.68 0 11.413333 7.68 24.682667 29.333333 20.522666a427.584 427.584 0 0 0 196.437333-136.234666C765.312 877.013333 645.845333 938.666667 512 938.666667c-155.84 0-292.181333-83.562667-366.634667-208.32z" fill="#FFFFFF" p-id="16517"></path></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1 +0,0 @@
<svg t="1783506644977" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6094" width="200" height="200"><path d="M214.101333 512c0-32.512 5.546667-63.701333 15.36-92.928L57.173333 290.218667A491.861333 491.861333 0 0 0 4.693333 512c0 79.701333 18.858667 154.88 52.394667 221.610667l172.202667-129.066667A290.56 290.56 0 0 1 214.101333 512" fill="#FBBC05" p-id="6095"></path><path d="M516.693333 216.192c72.106667 0 137.258667 25.002667 188.458667 65.962667L854.101333 136.533333C763.349333 59.178667 646.997333 11.392 516.693333 11.392c-202.325333 0-376.234667 113.28-459.52 278.826667l172.373334 128.853333c39.68-118.016 152.832-202.88 287.146666-202.88" fill="#EA4335" p-id="6096"></path><path d="M516.693333 807.808c-134.357333 0-247.509333-84.864-287.232-202.88l-172.288 128.853333c83.242667 165.546667 257.152 278.826667 459.52 278.826667 124.842667 0 244.053333-43.392 333.568-124.757333l-163.584-123.818667c-46.122667 28.458667-104.234667 43.776-170.026666 43.776" fill="#34A853" p-id="6097"></path><path d="M1005.397333 512c0-29.568-4.693333-61.44-11.648-91.008H516.650667V614.4h274.602666c-13.696 65.962667-51.072 116.650667-104.533333 149.632l163.541333 123.818667c93.994667-85.418667 155.136-212.650667 155.136-375.850667" fill="#4285F4" p-id="6098"></path></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg t="1781956725670" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14587" width="200" height="200"><path d="M211.992539 810.97913a425.984 425.984 0 1 0 602.421836-602.442865 425.984 425.984 0 1 0-602.421836 602.442865Z" fill="#00A0E9" p-id="14588"></path><path d="M383.5392 657.5616c-10.3936-18.8928-21.0944-38.4512-31.9488-58.112-1.9968 3.0208-4.0448 6.8096-6.8096 10.0352-5.888 6.8608-11.6224 14.0288-18.432 19.9168-5.9904 5.2224-11.4176 3.4304-14.592-4.0448-5.4272-12.9024-6.6048-26.6752-4.3008-40.192 5.7856-33.5872 22.0672-62.0032 45.1072-86.6816 3.0208-3.2256 4.096-6.3488 3.7376-10.5472-1.1776-13.9264-1.024-27.7504 5.0176-40.704 1.3824-3.0208 2.9184-6.5536 5.4272-8.2944 4.7104-3.328 5.1712-7.6288 5.4784-12.7488 1.5872-26.3168 6.7584-51.8144 19.3536-75.264 21.504-40.0384 56.064-62.0032 99.9424-68.4544 50.176-7.3728 93.696 7.3728 127.6928 45.9264 21.8112 24.7296 30.2592 55.0912 33.9456 87.1424 0.256 2.3552 0.512 4.7104 0.5632 7.1168 0.256 6.656 0.9216 12.3392 6.656 17.7664 6.8096 6.4 8.8064 16.2304 9.3696 25.6512 0.4608 7.8848-0.1536 15.8208 0.256 23.7056 0.1536 2.6112 1.28 5.7344 3.0208 7.68 23.3472 25.4976 40.192 54.528 45.9264 88.9344 2.0992 12.4416 0.8192 25.344-3.7888 37.4272-3.584 9.3696-9.3184 11.3152-16.7424 4.7616-6.2976-5.5296-11.52-12.3904-16.9472-18.8928-2.7136-3.2768-4.8128-7.1168-6.8608-10.1376-10.8032 19.6096-21.504 39.168-32.2048 58.6752 4.864 3.072 10.9568 6.4 16.384 10.496 9.7792 7.3216 15.8208 17.0496 16.7936 29.4912 1.024 13.4144-5.888 24.32-20.0704 31.3344-14.3872 7.0656-30.0032 8.704-45.6192 9.3696-22.6304 0.9728-44.5952-3.2256-65.3824-12.0832-7.1168-3.0208-13.568-8.0384-19.6096-12.9536-6.0416-4.864-18.0224-5.4272-23.5008 0.1024-10.9056 11.008-24.8832 15.6672-39.3216 19.5072-27.392 7.2704-54.7328 7.6288-82.0224-0.2048-2.816-0.8192-5.5808-1.9456-8.2944-3.1744-29.5424-13.4656-24.4224-41.9328-10.0864-56.064 6.7072-6.656 15.0528-11.4688 21.8624-16.4864z" fill="#FFFFFF" p-id="14589"></path></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1 +0,0 @@
<svg t="1781956677353" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11244" width="200" height="200"><path d="M0 0h1024v1024H0z" fill-opacity="0" p-id="11245"></path><path d="M1024 636c0-141.9-141.9-257.4-301.5-257.4-169.1 0-301.9 115.5-301.9 257.4 0 142.3 132.8 257.4 301.9 257.4 35.4 0 71.1-9 106.5-17.6l97.5 53.4-26.7-88.8C970.9 787 1024 715.8 1024 636z m-399.4-44.4c-17.6 0-35.3-17.7-35.3-35.4 0-17.6 17.7-35.3 35.3-35.3 26.8 0 44.5 17.7 44.5 35.3 0 17.8-17.7 35.4-44.5 35.4z m195.4 0c-17.7 0-35.3-17.7-35.3-35.4 0-17.6 17.6-35.3 35.3-35.3 26.7 0 44.4 17.7 44.4 35.3 0 17.8-18.1 35.4-44.4 35.4zM693.2 347.2c11.6 0 23.3 0.8 34.9 2C696.9 203.1 540.8 94.8 362.5 94.8 163.4 94.8 0 230.4 0 403c0 99.6 54.2 181.4 144.9 244.9L108.8 757l126.8-63.8c45.4 8.8 81.9 18.1 126.8 18.1 11.2 0 22.5-0.4 33.7-1.6C389 685.6 385 660 385 633.9c0-158.2 136.1-286.7 308.2-286.7z m-195.1-98.3c27.3 0 45.3 18.1 45.3 45.4 0 27.3-18.1 45.3-45.3 45.3-27.3 0-54.6-18.1-54.6-45.3 0.5-27.4 27.7-45.4 54.6-45.4z m-253.6 90.7c-27.3 0-54.6-18-54.6-45.3 0-27.3 27.3-45.4 54.6-45.4s45.4 18.1 45.4 45.4c-0.1 26.8-18.1 45.3-45.4 45.3z" fill="#21C110" p-id="11246"></path></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1 +0,0 @@
<svg t="1782041442245" class="icon" viewBox="0 0 1229 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6851" width="200" height="200"><path d="M702.72 849.92c-76.8 30.72-158.72 35.84-240.64 30.72-35.84-5.12-71.68-10.24-107.52-20.48-5.12 0-10.24 0-15.36 5.12-46.08 20.48-92.16 46.08-133.12 66.56-15.36 10.24-30.72 10.24-46.08 0s-15.36-25.6-15.36-46.08c10.24-35.84 10.24-71.68 15.36-107.52 0-5.12-5.12-10.24-5.12-15.36-51.2-51.2-92.16-102.4-122.88-168.96-51.2-122.88-40.96-245.76 30.72-358.4C134.4 112.64 247.04 46.08 380.16 15.36S641.28 0 764.16 61.44c112.64 56.32 194.56 143.36 230.4 266.24 15.36 46.08 20.48 92.16 15.36 138.24-25.6-25.6-56.32-30.72-87.04-15.36 0-30.72 0-61.44-10.24-92.16-20.48-71.68-61.44-128-112.64-174.08-87.04-71.68-194.56-102.4-307.2-102.4-117.76 10.24-220.16 51.2-302.08 133.12-66.56 66.56-102.4 148.48-97.28 245.76 5.12 81.92 40.96 148.48 92.16 204.8l40.96 40.96c20.48 15.36 25.6 30.72 15.36 51.2-5.12 20.48-10.24 46.08-15.36 66.56 0 5.12-5.12 10.24 0 10.24 5.12 5.12 10.24 0 10.24 0 25.6-15.36 56.32-30.72 81.92-51.2 15.36-10.24 30.72-10.24 51.2-5.12 87.04 25.6 179.2 25.6 266.24 0 5.12 0 10.24-5.12 10.24 5.12 10.24 30.72 25.6 51.2 56.32 66.56z" fill="#0082EF" p-id="6852"></path><path d="M1214.72 747.52c0 35.84-25.6 61.44-56.32 66.56-51.2 10.24-92.16 30.72-128 66.56-10.24 10.24-15.36 10.24-25.6 5.12-5.12-5.12-5.12-15.36 0-25.6 35.84-35.84 56.32-81.92 66.56-128 5.12-35.84 40.96-56.32 76.8-56.32 40.96 5.12 66.56 35.84 66.56 71.68z" fill="#0081EE" p-id="6853"></path><path d="M953.6 1024c-35.84 0-66.56-25.6-71.68-56.32-5.12-51.2-30.72-92.16-66.56-122.88-5.12-5.12-10.24-10.24-5.12-20.48 5.12-15.36 15.36-15.36 25.6-10.24 10.24 5.12 15.36 15.36 20.48 20.48 30.72 25.6 66.56 40.96 102.4 46.08 35.84 5.12 61.44 40.96 56.32 76.8 5.12 35.84-25.6 66.56-61.44 66.56z" fill="#FA6202" p-id="6854"></path><path d="M682.24 757.76c0-35.84 20.48-61.44 56.32-71.68 51.2-10.24 92.16-30.72 128-66.56 10.24-10.24 20.48-10.24 25.6 0 5.12 5.12 5.12 15.36-5.12 25.6-30.72 30.72-51.2 66.56-61.44 112.64 0 5.12 0 15.36-5.12 20.48-10.24 35.84-40.96 56.32-76.8 51.2-35.84-5.12-61.44-35.84-61.44-71.68z" fill="#FECD00" p-id="6855"></path><path d="M1035.52 578.56c15.36 30.72 30.72 56.32 51.2 76.8 10.24 10.24 10.24 20.48 5.12 25.6-5.12 10.24-15.36 10.24-25.6 0-25.6-30.72-61.44-51.2-97.28-61.44-10.24-5.12-20.48-5.12-30.72-5.12-20.48-5.12-40.96-15.36-46.08-40.96-10.24-25.6-10.24-51.2 10.24-71.68 20.48-25.6 46.08-30.72 71.68-25.6 25.6 10.24 46.08 25.6 51.2 56.32 0 15.36 5.12 30.72 10.24 46.08z" fill="#2CBD00" p-id="6856"></path></svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,94 +1,13 @@
<script lang="ts" setup>
import type { ChannelMchOption } from '#/types/web';
import { computed } from 'vue';
import { ChannelLogo } from '@daxpay/ui-biz/channel';
/**
* 通道商户下拉选择
*
* - 选项数据由调用方通过 `options` 传入(类型 [ChannelMchOption], 含 product/channel 字段)
* - 下拉项与选中态都会渲染支付产品图标(通过 [ChannelLogo] 派生)
* - 当多个通道商户名重复时, 图标可辅助区分
*/
const props = withDefaults(
defineProps<{
allowClear?: boolean;
disabled?: boolean;
/** 通道商户候选, 携带 product/channel 用于显示图标 */
options?: ChannelMchOption[];
placeholder?: string;
/** 自定义下拉尺寸的 class */
rootClassName?: string;
/** 是否启用搜索(默认开启) */
showSearch?: boolean;
/** 当前选中的通道商户号 */
value?: string;
}>(),
{
value: '',
options: () => [],
placeholder: '',
disabled: false,
allowClear: true,
showSearch: true,
rootClassName: '',
},
);
const emit = defineEmits<{
(e: 'update:value', val: string | undefined): void;
(e: 'change', val: string | undefined, option?: ChannelMchOption): void;
}>();
const innerOptions = computed(() => props.options);
// 选中态根据 value 查回 option 拿 product/channel 渲染图标
const selectedOption = computed<ChannelMchOption | undefined>(() =>
innerOptions.value.find((o) => o.value === props.value),
);
function onChange(val: string | undefined, option: ChannelMchOption | undefined) {
emit('update:value', val);
emit('change', val, option);
}
// 兼容旧路径 `#/components/channel/ChannelMerchantSelect.vue`
import { ChannelMerchantSelect } from '@daxpay/ui-biz/channel';
defineOptions({ inheritAttrs: false });
</script>
<template>
<a-select
:value="value"
:options="innerOptions"
:placeholder="placeholder"
:disabled="disabled"
:allow-clear="allowClear"
:show-search="showSearch"
:class="rootClassName"
option-filter-prop="label"
option-label-prop="label"
@change="onChange"
>
<template #optionRender="{ option }">
<div class="flex items-center gap-2">
<!-- 支付产品图标(优先产品级, 回退到通道级) -->
<ChannelLogo
:product="(option.data as ChannelMchOption).product"
:channel="(option.data as ChannelMchOption).channel"
:size="18"
/>
<span>{{ option.data.label }}</span>
</div>
<ChannelMerchantSelect v-bind="$attrs">
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
<template #labelRender>
<div class="inline-flex items-center gap-1">
<ChannelLogo
v-if="selectedOption"
:product="selectedOption.product"
:channel="selectedOption.channel"
:size="14"
/>
<span>{{ selectedOption?.label ?? value }}</span>
</div>
</template>
</a-select>
</ChannelMerchantSelect>
</template>

View File

@@ -1 +1 @@
export { ChannelLogo, resolveChannelLogoUrl } from '@daxpay/ui-biz/channel';
export { ChannelLogo, ChannelMerchantSelect, resolveChannelLogoUrl } from '@daxpay/ui-biz/channel';

View File

@@ -1,56 +0,0 @@
<script setup lang="ts">
import type { PasswordPolicyValidateConfig } from '#/api/system/security.api';
import { useSlots } from 'vue';
import PasswordStrength from './PasswordStrength.vue';
interface Props {
/**
* 是否显示密码强度
*/
passwordStrength?: boolean;
/**
* 密码策略配置
*/
config?: PasswordPolicyValidateConfig;
}
defineOptions({
inheritAttrs: false,
});
const { passwordStrength = false, config = {} } = defineProps<Props>();
const modelValue = defineModel<string>();
const slots = useSlots();
</script>
<template>
<div class="input-password-wrapper">
<a-input-password v-bind="$attrs" v-model:value="modelValue" />
<template v-if="passwordStrength">
<PasswordStrength :password="modelValue" :config="config" />
<p v-if="slots.strengthText" class="strength-text">
<slot name="strengthText" />
</p>
</template>
</div>
</template>
<style lang="less" scoped>
.input-password-wrapper {
width: 100%;
}
.strength-text {
margin-top: 6px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.dark .strength-text {
color: rgba(255, 255, 255, 0.45);
}
</style>

View File

@@ -1 +1 @@
export { default as InputPassword } from './InputPassword.vue';
export { InputPassword } from '@daxpay/ui-biz/components/input-password';

View File

@@ -1,24 +1,13 @@
<script lang="ts" setup>
defineOptions({ name: 'RouteQueryMissingState' });
defineProps<{
/** 空状态描述(已国际化) */
description: string;
/** 返回按钮文案(已国际化) */
backText: string;
}>();
const emits = defineEmits<{
back: [];
}>();
// 兼容旧路径 `#/components/route/RouteQueryMissingState.vue`
import { RouteQueryMissingState } from '@daxpay/ui-biz/components/route';
defineOptions({ inheritAttrs: false });
</script>
<template>
<div class="m-4 flex items-center justify-center" style="min-height: 400px">
<a-empty :description="description">
<a-button type="primary" @click="emits('back')">
{{ backText }}
</a-button>
</a-empty>
</div>
<RouteQueryMissingState v-bind="($attrs as any)">
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</RouteQueryMissingState>
</template>

View File

@@ -0,0 +1 @@
export { RouteQueryMissingState } from '@daxpay/ui-biz/components/route';

View File

@@ -1,95 +1,13 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { socialColorMap, socialLogoMap } from '#/enums/social';
const props = withDefaults(
defineProps<{
// 平台编码(weChat/weCom/qq/github/gitee/feishu/dingTalk/douyin/alipay)
source: string;
// 图标尺寸(px)
size?: number;
// 圆角, 默认圆形
radius?: string;
}>(),
{
size: 64,
radius: '50%',
},
);
// SVG 文件 URL, 找不到时为 undefined(显示占位块)
const logoSrc = computed(() => {
const fileName = socialLogoMap[props.source];
if (!fileName) {
return undefined;
}
try {
return new URL(`/src/assets/social/${fileName}.svg`, import.meta.url).href;
} catch {
return undefined;
}
});
// 品牌色(用于占位块背景)
const brandColor = computed(
() => socialColorMap[props.source] ?? '#8b8b8b',
);
// 占位块显示的平台首字母
const fallbackLetter = computed(() => {
return props.source?.charAt(0)?.toUpperCase() || '?';
});
// 兼容旧路径 `#/components/social/SocialLogo.vue`
import { SocialLogo } from '@daxpay/ui-biz/components/social';
defineOptions({ inheritAttrs: false });
</script>
<template>
<span
class="social-logo"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<img
v-if="logoSrc"
:src="logoSrc"
:width="size"
:height="size"
:alt="source"
class="logo-img"
/>
<span
v-else
class="fallback-icon"
:style="{
width: `${size}px`,
height: `${size}px`,
backgroundColor: brandColor,
borderRadius: radius,
fontSize: `${Math.max(size! * 0.4, 16)}px`,
}"
>
{{ fallbackLetter }}
</span>
</span>
<SocialLogo v-bind="($attrs as any)">
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</SocialLogo>
</template>
<style scoped>
.social-logo {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.logo-img {
display: block;
object-fit: contain;
}
.fallback-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
line-height: 1;
}
</style>

View File

@@ -1 +1 @@
export { default as SocialLogo } from './SocialLogo.vue';
export { SocialLogo } from '@daxpay/ui-biz/components/social';

View File

@@ -1,154 +1 @@
import type { PasswordPolicyValidateConfig } from '#/api/system/security.api';
import { $t } from '@vben/locales';
/**
* 密码条件项
*/
export interface PasswordCondition {
key: string;
label: string;
satisfied: boolean;
}
/**
* 获取密码条件列表(用于显示状态)
*/
export function getPasswordConditions(password: string, config: PasswordPolicyValidateConfig): PasswordCondition[] {
const conditions: PasswordCondition[] = [];
if (!config.enabled) {
return conditions;
}
// 长度条件
if (config.minLength && config.maxLength) {
conditions.push({
key: 'length',
label: $t('iam.user.passwordPolicy.hintLength', { min: config.minLength, max: config.maxLength }),
satisfied: password.length >= config.minLength && password.length <= config.maxLength,
});
}
// 大写字母
if (config.requireUppercase) {
conditions.push({
key: 'uppercase',
label: $t('iam.user.passwordPolicy.hintUppercase'),
satisfied: /[A-Z]/.test(password),
});
}
// 小写字母
if (config.requireLowercase) {
conditions.push({
key: 'lowercase',
label: $t('iam.user.passwordPolicy.hintLowercase'),
satisfied: /[a-z]/.test(password),
});
}
// 数字
if (config.requireDigit) {
conditions.push({
key: 'digit',
label: $t('iam.user.passwordPolicy.hintDigit'),
satisfied: /\d/.test(password),
});
}
// 特殊字符
if (config.requireSpecialChar && config.specialChars) {
conditions.push({
key: 'specialChar',
label: $t('iam.user.passwordPolicy.hintSpecialChar', { chars: config.specialChars }),
satisfied: new RegExp(`[${escapeRegExp(config.specialChars)}]`).test(password),
});
}
return conditions;
}
/**
* 计算密码强度(根据配置)
* 强度 = 满足的条件数 / 总条件数 * 5
*/
export function calculatePasswordStrength(password: string, config: PasswordPolicyValidateConfig): number {
if (!password) return 0;
const conditions = getPasswordConditions(password, config);
const satisfiedCount = conditions.filter((c) => c.satisfied).length;
const totalCount = conditions.length;
if (totalCount === 0) return 0;
// 按比例计算强度1-5
return Math.ceil((satisfiedCount / totalCount) * 5);
}
/**
* 生成简化的表单校验规则
*/
export function generatePasswordRules(config: PasswordPolicyValidateConfig) {
return [
{ required: true, message: $t('iam.user.validation.passwordRequired') },
{
validator: (_rule: any, value: string) => {
if (!value) return Promise.resolve();
// 密码不允许中文
if (/[\u4E00-\u9FA5]/.test(value)) {
return Promise.reject($t('iam.user.validation.passwordNoChinese'));
}
if (!config.enabled) return Promise.resolve();
const conditions = getPasswordConditions(value, config);
const allSatisfied = conditions.every((c) => c.satisfied);
if (!allSatisfied) {
return Promise.reject($t('iam.user.passwordPolicy.notMeetRequirements'));
}
return Promise.resolve();
},
trigger: 'change',
},
];
}
/**
* 账号验证规则(只允许字母、数字、下划线、中划线)
*/
export function generateAccountRules() {
return [
{ required: true, message: $t('common.pleaseInput') },
{ min: 6, max: 20, message: $t('iam.user.validation.accountLength') },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: $t('iam.user.validation.accountFormat') },
];
}
/**
* 计算密码内在强度(固定规则,不依赖后台配置)
* 用于强度条的显示
*/
export function calculateIntrinsicStrength(password: string): number {
if (!password) return 0;
let strength = 0;
// 长度 >= 8
if (password.length >= 8) strength++;
// 包含小写字母
if (/[a-z]/.test(password)) strength++;
// 包含大写字母
if (/[A-Z]/.test(password)) strength++;
// 包含数字
if (/\d/.test(password)) strength++;
// 包含特殊字符
if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) strength++;
return strength;
}
/**
* 转义正则表达式特殊字符
*/
function escapeRegExp(string: string): string {
return string.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
}
export * from '@daxpay/ui-biz/utils/password-validator';

View File

@@ -109,10 +109,18 @@
"types": "./src/utils/timezone-data.ts",
"default": "./src/utils/timezone-data.ts"
},
"./utils/password-validator": {
"types": "./src/utils/password-validator.ts",
"default": "./src/utils/password-validator.ts"
},
"./types/web": {
"types": "./src/types/web.ts",
"default": "./src/types/web.ts"
},
"./types/password-policy": {
"types": "./src/types/password-policy.ts",
"default": "./src/types/password-policy.ts"
},
"./components/charts": {
"types": "./src/components/charts/index.ts",
"default": "./src/components/charts/index.ts"
@@ -145,6 +153,18 @@
"types": "./src/components/login-expired-modal/index.ts",
"default": "./src/components/login-expired-modal/index.ts"
},
"./components/route": {
"types": "./src/components/route/index.ts",
"default": "./src/components/route/index.ts"
},
"./components/input-password": {
"types": "./src/components/input-password/index.ts",
"default": "./src/components/input-password/index.ts"
},
"./components/social": {
"types": "./src/components/social/index.ts",
"default": "./src/components/social/index.ts"
},
"./components/AntdAppProvider": {
"types": "./src/components/AntdAppProvider.vue",
"default": "./src/components/AntdAppProvider.vue"

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 739 B

After

Width:  |  Height:  |  Size: 739 B

View File

Before

Width:  |  Height:  |  Size: 703 B

After

Width:  |  Height:  |  Size: 703 B

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 913 B

After

Width:  |  Height:  |  Size: 913 B

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import type { ChannelMchOption } from '../../types/web';
import { computed } from 'vue';
import ChannelLogo from './ChannelLogo.vue';
/**
* 通道商户下拉选择
*
* - 选项数据由调用方通过 `options` 传入(类型 [ChannelMchOption], 含 product/channel 字段)
* - 下拉项与选中态都会渲染支付产品图标(通过 [ChannelLogo] 派生)
* - 当多个通道商户名重复时, 图标可辅助区分
*/
const props = withDefaults(
defineProps<{
allowClear?: boolean;
disabled?: boolean;
/** 通道商户候选, 携带 product/channel 用于显示图标 */
options?: ChannelMchOption[];
placeholder?: string;
/** 自定义下拉尺寸的 class */
rootClassName?: string;
/** 是否启用搜索(默认开启) */
showSearch?: boolean;
/** 当前选中的通道商户号 */
value?: string;
}>(),
{
value: '',
options: () => [],
placeholder: '',
disabled: false,
allowClear: true,
showSearch: true,
rootClassName: '',
},
);
const emit = defineEmits<{
(e: 'update:value', val: string | undefined): void;
(e: 'change', val: string | undefined, option?: ChannelMchOption): void;
}>();
const innerOptions = computed(() => props.options);
// 选中态根据 value 查回 option 拿 product/channel 渲染图标
const selectedOption = computed<ChannelMchOption | undefined>(() =>
innerOptions.value.find((o) => o.value === props.value),
);
function onChange(val: string | undefined, option: ChannelMchOption | undefined) {
emit('update:value', val);
emit('change', val, option);
}
</script>
<template>
<a-select
:value="value"
:options="innerOptions"
:placeholder="placeholder"
:disabled="disabled"
:allow-clear="allowClear"
:show-search="showSearch"
:class="rootClassName"
option-filter-prop="label"
option-label-prop="label"
@change="onChange"
>
<template #optionRender="{ option }">
<div class="flex items-center gap-2">
<!-- 支付产品图标(优先产品级, 回退到通道级) -->
<ChannelLogo
:product="(option.data as ChannelMchOption).product"
:channel="(option.data as ChannelMchOption).channel"
:size="18"
/>
<span>{{ option.data.label }}</span>
</div>
</template>
<template #labelRender>
<div class="inline-flex items-center gap-1">
<ChannelLogo
v-if="selectedOption"
:product="selectedOption.product"
:channel="selectedOption.channel"
:size="14"
/>
<span>{{ selectedOption?.label ?? value }}</span>
</div>
</template>
</a-select>
</template>

View File

@@ -1,2 +1,3 @@
export { default as ChannelLogo } from './ChannelLogo.vue';
export { default as ChannelMerchantSelect } from './ChannelMerchantSelect.vue';
export { resolveChannelLogoUrl } from './resolveLogo';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { PasswordPolicyValidateConfig } from '#/api/system/security.api';
import type { PasswordPolicyValidateConfig } from '../../types/password-policy';
import { useSlots } from 'vue';

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { PasswordPolicyValidateConfig } from '#/api/system/security.api';
import type { PasswordCondition } from '#/utils/password-validator';
import type { PasswordPolicyValidateConfig } from '../../types/password-policy';
import type { PasswordCondition } from '../../utils/password-validator';
import { computed } from 'vue';
import { IconifyIcon } from '@vben-core/icons';
import { calculateIntrinsicStrength, getPasswordConditions } from '#/utils/password-validator';
import { calculateIntrinsicStrength, getPasswordConditions } from '../../utils/password-validator';
interface Props {
password?: string;

View File

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

View File

@@ -0,0 +1,24 @@
<script lang="ts" setup>
defineOptions({ name: 'RouteQueryMissingState' });
defineProps<{
/** 空状态描述(已国际化) */
description: string;
/** 返回按钮文案(已国际化) */
backText: string;
}>();
const emits = defineEmits<{
back: [];
}>();
</script>
<template>
<div class="m-4 flex items-center justify-center" style="min-height: 400px">
<a-empty :description="description">
<a-button type="primary" @click="emits('back')">
{{ backText }}
</a-button>
</a-empty>
</div>
</template>

View File

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

View File

@@ -0,0 +1,98 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { socialColorMap, socialLogoMap } from '../../enums/social';
// 静态加载所有社交图标 SVG (Vite 构建期解析为资源 URL, 运行时按文件名查找)
const socialIcons = import.meta.glob('../../assets/social/*.svg', {
eager: true,
query: '?url',
import: 'default',
}) as Record<string, string>;
const props = withDefaults(
defineProps<{
// 平台编码(weChat/weCom/qq/github/gitee/feishu/dingTalk/douyin/alipay)
source: string;
// 图标尺寸(px)
size?: number;
// 圆角, 默认圆形
radius?: string;
}>(),
{
size: 64,
radius: '50%',
},
);
// SVG 文件 URL, 找不到时为 undefined(显示占位块)
const logoSrc = computed(() => {
const fileName = socialLogoMap[props.source];
if (!fileName) {
return undefined;
}
return socialIcons[`../../assets/social/${fileName}.svg`];
});
// 品牌色(用于占位块背景)
const brandColor = computed(
() => socialColorMap[props.source] ?? '#8b8b8b',
);
// 占位块显示的平台首字母
const fallbackLetter = computed(() => {
return props.source?.charAt(0)?.toUpperCase() || '?';
});
</script>
<template>
<span
class="social-logo"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<img
v-if="logoSrc"
:src="logoSrc"
:width="size"
:height="size"
:alt="source"
class="logo-img"
/>
<span
v-else
class="fallback-icon"
:style="{
width: `${size}px`,
height: `${size}px`,
backgroundColor: brandColor,
borderRadius: radius,
fontSize: `${Math.max(size! * 0.4, 16)}px`,
}"
>
{{ fallbackLetter }}
</span>
</span>
</template>
<style scoped>
.social-logo {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.logo-img {
display: block;
object-fit: contain;
}
.fallback-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
line-height: 1;
}
</style>

View File

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

View File

@@ -0,0 +1,30 @@
/**
* 密码策略配置
*/
export interface PasswordPolicyConfig {
/** 是否启用 */
enabled?: boolean;
/** 最小长度 */
minLength?: number;
/** 最大长度 */
maxLength?: number;
/** 是否要求大写字母 */
requireUppercase?: boolean;
/** 是否要求小写字母 */
requireLowercase?: boolean;
/** 是否要求数字 */
requireDigit?: boolean;
/** 是否要求特殊字符 */
requireSpecialChar?: boolean;
/** 特殊字符集合 */
specialChars?: string;
/** 密码轮换天数 */
rotationDays?: number;
/** 历史密码检查数量 */
historyCount?: number;
}
/**
* 密码策略校验配置(供前端校验使用)
*/
export type PasswordPolicyValidateConfig = PasswordPolicyConfig;

View File

@@ -0,0 +1,154 @@
import type { PasswordPolicyValidateConfig } from '../types/password-policy';
import { $t } from '@vben/locales';
/**
* 密码条件项
*/
export interface PasswordCondition {
key: string;
label: string;
satisfied: boolean;
}
/**
* 获取密码条件列表(用于显示状态)
*/
export function getPasswordConditions(password: string, config: PasswordPolicyValidateConfig): PasswordCondition[] {
const conditions: PasswordCondition[] = [];
if (!config.enabled) {
return conditions;
}
// 长度条件
if (config.minLength && config.maxLength) {
conditions.push({
key: 'length',
label: $t('iam.user.passwordPolicy.hintLength', { min: config.minLength, max: config.maxLength }),
satisfied: password.length >= config.minLength && password.length <= config.maxLength,
});
}
// 大写字母
if (config.requireUppercase) {
conditions.push({
key: 'uppercase',
label: $t('iam.user.passwordPolicy.hintUppercase'),
satisfied: /[A-Z]/.test(password),
});
}
// 小写字母
if (config.requireLowercase) {
conditions.push({
key: 'lowercase',
label: $t('iam.user.passwordPolicy.hintLowercase'),
satisfied: /[a-z]/.test(password),
});
}
// 数字
if (config.requireDigit) {
conditions.push({
key: 'digit',
label: $t('iam.user.passwordPolicy.hintDigit'),
satisfied: /\d/.test(password),
});
}
// 特殊字符
if (config.requireSpecialChar && config.specialChars) {
conditions.push({
key: 'specialChar',
label: $t('iam.user.passwordPolicy.hintSpecialChar', { chars: config.specialChars }),
satisfied: new RegExp(`[${escapeRegExp(config.specialChars)}]`).test(password),
});
}
return conditions;
}
/**
* 计算密码强度(根据配置)
* 强度 = 满足的条件数 / 总条件数 * 5
*/
export function calculatePasswordStrength(password: string, config: PasswordPolicyValidateConfig): number {
if (!password) return 0;
const conditions = getPasswordConditions(password, config);
const satisfiedCount = conditions.filter((c) => c.satisfied).length;
const totalCount = conditions.length;
if (totalCount === 0) return 0;
// 按比例计算强度1-5
return Math.ceil((satisfiedCount / totalCount) * 5);
}
/**
* 生成简化的表单校验规则
*/
export function generatePasswordRules(config: PasswordPolicyValidateConfig) {
return [
{ required: true, message: $t('iam.user.validation.passwordRequired') },
{
validator: (_rule: any, value: string) => {
if (!value) return Promise.resolve();
// 密码不允许中文
if (/[\u4E00-\u9FA5]/.test(value)) {
return Promise.reject($t('iam.user.validation.passwordNoChinese'));
}
if (!config.enabled) return Promise.resolve();
const conditions = getPasswordConditions(value, config);
const allSatisfied = conditions.every((c) => c.satisfied);
if (!allSatisfied) {
return Promise.reject($t('iam.user.passwordPolicy.notMeetRequirements'));
}
return Promise.resolve();
},
trigger: 'change',
},
];
}
/**
* 账号验证规则(只允许字母、数字、下划线、中划线)
*/
export function generateAccountRules() {
return [
{ required: true, message: $t('common.pleaseInput') },
{ min: 6, max: 20, message: $t('iam.user.validation.accountLength') },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: $t('iam.user.validation.accountFormat') },
];
}
/**
* 计算密码内在强度(固定规则,不依赖后台配置)
* 用于强度条的显示
*/
export function calculateIntrinsicStrength(password: string): number {
if (!password) return 0;
let strength = 0;
// 长度 >= 8
if (password.length >= 8) strength++;
// 包含小写字母
if (/[a-z]/.test(password)) strength++;
// 包含大写字母
if (/[A-Z]/.test(password)) strength++;
// 包含数字
if (/\d/.test(password)) strength++;
// 包含特殊字符
if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) strength++;
return strength;
}
/**
* 转义正则表达式特殊字符
*/
function escapeRegExp(string: string): string {
return string.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
}