feat: 优化数据权限模板与营销抽屉等运营配置体验
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m49s

理顺模板配置流程与文案,并统一左侧菜单对比度、会员/券/用户编辑抽屉与因子标签展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-07 18:25:31 +08:00
parent aaf94f2e5a
commit 8c47c2772a
14 changed files with 1727 additions and 1305 deletions

View File

@ -590,13 +590,13 @@
.custom-left-menu {
--clm-bg: #ffffff;
--clm-text: #1a1c20;
--clm-muted: #8a8f98;
--clm-faint: #b0b4ba;
--clm-muted: #6b7280;
--clm-faint: #9ca3af;
--clm-hover: rgba(15, 16, 17, 0.04);
--clm-selected: var(--app-primary-color-light);
--clm-selected-text: var(--app-primary-color);
--clm-selected-border: color-mix(in srgb, var(--app-primary-color) 32%, transparent);
--clm-border: rgba(15, 16, 17, 0.08);
--clm-selected-border: color-mix(in srgb, var(--app-primary-color) 40%, transparent);
--clm-border: rgba(15, 16, 17, 0.12);
--clm-radius: 8px;
/* 列表卡片统一高度 */
--clm-node-title-line-height: 18px;
@ -856,54 +856,58 @@
:deep(.arco-tree-node-line),
:deep(.arco-tree-node-line-top),
:deep(.arco-tree-node-line-bottom) {
border-color: rgba(15, 16, 17, 0.08) !important;
border-color: var(--clm-border) !important;
}
/*
* 层级树(组织等)节点本身就是可选内容,不能套用「分组头」透明边框/淡字样式。
*/
.tree-node {
margin: calc(var(--clm-node-gap) / 2) 0;
padding: var(--clm-node-padding-y) 8px;
padding: 8px 10px;
border-radius: var(--clm-radius);
border: 1px solid rgba(15, 16, 17, 0.06);
background: transparent;
border: 1px solid var(--clm-border);
background: var(--color-fill-1, #f7f8fa);
box-sizing: border-box;
height: var(--clm-node-height);
min-height: var(--clm-node-height);
max-height: var(--clm-node-height);
overflow: hidden;
height: auto;
min-height: 44px;
max-height: none;
overflow: visible;
display: flex;
align-items: stretch;
align-items: center;
cursor: pointer;
&.tree-node--with-code {
min-height: var(--clm-node-height);
&.tree-node--branch,
&.tree-node--leaf {
background: var(--color-fill-1, #f7f8fa);
border-color: var(--clm-border);
}
&.tree-node--branch {
height: auto;
min-height: 34px;
max-height: none;
margin: calc(var(--clm-node-gap) + 4px) 0 calc(var(--clm-node-gap) / 2);
padding: 6px 8px;
border-color: transparent;
background: transparent;
overflow: visible;
.node-second-row-spacer-cell {
display: none;
}
.node-title {
font-size: 13px;
font-weight: 600;
color: var(--clm-text);
}
&.tree-node--leaf {
background: var(--color-fill-1);
.node-subtitle {
color: var(--clm-muted);
font-size: 12px;
}
.node-subtitle-cell {
padding-top: 2px;
padding-left: 11px;
}
}
&:hover {
background: var(--clm-hover);
border-color: rgba(15, 16, 17, 0.1);
}
&.tree-node--branch:hover {
background: transparent;
border-color: transparent;
border-color: rgba(15, 16, 17, 0.18);
}
&.selected {
@ -914,39 +918,29 @@
.node-title {
color: var(--clm-selected-text) !important;
font-weight: 560;
font-weight: 600;
}
.node-subtitle {
color: var(--clm-muted) !important;
}
&.tree-node--branch {
background: transparent !important;
border-color: transparent !important;
.node-title {
color: var(--clm-muted) !important;
font-weight: 600;
}
.node-title::before {
background: var(--app-primary-color);
}
}
}
}
.tree-node--depth-1.tree-node--branch .node-title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--clm-muted);
.tree-node--depth-1 .node-title {
font-size: 13px;
font-weight: 650;
color: var(--clm-text);
}
.tree-node--depth-2 .node-title {
font-size: 12px;
font-weight: 500;
font-size: 13px;
font-weight: 600;
color: var(--clm-text);
}
.tree-node--depth-3,
@ -954,25 +948,26 @@
.tree-node--depth-5,
.tree-node--depth-6 {
.node-title {
font-size: 11px;
font-weight: 450;
font-size: 12px;
font-weight: 500;
color: var(--clm-text);
}
}
.tree-node--branch .node-title::before {
.tree-node .node-title::before {
content: '';
display: inline-block;
width: 3px;
height: 12px;
margin-right: 8px;
border-radius: 2px;
background: color-mix(in srgb, var(--app-primary-color) 55%, var(--clm-faint));
background: color-mix(in srgb, var(--app-primary-color) 70%, var(--clm-muted));
vertical-align: -2px;
}
.node-subtitle {
color: var(--clm-muted);
font-size: 10px;
font-size: 12px;
}
}
}

View File

@ -257,10 +257,11 @@ const modelTab = computed({
.shell-tabs {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 12px;
padding: 10px 24px;
padding: 12px 24px;
border-bottom: 1px solid var(--color-border-2);
background: var(--color-fill-1);
flex-shrink: 0;
}

View File

@ -1,68 +1,47 @@
<template>
<a-drawer
:visible="visible"
:width="640"
:width="drawerWidth"
unmount-on-close
:esc-to-close="true"
:mask-closable="!confirmLoading"
class="user-edit-drawer"
:header="false"
:footer="false"
class="user-edit-drawer"
:drawer-style="drawerShellStyle"
:body-style="drawerBodyStyle"
@update:visible="onVisibleChange"
@cancel="handleCancel"
>
<div class="ue-drawer">
<header class="ue-drawer__header">
<div class="ue-drawer__title-row">
<div>
<p class="ue-drawer__eyebrow">{{ t('system.user.drawer.eyebrow') }}</p>
<h3 class="ue-drawer__title">
<header class="ue-drawer__bar">
<div class="ue-drawer__bar-main">
<h2 class="ue-drawer__title">
{{ isEdit ? t('system.user.modal.edit') : t('system.user.modal.add') }}
</h3>
</div>
<button type="button" class="ue-drawer__close" @click="handleCancel">
<icon-close />
</button>
</div>
<p class="ue-drawer__desc">{{ t('system.user.drawer.desc') }}</p>
</header>
<div class="ue-drawer__body">
<section class="ue-preview">
<div class="ue-preview__avatar">
<img v-if="avatarUrl" :src="avatarUrl" alt="" />
<span v-else>{{ previewInitial }}</span>
</div>
<div class="ue-preview__meta">
<h4 class="ue-preview__name">
{{ formState.name || t('system.user.drawer.nameFallback') }}
</h4>
<p class="ue-preview__account">
{{ formState.account || t('system.user.drawer.accountFallback') }}
<template v-if="formState.employeeNo"> · {{ formState.employeeNo }}</template>
</p>
<div class="ue-preview__tags">
<span v-if="formState.phone" class="ue-preview__tag">{{ formState.phone }}</span>
<span v-if="orgDisplayName" class="ue-preview__tag">{{ orgDisplayName }}</span>
<span
v-if="employeeFormState.employmentStatus"
class="ue-preview__tag ue-preview__tag--accent"
>
{{ employmentStatusText }}
</span>
</div>
</div>
<div v-if="isEdit && canResetPassword" class="ue-preview__actions">
</h2>
<span class="ue-drawer__chip">{{ t('system.user.drawer.eyebrow') }}</span>
<button
v-if="isEdit && canResetPassword"
type="button"
class="ue-preview__action"
:disabled="resetPwdLoading"
class="ue-drawer__action"
:disabled="resetPwdLoading || confirmLoading"
@click="openResetPasswordModal"
>
{{ t('system.user.actions.resetPassword') }}
</button>
</div>
</section>
<button
type="button"
class="ue-drawer__close"
:aria-label="t('common.cancel')"
:disabled="confirmLoading"
@click="handleCancel"
>
<icon-close />
</button>
</header>
<div class="ue-drawer__body">
<a-form
ref="formRef"
class="ue-form"
@ -72,15 +51,13 @@
:label-col-props="{ span: 24 }"
:wrapper-col-props="{ span: 24 }"
>
<section class="ue-section">
<div class="ue-section__head">
<span class="ue-section__index">01</span>
<div>
<h5 class="ue-section__title">{{ t('system.user.drawer.section.account') }}</h5>
<p class="ue-section__hint">{{ t('system.user.drawer.section.accountHint') }}</p>
<section class="ue-card">
<div class="ue-card__head">
<h4 class="ue-card__title">{{ t('system.user.drawer.section.account') }}</h4>
<span class="ue-card__hint">{{ t('system.user.drawer.section.accountHint') }}</span>
</div>
</div>
<a-row :gutter="16">
<div class="ue-card__body">
<a-row :gutter="[16, 0]">
<a-col :span="12">
<a-form-item :label="t('system.user.form.name')" field="name">
<a-input
@ -168,17 +145,16 @@
</a-form-item>
</a-col>
</a-row>
</div>
</section>
<section class="ue-section">
<div class="ue-section__head">
<span class="ue-section__index">02</span>
<div>
<h5 class="ue-section__title">{{ t('system.user.drawer.section.contact') }}</h5>
<p class="ue-section__hint">{{ t('system.user.drawer.section.contactHint') }}</p>
<section class="ue-card">
<div class="ue-card__head">
<h4 class="ue-card__title">{{ t('system.user.drawer.section.contact') }}</h4>
<span class="ue-card__hint">{{ t('system.user.drawer.section.contactHint') }}</span>
</div>
</div>
<a-row :gutter="16">
<div class="ue-card__body">
<a-row :gutter="[16, 0]">
<a-col :span="12">
<a-form-item :label="t('system.user.form.phone')" field="phone">
<a-input
@ -234,17 +210,17 @@
</a-form-item>
</a-col>
</a-row>
</div>
</section>
<section class="ue-section">
<div class="ue-section__head">
<span class="ue-section__index">03</span>
<div>
<h5 class="ue-section__title">{{ t('system.employee.section') }}</h5>
<p class="ue-section__hint">{{ t('system.user.drawer.section.employeeHint') }}</p>
</div>
<section class="ue-card">
<div class="ue-card__head">
<h4 class="ue-card__title">{{ t('system.employee.section') }}</h4>
<span class="ue-card__hint">{{ t('system.user.drawer.section.employeeHint') }}</span>
</div>
<div class="ue-card__body">
<EmployeeProfileFields :model-value="employeeFormState" hide-title dense />
</div>
</section>
</a-form>
</div>
@ -303,7 +279,6 @@ import EmployeeProfileFields from '@/components/EmployeeProfileFields/index.vue'
import { resetUserPassword } from '@/api/rbac/internal-user'
import type { EmployeeFormState } from '@/types/employee-profile'
import type { UserFormExtState } from '@/types/rbac-user-profile'
import { resolveAvatarUrl } from '@/utils/avatar'
import { buildAdvisorInviteRegisterUrl } from '@/utils/inviteLink'
import { hasPermission } from '@/utils/rbac-permission'
import { $baseMessage } from '@/utils'
@ -362,43 +337,23 @@ const canResetPassword = computed(
() => hasPermission('Rbac.Users.ResetPassword') || hasPermission('Rbac.Users.Update'),
)
const avatarUrl = computed(() => {
const raw = props.formState.avatar?.trim()
return raw ? resolveAvatarUrl(raw) : ''
const drawerWidth = computed(() => {
if (typeof window !== 'undefined' && window.innerWidth < 768) return '100%'
return 640
})
const previewInitial = computed(() => {
const name = props.formState.name?.trim()
if (name) return name.slice(0, 1)
const account = props.formState.account?.trim()
return account ? account.slice(0, 1).toUpperCase() : 'U'
})
const drawerShellStyle = {
borderRadius: '16px 0 0 16px',
overflow: 'hidden',
boxShadow: '-12px 0 40px rgba(15, 23, 42, 0.12)',
}
const orgDisplayName = computed(() => {
const id = props.formState.organizationId
if (!id) return ''
const find = (nodes: any[]): string => {
for (const node of nodes || []) {
if (node.value === id || node.key === id || node.id === id) {
return String(node.label || node.title || node.name || '')
const drawerBodyStyle = {
padding: '0',
background: 'var(--app-page-bg)',
height: '100%',
overflow: 'hidden',
}
const child = find(node.children || [])
if (child) return child
}
return ''
}
return find(props.organizationTreeData || [])
})
const employmentStatusText = computed(() => {
const map: Record<number, string> = {
1: t('system.employee.status.probation'),
2: t('system.employee.status.regular'),
3: t('system.employee.status.suspended'),
4: t('system.employee.status.resigned'),
}
return map[props.employeeFormState.employmentStatus] || ''
})
const advisorInviteUrl = computed(() =>
buildAdvisorInviteRegisterUrl(String(props.formState.account || '')),
@ -514,77 +469,105 @@ defineExpose({
<style scoped lang="less">
.ue-drawer {
--ue-ink: #1a2e38;
--ue-muted: rgba(26, 46, 56, 0.68);
--ue-soft: rgba(26, 46, 56, 0.48);
--ue-radius: 16px;
--ue-edge: var(--app-acrylic-border);
--ue-panel-bg: var(--app-acrylic-bg);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
margin: -16px;
background: var(--app-page-bg, #f0f3f4);
background: var(--app-page-bg);
outline: none;
}
.ue-drawer__header {
flex-shrink: 0;
padding: 18px 20px 14px;
border-bottom: 1px solid var(--ue-edge);
background: var(--ue-panel-bg);
backdrop-filter: var(--app-acrylic-blur);
-webkit-backdrop-filter: var(--app-acrylic-blur);
}
.ue-drawer__title-row {
.ue-drawer__bar {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
margin: 12px 12px 0;
padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 14px;
background: linear-gradient(155deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.72) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 4px 14px rgba(0, 121, 121, 0.05);
}
.ue-drawer__eyebrow {
margin: 0 0 4px;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--app-primary-color);
font-weight: 600;
.ue-drawer__bar-main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
}
.ue-drawer__title {
margin: 0;
font-size: 18px;
line-height: 1.35;
font-size: 16px;
font-weight: 700;
color: var(--ue-ink);
color: var(--color-text-1);
line-height: 1.3;
white-space: nowrap;
}
.ue-drawer__desc {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.55;
color: var(--ue-muted);
.ue-drawer__chip {
flex-shrink: 0;
height: 24px;
padding: 0 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--app-primary-color) 10%, var(--app-card-bg));
color: var(--app-primary-color);
font-size: 12px;
line-height: 24px;
}
.ue-drawer__action {
flex-shrink: 0;
height: 24px;
padding: 0 10px;
border: 1px solid color-mix(in srgb, var(--app-primary-color) 28%, var(--color-border-2));
border-radius: 999px;
background: transparent;
color: var(--app-primary-color);
font-size: 12px;
line-height: 22px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--app-primary-color) 8%, var(--app-card-bg));
border-color: var(--app-primary-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.ue-drawer__close {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--ue-edge);
border-radius: 12px;
background: var(--ue-panel-bg);
color: var(--ue-muted);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 10px;
background: rgba(255, 255, 255, 0.72);
color: var(--color-text-2);
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
transition: color 0.2s, background 0.2s, border-color 0.2s;
&:hover {
border-color: color-mix(in srgb, var(--app-primary-color) 22%, var(--ue-edge));
background: var(--app-acrylic-hover-bg);
&:hover:not(:disabled) {
color: var(--app-primary-color);
border-color: color-mix(in srgb, var(--app-primary-color) 30%, var(--app-card-bg));
background: color-mix(in srgb, var(--app-primary-color) 8%, var(--app-card-bg));
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
@ -592,174 +575,46 @@ defineExpose({
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 16px 10px;
padding: 10px 12px 16px;
}
.ue-preview {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 14px;
padding: 14px 16px;
border-radius: var(--ue-radius);
border: 1px solid var(--ue-edge);
background: var(--ue-panel-bg);
backdrop-filter: var(--app-acrylic-blur);
-webkit-backdrop-filter: var(--app-acrylic-blur);
box-shadow: none;
color: var(--ue-ink);
}
.ue-preview__meta {
flex: 1;
min-width: 0;
}
.ue-preview__actions {
flex-shrink: 0;
margin-left: auto;
}
.ue-preview__action {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 12px;
border: 1px solid var(--ue-edge);
border-radius: 12px;
background: var(--ue-panel-bg);
color: var(--app-primary-color);
font-size: 13px;
font-weight: 600;
line-height: 1;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
&:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--app-primary-color) 22%, var(--ue-edge));
background: var(--app-acrylic-hover-bg);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.ue-reset-tip {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
color: var(--ue-muted);
}
.ue-preview__avatar {
width: 52px;
height: 52px;
border-radius: 14px;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 1px solid var(--ue-edge);
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
color: var(--app-primary-color);
font-size: 20px;
font-weight: 700;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.ue-preview__name {
margin: 0;
font-size: 17px;
font-weight: 700;
line-height: 1.3;
color: var(--ue-ink);
}
.ue-preview__account {
margin: 4px 0 0;
font-size: 12px;
color: var(--ue-soft);
}
.ue-preview__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.ue-preview__tag {
display: inline-flex;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
border: 1px solid var(--ue-edge);
background: color-mix(in srgb, #fff 55%, transparent);
color: var(--ue-muted);
&--accent {
border-color: color-mix(in srgb, var(--app-primary-color) 22%, var(--ue-edge));
background: color-mix(in srgb, var(--app-primary-color) 10%, #fff);
color: var(--app-primary-color);
}
}
.ue-section {
margin-bottom: 14px;
padding: 14px 14px 6px;
border-radius: var(--ue-radius);
border: 1px solid var(--ue-edge);
background: var(--ue-panel-bg);
backdrop-filter: var(--app-acrylic-blur);
-webkit-backdrop-filter: var(--app-acrylic-blur);
box-shadow: none;
}
.ue-section__head {
display: flex;
gap: 10px;
align-items: flex-start;
.ue-card {
margin-bottom: 12px;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 14px;
background: var(--app-card-bg);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 4px 14px rgba(0, 121, 121, 0.04);
overflow: hidden;
}
.ue-section__index {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: var(--app-primary-color);
border: 1px solid color-mix(in srgb, var(--app-primary-color) 18%, var(--ue-edge));
background: color-mix(in srgb, var(--app-primary-color) 10%, #fff);
.ue-card__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 14px 16px 0;
}
.ue-section__title {
.ue-card__title {
margin: 0;
font-size: 14px;
font-weight: 700;
color: var(--ue-ink);
font-size: 15px;
font-weight: 600;
color: var(--color-text-1);
}
.ue-section__hint {
margin: 2px 0 0;
.ue-card__hint {
font-size: 12px;
color: var(--ue-soft);
color: var(--color-text-3);
line-height: 1.4;
}
.ue-card__body {
padding: 12px 16px 4px;
}
.ue-form {
:deep(.arco-form-item) {
margin-bottom: 14px;
@ -768,7 +623,7 @@ defineExpose({
:deep(.arco-form-item-label) {
font-size: 13px;
font-weight: 500;
color: var(--ue-ink);
color: var(--color-text-1);
}
:deep(.arco-input-wrapper),
@ -776,10 +631,9 @@ defineExpose({
:deep(.arco-select-view-multiple),
:deep(.arco-textarea-wrapper),
:deep(.arco-input-number),
:deep(.arco-picker) {
border-radius: 12px;
border-color: color-mix(in srgb, var(--ue-edge) 70%, #d0d5dd);
background: color-mix(in srgb, #fff 72%, transparent);
:deep(.arco-picker),
:deep(.arco-tree-select-view) {
border-radius: 8px;
}
}
@ -807,43 +661,43 @@ defineExpose({
.ue-invite-copy {
flex-shrink: 0;
border-radius: 12px;
}
.ue-invite-hint {
color: var(--ue-soft);
color: var(--color-text-3);
font-size: 12px;
line-height: 1.5;
}
.ue-reset-tip {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
color: var(--color-text-2);
}
.ue-drawer__footer {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 16px 16px;
border-top: 1px solid var(--ue-edge);
background: var(--ue-panel-bg);
backdrop-filter: var(--app-acrylic-blur);
-webkit-backdrop-filter: var(--app-acrylic-blur);
margin: 0 12px 12px;
padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 14px;
background: linear-gradient(155deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.72) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 4px 14px rgba(0, 121, 121, 0.05);
}
</style>
<style lang="less">
.user-edit-drawer {
.arco-drawer-header {
display: none;
}
.arco-drawer-body {
padding: 16px;
padding: 0 !important;
height: 100%;
overflow: hidden;
background: var(--app-page-bg, #f0f3f4);
}
.arco-drawer {
background: var(--app-page-bg, #f0f3f4);
}
}
</style>

View File

@ -102,8 +102,6 @@ export default {
'marketing.memberLevel.section.pricingHint': 'Rolling 12-month points; discount on base freight only',
'marketing.memberLevel.section.display': 'Display & benefits',
'marketing.memberLevel.section.displayHint': 'Portal display only; does not affect pricing',
'marketing.memberLevel.preview.nameFallback': 'Untitled level',
'marketing.memberLevel.preview.codeFallback': 'Code pending',
'menu.arcoWebsite': 'Arco Design',
'menu.faq': 'FAQ',
'menu.example': 'Example',
@ -1370,17 +1368,23 @@ export default {
'system.dataPerm.templates.search': 'Search name or code',
'system.dataPerm.templates.title': 'Permission templates',
'system.dataPerm.templates.loading': 'Loading templates...',
'system.dataPerm.templates.emptySelect': 'Select a template on the left, or click Add',
'system.dataPerm.templates.emptyDesc': 'Templates define five-dimension permission rules and can be applied to users or roles',
'system.dataPerm.templates.save': 'Save',
'system.dataPerm.templates.emptySelect': 'Select a template on the left, or click New template',
'system.dataPerm.templates.emptyDesc':
'Flow: ① name & code → ② configure rules → ③ save, then link a role and apply to role/users',
'system.dataPerm.templates.save': 'Save template',
'system.dataPerm.templates.apply': 'Apply template',
'system.dataPerm.templates.field.name': 'Name',
'system.dataPerm.templates.field.code': 'Code',
'system.dataPerm.templates.field.description': 'Description',
'system.dataPerm.templates.field.name': 'Template name',
'system.dataPerm.templates.field.namePlaceholder': 'e.g. Sales staff data scope',
'system.dataPerm.templates.field.code': 'Template code',
'system.dataPerm.templates.field.codePlaceholder': 'Letters/numbers, e.g. SalesStaff',
'system.dataPerm.templates.field.description': 'Purpose',
'system.dataPerm.templates.field.descriptionPlaceholder':
'For teammates: who this is for and what data they can see',
'system.dataPerm.templates.field.roleCode': 'Bound role code',
'system.dataPerm.templates.field.bindRole': 'Linked role',
'system.dataPerm.templates.field.bindRolePlaceholder': 'Select an RBAC role',
'system.dataPerm.templates.field.bindRoleExtra': 'Saved as roleCode — identifies which job role this template belongs to',
'system.dataPerm.templates.field.bindRole': 'Linked job role',
'system.dataPerm.templates.field.bindRolePlaceholder': 'Optional RBAC role tag',
'system.dataPerm.templates.field.bindRoleExtra':
'Only tags which job this template belongs to — it does not grant access. Use A / B below to apply.',
// Order - Batch Jobs
'order.batchJobs.filter.jobId': 'Job ID',
@ -1453,14 +1457,27 @@ export default {
'order.batchJobs.action.importRow': 'Order Import',
'order.batchJobs.action.unknown': 'Other',
'system.dataPerm.templates.section.bindApply': 'Role link & apply',
'system.dataPerm.templates.section.identity': '① What this template is',
'system.dataPerm.templates.section.identityHint':
'Name and code help you find it in the list; you can configure rules before saving',
'system.dataPerm.templates.section.bindApply': '③ Link role & apply (after save)',
'system.dataPerm.templates.section.applyNow': 'Apply now',
'system.dataPerm.templates.bindApplyHint': 'Linking a role is metadata only. Use "Apply now" below to write template items into role or user permissions. Linking does not apply by itself.',
'system.dataPerm.templates.applyToRole': 'Sync to linked role',
'system.dataPerm.templates.applyToRoleDesc': 'Write template items into the role\'s data permissions (replaces role direct config)',
'system.dataPerm.templates.bindApplyHint':
'Linking a role ≠ granting access. Save and finish rules first, then use A for the role or B for users.',
'system.dataPerm.templates.flow.step1': 'Identity',
'system.dataPerm.templates.flow.step2': 'Rules',
'system.dataPerm.templates.flow.step3': 'Apply',
'system.dataPerm.templates.flow.goItemsTitle': 'Next: configure permission rules',
'system.dataPerm.templates.flow.goItemsDesc':
'Rules define org / data scope / secret level access. Come back here to apply after editing.',
'system.dataPerm.templates.flow.goItemsBtn': 'Configure rules',
'system.dataPerm.templates.applyToRole': 'A. Sync to linked role',
'system.dataPerm.templates.applyToRoleDesc':
'Write current rules into the role data permissions (replaces role direct config)',
'system.dataPerm.templates.applyToRoleBtn': 'Write to role',
'system.dataPerm.templates.applyToUsers': 'Apply to users',
'system.dataPerm.templates.applyToUsersDesc': 'Batch write direct user data permissions; replace or merge',
'system.dataPerm.templates.applyToUsers': 'B. Apply to selected users',
'system.dataPerm.templates.applyToUsersDesc':
'Batch write user direct permissions — for exceptions or temporary grants; role itself unchanged',
'system.dataPerm.templates.applyToUsersBtn': 'Apply to users',
'system.dataPerm.templates.field.targetUsers': 'Target users',
'system.dataPerm.templates.field.targetUsersPlaceholder': 'Search and select internal users',
@ -1468,18 +1485,24 @@ export default {
'system.dataPerm.templates.field.temporary': 'Temporary grant (users only)',
'system.dataPerm.templates.field.effectiveTime': 'Effective from',
'system.dataPerm.templates.field.expireTime': 'Expires at',
'system.dataPerm.templates.mode.replace': 'Replace direct config',
'system.dataPerm.templates.mode.replace': 'Replace direct config (usual)',
'system.dataPerm.templates.mode.merge': 'Merge with existing',
'system.dataPerm.templates.applyNeedSave': 'Save the template before applying',
'system.dataPerm.templates.applyNeedSave':
'Click Save template first — then you can link a role and apply',
'system.dataPerm.templates.bindRoleFirst': 'Select a linked role first',
'system.dataPerm.templates.selectUsersFirst': 'Select target users',
'system.dataPerm.templates.expireTimeRequired': 'Expire time is required for temporary grants',
'system.dataPerm.templates.applySuccess': 'Applied to {count} target(s)',
'system.dataPerm.templates.applyPartial': '{success} succeeded, {fail} failed',
'system.dataPerm.templates.roleCodePlaceholder': 'Optional, auto-apply when role is assigned',
'system.dataPerm.templates.itemsTitle': '模板明细',
'system.dataPerm.templates.itemsHintForm': 'Select a permission type and fill the form — no JSON required',
'system.dataPerm.templates.addExample': 'Add example preset',
'system.dataPerm.templates.itemsTitle': 'Template items',
'system.dataPerm.templates.itemsGuideTitle': '② How to configure rules',
'system.dataPerm.templates.itemsHintForm':
'Each rule picks a dimension (org / data scope / biz tag / secret / filter). Fill the form — no JSON. Use presets for common job roles.',
'system.dataPerm.templates.itemsEmptyHint':
'Unsure? Start from the “Sales staff” preset, then tweak for your job role',
'system.dataPerm.templates.itemIndex': 'Rule {index}',
'system.dataPerm.templates.addExample': 'Generate from preset',
'system.dataPerm.templates.exampleAdded': 'Added example "{name}"',
'system.dataPerm.templates.itemInvalid': 'Item #{index} is incomplete: {field}',
'system.dataPerm.templates.example.salesStaff': 'Sales staff',
@ -1518,7 +1541,7 @@ export default {
'system.dataPerm.templates.form.invalid.filterResource': 'Please select a module',
'system.dataPerm.templates.form.invalid.filterCondition': 'Add at least one filter condition',
'system.dataPerm.templates.itemJsonPlaceholder': 'JSON, e.g. {"dataScope":1}',
'system.dataPerm.templates.addItem': 'Add item row',
'system.dataPerm.templates.addItem': 'Add a rule',
'system.dataPerm.templates.removeItem': 'Remove',
'system.dataPerm.templates.nameCodeRequired': 'Name and code are required',
'system.dataPerm.templates.invalidJson': 'Invalid JSON in template items',
@ -1527,15 +1550,15 @@ export default {
'system.dataPerm.templates.deleteSuccess': 'Deleted',
'system.dataPerm.templates.builtinDeleteDenied': 'Built-in templates cannot be deleted',
'system.dataPerm.templates.deleteConfirm': 'Delete template "{name}"?',
'system.dataPerm.templates.tab.basic': 'Basic info',
'system.dataPerm.templates.tab.items': 'Template items',
'system.dataPerm.templates.tab.basic': 'Basics',
'system.dataPerm.templates.tab.items': 'Rules',
'system.dataPerm.templates.itemsHint': 'Each row is a JSON config for one permission dimension',
'system.dataPerm.templates.itemsEmpty': 'No items yet. Click below to add one.',
'system.dataPerm.templates.itemsEmpty': 'No permission rules yet',
'system.dataPerm.templates.jsonPreset': 'JSON preset',
'system.dataPerm.templates.dimensionPlaceholder': 'Select dimension',
'system.dataPerm.templates.tag.creating': 'Creating',
'system.dataPerm.templates.tag.builtin': 'Built-in',
'system.dataPerm.templates.stat.items': 'Items',
'system.dataPerm.templates.stat.items': 'Rules',
'system.dataPerm.templates.stat.dimensions': 'Dimensions',
'menu.xhs': 'Xiaohongshu',

View File

@ -116,8 +116,11 @@ export default {
'marketing.couponTemplate.grantChannel.route': 'Route',
'marketing.couponTemplate.grantChannel.festival': 'Festival',
'marketing.couponTemplate.validType.fixed': 'Fixed dates',
'marketing.couponTemplate.validType.fixedDesc': 'Valid between start and end time',
'marketing.couponTemplate.validType.afterReceive': 'N days after claim',
'marketing.couponTemplate.validType.afterReceiveDesc': 'Countdown starts on claim day',
'marketing.couponTemplate.validType.afterUnlock': 'N days after unlock',
'marketing.couponTemplate.validType.afterUnlockDesc': 'Countdown starts on unlock day',
'marketing.couponTemplate.actions.publish': 'Publish',
'marketing.couponTemplate.actions.end': 'End',
'marketing.couponTemplate.publishConfirmTitle': 'Publish template',

View File

@ -116,8 +116,11 @@ export default {
'marketing.couponTemplate.grantChannel.route': '线路定向券',
'marketing.couponTemplate.grantChannel.festival': '节日券',
'marketing.couponTemplate.validType.fixed': '固定日期',
'marketing.couponTemplate.validType.fixedDesc': '指定起止时间生效',
'marketing.couponTemplate.validType.afterReceive': '领取后 N 天',
'marketing.couponTemplate.validType.afterReceiveDesc': '领券当日起算有效天数',
'marketing.couponTemplate.validType.afterUnlock': '解锁后 N 天',
'marketing.couponTemplate.validType.afterUnlockDesc': '解锁当日起算有效天数',
'marketing.couponTemplate.actions.publish': '发布',
'marketing.couponTemplate.actions.end': '停发',
'marketing.couponTemplate.publishConfirmTitle': '确认发布券模板',

View File

@ -103,8 +103,6 @@ export default {
'marketing.memberLevel.section.pricingHint': '滚动 12 个月积分定级;折扣只打基础运费',
'marketing.memberLevel.section.display': '展示与权益',
'marketing.memberLevel.section.displayHint': '门户展示用,不影响计价逻辑',
'marketing.memberLevel.preview.nameFallback': '未命名等级',
'marketing.memberLevel.preview.codeFallback': '编码待填写',
'menu.arcoWebsite': 'Arco Design',
'menu.faq': '常见问题',
'menu.example': '示例页',
@ -3560,25 +3558,39 @@ export default {
'system.dataPerm.templates.search': '搜索模板名称/编码',
'system.dataPerm.templates.title': '权限模板',
'system.dataPerm.templates.loading': '正在加载模板列表...',
'system.dataPerm.templates.emptySelect': '请从左侧选择模板,或点击新建',
'system.dataPerm.templates.emptyDesc': '权限模板可批量定义五维权限规则,并一键套用到用户或角色',
'system.dataPerm.templates.save': '保存',
'system.dataPerm.templates.emptySelect': '请从左侧选择模板,或点击「新建模板」',
'system.dataPerm.templates.emptyDesc':
'配置顺序:① 填写名称编码 → ② 配置权限规则 → ③ 保存后关联角色并套用到岗位/用户',
'system.dataPerm.templates.save': '保存模板',
'system.dataPerm.templates.apply': '套用模板',
'system.dataPerm.templates.field.name': '名称',
'system.dataPerm.templates.field.code': '编码',
'system.dataPerm.templates.field.description': '说明',
'system.dataPerm.templates.field.name': '模板名称',
'system.dataPerm.templates.field.namePlaceholder': '如:普通销售数据权限',
'system.dataPerm.templates.field.code': '模板编码',
'system.dataPerm.templates.field.codePlaceholder': '英文/数字,如 SalesStaff',
'system.dataPerm.templates.field.description': '用途说明',
'system.dataPerm.templates.field.descriptionPlaceholder': '写给同事看:这份模板给谁用、能看什么数据',
'system.dataPerm.templates.field.roleCode': '绑定角色 Code',
'system.dataPerm.templates.field.bindRole': '关联角色',
'system.dataPerm.templates.field.bindRolePlaceholder': '选择要关联的 RBAC 角色',
'system.dataPerm.templates.field.bindRoleExtra': '保存后写入 roleCode,用于标识「哪个岗位对应这份模板」',
'system.dataPerm.templates.section.bindApply': '关联角色与套用',
'system.dataPerm.templates.field.bindRole': '关联岗位角色',
'system.dataPerm.templates.field.bindRolePlaceholder': '选择要打标的 RBAC 角色(可空)',
'system.dataPerm.templates.field.bindRoleExtra':
'只是给模板打上「属于哪个岗位」的标签,不会自动改权限;真正生效请用下方 A / B 操作',
'system.dataPerm.templates.section.identity': '① 模板是什么',
'system.dataPerm.templates.section.identityHint': '先起好名称和编码,方便在左侧列表里辨认;保存前可先去配置规则',
'system.dataPerm.templates.section.bindApply': '③ 关联角色并套用(保存后才能生效)',
'system.dataPerm.templates.section.applyNow': '立即生效',
'system.dataPerm.templates.bindApplyHint': '「关联角色」是模板元数据,标明岗位归属;下方「立即生效」才会把明细写入角色或用户的权限配置。两者不重复:关联不等于已套用。',
'system.dataPerm.templates.applyToRole': '同步到关联角色',
'system.dataPerm.templates.applyToRoleDesc': '将本模板明细写入该角色的数据权限(覆盖角色直配)',
'system.dataPerm.templates.bindApplyHint':
'关联角色 ≠ 已授权。请先保存模板并配好「权限规则」,再选 A 写入角色,或选 B 套用到具体用户。',
'system.dataPerm.templates.flow.step1': '填身份',
'system.dataPerm.templates.flow.step2': '配规则',
'system.dataPerm.templates.flow.step3': '套用生效',
'system.dataPerm.templates.flow.goItemsTitle': '下一步:配置权限规则',
'system.dataPerm.templates.flow.goItemsDesc': '规则决定「能看哪些组织 / 数据范围 / 密级」等,配完再回来套用',
'system.dataPerm.templates.flow.goItemsBtn': '去配置规则',
'system.dataPerm.templates.applyToRole': 'A. 同步到关联角色',
'system.dataPerm.templates.applyToRoleDesc': '把当前规则写入该角色的数据权限(覆盖角色上原有直配)',
'system.dataPerm.templates.applyToRoleBtn': '写入角色权限',
'system.dataPerm.templates.applyToUsers': '套用到指定用户',
'system.dataPerm.templates.applyToUsersDesc': '批量写入用户的直配数据权限,可选择覆盖或合并',
'system.dataPerm.templates.applyToUsers': 'B. 套用到指定用户',
'system.dataPerm.templates.applyToUsersDesc': '批量写入用户直配权限;适合特例或临时授权,不影响角色本身',
'system.dataPerm.templates.applyToUsersBtn': '套用到用户',
'system.dataPerm.templates.field.targetUsers': '目标用户',
'system.dataPerm.templates.field.targetUsersPlaceholder': '搜索并选择内部用户',
@ -3586,9 +3598,9 @@ export default {
'system.dataPerm.templates.field.temporary': '临时授权(仅对用户生效)',
'system.dataPerm.templates.field.effectiveTime': '生效时间',
'system.dataPerm.templates.field.expireTime': '过期时间',
'system.dataPerm.templates.mode.replace': '覆盖直配',
'system.dataPerm.templates.mode.replace': '覆盖直配(常用)',
'system.dataPerm.templates.mode.merge': '与现有合并',
'system.dataPerm.templates.applyNeedSave': '请先保存模板,再进行套用操作',
'system.dataPerm.templates.applyNeedSave': '新建模板请先点右上角「保存模板」,保存后才能关联角色并套用',
'system.dataPerm.templates.bindRoleFirst': '请先选择关联角色',
'system.dataPerm.templates.selectUsersFirst': '请选择目标用户',
'system.dataPerm.templates.expireTimeRequired': '临时授权需填写过期时间',
@ -3597,8 +3609,8 @@ export default {
'system.dataPerm.templates.roleCodePlaceholder': '可选,分配角色时自动套用',
'system.dataPerm.templates.itemsTitle': '模板明细',
'system.dataPerm.templates.itemJsonPlaceholder': 'JSON,如 {"dataScope":1}',
'system.dataPerm.templates.addItem': '添加明细行',
'system.dataPerm.templates.removeItem': '删',
'system.dataPerm.templates.addItem': '添加一条规则',
'system.dataPerm.templates.removeItem': '删除',
'system.dataPerm.templates.nameCodeRequired': '请填写名称和编码',
'system.dataPerm.templates.invalidJson': '明细 JSON 格式不正确',
'system.dataPerm.templates.createSuccess': '创建成功',
@ -3607,10 +3619,14 @@ export default {
'system.dataPerm.templates.builtinDeleteDenied': '内置模板不可删除',
'system.dataPerm.templates.deleteConfirm': '确定删除模板「{name}」?',
'system.dataPerm.templates.tab.basic': '基本信息',
'system.dataPerm.templates.tab.items': '模板明细',
'system.dataPerm.templates.tab.items': '权限规则',
'system.dataPerm.templates.itemsGuideTitle': '② 权限规则怎么配',
'system.dataPerm.templates.itemsHint': '每条明细对应一个维度的权限 JSON 配置',
'system.dataPerm.templates.itemsHintForm': '选择权限类型后填写表单即可,无需手写 JSON',
'system.dataPerm.templates.addExample': '添加示例配置',
'system.dataPerm.templates.itemsHintForm':
'每条规则选一种维度(组织 / 数据范围 / 业务标签 / 密级 / 过滤),填表即可,不用写 JSON。可先用「示例配置」一键生成常用岗位。',
'system.dataPerm.templates.itemsEmptyHint': '不会配时可先点「普通销售」示例,再按实际岗位微调',
'system.dataPerm.templates.itemIndex': '规则 {index}',
'system.dataPerm.templates.addExample': '用示例快速生成',
'system.dataPerm.templates.exampleAdded': '已添加示例「{name}」',
'system.dataPerm.templates.itemInvalid': '第 {index} 条明细未填写完整:{field}',
'system.dataPerm.templates.example.salesStaff': '普通销售',
@ -3648,12 +3664,12 @@ export default {
'system.dataPerm.templates.form.invalid.secretLevels': '请至少勾选一个密级',
'system.dataPerm.templates.form.invalid.filterResource': '请选择适用模块',
'system.dataPerm.templates.form.invalid.filterCondition': '请至少填写一条过滤条件',
'system.dataPerm.templates.itemsEmpty': '暂无明细,点击下方按钮添加',
'system.dataPerm.templates.itemsEmpty': '还没有权限规则',
'system.dataPerm.templates.jsonPreset': 'JSON 预设',
'system.dataPerm.templates.dimensionPlaceholder': '选择维度',
'system.dataPerm.templates.dimensionPlaceholder': '选择权限维度',
'system.dataPerm.templates.tag.creating': '新建中',
'system.dataPerm.templates.tag.builtin': '内置',
'system.dataPerm.templates.stat.items': '明细数',
'system.dataPerm.templates.stat.items': '规则数',
'system.dataPerm.templates.stat.dimensions': '维度数',
'system.dict.import.modalTitle': '批量导入字典',
'system.dict.import.tip':

View File

@ -0,0 +1,47 @@
/**
* 计费因素「因素值」行内标签:按字符宽度估算可展示数量,避免固定 3 个浪费空间。
* 与 .factor-value-tag max-width:148px / small closable 样式对齐。
*/
const TAG_MAX_WIDTH = 148
const TAG_CHROME_WIDTH = 30 // 内边距 + 关闭按钮
const TAG_GAP = 4
const ADD_BTN_WIDTH = 32
const MORE_BTN_WIDTH = 44
/** 序号 + 因素 + 操作列及沟槽,用于无实测 cell 时估算因素值列宽 */
const OTHER_COLUMNS_WIDTH = 52 + 140 + 64 + 24
export function estimateFactorValueTagWidth(label: string): number {
let text = 0
for (const ch of [...String(label ?? '')]) {
text += /[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/.test(ch) ? 12 : 7
}
return Math.min(TAG_MAX_WIDTH, Math.max(36, Math.ceil(text) + TAG_CHROME_WIDTH))
}
export function estimateFactorValuesColumnWidth(tableWrapWidth: number): number {
return Math.max(200, Math.floor(Number(tableWrapWidth) || 0) - OTHER_COLUMNS_WIDTH)
}
/** 在 availableWidth 内最多可展示几个标签(含关闭钮);装不下时预留 +N */
export function resolveVisibleFactorValueCount(
labels: string[],
availableWidth: number,
): number {
if (!labels.length) return 0
const widths = labels.map((label) => estimateFactorValueTagWidth(label))
const sumWidth = (n: number) =>
widths.slice(0, n).reduce((acc, w, i) => acc + w + (i > 0 ? TAG_GAP : 0), 0)
const fullBudget = Math.max(60, availableWidth - ADD_BTN_WIDTH)
if (sumWidth(labels.length) <= fullBudget) return labels.length
const withMoreBudget = Math.max(60, availableWidth - ADD_BTN_WIDTH - MORE_BTN_WIDTH)
let best = 1
for (let n = 1; n < labels.length; n++) {
if (sumWidth(n) <= withMoreBudget) best = n
else break
}
return best
}

View File

@ -3,6 +3,17 @@
* 界面与快照统一存为 (0-0.5] 形式,便于 0.312 等重量命中。
*/
/** 重量因素值下拉:0.5 步进至 30,其后固定 999(卖价/买价共用) */
export function buildWeightFactorSelectOptions(): Array<{ label: string; value: string }> {
return [
...Array.from({ length: 60 }, (_, index) => {
const v = ((index + 1) * 0.5).toFixed(1).replace(/\.0$/, '');
return { label: v, value: v };
}),
{ label: '999', value: '999' },
];
}
export function isWeightPricingFactor(factorKey: string): boolean {
const k = String(factorKey ?? '').trim();
if (!k) return false;

View File

@ -32,11 +32,16 @@
import { getDictListAll, getDictListByTypes } from '@/api/system/dict';
import { getServiceChannelOptions } from '@/api/channel/serviceChannel';
import {
buildWeightFactorSelectOptions,
canonicalWeightFactorCellForApi,
isWeightPricingFactor,
sortFactorValuesBySize,
weightValueToIntervalCell,
} from '@/utils/priceWeightInterval';
import {
estimateFactorValuesColumnWidth,
resolveVisibleFactorValueCount,
} from '@/utils/factorValueTagPreview';
import { IconPlus, IconDelete, IconSearch, IconEdit, IconDragArrow, IconQuestionCircleFill, IconQuestionCircle, IconSync } from '@arco-design/web-vue/es/icon';
import FormLabelWithPreview from './components/FormLabelWithPreview.vue';
import BillingConditionEditor from './components/BillingConditionEditor.vue';
@ -408,14 +413,8 @@
}
}
/** 重量因素值:0.5 步进至 30,其后固定 999 */
const weightFactorOptions: IdSelectOption[] = [
...Array.from({ length: 60 }, (_, index) => {
const v = ((index + 1) * 0.5).toFixed(1).replace(/\.0$/, '');
return { label: v, value: v };
}),
{ label: '999', value: '999' },
];
/** 重量因素值:与买价共用(0.5 步进至 30,其后固定 999) */
const weightFactorOptions: IdSelectOption[] = buildWeightFactorSelectOptions();
function filterKpiAreasByType(items: any[], areaType: string) {
const want = areaType.toLowerCase();
@ -2884,12 +2883,17 @@
billingFactorsHeightRaf = requestAnimationFrame(() => {
billingFactorsHeightRaf = 0;
syncBillingFactorsTableHeight();
syncFactorValuesCellWidth();
});
});
billingFactorsResizeObserver.observe(el);
nextTick(() => {
syncBillingFactorsTableHeight(true);
setTimeout(() => syncBillingFactorsTableHeight(true), 80);
syncFactorValuesCellWidth();
setTimeout(() => {
syncBillingFactorsTableHeight(true);
syncFactorValuesCellWidth();
}, 80);
});
}
@ -3150,16 +3154,33 @@
row.factorValues.splice(valueIndex, 1);
};
/** 因素值预览:行内最多展示条数,超出用 +N 弹层 */
const FACTOR_VALUE_PREVIEW_LIMIT = 10;
/** 因素值列可用宽度(随表格 resize 更新),用于按字符宽动态算可见标签数 */
const factorValuesCellWidth = ref(520);
function syncFactorValuesCellWidth() {
const el = billingFactorsTableWrapRef.value;
if (!el) return;
const cell = el.querySelector('.factor-values-cell') as HTMLElement | null;
const w = cell?.clientWidth || estimateFactorValuesColumnWidth(el.clientWidth);
if (w > 0 && Math.abs(w - factorValuesCellWidth.value) > 2) {
factorValuesCellWidth.value = w;
}
}
function getFactorValuePreview(row: any) {
const values = Array.isArray(row?.factorValues) ? row.factorValues : [];
const limit = FACTOR_VALUE_PREVIEW_LIMIT;
const items = values.map((value: string, index: number) => {
const label = formatFactorValueTagDisplay(row.factor, value) || String(value ?? '');
return { value, index, label };
});
const limit = resolveVisibleFactorValueCount(
items.map((it) => it.label),
factorValuesCellWidth.value,
);
return {
values,
visible: values.slice(0, limit),
extraCount: Math.max(0, values.length - limit),
items,
visible: items.slice(0, limit),
extraCount: Math.max(0, items.length - limit),
};
}
@ -5344,16 +5365,17 @@
:key="row.id"
class="factor-values-cell"
>
<template v-if="preview.values.length">
<template v-if="preview.items.length">
<a-tag
v-for="(value, valueIndex) in preview.visible"
:key="`${row.id}-v-${valueIndex}`"
v-for="item in preview.visible"
:key="`${row.id}-v-${item.index}`"
size="small"
closable
class="factor-value-tag"
@close="handleDeleteFactorValue(row, valueIndex)"
:title="item.label"
@close="handleDeleteFactorValue(row, item.index)"
>
{{ formatFactorValueTagDisplay(row.factor, value) }}
<span class="factor-value-tag__text">{{ item.label }}</span>
</a-tag>
<a-popover
v-if="preview.extraCount > 0"
@ -5367,17 +5389,19 @@
<template #content>
<div class="factor-values-popover">
<div class="factor-values-popover__title">
已选 {{ preview.values.length }} 项
已选 {{ preview.items.length }} 项
</div>
<div class="factor-values-popover__list">
<a-tag
v-for="(value, valueIndex) in preview.values"
:key="`${row.id}-all-${valueIndex}`"
v-for="item in preview.items"
:key="`${row.id}-all-${item.index}`"
size="small"
closable
@close="handleDeleteFactorValue(row, valueIndex)"
class="factor-value-tag factor-value-tag--popover"
:title="item.label"
@close="handleDeleteFactorValue(row, item.index)"
>
{{ formatFactorValueTagDisplay(row.factor, value) }}
<span class="factor-value-tag__text">{{ item.label }}</span>
</a-tag>
</div>
</div>
@ -6877,21 +6901,36 @@
.factor-values-cell {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
max-width: 100%;
max-height: 48px;
min-width: 0;
overflow: hidden;
.factor-value-tag {
flex-shrink: 0;
max-width: 88px;
display: inline-flex;
align-items: center;
flex: 0 1 auto;
max-width: 148px;
min-width: 0;
overflow: hidden;
vertical-align: middle;
.factor-value-tag__text {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.arco-tag-close-btn) {
flex-shrink: 0;
margin-left: 4px;
}
}
.factor-value-more {
flex-shrink: 0;
cursor: pointer;
@ -6931,6 +6970,16 @@
}
}
.factor-value-tag--popover {
max-width: 100%;
.factor-value-tag__text {
white-space: normal;
overflow: visible;
text-overflow: unset;
}
}
.factor-values-popover {
display: flex;
flex-direction: column;

View File

@ -675,16 +675,17 @@
:key="row.id"
class="factor-values-cell"
>
<template v-if="preview.values.length">
<template v-if="preview.items.length">
<a-tag
v-for="(value, valueIndex) in preview.visible"
:key="`${row.id}-v-${valueIndex}`"
v-for="item in preview.visible"
:key="`${row.id}-v-${item.index}`"
size="small"
closable
class="factor-value-tag"
@close="handleDeleteFactorValue(row, valueIndex)"
:title="item.label"
@close="handleDeleteFactorValue(row, item.index)"
>
{{ formatFactorValueTagDisplay(row.factor, value) }}
<span class="factor-value-tag__text">{{ item.label }}</span>
</a-tag>
<a-popover
v-if="preview.extraCount > 0"
@ -698,17 +699,19 @@
<template #content>
<div class="factor-values-popover">
<div class="factor-values-popover__title">
已选 {{ preview.values.length }} 项
已选 {{ preview.items.length }} 项
</div>
<div class="factor-values-popover__list">
<a-tag
v-for="(value, valueIndex) in preview.values"
:key="`${row.id}-all-${valueIndex}`"
v-for="item in preview.items"
:key="`${row.id}-all-${item.index}`"
size="small"
closable
@close="handleDeleteFactorValue(row, valueIndex)"
class="factor-value-tag factor-value-tag--popover"
:title="item.label"
@close="handleDeleteFactorValue(row, item.index)"
>
{{ formatFactorValueTagDisplay(row.factor, value) }}
<span class="factor-value-tag__text">{{ item.label }}</span>
</a-tag>
</div>
</div>
@ -1434,11 +1437,16 @@ import dayjs from 'dayjs'
import * as XLSX from 'xlsx'
import Sortable from 'sortablejs'
import {
buildWeightFactorSelectOptions,
canonicalWeightFactorCellForApi,
isWeightPricingFactor,
sortFactorValuesBySize,
weightValueToIntervalCell,
} from '@/utils/priceWeightInterval'
import {
estimateFactorValuesColumnWidth,
resolveVisibleFactorValueCount,
} from '@/utils/factorValueTagPreview'
import { getAppGetList } from '@/api/ApplicationServices/apps'
import {
getServiceProviderGetServiceProviderDropdownList,
@ -1775,10 +1783,8 @@ function formatPriceTableFactorCell(factorIndex: number | undefined, raw: string
type IdSelectOption = { label: string; value: string }
const weightFactorOptions: IdSelectOption[] = Array.from({ length: 20 }, (_, index) => {
const v = ((index + 1) * 0.5).toFixed(1).replace(/\.0$/, '')
return { label: v, value: v }
})
/** 重量因素值:与产品卖价共用(0.5 步进至 30,其后固定 999) */
const weightFactorOptions: IdSelectOption[] = buildWeightFactorSelectOptions()
function filterKpiAreasByType(items: any[], areaType: string) {
const want = areaType.toLowerCase()
@ -2781,16 +2787,33 @@ function clearAllPriceListRowsSelection() {
priceListHotRef.value?.clearSelection()
}
/** 因素值预览:行内最多展示条数,超出用 +N 弹层 */
const FACTOR_VALUE_PREVIEW_LIMIT = 10
/** 因素值列可用宽度(随表格 resize 更新),用于按字符宽动态算可见标签数 */
const factorValuesCellWidth = ref(520)
function syncFactorValuesCellWidth() {
const el = billingFactorsTableWrapRef.value
if (!el) return
const cell = el.querySelector('.factor-values-cell') as HTMLElement | null
const w = cell?.clientWidth || estimateFactorValuesColumnWidth(el.clientWidth)
if (w > 0 && Math.abs(w - factorValuesCellWidth.value) > 2) {
factorValuesCellWidth.value = w
}
}
function getFactorValuePreview(row: any) {
const values = Array.isArray(row?.factorValues) ? row.factorValues : []
const limit = FACTOR_VALUE_PREVIEW_LIMIT
const items = values.map((value: string, index: number) => {
const label = formatFactorValueTagDisplay(row.factor, value) || String(value ?? '')
return { value, index, label }
})
const limit = resolveVisibleFactorValueCount(
items.map((it) => it.label),
factorValuesCellWidth.value,
)
return {
values,
visible: values.slice(0, limit),
extraCount: Math.max(0, values.length - limit),
items,
visible: items.slice(0, limit),
extraCount: Math.max(0, items.length - limit),
}
}
@ -4543,12 +4566,17 @@ function setupBillingFactorsResizeObserver() {
billingFactorsHeightRaf = requestAnimationFrame(() => {
billingFactorsHeightRaf = 0
syncBillingFactorsTableHeight()
syncFactorValuesCellWidth()
})
})
billingFactorsResizeObserver.observe(el)
nextTick(() => {
syncBillingFactorsTableHeight(true)
setTimeout(() => syncBillingFactorsTableHeight(true), 80)
syncFactorValuesCellWidth()
setTimeout(() => {
syncBillingFactorsTableHeight(true)
syncFactorValuesCellWidth()
}, 80)
})
}
@ -5586,21 +5614,36 @@ const handleConfirmPublish = async () => {
.factor-values-cell {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
max-width: 100%;
max-height: 48px;
min-width: 0;
overflow: hidden;
.factor-value-tag {
flex-shrink: 0;
max-width: 88px;
display: inline-flex;
align-items: center;
flex: 0 1 auto;
max-width: 148px;
min-width: 0;
overflow: hidden;
vertical-align: middle;
.factor-value-tag__text {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.arco-tag-close-btn) {
flex-shrink: 0;
margin-left: 4px;
}
}
.factor-value-more {
flex-shrink: 0;
cursor: pointer;
@ -5640,6 +5683,16 @@ const handleConfirmPublish = async () => {
}
}
.factor-value-tag--popover {
max-width: 100%;
.factor-value-tag__text {
white-space: normal;
overflow: visible;
text-overflow: unset;
}
}
.factor-values-popover {
display: flex;
flex-direction: column;

View File

@ -79,7 +79,28 @@
:default-span="12"
:label-col-props="{ span: 24 }"
:wrapper-col-props="{ span: 24 }"
/>
>
<template #validType="{ model }">
<div class="valid-type-options" role="radiogroup">
<button
v-for="opt in validTypeOptions"
:key="opt.value"
type="button"
class="valid-type-option"
role="radio"
:aria-checked="Number(model.validType) === opt.value"
:class="{ 'is-active': Number(model.validType) === opt.value }"
@click="model.validType = opt.value"
>
<span class="valid-type-option__radio" aria-hidden="true" />
<span class="valid-type-option__text">
<span class="valid-type-option__title">{{ opt.label }}</span>
<span class="valid-type-option__desc">{{ opt.desc }}</span>
</span>
</button>
</div>
</template>
</FormPro>
</div>
</ModalPro>
</MarketingPageShell>
@ -182,6 +203,24 @@ const grantChannelOptions = computed(() => [
{ label: t('marketing.couponTemplate.grantChannel.festival'), value: 'festival' },
])
const validTypeOptions = computed(() => [
{
value: 1,
label: t('marketing.couponTemplate.validType.fixed'),
desc: t('marketing.couponTemplate.validType.fixedDesc'),
},
{
value: 2,
label: t('marketing.couponTemplate.validType.afterReceive'),
desc: t('marketing.couponTemplate.validType.afterReceiveDesc'),
},
{
value: 3,
label: t('marketing.couponTemplate.validType.afterUnlock'),
desc: t('marketing.couponTemplate.validType.afterUnlockDesc'),
},
])
const itemList = computed(() => [
{
field: 'templateCode',
@ -283,17 +322,11 @@ const itemList = computed(() => [
{
field: 'validType',
label: t('marketing.couponTemplate.form.validType'),
comp: 'a-radio-group',
comp: 'slot',
span: 24,
required: true,
rules: [{ required: true, message: t('marketing.couponTemplate.form.validType') }],
compProps: {
type: 'button',
options: [
{ label: t('marketing.couponTemplate.validType.fixed'), value: 1 },
{ label: t('marketing.couponTemplate.validType.afterReceive'), value: 2 },
{ label: t('marketing.couponTemplate.validType.afterUnlock'), value: 3 },
],
},
compProps: {},
},
{
field: 'validStartTime',
@ -667,15 +700,91 @@ onMounted(() => loadList())
width: 100%;
border-radius: 4px;
}
:deep(.arco-radio-group-button) {
display: flex;
width: 100%;
.arco-radio-button {
flex: 1;
text-align: center;
}
.valid-type-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
width: 100%;
}
.valid-type-option {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
margin: 0;
padding: 12px 12px;
border: 1px solid var(--color-border-2);
border-radius: 10px;
background: var(--app-card-bg);
text-align: left;
cursor: pointer;
transition:
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease;
&:hover {
border-color: color-mix(in srgb, var(--app-primary-color) 40%, var(--color-border-2));
background: color-mix(in srgb, var(--app-primary-color) 4%, var(--app-card-bg));
}
&.is-active {
border-color: color-mix(in srgb, var(--app-primary-color) 55%, var(--color-border-2));
background: color-mix(in srgb, var(--app-primary-color) 8%, var(--app-card-bg));
box-shadow: 0 4px 12px color-mix(in srgb, var(--app-primary-color) 12%, transparent);
}
}
.valid-type-option__radio {
flex-shrink: 0;
width: 16px;
height: 16px;
margin-top: 2px;
border: 1.5px solid var(--color-border-3, #c9cdd4);
border-radius: 50%;
background: #fff;
box-sizing: border-box;
position: relative;
.is-active & {
border-color: var(--app-primary-color);
&::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: var(--app-primary-color);
}
}
}
.valid-type-option__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.valid-type-option__title {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.3;
}
.valid-type-option__desc {
font-size: 12px;
color: var(--color-text-3);
line-height: 1.4;
}
@media (max-width: 720px) {
.valid-type-options {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -52,79 +52,41 @@
<a-drawer
v-model:visible="drawerVisible"
:width="560"
:width="drawerWidth"
unmount-on-close
:esc-to-close="true"
:mask-closable="!drawerLoading"
class="member-level-drawer"
:header="false"
:footer="false"
class="member-level-drawer"
:drawer-style="drawerShellStyle"
:body-style="drawerBodyStyle"
@cancel="handleDrawerCancel"
>
<div class="ml-drawer">
<header class="ml-drawer__header">
<div class="ml-drawer__title-row">
<div>
<p class="ml-drawer__eyebrow">{{ t('menu.marketing.memberLevel') }}</p>
<h3 class="ml-drawer__title">
<header class="ml-drawer__bar">
<div class="ml-drawer__bar-main">
<h2 class="ml-drawer__title">
{{
editingId
? t('marketing.memberLevel.modal.edit')
: t('marketing.memberLevel.modal.add')
}}
</h3>
</h2>
<span class="ml-drawer__chip">{{ t('menu.marketing.memberLevel') }}</span>
</div>
<button type="button" class="ml-drawer__close" @click="handleDrawerCancel">
<button
type="button"
class="ml-drawer__close"
:aria-label="t('common.cancel')"
:disabled="drawerLoading"
@click="handleDrawerCancel"
>
<icon-close />
</button>
</div>
<p class="ml-drawer__desc">{{ t('marketing.memberLevel.modalTip') }}</p>
</header>
<div class="ml-drawer__body">
<section class="ml-preview" :class="{ 'is-disabled': formState.status === 1 }">
<div class="ml-preview__badge">
<span class="ml-preview__dot" />
{{
formState.status === 2
? t('common.enabled')
: t('common.disabled')
}}
</div>
<div class="ml-preview__main">
<div
class="ml-preview__icon"
:style="previewToneStyle"
aria-hidden="true"
>
<img v-if="formState.iconUrl" :src="formState.iconUrl" alt="" />
<span
v-else-if="previewGlyphHtml"
class="ml-preview__glyph"
v-html="previewGlyphHtml"
/>
<span v-else>{{ previewInitial }}</span>
</div>
<div class="ml-preview__meta">
<h4 class="ml-preview__name">
{{ formState.levelName || t('marketing.memberLevel.preview.nameFallback') }}
</h4>
<p class="ml-preview__code">
{{ formState.levelCode || t('marketing.memberLevel.preview.codeFallback') }}
</p>
</div>
</div>
<div class="ml-preview__stats">
<div class="ml-preview__stat">
<span class="ml-preview__stat-label">{{ t('marketing.memberLevel.form.minPoints') }}</span>
<span class="ml-preview__stat-value">{{ formState.minPoints ?? 0 }}</span>
</div>
<div class="ml-preview__stat">
<span class="ml-preview__stat-label">{{ t('marketing.memberLevel.form.extraDiscountRate') }}</span>
<span class="ml-preview__stat-value">{{ previewDiscountText }}</span>
</div>
</div>
</section>
<a-form
ref="formRef"
class="ml-form"
@ -134,15 +96,13 @@
:label-col-props="{ span: 24 }"
:wrapper-col-props="{ span: 24 }"
>
<section class="ml-section">
<div class="ml-section__head">
<span class="ml-section__index">01</span>
<div>
<h5 class="ml-section__title">{{ t('marketing.memberLevel.section.basic') }}</h5>
<p class="ml-section__hint">{{ t('marketing.memberLevel.section.basicHint') }}</p>
<section class="ml-card">
<div class="ml-card__head">
<h4 class="ml-card__title">{{ t('marketing.memberLevel.section.basic') }}</h4>
<span class="ml-card__hint">{{ t('marketing.memberLevel.section.basicHint') }}</span>
</div>
</div>
<a-row :gutter="16">
<div class="ml-card__body">
<a-row :gutter="[16, 0]">
<a-col :span="12">
<a-form-item
:label="t('marketing.memberLevel.form.levelCode')"
@ -170,10 +130,26 @@
</a-col>
<a-col :span="12">
<a-form-item :label="t('marketing.memberLevel.form.status')" field="status">
<a-radio-group v-model="formState.status" type="button" class="ml-status-group">
<a-radio :value="2">{{ t('common.enabled') }}</a-radio>
<a-radio :value="1">{{ t('common.disabled') }}</a-radio>
</a-radio-group>
<div class="ml-status-chips" role="group">
<button
type="button"
class="ml-status-chip"
:class="{ 'is-active': formState.status === 2 }"
@click="formState.status = 2"
>
<span class="ml-status-chip__dot" />
{{ t('common.enabled') }}
</button>
<button
type="button"
class="ml-status-chip"
:class="{ 'is-active': formState.status === 1 }"
@click="formState.status = 1"
>
<span class="ml-status-chip__dot" />
{{ t('common.disabled') }}
</button>
</div>
</a-form-item>
</a-col>
<a-col :span="12">
@ -192,17 +168,16 @@
</a-form-item>
</a-col>
</a-row>
</div>
</section>
<section class="ml-section">
<div class="ml-section__head">
<span class="ml-section__index">02</span>
<div>
<h5 class="ml-section__title">{{ t('marketing.memberLevel.section.pricing') }}</h5>
<p class="ml-section__hint">{{ t('marketing.memberLevel.section.pricingHint') }}</p>
<section class="ml-card">
<div class="ml-card__head">
<h4 class="ml-card__title">{{ t('marketing.memberLevel.section.pricing') }}</h4>
<span class="ml-card__hint">{{ t('marketing.memberLevel.section.pricingHint') }}</span>
</div>
</div>
<a-row :gutter="16">
<div class="ml-card__body">
<a-row :gutter="[16, 0]">
<a-col :span="12">
<a-form-item
:label="t('marketing.memberLevel.form.minPoints')"
@ -224,6 +199,7 @@
field="extraDiscountRate"
:tooltip="t('marketing.memberLevel.form.extraDiscountRateExtra')"
>
<div class="ml-discount-field">
<a-input-number
v-model="formState.extraDiscountRate"
:min="0"
@ -233,28 +209,31 @@
style="width: 100%"
placeholder="0"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
:label="t('marketing.memberLevel.form.canDowngrade')"
field="canDowngrade"
:tooltip="t('marketing.memberLevel.form.canDowngradeExtra')"
>
<a-switch v-model="formState.canDowngrade" />
<span class="ml-discount-badge">{{ previewDiscountText }}</span>
</div>
</a-form-item>
</a-col>
</a-row>
<div class="ml-switch-row">
<div class="ml-switch-row__text">
<div class="ml-switch-row__label">
{{ t('marketing.memberLevel.form.canDowngrade') }}
</div>
<p class="ml-switch-row__hint">
{{ t('marketing.memberLevel.form.canDowngradeExtra') }}
</p>
</div>
<a-switch v-model="formState.canDowngrade" />
</div>
</div>
</section>
<section class="ml-section">
<div class="ml-section__head">
<span class="ml-section__index">03</span>
<div>
<h5 class="ml-section__title">{{ t('marketing.memberLevel.section.display') }}</h5>
<p class="ml-section__hint">{{ t('marketing.memberLevel.section.displayHint') }}</p>
</div>
<section class="ml-card">
<div class="ml-card__head">
<h4 class="ml-card__title">{{ t('marketing.memberLevel.section.display') }}</h4>
<span class="ml-card__hint">{{ t('marketing.memberLevel.section.displayHint') }}</span>
</div>
<div class="ml-card__body">
<a-form-item
:label="t('marketing.memberLevel.form.iconUrl')"
field="iconUrl"
@ -276,6 +255,7 @@
:placeholder="t('marketing.memberLevel.form.benefitDescPlaceholder')"
/>
</a-form-item>
</div>
</section>
</a-form>
</div>
@ -308,11 +288,7 @@ import {
getMemberLevelList,
updateMemberLevel,
} from '@/api/marketing/memberLevel'
import {
getMemberLevelTone,
renderMemberLevelCellHtml,
renderMemberLevelGlyphSvg,
} from '@/utils/memberLevelIcon'
import { renderMemberLevelCellHtml } from '@/utils/memberLevelIcon'
import MarketingPageShell from '../components/MarketingPageShell.vue'
import MarketingPageTipIcon from '../components/MarketingPageTipIcon.vue'
@ -370,26 +346,26 @@ const formRules = computed(() => ({
status: [{ required: true, message: t('marketing.memberLevel.form.status') }],
}))
const previewInitial = computed(() => {
const name = formState.levelName?.trim()
if (name) return name.slice(0, 1)
const code = formState.levelCode?.trim()
return code ? code.slice(0, 1).toUpperCase() : 'M'
})
const previewTone = computed(() =>
getMemberLevelTone(formState.levelCode, formState.levelName),
)
const previewToneStyle = computed(() => {
if (!previewTone.value) return undefined
return { color: '#fff' }
})
const previewGlyphHtml = computed(() =>
previewTone.value ? renderMemberLevelGlyphSvg(previewTone.value.key, 28) : '',
)
const previewDiscountText = computed(() => formatFreightOff(formState.extraDiscountRate))
const drawerWidth = computed(() => {
if (typeof window !== 'undefined' && window.innerWidth < 768) return '100%'
return 640
})
const drawerShellStyle = {
borderRadius: '16px 0 0 16px',
overflow: 'hidden',
boxShadow: '-12px 0 40px rgba(15, 23, 42, 0.12)',
}
const drawerBodyStyle = {
padding: '0',
background: 'var(--app-page-bg)',
height: '100%',
overflow: 'hidden',
}
function formatFreightOff(rate: unknown) {
const n = Number(rate || 0)
if (!n) return t('marketing.memberLevel.discount.original')
@ -599,67 +575,76 @@ onMounted(() => loadList())
flex-direction: column;
height: 100%;
min-height: 0;
margin: -16px;
background: var(--app-page-bg);
outline: none;
}
.ml-drawer__header {
flex-shrink: 0;
padding: 20px 24px 16px;
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--app-primary-color) 12%, var(--app-card-bg)) 0%,
var(--app-card-bg) 58%
);
border-bottom: 1px solid var(--color-border-2, var(--color-border-2));
}
.ml-drawer__title-row {
.ml-drawer__bar {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
margin: 12px 12px 0;
padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 14px;
background: linear-gradient(155deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.72) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 4px 14px rgba(0, 121, 121, 0.05);
}
.ml-drawer__eyebrow {
margin: 0 0 4px;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--app-primary-color);
font-weight: 600;
.ml-drawer__bar-main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.ml-drawer__title {
margin: 0;
font-size: 18px;
line-height: 1.35;
font-weight: 600;
color: var(--color-text-1, #1d2129);
font-size: 16px;
font-weight: 700;
color: var(--color-text-1);
line-height: 1.3;
white-space: nowrap;
}
.ml-drawer__desc {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.55;
color: var(--color-text-2, #4e5969);
.ml-drawer__chip {
flex-shrink: 0;
height: 24px;
padding: 0 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--app-primary-color) 10%, var(--app-card-bg));
color: var(--app-primary-color);
font-size: 12px;
line-height: 24px;
}
.ml-drawer__close {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: rgba(0, 0, 0, 0.04);
color: var(--color-text-2, #4e5969);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 10px;
background: rgba(255, 255, 255, 0.72);
color: var(--color-text-2);
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
transition: color 0.2s, background 0.2s, border-color 0.2s;
&:hover {
background: color-mix(in srgb, var(--app-primary-color) 12%, var(--app-card-bg));
&:hover:not(:disabled) {
color: var(--app-primary-color);
border-color: color-mix(in srgb, var(--app-primary-color) 30%, var(--app-card-bg));
background: color-mix(in srgb, var(--app-primary-color) 8%, var(--app-card-bg));
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
@ -667,184 +652,46 @@ onMounted(() => loadList())
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 24px 8px;
padding: 10px 12px 16px;
}
.ml-preview {
margin-bottom: 18px;
padding: 16px;
border-radius: 12px;
background:
radial-gradient(
120% 140% at 0% 0%,
color-mix(in srgb, #fff 28%, transparent),
transparent 55%
),
linear-gradient(
135deg,
color-mix(in srgb, var(--app-primary-color) 92%, #102020) 0%,
color-mix(in srgb, var(--app-primary-color) 62%, #1c3333) 100%
);
color: #fff;
box-shadow: 0 10px 28px color-mix(in srgb, var(--app-primary-color) 28%, transparent);
transition: opacity 0.2s ease;
&.is-disabled {
opacity: 0.72;
filter: grayscale(0.25);
}
}
.ml-preview__badge {
display: inline-flex;
align-items: center;
gap: 6px;
.ml-card {
margin-bottom: 12px;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 14px;
background: var(--app-card-bg);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 4px 14px rgba(0, 121, 121, 0.04);
overflow: hidden;
}
.ml-preview__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #00b42a;
}
.ml-preview.is-disabled .ml-preview__dot {
background: #c9cdd4;
}
.ml-preview__main {
.ml-card__head {
display: flex;
align-items: center;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
padding: 14px 16px 0;
}
.ml-preview__icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.14);
font-size: 20px;
font-weight: 700;
overflow: hidden;
color: #fff;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.ml-preview__glyph {
display: inline-flex;
width: 28px;
height: 28px;
:deep(svg) {
display: block;
width: 100%;
height: 100%;
}
}
.ml-preview__name {
.ml-card__title {
margin: 0;
font-size: 18px;
font-weight: 650;
line-height: 1.3;
}
.ml-preview__code {
margin: 4px 0 0;
font-size: 12px;
opacity: 0.72;
letter-spacing: 0.02em;
}
.ml-preview__stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.ml-preview__stat {
padding: 10px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
min-width: 0;
}
.ml-preview__stat-label {
display: block;
font-size: 11px;
opacity: 0.7;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ml-preview__stat-value {
display: block;
font-size: 14px;
font-size: 15px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-text-1);
}
.ml-section {
margin-bottom: 18px;
padding: 14px 14px 4px;
border-radius: 10px;
background: var(--color-fill-2);
border: 1px solid var(--color-border-2, var(--color-border-2));
}
.ml-section__head {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 12px;
}
.ml-section__index {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 14%, var(--app-card-bg));
}
.ml-section__title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--color-text-1, #1d2129);
}
.ml-section__hint {
margin: 2px 0 0;
.ml-card__hint {
font-size: 12px;
color: var(--color-text-3, #86909c);
color: var(--color-text-3);
line-height: 1.4;
}
.ml-card__body {
padding: 12px 16px 4px;
}
.ml-form {
:deep(.arco-form-item) {
margin-bottom: 14px;
@ -853,25 +700,120 @@ onMounted(() => loadList())
:deep(.arco-form-item-label) {
font-size: 13px;
font-weight: 500;
color: var(--color-text-1, #1d2129);
color: var(--color-text-1);
}
:deep(.arco-input-wrapper),
:deep(.arco-select-view-single),
:deep(.arco-textarea-wrapper),
:deep(.arco-input-number) {
border-radius: 6px;
border-radius: 8px;
}
}
.ml-status-group {
.ml-status-chips {
display: flex;
gap: 8px;
width: 100%;
:deep(.arco-radio-button) {
flex: 1;
text-align: center;
}
.ml-status-chip {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
flex: 1;
height: 32px;
padding: 0 10px;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 999px;
background: linear-gradient(155deg, rgba(255, 255, 255, 0.88) 0%, rgba(255, 255, 255, 0.62) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
color: var(--color-text-2);
font-size: 12px;
cursor: pointer;
transition: border-color 0.2s, color 0.2s, background 0.2s, box-shadow 0.2s;
&:hover {
color: var(--app-primary-color);
border-color: color-mix(in srgb, var(--app-primary-color) 35%, var(--app-card-bg));
}
&.is-active {
color: var(--app-primary-color);
border-color: color-mix(in srgb, var(--app-primary-color) 40%, var(--app-card-bg));
background: color-mix(in srgb, var(--app-primary-color) 10%, var(--app-card-bg));
font-weight: 600;
box-shadow: 0 4px 12px rgba(0, 121, 121, 0.08);
}
}
.ml-status-chip__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #c9cdd4;
.is-active & {
background: var(--app-primary-color);
}
}
.ml-discount-field {
display: flex;
align-items: center;
gap: 8px;
:deep(.arco-input-number) {
flex: 1;
min-width: 0;
}
}
.ml-discount-badge {
flex-shrink: 0;
max-width: 88px;
height: 28px;
padding: 0 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--app-primary-color) 10%, #fff);
color: var(--app-primary-color);
font-size: 12px;
font-weight: 600;
line-height: 28px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ml-switch-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin: 0 0 14px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--app-primary-color) 14%, var(--color-border-2));
background: color-mix(in srgb, var(--app-primary-color) 5%, var(--app-card-bg));
}
.ml-switch-row__text {
min-width: 0;
flex: 1;
}
.ml-switch-row__label {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
.ml-switch-row__hint {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--color-text-3);
}
.ml-drawer__footer {
@ -879,20 +821,21 @@ onMounted(() => loadList())
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 24px 18px;
border-top: 1px solid var(--color-border-2, var(--color-border-2));
background: var(--app-card-bg);
margin: 0 12px 12px;
padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 14px;
background: linear-gradient(155deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.72) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 4px 14px rgba(0, 121, 121, 0.05);
}
</style>
<style lang="less">
.member-level-drawer {
.arco-drawer-header {
display: none;
}
.arco-drawer-body {
padding: 16px;
padding: 0 !important;
height: 100%;
overflow: hidden;
}

View File

@ -27,7 +27,9 @@
@delete-node="handleDeleteNode"
>
<template #node-second-row-extra="{ nodeData }">
<span v-if="nodeData?.isBuiltIn" class="built-in-tag">内置</span>
<span v-if="nodeData?.isBuiltIn" class="built-in-tag">{{
t('system.dataPerm.templates.tag.builtin')
}}</span>
</template>
</CustomLeftMenu>
@ -39,7 +41,6 @@
:title="panelTitle"
:subtitle="isCreating ? '' : form.code"
:tags="templateTags"
:stats="templateStats"
:tabs="templateTabs"
v-model:active-tab="activeTab"
:loading="detailLoading"
@ -49,6 +50,28 @@
<icon-file />
</div>
</template>
<template #header-extra>
<ol class="tpl-flow">
<li
class="tpl-flow__item"
:class="{ 'is-active': activeTab === 'basic', 'is-done': !!form.name && !!form.code }"
>
<span class="tpl-flow__num">1</span>
<span class="tpl-flow__text">{{ t('system.dataPerm.templates.flow.step1') }}</span>
</li>
<li
class="tpl-flow__item"
:class="{ 'is-active': activeTab === 'items', 'is-done': form.items.length > 0 }"
>
<span class="tpl-flow__num">2</span>
<span class="tpl-flow__text">{{ t('system.dataPerm.templates.flow.step2') }}</span>
</li>
<li class="tpl-flow__item" :class="{ 'is-done': canApply && !!bindForm.boundRoleId }">
<span class="tpl-flow__num">3</span>
<span class="tpl-flow__text">{{ t('system.dataPerm.templates.flow.step3') }}</span>
</li>
</ol>
</template>
<template #actions>
<a-button
v-if="hasPermission('Rbac.DataPerm.Template')"
@ -60,34 +83,69 @@
</a-button>
</template>
<div v-show="activeTab === 'basic'">
<div class="section-card">
<h3 class="section-card-title">{{ t('system.dataPerm.templates.tab.basic') }}</h3>
<div v-show="activeTab === 'basic'" class="tpl-pane">
<section class="tpl-card">
<div class="tpl-card__head">
<div>
<h3 class="tpl-card__title">{{ t('system.dataPerm.templates.section.identity') }}</h3>
<p class="tpl-card__hint">{{ t('system.dataPerm.templates.section.identityHint') }}</p>
</div>
</div>
<div class="tpl-card__body">
<a-form :model="form" layout="vertical" class="template-form">
<a-row :gutter="20">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('system.dataPerm.templates.field.name')" required>
<a-input v-model="form.name" />
<a-input
v-model="form.name"
:placeholder="t('system.dataPerm.templates.field.namePlaceholder')"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('system.dataPerm.templates.field.code')" required>
<a-input v-model="form.code" :disabled="!isCreating && form.isBuiltIn" />
<a-input
v-model="form.code"
:placeholder="t('system.dataPerm.templates.field.codePlaceholder')"
:disabled="!isCreating && form.isBuiltIn"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item :label="t('system.dataPerm.templates.field.description')">
<a-textarea
v-model="form.description"
:placeholder="t('system.dataPerm.templates.field.descriptionPlaceholder')"
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item :label="t('system.dataPerm.templates.field.description')">
<a-textarea v-model="form.description" :auto-size="{ minRows: 2 }" />
</a-form-item>
</a-form>
</div>
</section>
<div class="section-card">
<h3 class="section-card-title">{{ t('system.dataPerm.templates.section.bindApply') }}</h3>
<a-alert type="info" class="bind-alert">
{{ t('system.dataPerm.templates.bindApplyHint') }}
</a-alert>
<section class="tpl-next-card">
<div class="tpl-next-card__main">
<p class="tpl-next-card__title">{{ t('system.dataPerm.templates.flow.goItemsTitle') }}</p>
<p class="tpl-next-card__desc">{{ t('system.dataPerm.templates.flow.goItemsDesc') }}</p>
</div>
<a-button type="primary" @click="activeTab = 'items'">
{{ t('system.dataPerm.templates.flow.goItemsBtn') }}
<template v-if="form.items.length">({{ form.items.length }})</template>
</a-button>
</section>
<section class="tpl-card">
<div class="tpl-card__head">
<div>
<h3 class="tpl-card__title">{{ t('system.dataPerm.templates.section.bindApply') }}</h3>
<p class="tpl-card__hint">{{ t('system.dataPerm.templates.bindApplyHint') }}</p>
</div>
</div>
<div class="tpl-card__body">
<a-form :model="bindForm" layout="vertical" class="template-form">
<a-form-item
:label="t('system.dataPerm.templates.field.bindRole')"
@ -106,14 +164,17 @@
</a-form>
<template v-if="canApply">
<a-divider orientation="left">{{ t('system.dataPerm.templates.section.applyNow') }}</a-divider>
<div class="apply-row">
<div class="apply-row-text">
<span class="apply-row-title">{{ t('system.dataPerm.templates.applyToRole') }}</span>
<span class="apply-row-desc">{{ t('system.dataPerm.templates.applyToRoleDesc') }}</span>
<div class="tpl-apply-grid">
<div class="tpl-apply-block">
<div class="tpl-apply-block__head">
<span class="tpl-apply-block__badge">A</span>
<div>
<p class="tpl-apply-block__title">{{ t('system.dataPerm.templates.applyToRole') }}</p>
<p class="tpl-apply-block__desc">{{ t('system.dataPerm.templates.applyToRoleDesc') }}</p>
</div>
</div>
<a-button
type="primary"
:disabled="!bindForm.boundRoleId"
:loading="roleApplyLoading"
@click="applyToBoundRole"
@ -122,12 +183,15 @@
</a-button>
</div>
<a-divider />
<div class="apply-user-block">
<p class="apply-block-title">{{ t('system.dataPerm.templates.applyToUsers') }}</p>
<p class="apply-block-desc">{{ t('system.dataPerm.templates.applyToUsersDesc') }}</p>
<a-form :model="applyForm" layout="vertical">
<div class="tpl-apply-block">
<div class="tpl-apply-block__head">
<span class="tpl-apply-block__badge">B</span>
<div>
<p class="tpl-apply-block__title">{{ t('system.dataPerm.templates.applyToUsers') }}</p>
<p class="tpl-apply-block__desc">{{ t('system.dataPerm.templates.applyToUsersDesc') }}</p>
</div>
</div>
<a-form :model="applyForm" layout="vertical" class="template-form">
<a-form-item :label="t('system.dataPerm.templates.field.targetUsers')" required>
<a-select
v-model="applyForm.targetUserIds"
@ -140,10 +204,24 @@
/>
</a-form-item>
<a-form-item :label="t('system.dataPerm.templates.field.applyMode')">
<a-radio-group v-model="applyForm.mode">
<a-radio value="replace">{{ t('system.dataPerm.templates.mode.replace') }}</a-radio>
<a-radio value="merge">{{ t('system.dataPerm.templates.mode.merge') }}</a-radio>
</a-radio-group>
<div class="tpl-mode-chips" role="group">
<button
type="button"
class="tpl-mode-chip"
:class="{ 'is-active': applyForm.mode === 'replace' }"
@click="applyForm.mode = 'replace'"
>
{{ t('system.dataPerm.templates.mode.replace') }}
</button>
<button
type="button"
class="tpl-mode-chip"
:class="{ 'is-active': applyForm.mode === 'merge' }"
@click="applyForm.mode = 'merge'"
>
{{ t('system.dataPerm.templates.mode.merge') }}
</button>
</div>
</a-form-item>
<a-form-item>
<a-checkbox v-model="applyForm.temporaryEnabled">
@ -179,19 +257,24 @@
</a-button>
</a-form>
</div>
</template>
<a-alert v-else type="warning" class="bind-alert">
{{ t('system.dataPerm.templates.applyNeedSave') }}
</a-alert>
</div>
</template>
<div v-else class="tpl-warn">
{{ t('system.dataPerm.templates.applyNeedSave') }}
</div>
</div>
</section>
</div>
<div v-show="activeTab === 'items'">
<div class="sticky-toolbar">
<span class="items-hint">{{ t('system.dataPerm.templates.itemsHintForm') }}</span>
<div v-show="activeTab === 'items'" class="tpl-pane">
<div class="tpl-items-guide">
<div class="tpl-items-guide__text">
<p class="tpl-items-guide__title">{{ t('system.dataPerm.templates.itemsGuideTitle') }}</p>
<p class="tpl-items-guide__desc">{{ t('system.dataPerm.templates.itemsHintForm') }}</p>
</div>
<a-space wrap>
<a-dropdown @select="applyExamplePreset">
<a-button type="outline">
<a-button>
{{ t('system.dataPerm.templates.addExample') }}
<icon-down />
</a-button>
@ -204,13 +287,15 @@
</a-doption>
</template>
</a-dropdown>
<a-button type="dashed" @click="addItem">{{ t('system.dataPerm.templates.addItem') }}
<a-button type="primary" @click="addItem">
{{ t('system.dataPerm.templates.addItem') }}
</a-button>
</a-space>
</div>
<div v-if="form.items.length === 0" class="items-empty section-card">
<p>{{ t('system.dataPerm.templates.itemsEmpty') }}</p>
<div v-if="form.items.length === 0" class="tpl-items-empty">
<p class="tpl-items-empty__title">{{ t('system.dataPerm.templates.itemsEmpty') }}</p>
<p class="tpl-items-empty__desc">{{ t('system.dataPerm.templates.itemsEmptyHint') }}</p>
<a-space>
<a-button type="primary" @click="addItem">{{ t('system.dataPerm.templates.addItem') }}</a-button>
<a-button @click="applyExamplePreset('salesStaff')">
@ -219,26 +304,26 @@
</a-space>
</div>
<div v-for="(item, idx) in form.items" :key="item._key" class="item-card section-card">
<div v-for="(item, idx) in form.items" :key="item._key" class="item-card">
<div class="item-card-head">
<div class="item-card-head-left">
<span class="item-index">#{{ idx + 1 }}</span>
<span class="item-index">{{ t('system.dataPerm.templates.itemIndex', { index: idx + 1 }) }}</span>
<a-select
v-model="item.form.dimensionType"
:options="dimensionOptions"
style="width: 160px"
style="width: 168px"
:placeholder="t('system.dataPerm.templates.dimensionPlaceholder')"
@change="(v: string) => onDimensionChange(item, v)"
/>
<a-tag :color="dimensionColor(item.form.dimensionType)" size="small">
{{ dimensionLabel(item.form.dimensionType) }}
</a-tag>
</div>
<a-button type="text" status="danger" size="mini" @click="form.items.splice(idx, 1)">
{{ t('system.dataPerm.templates.removeItem') }}
</a-button>
</div>
<div class="item-card-body">
<p v-if="dimensionTip(item.form.dimensionType)" class="item-tip">
{{ dimensionTip(item.form.dimensionType) }}
</p>
<TemplateItemForm
v-model="item.form"
:org-tree="orgTree"
@ -260,7 +345,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message, Modal } from '@arco-design/web-vue'
import { IconFile, IconPlus, IconDown } from '@arco-design/web-vue/es/icon'
import { IconFile, IconDown } from '@arco-design/web-vue/es/icon'
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue'
import RbacDetailShell from '@/components/RbacSplit/RbacDetailShell.vue'
import type { RbacDetailTag } from '@/components/RbacSplit/RbacDetailShell.vue'
@ -391,7 +476,7 @@ const templateTags = computed((): RbacDetailTag[] => {
if (isCreating.value) {
tags.push({ label: t('system.dataPerm.templates.tag.creating'), color: 'orangered' })
} else if (form.isBuiltIn) {
tags.push({ label: t('system.dataPerm.templates.tag.builtin'), color: 'arcoblue' })
tags.push({ label: t('system.dataPerm.templates.tag.builtin'), color: 'gray' })
}
if (form.roleCode) {
tags.push({ label: form.roleCode, color: 'purple' })
@ -399,14 +484,6 @@ const templateTags = computed((): RbacDetailTag[] => {
return tags
})
const templateStats = computed(() => [
{ label: t('system.dataPerm.templates.stat.items'), value: form.items.length },
{
label: t('system.dataPerm.templates.stat.dimensions'),
value: new Set(form.items.map((i) => i.form.dimensionType)).size,
},
])
const templateTabs = computed(() => [
{ key: 'basic', label: t('system.dataPerm.templates.tab.basic') },
{
@ -416,6 +493,12 @@ const templateTabs = computed(() => [
},
])
function dimensionTip(type: string) {
const key = `system.dataPerm.templates.form.tip.${type}`
const text = t(key)
return text === key ? '' : text
}
const templateTreeData = computed(() =>
buildFlatLeftMenuTree('category-templates', t('system.dataPerm.templates.title'), dataSource.value, {
titleKey: 'name',
@ -441,21 +524,6 @@ function newItemRow(formModel?: TemplateItemFormModel): TemplateItemRow {
}
}
function dimensionLabel(type: string) {
return dimensionOptions.value.find((o) => o.value === type)?.label || type
}
function dimensionColor(type: string) {
const map: Record<string, string> = {
org: 'blue',
data_scope: 'green',
biz: 'orange',
secret: 'red',
filter: 'purple',
}
return map[type] || 'gray'
}
function convertOrgTree(data: OrganizationTreeDto[]): typeof orgTree.value {
return data.map((item) => ({
key: item.id!,
@ -820,30 +888,298 @@ onMounted(async () => {
@import '@/components/RbacSplit/rbac-split-layout.less';
.built-in-tag {
font-size: 10px;
color: rgba(255, 255, 255, 0.65);
margin-left: 4px;
display: inline-block;
margin: 0;
padding: 0;
border: 0;
background: transparent;
font-size: 11px;
font-weight: 400;
line-height: 1.2;
color: var(--color-text-3);
white-space: nowrap;
vertical-align: middle;
}
:deep(.node-second-row-extra-cell),
:deep(.node-second-row-extra-slot) {
white-space: nowrap;
}
.tpl-flow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
margin: 0;
padding: 0;
list-style: none;
}
.tpl-flow__item {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--color-text-3);
}
.tpl-flow__num {
width: 20px;
height: 20px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: var(--color-text-3);
background: var(--color-fill-2);
border: 1px solid var(--color-border-2);
}
.tpl-flow__text {
font-size: 12px;
line-height: 1.3;
}
.tpl-flow__item.is-active {
color: var(--color-text-1);
.tpl-flow__num {
color: #fff;
background: var(--app-primary-color, #1d7281);
border-color: var(--app-primary-color, #1d7281);
}
.tpl-flow__text {
font-weight: 600;
}
}
.tpl-flow__item.is-done:not(.is-active) {
color: var(--color-text-2);
.tpl-flow__num {
color: var(--app-primary-color, #1d7281);
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 12%, #fff);
border-color: color-mix(in srgb, var(--app-primary-color, #1d7281) 28%, #fff);
}
}
.tpl-pane {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 960px;
}
.tpl-card {
border: 1px solid var(--color-border-2);
border-radius: 10px;
background: #fff;
overflow: hidden;
}
.tpl-card__head {
padding: 14px 18px 0;
}
.tpl-card__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.35;
}
.tpl-card__hint {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.55;
color: var(--color-text-3);
}
.tpl-card__body {
padding: 12px 18px 16px;
}
.tpl-next-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
border-radius: 10px;
border: 1px dashed color-mix(in srgb, var(--app-primary-color, #1d7281) 35%, #fff);
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 6%, #fff);
}
.tpl-next-card__main {
min-width: 0;
}
.tpl-next-card__title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
.tpl-next-card__desc {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--color-text-3);
}
.tpl-apply-grid {
display: grid;
grid-template-columns: 1fr 1.35fr;
gap: 12px;
margin-top: 4px;
@media (max-width: 1100px) {
grid-template-columns: 1fr;
}
}
.tpl-apply-block {
display: flex;
flex-direction: column;
gap: 14px;
padding: 14px;
border-radius: 8px;
border: 1px solid var(--color-border-2);
background: var(--color-fill-1);
}
.tpl-apply-block__head {
display: flex;
gap: 10px;
align-items: flex-start;
}
.tpl-apply-block__badge {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: var(--app-primary-color, #1d7281);
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 12%, #fff);
}
.tpl-apply-block__title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
.tpl-apply-block__desc {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--color-text-3);
}
.tpl-mode-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tpl-mode-chip {
height: 32px;
padding: 0 12px;
border-radius: 4px;
border: 1px solid var(--color-border-2);
background: #fff;
color: var(--color-text-2);
font-size: 13px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
&:hover {
border-color: var(--app-primary-color, #1d7281);
color: var(--app-primary-color, #1d7281);
}
&.is-active {
border-color: var(--app-primary-color, #1d7281);
color: var(--app-primary-color, #1d7281);
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 8%, #fff);
font-weight: 600;
}
}
.tpl-warn {
margin-top: 4px;
padding: 10px 12px;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
color: #ad6800;
background: #fff7e6;
border: 1px solid #ffe58f;
}
.tpl-items-guide {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-radius: 10px;
border: 1px solid var(--color-border-2);
background: #fff;
}
.tpl-items-guide__title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
.tpl-items-guide__desc {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.55;
color: var(--color-text-3);
max-width: 560px;
}
.tpl-items-empty {
text-align: center;
padding: 48px 24px;
border-radius: 10px;
border: 1px dashed var(--color-border-3);
background: var(--color-fill-1);
color: var(--color-text-3);
}
.tpl-items-empty__title {
margin: 0 0 6px;
font-size: 14px;
font-weight: 600;
color: var(--color-text-2);
}
.tpl-items-empty__desc {
margin: 0 0 18px;
font-size: 12px;
line-height: 1.55;
}
.template-form {
max-width: 960px;
}
.items-hint {
font-size: 13px;
color: var(--color-text-3);
}
.items-empty {
text-align: center;
padding: 40px 24px;
color: var(--color-text-3);
p {
margin: 0 0 16px;
}
}
.preset-option {
display: flex;
flex-direction: column;
@ -862,9 +1198,12 @@ onMounted(async () => {
}
.item-card {
margin-bottom: 12px;
margin-bottom: 0;
padding: 0;
overflow: hidden;
border: 1px solid var(--color-border-2);
border-radius: 10px;
background: #fff;
}
.item-card-head {
@ -890,52 +1229,28 @@ onMounted(async () => {
}
.item-card-body {
padding: 16px 20px;
padding: 14px 18px 16px;
}
.bind-alert {
margin-bottom: 16px;
}
.apply-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
}
.apply-row-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.apply-row-title {
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
}
.apply-row-desc {
font-size: 12px;
color: var(--color-text-3);
}
.apply-user-block {
padding-top: 4px;
}
.apply-block-title {
margin: 0 0 4px;
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
}
.apply-block-desc {
.item-tip {
margin: 0 0 12px;
padding: 8px 10px;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
color: var(--color-text-2);
background: var(--color-fill-1);
border: 1px solid var(--color-border-2);
}
:deep(.shell-tabs .t-tabs) {
width: auto;
max-width: none;
min-width: 0;
}
:deep(.shell-tabs .t-tab) {
padding: 0 10px;
font-size: 12px;
color: var(--color-text-3);
}
</style>