refactor(adminui): remove tenant admin flows and fix build
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
.pnpm-store/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
pnpm dlx commitlint --edit $1
|
if [ ! -w "${HOME:-/root}" ]; then
|
||||||
|
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/tmp}"
|
||||||
|
export COREPACK_HOME="${COREPACK_HOME:-/tmp/corepack}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
corepack pnpm dlx commitlint --edit "$1"
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
pnpm run lint:lint-staged
|
if [ ! -w "${HOME:-/root}" ]; then
|
||||||
|
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/tmp}"
|
||||||
|
export COREPACK_HOME="${COREPACK_HOME:-/tmp/corepack}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
corepack pnpm run lint:lint-staged
|
||||||
|
|||||||
@@ -15,9 +15,15 @@ const __filename = fileURLToPath(import.meta.url)
|
|||||||
const __dirname = dirname(__filename)
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
// 读取 .auto-import.json 文件的内容,并将其解析为 JSON 对象
|
// 读取 .auto-import.json 文件的内容,并将其解析为 JSON 对象
|
||||||
const autoImportConfig = JSON.parse(
|
// 说明:该文件由 unplugin-auto-import 在 Vite 启动时生成,首次安装依赖后可能尚未生成
|
||||||
fs.readFileSync(path.resolve(__dirname, '.auto-import.json'), 'utf-8')
|
let autoImportConfig = { globals: {} }
|
||||||
)
|
try {
|
||||||
|
autoImportConfig = JSON.parse(
|
||||||
|
fs.readFileSync(path.resolve(__dirname, '.auto-import.json'), 'utf-8')
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
autoImportConfig = { globals: {} }
|
||||||
|
}
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
// 指定文件匹配规则
|
// 指定文件匹配规则
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
packages:
|
||||||
|
- .
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@parcel/watcher'
|
- '@parcel/watcher'
|
||||||
- '@tailwindcss/oxide'
|
- '@tailwindcss/oxide'
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
import request from '@/utils/http'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自助注册租户
|
|
||||||
* @param data 自助注册参数
|
|
||||||
*/
|
|
||||||
export function fetchSelfRegisterTenant(data: Api.Tenant.SelfRegisterTenantCommand) {
|
|
||||||
return request.post<Api.Tenant.SelfRegisterResult>({
|
|
||||||
url: '/api/public/v1/tenants/self-register',
|
|
||||||
data
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询租户入驻进度
|
|
||||||
* @param tenantId 租户ID
|
|
||||||
*/
|
|
||||||
export function fetchTenantProgress(tenantId: string) {
|
|
||||||
return request.get<Api.Tenant.TenantProgress>({
|
|
||||||
url: `/api/public/v1/tenants/${tenantId}/status`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提交或更新租户实名信息
|
|
||||||
* @param tenantId 租户ID
|
|
||||||
* @param data 实名资料
|
|
||||||
*/
|
|
||||||
export function submitTenantVerification(
|
|
||||||
tenantId: string,
|
|
||||||
data: Api.Tenant.SubmitTenantVerificationCommand
|
|
||||||
) {
|
|
||||||
return request.post<Api.Tenant.TenantVerificationDto>({
|
|
||||||
url: `/api/public/v1/tenants/${tenantId}/verification`,
|
|
||||||
data: { ...data, tenantId }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提交或更新租户实名信息(管理端)
|
|
||||||
* @param tenantId 租户ID
|
|
||||||
* @param data 实名资料
|
|
||||||
*/
|
|
||||||
export function submitTenantVerificationAdmin(
|
|
||||||
tenantId: string,
|
|
||||||
data: Api.Tenant.SubmitTenantVerificationCommand
|
|
||||||
) {
|
|
||||||
return request.post<Api.Tenant.TenantVerificationDto>({
|
|
||||||
url: `/api/admin/v1/tenants/${tenantId}/verification`,
|
|
||||||
data: { ...data, tenantId }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建租户订阅
|
|
||||||
* @param tenantId 租户ID
|
|
||||||
* @param data 订阅参数
|
|
||||||
*/
|
|
||||||
export function createTenantSubscription(
|
|
||||||
tenantId: string,
|
|
||||||
data: Api.Tenant.CreateTenantSubscriptionCommand
|
|
||||||
) {
|
|
||||||
return request.post<Api.Tenant.TenantSubscriptionDto>({
|
|
||||||
url: `/api/admin/v1/tenants/${tenantId}/subscriptions`,
|
|
||||||
data: { ...data, tenantId }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初次绑定租户订阅(自助入驻)
|
|
||||||
* @param tenantId 租户ID
|
|
||||||
* @param data 初次绑定参数
|
|
||||||
*/
|
|
||||||
export function bindInitialTenantSubscription(
|
|
||||||
tenantId: string,
|
|
||||||
data: Api.Tenant.BindInitialTenantSubscriptionCommand
|
|
||||||
) {
|
|
||||||
return request.post<Api.Tenant.TenantSubscriptionDto>({
|
|
||||||
url: `/api/public/v1/tenants/${tenantId}/subscriptions/initial`,
|
|
||||||
data: { ...data, tenantId }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 升降配租户套餐
|
|
||||||
* @param tenantId 租户ID
|
|
||||||
* @param subscriptionId 订阅ID
|
|
||||||
* @param data 升降配参数
|
|
||||||
*/
|
|
||||||
export function changeTenantSubscriptionPlan(
|
|
||||||
tenantId: string,
|
|
||||||
subscriptionId: string,
|
|
||||||
data: Api.Tenant.ChangeTenantSubscriptionPlanCommand
|
|
||||||
) {
|
|
||||||
return request.put<Api.Tenant.TenantSubscriptionDto>({
|
|
||||||
url: `/api/admin/v1/tenants/${tenantId}/subscriptions/${subscriptionId}/plan`,
|
|
||||||
data: { ...data, tenantId, tenantSubscriptionId: subscriptionId }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -329,25 +329,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetTypeOptions: Array<{ value: AnnouncementTargetType; labelKey: string }> = [
|
const targetTypeOptions: Array<{ value: AnnouncementTargetType; labelKey: string }> = [
|
||||||
{ value: 'all', labelKey: 'announcement.audience.type.all' },
|
{ value: 'All', labelKey: 'announcement.audience.type.all' },
|
||||||
{ value: 'roles', labelKey: 'announcement.audience.type.roles' },
|
{ value: 'Roles', labelKey: 'announcement.audience.type.roles' },
|
||||||
{ value: 'users', labelKey: 'announcement.audience.type.users' },
|
{ value: 'Users', labelKey: 'announcement.audience.type.users' },
|
||||||
{ value: 'rules', labelKey: 'announcement.audience.type.rules' },
|
{ value: 'Rules', labelKey: 'announcement.audience.type.rules' },
|
||||||
{ value: 'manual', labelKey: 'announcement.audience.type.manual' }
|
{ value: 'Manual', labelKey: 'announcement.audience.type.manual' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const targetTypeHelperMap: Record<AnnouncementTargetType, string> = {
|
const targetTypeHelperMap: Record<AnnouncementTargetType, string> = {
|
||||||
all: 'announcement.audience.helper.all',
|
All: 'announcement.audience.helper.all',
|
||||||
roles: 'announcement.audience.helper.roles',
|
Roles: 'announcement.audience.helper.roles',
|
||||||
users: 'announcement.audience.helper.users',
|
Users: 'announcement.audience.helper.users',
|
||||||
rules: 'announcement.audience.helper.rules',
|
Rules: 'announcement.audience.helper.rules',
|
||||||
manual: 'announcement.audience.helper.manual'
|
Manual: 'announcement.audience.helper.manual'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 本地状态与加载状态
|
// 2. 本地状态与加载状态
|
||||||
const currentTenantId = computed(() => String(userStore.info?.tenantId || ''))
|
const currentTenantId = computed(() => String(userStore.info?.tenantId || ''))
|
||||||
const localState = reactive({
|
const localState = reactive({
|
||||||
targetType: 'all' as AnnouncementTargetType,
|
targetType: 'All' as AnnouncementTargetType,
|
||||||
rules: {
|
rules: {
|
||||||
departments: [] as string[],
|
departments: [] as string[],
|
||||||
roles: [] as string[],
|
roles: [] as string[],
|
||||||
@@ -371,10 +371,10 @@
|
|||||||
const isSyncing = ref(false)
|
const isSyncing = ref(false)
|
||||||
|
|
||||||
// 3. 计算属性
|
// 3. 计算属性
|
||||||
const isRoleMode = computed(() => localState.targetType === 'roles')
|
const isRoleMode = computed(() => localState.targetType === 'Roles')
|
||||||
const isUsersMode = computed(() => localState.targetType === 'users')
|
const isUsersMode = computed(() => localState.targetType === 'Users')
|
||||||
const isRulesMode = computed(() => localState.targetType === 'rules')
|
const isRulesMode = computed(() => localState.targetType === 'Rules')
|
||||||
const isManualMode = computed(() => localState.targetType === 'manual')
|
const isManualMode = computed(() => localState.targetType === 'Manual')
|
||||||
const isRulesOrRoles = computed(() => isRulesMode.value || isRoleMode.value)
|
const isRulesOrRoles = computed(() => isRulesMode.value || isRoleMode.value)
|
||||||
|
|
||||||
const selectedUserCount = computed(() => localState.userIds.length)
|
const selectedUserCount = computed(() => localState.userIds.length)
|
||||||
@@ -390,7 +390,7 @@
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const manualTransferTitles = computed(() => [
|
const manualTransferTitles = computed<[string, string]>(() => [
|
||||||
t('announcement.audience.manual.transferLeft'),
|
t('announcement.audience.manual.transferLeft'),
|
||||||
t('announcement.audience.manual.transferRight')
|
t('announcement.audience.manual.transferRight')
|
||||||
])
|
])
|
||||||
@@ -415,12 +415,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildRulesPayload = (mode: 'rules' | 'roles'): TargetRules => {
|
const buildRulesPayload = (
|
||||||
|
mode: Extract<AnnouncementTargetType, 'Rules' | 'Roles'>
|
||||||
|
): TargetRules => {
|
||||||
// 1. 获取清洗后的规则数据
|
// 1. 获取清洗后的规则数据
|
||||||
const cleaned = normalizeRules(localState.rules)
|
const cleaned = normalizeRules(localState.rules)
|
||||||
|
|
||||||
// 2. 角色模式仅返回角色规则
|
// 2. 角色模式仅返回角色规则
|
||||||
if (mode === 'roles') {
|
if (mode === 'Roles') {
|
||||||
return {
|
return {
|
||||||
roles: cleaned.roles
|
roles: cleaned.roles
|
||||||
}
|
}
|
||||||
@@ -435,8 +437,8 @@
|
|||||||
const targetType = localState.targetType
|
const targetType = localState.targetType
|
||||||
|
|
||||||
// 2. 角色与规则模式
|
// 2. 角色与规则模式
|
||||||
if (targetType === 'roles' || targetType === 'rules') {
|
if (targetType === 'Roles' || targetType === 'Rules') {
|
||||||
const rules = buildRulesPayload(targetType === 'roles' ? 'roles' : 'rules')
|
const rules = buildRulesPayload(targetType)
|
||||||
const hasRules =
|
const hasRules =
|
||||||
(rules.departments && rules.departments.length > 0) ||
|
(rules.departments && rules.departments.length > 0) ||
|
||||||
(rules.roles && rules.roles.length > 0) ||
|
(rules.roles && rules.roles.length > 0) ||
|
||||||
@@ -449,7 +451,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 指定用户与手动选择
|
// 3. 指定用户与手动选择
|
||||||
if (targetType === 'users' || targetType === 'manual') {
|
if (targetType === 'Users' || targetType === 'Manual') {
|
||||||
const userIds = normalizeArray(localState.userIds)
|
const userIds = normalizeArray(localState.userIds)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -494,9 +496,12 @@
|
|||||||
// 1. 仅规则/角色模式触发
|
// 1. 仅规则/角色模式触发
|
||||||
if (!isRulesOrRoles.value) return
|
if (!isRulesOrRoles.value) return
|
||||||
|
|
||||||
|
const targetType = localState.targetType
|
||||||
|
if (targetType !== 'Rules' && targetType !== 'Roles') return
|
||||||
|
|
||||||
// 2. 发起预估请求
|
// 2. 发起预估请求
|
||||||
estimateLoading.value = true
|
estimateLoading.value = true
|
||||||
const rules = buildRulesPayload(localState.targetType === 'roles' ? 'roles' : 'rules')
|
const rules = buildRulesPayload(targetType)
|
||||||
const estimate = await announcementStore.estimateAudience(rules)
|
const estimate = await announcementStore.estimateAudience(rules)
|
||||||
|
|
||||||
// 3. 更新预估结果
|
// 3. 更新预估结果
|
||||||
@@ -645,25 +650,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUserSuggestions = useDebounceFn(
|
const fetchUserSuggestionsDebounced = useDebounceFn(async (queryString: string) => {
|
||||||
async (queryString: string, callback: (results: UserSuggestion[]) => void) => {
|
// 1. 拉取候选用户
|
||||||
// 1. 拉取候选用户
|
const options = await searchUsers(queryString)
|
||||||
const options = await searchUsers(queryString)
|
|
||||||
|
|
||||||
// 2. 转换为自动完成格式
|
// 2. 转换为自动完成格式
|
||||||
const suggestions = options.map((option) => ({
|
return options.map(
|
||||||
|
(option): UserSuggestion => ({
|
||||||
value: option.label,
|
value: option.label,
|
||||||
label: option.label,
|
label: option.label,
|
||||||
id: option.id,
|
id: option.id,
|
||||||
option
|
option
|
||||||
}))
|
})
|
||||||
|
)
|
||||||
|
}, 300)
|
||||||
|
|
||||||
callback(suggestions)
|
const handleUserSuggestions = (
|
||||||
},
|
queryString: string,
|
||||||
300
|
callback: (results: UserSuggestion[]) => void
|
||||||
)
|
) => {
|
||||||
|
void fetchUserSuggestionsDebounced(queryString)
|
||||||
|
.then((suggestions) => callback(suggestions))
|
||||||
|
.catch(() => callback([]))
|
||||||
|
}
|
||||||
|
|
||||||
const handleUserSelect = (suggestion: UserSuggestion) => {
|
const handleUserSelect = (item: Record<string, any>) => {
|
||||||
|
const suggestion = item as UserSuggestion
|
||||||
// 1. 写入候选列表
|
// 1. 写入候选列表
|
||||||
upsertUserOption(suggestion.option)
|
upsertUserOption(suggestion.option)
|
||||||
|
|
||||||
@@ -689,7 +701,7 @@
|
|||||||
isSyncing.value = true
|
isSyncing.value = true
|
||||||
|
|
||||||
// 2. 同步目标类型
|
// 2. 同步目标类型
|
||||||
localState.targetType = value?.targetType ?? 'all'
|
localState.targetType = value?.targetType ?? 'All'
|
||||||
|
|
||||||
// 3. 同步规则与用户列表
|
// 3. 同步规则与用户列表
|
||||||
const normalizedRules = normalizeRules(value?.targetRules)
|
const normalizedRules = normalizeRules(value?.targetRules)
|
||||||
|
|||||||
@@ -230,14 +230,7 @@ function handleLoginStatus(
|
|||||||
* 检查路由是否为无需登录的静态路由
|
* 检查路由是否为无需登录的静态路由
|
||||||
*/
|
*/
|
||||||
function isPublicStaticRoute(path: string): boolean {
|
function isPublicStaticRoute(path: string): boolean {
|
||||||
// 0. 入驻相关页面虽然是静态路由,但要求登录访问
|
return isStaticRoute(path)
|
||||||
const loginRequiredStaticPaths = [
|
|
||||||
'/onboarding/status',
|
|
||||||
'/onboarding/pricing',
|
|
||||||
'/onboarding/waiting',
|
|
||||||
'/onboarding/error'
|
|
||||||
]
|
|
||||||
return isStaticRoute(path) && !loginRequiredStaticPaths.includes(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,28 +26,6 @@ const dictionaryRoutes: AppRouteRecord = {
|
|||||||
permission: ['dictionary:group:read']
|
permission: ['dictionary:group:read']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'tenant',
|
|
||||||
name: 'TenantDictionary',
|
|
||||||
component: () => import('@views/tenant/dictionary/index.vue'),
|
|
||||||
meta: {
|
|
||||||
title: 'menus.dictionary.tenant',
|
|
||||||
icon: 'ri:bookmark-3-line',
|
|
||||||
roles: ['TenantAdmin'],
|
|
||||||
permission: ['dictionary:group:read']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'override',
|
|
||||||
name: 'TenantDictionaryOverride',
|
|
||||||
component: () => import('@views/tenant/dictionary-override/index.vue'),
|
|
||||||
meta: {
|
|
||||||
title: 'menus.dictionary.override',
|
|
||||||
icon: 'ri:settings-3-line',
|
|
||||||
roles: ['TenantAdmin'],
|
|
||||||
permission: ['dictionary:override:read']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'label-override',
|
path: 'label-override',
|
||||||
name: 'PlatformLabelOverride',
|
name: 'PlatformLabelOverride',
|
||||||
|
|||||||
@@ -41,46 +41,6 @@ const tenantRoutes: AppRouteRecord = {
|
|||||||
icon: 'ri:bar-chart-box-line',
|
icon: 'ri:bar-chart-box-line',
|
||||||
isHideMenu: false
|
isHideMenu: false
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'announcements',
|
|
||||||
name: 'TenantAnnouncementList',
|
|
||||||
component: () => import('@views/tenant/announcements/index.vue'),
|
|
||||||
meta: {
|
|
||||||
title: 'menus.announcement.tenant.list',
|
|
||||||
icon: 'ri:notification-3-line',
|
|
||||||
permission: ['tenant-announcement:read'],
|
|
||||||
authList: [
|
|
||||||
{ title: '创建租户公告', authMark: 'tenant-announcement:create' },
|
|
||||||
{ title: '查看租户公告', authMark: 'tenant-announcement:read' },
|
|
||||||
{ title: '编辑租户公告', authMark: 'tenant-announcement:update' },
|
|
||||||
{ title: '删除租户公告', authMark: 'tenant-announcement:delete' },
|
|
||||||
{ title: '发布租户公告', authMark: 'tenant-announcement:publish' },
|
|
||||||
{ title: '撤销租户公告', authMark: 'tenant-announcement:revoke' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'announcements/create',
|
|
||||||
name: 'TenantAnnouncementCreate',
|
|
||||||
component: () => import('@views/tenant/announcements/create.vue'),
|
|
||||||
meta: {
|
|
||||||
title: 'menus.announcement.tenant.create',
|
|
||||||
icon: 'ri:add-circle-line',
|
|
||||||
permission: ['tenant-announcement:create'],
|
|
||||||
hideMenu: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'announcements/:announcementId/edit',
|
|
||||||
name: 'TenantAnnouncementEdit',
|
|
||||||
component: () => import('@views/tenant/announcements/create.vue'),
|
|
||||||
meta: {
|
|
||||||
title: 'menus.announcement.tenant.edit',
|
|
||||||
icon: 'ri:edit-line',
|
|
||||||
permission: ['tenant-announcement:update'],
|
|
||||||
hideMenu: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,42 +24,6 @@ export const staticRoutes: AppRouteRecordRaw[] = [
|
|||||||
component: () => import('@views/auth/login/index.vue'),
|
component: () => import('@views/auth/login/index.vue'),
|
||||||
meta: { title: 'menus.login.title', isHideTab: true }
|
meta: { title: 'menus.login.title', isHideTab: true }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/auth/register',
|
|
||||||
name: 'Register',
|
|
||||||
component: () => import('@views/auth/register/index.vue'),
|
|
||||||
meta: { title: 'menus.register.title', isHideTab: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/onboarding/status',
|
|
||||||
name: 'TenantOnboardingStatus',
|
|
||||||
component: () => import('@views/onboarding/status/index.vue'),
|
|
||||||
meta: { title: 'menus.onboarding.status', isHideTab: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/onboarding/pricing',
|
|
||||||
name: 'TenantOnboardingPricing',
|
|
||||||
component: () => import('@views/onboarding/pricing/index.vue'),
|
|
||||||
meta: { title: 'menus.onboarding.pricing', isHideTab: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/onboarding/waiting',
|
|
||||||
name: 'TenantOnboardingWaiting',
|
|
||||||
component: () => import('@views/onboarding/waiting/index.vue'),
|
|
||||||
meta: { title: 'menus.onboarding.waiting', isHideTab: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/onboarding/error',
|
|
||||||
name: 'TenantOnboardingError',
|
|
||||||
component: () => import('@views/onboarding/error/index.vue'),
|
|
||||||
meta: { title: 'menus.onboarding.error', isHideTab: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/terms-of-service',
|
|
||||||
name: 'TermsOfService',
|
|
||||||
component: () => import('@views/onboarding/terms-of-service/index.vue'),
|
|
||||||
meta: { title: 'menus.termsOfService.title', isHideTab: true }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/auth/forget-password',
|
path: '/auth/forget-password',
|
||||||
name: 'ForgetPassword',
|
name: 'ForgetPassword',
|
||||||
|
|||||||
@@ -231,13 +231,13 @@ export const useAnnouncementStore = defineStore(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 规则/角色模式使用规则参数
|
// 2. 规则/角色模式使用规则参数
|
||||||
if ((data.targetType === 'rules' || data.targetType === 'roles') && data.targetRules) {
|
if ((data.targetType === 'Rules' || data.targetType === 'Roles') && data.targetRules) {
|
||||||
return JSON.stringify(data.targetRules)
|
return JSON.stringify(data.targetRules)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 手选用户模式使用用户ID参数
|
// 3. 手选用户模式使用用户ID参数
|
||||||
if (
|
if (
|
||||||
(data.targetType === 'users' || data.targetType === 'manual') &&
|
(data.targetType === 'Users' || data.targetType === 'Manual') &&
|
||||||
data.targetUserIds?.length
|
data.targetUserIds?.length
|
||||||
) {
|
) {
|
||||||
return JSON.stringify({ userIds: data.targetUserIds })
|
return JSON.stringify({ userIds: data.targetUserIds })
|
||||||
@@ -272,6 +272,10 @@ export const useAnnouncementStore = defineStore(
|
|||||||
return {
|
return {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
content: data.content,
|
content: data.content,
|
||||||
|
announcementType: data.announcementType,
|
||||||
|
priority: data.priority,
|
||||||
|
effectiveFrom: data.effectiveFrom,
|
||||||
|
effectiveTo: data.effectiveTo ?? null,
|
||||||
targetType: data.targetType,
|
targetType: data.targetType,
|
||||||
targetParameters: buildTargetParameters(data),
|
targetParameters: buildTargetParameters(data),
|
||||||
rowVersion: data.rowVersion
|
rowVersion: data.rowVersion
|
||||||
@@ -998,7 +1002,7 @@ export const useAnnouncementStore = defineStore(
|
|||||||
{
|
{
|
||||||
// 使用 storeId 走全局 StorageKeyManager(避免硬编码 Key)
|
// 使用 storeId 走全局 StorageKeyManager(避免硬编码 Key)
|
||||||
persist: {
|
persist: {
|
||||||
paths: ['unreadCount', 'statistics']
|
pick: ['unreadCount', 'statistics']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export const useDictionaryCacheStore = defineStore(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
persist: {
|
persist: {
|
||||||
paths: ['cache', 'expiryMap']
|
pick: ['cache', 'expiryMap']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -179,8 +179,8 @@
|
|||||||
const actionLoadingIds = ref<string[]>([])
|
const actionLoadingIds = ref<string[]>([])
|
||||||
|
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
type: null as TenantAnnouncementType | null,
|
type: undefined as TenantAnnouncementType | undefined,
|
||||||
dateRange: [] as Date[]
|
dateRange: [] as [Date, Date] | []
|
||||||
})
|
})
|
||||||
|
|
||||||
const pagination = ref({
|
const pagination = ref({
|
||||||
@@ -202,7 +202,6 @@
|
|||||||
|
|
||||||
// 3. 选项配置
|
// 3. 选项配置
|
||||||
const announcementTypeOptions = computed(() => [
|
const announcementTypeOptions = computed(() => [
|
||||||
{ value: null, label: t('announcementDrafts.type.all') },
|
|
||||||
{ value: TenantAnnouncementType.System, label: t('announcementDrafts.type.system') },
|
{ value: TenantAnnouncementType.System, label: t('announcementDrafts.type.system') },
|
||||||
{ value: TenantAnnouncementType.Billing, label: t('announcementDrafts.type.billing') },
|
{ value: TenantAnnouncementType.Billing, label: t('announcementDrafts.type.billing') },
|
||||||
{ value: TenantAnnouncementType.Operation, label: t('announcementDrafts.type.operation') },
|
{ value: TenantAnnouncementType.Operation, label: t('announcementDrafts.type.operation') },
|
||||||
@@ -280,7 +279,7 @@
|
|||||||
// 2. 过滤草稿列表
|
// 2. 过滤草稿列表
|
||||||
return mergedDrafts.value.filter((item) => {
|
return mergedDrafts.value.filter((item) => {
|
||||||
// 1. 类型筛选
|
// 1. 类型筛选
|
||||||
if (typeFilter !== null && item.announcementType !== typeFilter) {
|
if (typeFilter !== undefined && item.announcementType !== typeFilter) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,8 +355,9 @@
|
|||||||
const resolveServerLastSaved = (draft: TenantAnnouncementDto): IsoDateTimeString | null => {
|
const resolveServerLastSaved = (draft: TenantAnnouncementDto): IsoDateTimeString | null => {
|
||||||
// 1. 兼容后端可能返回的更新时间字段
|
// 1. 兼容后端可能返回的更新时间字段
|
||||||
const candidateFields = ['updatedAt', 'lastSaved', 'modifiedAt', 'createdAt'] as const
|
const candidateFields = ['updatedAt', 'lastSaved', 'modifiedAt', 'createdAt'] as const
|
||||||
|
const record = draft as unknown as Record<string, unknown>
|
||||||
for (const field of candidateFields) {
|
for (const field of candidateFields) {
|
||||||
const value = (draft as Record<string, unknown>)[field]
|
const value = record[field]
|
||||||
if (typeof value === 'string' && value) {
|
if (typeof value === 'string' && value) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
@@ -676,7 +676,7 @@
|
|||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
// 1. 重置筛选条件
|
// 1. 重置筛选条件
|
||||||
filters.value.type = null
|
filters.value.type = undefined
|
||||||
filters.value.dateRange = []
|
filters.value.dateRange = []
|
||||||
|
|
||||||
// 2. 重置分页
|
// 2. 重置分页
|
||||||
|
|||||||
@@ -90,12 +90,7 @@
|
|||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 text-sm text-gray-600">
|
<!-- 平台管理端不提供自助注册入口 -->
|
||||||
<span>{{ $t('login.noAccount') }}</span>
|
|
||||||
<RouterLink class="text-theme" :to="{ name: 'Register' }">{{
|
|
||||||
$t('login.register')
|
|
||||||
}}</RouterLink>
|
|
||||||
</div>
|
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,9 +104,7 @@
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { HttpError } from '@/utils/http/error'
|
import { HttpError } from '@/utils/http/error'
|
||||||
import { fetchLoginSimple } from '@/api/auth'
|
import { fetchLoginSimple } from '@/api/auth'
|
||||||
import { fetchTenantProgress } from '@/api/tenant-onboarding'
|
|
||||||
import { ElNotification, type FormInstance, type FormRules } from 'element-plus'
|
import { ElNotification, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { StorageConfig } from '@/utils/storage'
|
|
||||||
import {
|
import {
|
||||||
clearRememberLogin,
|
clearRememberLogin,
|
||||||
loadRememberLogin,
|
loadRememberLogin,
|
||||||
@@ -135,21 +128,6 @@
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const isPassing = ref(true)
|
const isPassing = ref(true)
|
||||||
|
|
||||||
// 本地枚举,避免运行时引用未定义的 Api 命名空间
|
|
||||||
const TenantVerificationStatus = {
|
|
||||||
Draft: 0,
|
|
||||||
Pending: 1,
|
|
||||||
Approved: 2,
|
|
||||||
Rejected: 3
|
|
||||||
} as const
|
|
||||||
const TenantStatus = {
|
|
||||||
PendingReview: 0,
|
|
||||||
Active: 1,
|
|
||||||
Suspended: 2,
|
|
||||||
Expired: 3,
|
|
||||||
Closed: 4
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const systemName = AppConfig.systemInfo.name
|
const systemName = AppConfig.systemInfo.name
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
@@ -230,18 +208,15 @@
|
|||||||
userStore.setToken(accessToken, refreshToken)
|
userStore.setToken(accessToken, refreshToken)
|
||||||
if (user) {
|
if (user) {
|
||||||
userStore.setUserInfo(user)
|
userStore.setUserInfo(user)
|
||||||
if (user.tenantId) {
|
|
||||||
localStorage.setItem(StorageConfig.TENANT_ID_KEY, String(user.tenantId))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
userStore.setLoginStatus(true)
|
userStore.setLoginStatus(true)
|
||||||
|
|
||||||
// 8. 登录成功处理
|
// 8. 登录成功处理
|
||||||
showLoginSuccessNotice()
|
showLoginSuccessNotice()
|
||||||
|
|
||||||
// 9. 跳转前检查租户入驻状态
|
// 9. 登录后跳转(平台管理端不处理租户入驻流程)
|
||||||
const redirect = route.query.redirect as string
|
const redirect = route.query.redirect as string | undefined
|
||||||
await handlePostLoginNavigation(redirect)
|
router.push(redirect || '/')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 处理 HttpError
|
// 处理 HttpError
|
||||||
if (error instanceof HttpError) {
|
if (error instanceof HttpError) {
|
||||||
@@ -264,72 +239,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePostLoginNavigation = async (redirect?: string) => {
|
|
||||||
const tenantId =
|
|
||||||
(route.query.tenantId as string) ||
|
|
||||||
userStore.getUserInfo?.tenantId ||
|
|
||||||
localStorage.getItem(StorageConfig.TENANT_ID_KEY)
|
|
||||||
|
|
||||||
// 1. 拉取入驻进度
|
|
||||||
if (!tenantId) {
|
|
||||||
router.push(redirect || '/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const progress = await fetchTenantProgress(String(tenantId))
|
|
||||||
|
|
||||||
// 2. 判定登录后跳转目标
|
|
||||||
const verificationStatus = progress.verificationStatus
|
|
||||||
const tenantStatus = progress.status
|
|
||||||
|
|
||||||
// 2.1 草稿:引导选择套餐
|
|
||||||
if (verificationStatus === TenantVerificationStatus.Draft) {
|
|
||||||
router.push({
|
|
||||||
name: 'TenantOnboardingPricing',
|
|
||||||
query: { tenantId: String(tenantId) }
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.2 待审核:进入等待页
|
|
||||||
if (verificationStatus === TenantVerificationStatus.Pending) {
|
|
||||||
router.push({
|
|
||||||
name: 'TenantOnboardingWaiting',
|
|
||||||
query: { tenantId: String(tenantId) }
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.3 状态异常:实名驳回或租户不可用
|
|
||||||
const isTenantErrorStatus =
|
|
||||||
tenantStatus === TenantStatus.Suspended ||
|
|
||||||
tenantStatus === TenantStatus.Expired ||
|
|
||||||
tenantStatus === TenantStatus.Closed
|
|
||||||
if (verificationStatus === TenantVerificationStatus.Rejected || isTenantErrorStatus) {
|
|
||||||
router.push({
|
|
||||||
name: 'TenantOnboardingError',
|
|
||||||
query: { tenantId: String(tenantId) }
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.4 其他非通过场景兜底回进度页
|
|
||||||
if (tenantStatus !== TenantStatus.Active) {
|
|
||||||
router.push({
|
|
||||||
name: 'TenantOnboardingStatus',
|
|
||||||
query: { tenantId: String(tenantId) }
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[Login] 入驻状态检查失败,按默认流程跳转:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 默认跳转控制台或 redirect
|
|
||||||
router.push(redirect || '/')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登录成功提示
|
// 登录成功提示
|
||||||
const showLoginSuccessNotice = () => {
|
const showLoginSuccessNotice = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -1,668 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { reactive, ref } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
import { fetchSelfRegisterTenant } from '@/api/tenant-onboarding'
|
|
||||||
import { saveRememberLogin } from '@/utils/storage/remember-login'
|
|
||||||
import { StorageConfig } from '@/utils/storage'
|
|
||||||
import { HttpError } from '@/utils/http/error'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
const loading = ref(false)
|
|
||||||
const showPassword = ref(false)
|
|
||||||
const errors = reactive<
|
|
||||||
Record<
|
|
||||||
'account' | 'name' | 'email' | 'phone' | 'password' | 'confirmPassword' | 'agreed',
|
|
||||||
string
|
|
||||||
>
|
|
||||||
>({
|
|
||||||
account: '',
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
password: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
agreed: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const formData = reactive<{
|
|
||||||
account: string
|
|
||||||
name: string
|
|
||||||
email: string
|
|
||||||
phone: string
|
|
||||||
password: string
|
|
||||||
confirmPassword: string
|
|
||||||
agreed: boolean
|
|
||||||
}>({
|
|
||||||
account: '',
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
password: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
agreed: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const setError = (field: keyof typeof errors, msg: string) => {
|
|
||||||
errors[field] = msg
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearError = (field: keyof typeof errors) => {
|
|
||||||
errors[field] = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateFields = (): string => {
|
|
||||||
// 重置错误
|
|
||||||
Object.keys(errors).forEach((key) => setError(key as keyof typeof errors, ''))
|
|
||||||
let firstError = ''
|
|
||||||
|
|
||||||
const recordError = (field: keyof typeof errors, msg: string) => {
|
|
||||||
setError(field, msg)
|
|
||||||
if (!firstError) firstError = msg
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 基础必填校验
|
|
||||||
if (!formData.account.trim()) recordError('account', t('register.rule.adminAccountRequired'))
|
|
||||||
|
|
||||||
// 1.1 账号格式校验(仅允许大小写字母与数字)
|
|
||||||
if (formData.account.trim() && !/^[A-Za-z0-9]+$/.test(formData.account.trim())) {
|
|
||||||
recordError('account', t('register.rule.adminAccountFormat'))
|
|
||||||
}
|
|
||||||
if (!formData.name.trim()) recordError('name', t('register.rule.adminDisplayNameRequired'))
|
|
||||||
if (!formData.email.trim()) {
|
|
||||||
recordError('email', t('register.rule.adminEmailRequired'))
|
|
||||||
} else {
|
|
||||||
const emailPattern = /^\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}$/
|
|
||||||
if (!emailPattern.test(formData.email.trim())) {
|
|
||||||
recordError('email', t('register.rule.adminEmailFormat'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!formData.phone.trim()) {
|
|
||||||
recordError('phone', t('register.rule.adminPhoneRequired'))
|
|
||||||
} else if (!/^0?(13|14|15|16|17|18|19)[0-9]{9}$/.test(formData.phone.trim())) {
|
|
||||||
recordError('phone', t('register.rule.adminPhoneFormat'))
|
|
||||||
}
|
|
||||||
if (!formData.password) {
|
|
||||||
recordError('password', t('register.rule.adminPasswordRequired'))
|
|
||||||
} else if (formData.password.length < 8) {
|
|
||||||
recordError('password', t('register.rule.adminPasswordLength'))
|
|
||||||
}
|
|
||||||
if (!formData.confirmPassword) {
|
|
||||||
recordError('confirmPassword', t('register.rule.confirmPasswordRequired'))
|
|
||||||
} else if (formData.password !== formData.confirmPassword) {
|
|
||||||
recordError('confirmPassword', t('register.rule.passwordMismatch'))
|
|
||||||
}
|
|
||||||
if (!formData.agreed) recordError('agreed', t('register.rule.agreementRequired'))
|
|
||||||
|
|
||||||
return firstError
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const errorMsg = validateFields()
|
|
||||||
if (errorMsg) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 组装请求体
|
|
||||||
const payload: Api.Tenant.SelfRegisterTenantCommand = {
|
|
||||||
adminAccount: formData.account,
|
|
||||||
adminDisplayName: formData.name,
|
|
||||||
adminEmail: formData.email,
|
|
||||||
adminPhone: formData.phone,
|
|
||||||
adminPassword: formData.password
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 发起注册
|
|
||||||
loading.value = true
|
|
||||||
const result = await fetchSelfRegisterTenant(payload)
|
|
||||||
|
|
||||||
// 3. 缓存租户ID与账号密码,便于后续登录
|
|
||||||
persistRegistrationInfo(
|
|
||||||
result.tenantId,
|
|
||||||
payload.adminAccount,
|
|
||||||
payload.adminPhone,
|
|
||||||
payload.adminPassword
|
|
||||||
)
|
|
||||||
|
|
||||||
// 4. 跳转登录页
|
|
||||||
await router.push({
|
|
||||||
name: 'Login',
|
|
||||||
query: { tenantId: result.tenantId }
|
|
||||||
})
|
|
||||||
ElMessage.success(t('register.success'))
|
|
||||||
} catch (error) {
|
|
||||||
handleRegisterError(error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistRegistrationInfo = (
|
|
||||||
tenantId: string,
|
|
||||||
account: string,
|
|
||||||
phone: string,
|
|
||||||
password: string
|
|
||||||
) => {
|
|
||||||
// 1. 缓存租户ID
|
|
||||||
localStorage.setItem(StorageConfig.TENANT_ID_KEY, tenantId)
|
|
||||||
// 2. 记住账号密码,便于登录页自动填充
|
|
||||||
saveRememberLogin({ account, phone, password })
|
|
||||||
// 3. 更新路由参数
|
|
||||||
router.replace({
|
|
||||||
query: { ...route.query, tenantId }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRegisterError = (error: unknown) => {
|
|
||||||
if (error instanceof HttpError) {
|
|
||||||
if (error.code === 429) {
|
|
||||||
ElMessage.error(t('register.rateLimit'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ElMessage.error(error.message || t('register.failed'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ElMessage.error(t('register.failed'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const goLogin = () => {
|
|
||||||
router.push({ name: 'Login' })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-screen flex">
|
|
||||||
<!-- 左侧:品牌与营销区域 (Left Side) -->
|
|
||||||
<!-- 在移动端隐藏,在 LG (大屏幕) 上显示 -->
|
|
||||||
<div
|
|
||||||
class="hidden lg:flex lg:w-5/12 relative bg-indigo-900 overflow-hidden flex-col justify-between p-12 text-white"
|
|
||||||
>
|
|
||||||
<!-- 背景装饰 -->
|
|
||||||
<div class="absolute top-0 left-0 w-full h-full opacity-20 pointer-events-none">
|
|
||||||
<div
|
|
||||||
class="absolute top-[-10%] right-[-10%] w-96 h-96 rounded-full bg-indigo-400 blur-3xl"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="absolute bottom-[-10%] left-[-10%] w-80 h-80 rounded-full bg-purple-500 blur-3xl"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Logo区域 -->
|
|
||||||
<div class="relative z-10 flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="w-10 h-10 bg-white/10 backdrop-blur-md rounded-lg flex items-center justify-center border border-white/20"
|
|
||||||
>
|
|
||||||
<!-- Layout Icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="text-indigo-300 w-6 h-6"
|
|
||||||
>
|
|
||||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
|
||||||
<line x1="3" x2="21" y1="9" y2="9" />
|
|
||||||
<line x1="9" x2="9" y1="21" y2="9" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span class="text-xl font-bold tracking-wide text-white">CloudSaaS</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 中间文案 -->
|
|
||||||
<div class="relative z-10 my-auto">
|
|
||||||
<h1 class="text-4xl font-bold leading-tight mb-6">
|
|
||||||
开启您的<br />
|
|
||||||
<span class="text-indigo-300">数字化管理</span> 新篇章
|
|
||||||
</h1>
|
|
||||||
<p class="text-indigo-100 text-lg leading-relaxed max-w-md opacity-90">
|
|
||||||
仅需几步,即可快速开通您的独立空间。为您提供安全、稳定、高效的企业级管理体验。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-8 flex gap-4">
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 text-sm text-indigo-200 bg-white/5 px-3 py-1.5 rounded-full border border-white/10"
|
|
||||||
>
|
|
||||||
<!-- ShieldCheck Icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
||||||
<path d="m9 12 2 2 4-4" />
|
|
||||||
</svg>
|
|
||||||
<span>企业级安全</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 text-sm text-indigo-200 bg-white/5 px-3 py-1.5 rounded-full border border-white/10"
|
|
||||||
>
|
|
||||||
<!-- CheckCircle2 Icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="m9 12 2 2 4-4" />
|
|
||||||
</svg>
|
|
||||||
<span>极速部署</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 底部版权 -->
|
|
||||||
<div class="relative z-10 text-sm text-indigo-300/60">
|
|
||||||
© 2025 CloudSaaS Inc. All rights reserved.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧:注册表单 (Right Side) -->
|
|
||||||
<div class="flex-1 flex flex-col justify-center items-center p-6 lg:p-12 overflow-y-auto">
|
|
||||||
<div
|
|
||||||
class="w-full max-w-2xl bg-white lg:bg-transparent lg:shadow-none shadow-xl rounded-2xl p-6 lg:p-0"
|
|
||||||
>
|
|
||||||
<!-- 表单头部 -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<div
|
|
||||||
class="inline-block px-3 py-1 mb-4 text-xs font-semibold tracking-wider text-indigo-600 uppercase bg-indigo-50 rounded-full"
|
|
||||||
>
|
|
||||||
自助入驻
|
|
||||||
</div>
|
|
||||||
<h2 class="text-3xl font-bold text-slate-900 mb-2">填写管理员信息</h2>
|
|
||||||
<p class="text-slate-500"> 快速开通独立空间,密码仅用于登录不会返回。 </p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
|
||||||
<!-- 6个字段的 Grid 布局 -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-6">
|
|
||||||
<!-- 1. 管理员账号 (必填) -->
|
|
||||||
<div class="col-span-1">
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">
|
|
||||||
管理员账号 <span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative group input-group">
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400 input-icon transition-colors"
|
|
||||||
>
|
|
||||||
<!-- User Icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
|
||||||
<circle cx="12" cy="7" r="4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
v-model="formData.account"
|
|
||||||
@input="clearError('account')"
|
|
||||||
required
|
|
||||||
placeholder="请输入登录账号"
|
|
||||||
class="w-full pl-10 pr-4 py-2.5 bg-white border rounded-lg text-sm focus:outline-none transition-all placeholder:text-slate-400"
|
|
||||||
:class="
|
|
||||||
errors.account
|
|
||||||
? 'border-rose-400 focus:border-rose-500 focus:ring-4 focus:ring-rose-100'
|
|
||||||
: 'border-slate-200 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<p v-if="errors.account" class="mt-1 text-xs text-rose-500">{{ errors.account }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 2. 管理员名称 -->
|
|
||||||
<div class="col-span-1">
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-1.5 justify-between">
|
|
||||||
<span>管理员名称</span> <span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative group input-group">
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400 input-icon transition-colors"
|
|
||||||
>
|
|
||||||
<!-- BadgeCheck Icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.78 4.78 4 4 0 0 1-6.74 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.74Z"
|
|
||||||
/>
|
|
||||||
<path d="m9 12 2 2 4-4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
v-model="formData.name"
|
|
||||||
@input="clearError('name')"
|
|
||||||
placeholder="用于您登陆系统"
|
|
||||||
class="w-full pl-10 pr-4 py-2.5 bg-white border rounded-lg text-sm focus:outline-none transition-all placeholder:text-slate-400"
|
|
||||||
:class="
|
|
||||||
errors.name
|
|
||||||
? 'border-rose-400 focus:border-rose-500 focus:ring-4 focus:ring-rose-100'
|
|
||||||
: 'border-slate-200 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<p v-if="errors.name" class="mt-1 text-xs text-rose-500">{{ errors.name }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 3. 管理员邮箱 (可选) -->
|
|
||||||
<div class="col-span-1">
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-1.5 justify-between">
|
|
||||||
<span>管理员邮箱(用于通过之后给您发送相关信息)</span
|
|
||||||
><span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative group input-group">
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400 input-icon transition-colors"
|
|
||||||
>
|
|
||||||
<!-- Mail Icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
|
||||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
v-model="formData.email"
|
|
||||||
@input="clearError('email')"
|
|
||||||
placeholder="admin@company.com"
|
|
||||||
class="w-full pl-10 pr-4 py-2.5 bg-white border rounded-lg text-sm focus:outline-none transition-all placeholder:text-slate-400"
|
|
||||||
:class="
|
|
||||||
errors.email
|
|
||||||
? 'border-rose-400 focus:border-rose-500 focus:ring-4 focus:ring-rose-100'
|
|
||||||
: 'border-slate-200 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<p v-if="errors.email" class="mt-1 text-xs text-rose-500">{{ errors.email }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 4. 管理员手机号 (必填) -->
|
|
||||||
<div class="col-span-1">
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">
|
|
||||||
管理员手机号 <span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative group input-group">
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400 input-icon transition-colors"
|
|
||||||
>
|
|
||||||
<!-- Smartphone Icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
|
||||||
<path d="M12 18h.01" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
v-model="formData.phone"
|
|
||||||
@input="clearError('phone')"
|
|
||||||
required
|
|
||||||
placeholder="请输入11位手机号"
|
|
||||||
class="w-full pl-10 pr-4 py-2.5 bg-white border rounded-lg text-sm focus:outline-none transition-all placeholder:text-slate-400"
|
|
||||||
:class="
|
|
||||||
errors.phone
|
|
||||||
? 'border-rose-400 focus:border-rose-500 focus:ring-4 focus:ring-rose-100'
|
|
||||||
: 'border-slate-200 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<p v-if="errors.phone" class="mt-1 text-xs text-rose-500">{{ errors.phone }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 5. 管理员密码 (必填) -->
|
|
||||||
<div class="col-span-1">
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">
|
|
||||||
管理员密码 <span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative group input-group">
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400 input-icon transition-colors"
|
|
||||||
>
|
|
||||||
<!-- Lock Icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
:type="showPassword ? 'text' : 'password'"
|
|
||||||
v-model="formData.password"
|
|
||||||
@input="clearError('password')"
|
|
||||||
required
|
|
||||||
placeholder="至少8位字符"
|
|
||||||
class="w-full pl-10 pr-10 py-2.5 bg-white border rounded-lg text-sm focus:outline-none transition-all placeholder:text-slate-400"
|
|
||||||
:class="
|
|
||||||
errors.password
|
|
||||||
? 'border-rose-400 focus:border-rose-500 focus:ring-4 focus:ring-rose-100'
|
|
||||||
: 'border-slate-200 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="showPassword = !showPassword"
|
|
||||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-600 transition-colors focus:outline-none"
|
|
||||||
>
|
|
||||||
<!-- Eye Icon -->
|
|
||||||
<svg
|
|
||||||
v-if="!showPassword"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
<!-- EyeOff Icon -->
|
|
||||||
<svg
|
|
||||||
v-else
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
|
|
||||||
<path
|
|
||||||
d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"
|
|
||||||
/>
|
|
||||||
<line x1="2" x2="22" y1="2" y2="22" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<p v-if="errors.password" class="mt-1 text-xs text-rose-500">{{
|
|
||||||
errors.password
|
|
||||||
}}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 6. 确认密码 (必填) -->
|
|
||||||
<div class="col-span-1">
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">
|
|
||||||
确认密码 <span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative group input-group">
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400 input-icon transition-colors"
|
|
||||||
>
|
|
||||||
<!-- CheckCircle2 Icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="m9 12 2 2 4-4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
:type="showPassword ? 'text' : 'password'"
|
|
||||||
v-model="formData.confirmPassword"
|
|
||||||
@input="clearError('confirmPassword')"
|
|
||||||
required
|
|
||||||
placeholder="再次输入密码"
|
|
||||||
class="w-full pl-10 pr-4 py-2.5 bg-white border rounded-lg text-sm focus:outline-none transition-all placeholder:text-slate-400"
|
|
||||||
:class="
|
|
||||||
errors.confirmPassword
|
|
||||||
? 'border-rose-400 focus:border-rose-500 focus:ring-4 focus:ring-rose-100'
|
|
||||||
: 'border-slate-200 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<p v-if="errors.confirmPassword" class="mt-1 text-xs text-rose-500">
|
|
||||||
{{ errors.confirmPassword }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 协议与提交 -->
|
|
||||||
<div class="mt-8 pt-2">
|
|
||||||
<div class="flex items-center mb-6">
|
|
||||||
<input
|
|
||||||
id="agreed"
|
|
||||||
type="checkbox"
|
|
||||||
v-model="formData.agreed"
|
|
||||||
required
|
|
||||||
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded cursor-pointer"
|
|
||||||
@change="clearError('agreed')"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
for="agreed"
|
|
||||||
class="ml-2 block text-sm text-slate-500 cursor-pointer select-none"
|
|
||||||
>
|
|
||||||
我已阅读并同意
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="text-indigo-600 hover:text-indigo-800 font-medium hover:underline"
|
|
||||||
>《隐私政策》</a
|
|
||||||
>
|
|
||||||
和
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="text-indigo-600 hover:text-indigo-800 font-medium hover:underline"
|
|
||||||
>《服务条款》</a
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full flex items-center justify-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-4 rounded-lg shadow-lg shadow-indigo-500/30 transition-all duration-200 transform hover:-translate-y-0.5 active:translate-y-0 active:shadow-none"
|
|
||||||
>
|
|
||||||
<span>立即提交注册</span>
|
|
||||||
<!-- ArrowRight Icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M5 12h14" />
|
|
||||||
<path d="m12 5 7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 底部登录链接 -->
|
|
||||||
<div class="text-center text-sm text-slate-500 mt-6">
|
|
||||||
已有账号?
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="text-indigo-600 font-medium hover:text-indigo-800 transition-colors"
|
|
||||||
@click.prevent="goLogin"
|
|
||||||
>去登录</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -111,20 +111,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formRef.value) return
|
const form = formRef.value
|
||||||
if (!currentMerchantId.value) return
|
if (!form) return
|
||||||
if (!currentRowVersion.value) {
|
|
||||||
|
const merchantId = currentMerchantId.value
|
||||||
|
if (!merchantId) return
|
||||||
|
|
||||||
|
const rowVersion = currentRowVersion.value
|
||||||
|
if (!rowVersion) {
|
||||||
ElMessage.error(t('merchant.message.rowVersionMissing'))
|
ElMessage.error(t('merchant.message.rowVersionMissing'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await formRef.value.validate(async (valid) => {
|
await form.validate(async (valid) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const result = await fetchUpdateMerchant(
|
const result = await fetchUpdateMerchant(
|
||||||
currentMerchantId.value,
|
merchantId,
|
||||||
{
|
{
|
||||||
name: formModel.name,
|
name: formModel.name,
|
||||||
licenseNumber: formModel.licenseNumber,
|
licenseNumber: formModel.licenseNumber,
|
||||||
@@ -132,7 +137,7 @@
|
|||||||
registeredAddress: formModel.registeredAddress,
|
registeredAddress: formModel.registeredAddress,
|
||||||
contactPhone: formModel.contactPhone,
|
contactPhone: formModel.contactPhone,
|
||||||
contactEmail: formModel.contactEmail,
|
contactEmail: formModel.contactEmail,
|
||||||
rowVersion: currentRowVersion.value
|
rowVersion
|
||||||
},
|
},
|
||||||
{ showErrorMessage: false }
|
{ showErrorMessage: false }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -136,19 +136,19 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleSearch = (params: MerchantListSearchForm) => {
|
const handleSearch = (params: MerchantListSearchForm) => {
|
||||||
searchParams.Keyword = params.keyword || undefined
|
searchParams.keyword = params.keyword || undefined
|
||||||
searchParams.Status = params.status
|
searchParams.status = params.status
|
||||||
searchParams.OperatingMode = params.operatingMode
|
searchParams.operatingMode = params.operatingMode
|
||||||
searchParams.TenantId = params.tenantId ? params.tenantId.trim() : undefined
|
searchParams.tenantId = params.tenantId ? params.tenantId.trim() : undefined
|
||||||
refreshData()
|
refreshData()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
formFilters.value = { keyword: '', status: undefined, operatingMode: undefined, tenantId: '' }
|
formFilters.value = { keyword: '', status: undefined, operatingMode: undefined, tenantId: '' }
|
||||||
searchParams.Keyword = undefined
|
searchParams.keyword = undefined
|
||||||
searchParams.Status = undefined
|
searchParams.status = undefined
|
||||||
searchParams.OperatingMode = undefined
|
searchParams.operatingMode = undefined
|
||||||
searchParams.TenantId = undefined
|
searchParams.tenantId = undefined
|
||||||
refreshData()
|
refreshData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,459 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { fetchTenantProgress } from '@/api/tenant-onboarding'
|
|
||||||
import { StorageConfig } from '@/utils/storage'
|
|
||||||
|
|
||||||
// 1. 本地枚举(与后端一致)
|
|
||||||
enum TenantStatus {
|
|
||||||
PendingReview = 0,
|
|
||||||
Active = 1,
|
|
||||||
Suspended = 2,
|
|
||||||
Expired = 3,
|
|
||||||
Closed = 4
|
|
||||||
}
|
|
||||||
|
|
||||||
enum VerificationStatus {
|
|
||||||
Draft = 0,
|
|
||||||
Pending = 1,
|
|
||||||
Approved = 2,
|
|
||||||
Rejected = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TenantData {
|
|
||||||
tenantId: string
|
|
||||||
status: TenantStatus
|
|
||||||
verificationStatus: VerificationStatus
|
|
||||||
rejectReason?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
// 2. 状态管理
|
|
||||||
const isLoading = ref<boolean>(false)
|
|
||||||
const tenantData = ref<TenantData | null>(null)
|
|
||||||
|
|
||||||
const resolvedTenantId = computed(() => {
|
|
||||||
return (
|
|
||||||
(route.query.tenantId as string | undefined) ||
|
|
||||||
localStorage.getItem(StorageConfig.TENANT_ID_KEY) ||
|
|
||||||
''
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadTenantProgress = async () => {
|
|
||||||
const tenantId = resolvedTenantId.value
|
|
||||||
if (!tenantId) return
|
|
||||||
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
const progress = await fetchTenantProgress(tenantId)
|
|
||||||
tenantData.value = {
|
|
||||||
tenantId: progress.tenantId,
|
|
||||||
status: Number(progress.status) as TenantStatus,
|
|
||||||
verificationStatus: Number(progress.verificationStatus) as VerificationStatus
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[OnboardingError] 获取租户状态失败:', error)
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 核心逻辑:计算当前页面配置
|
|
||||||
const pageConfig = computed(() => {
|
|
||||||
const status = tenantData.value?.status
|
|
||||||
const verificationStatus = tenantData.value?.verificationStatus
|
|
||||||
const rejectReason = tenantData.value?.rejectReason
|
|
||||||
|
|
||||||
// 3.1 账号级异常 (status: 2,3,4)
|
|
||||||
if (status === TenantStatus.Suspended) {
|
|
||||||
return {
|
|
||||||
type: 'error',
|
|
||||||
icon: 'suspended',
|
|
||||||
color: 'text-rose-600',
|
|
||||||
bgColor: 'bg-rose-50',
|
|
||||||
borderColor: 'border-rose-100',
|
|
||||||
title: t('onboarding.error.suspended.title'),
|
|
||||||
desc: t('onboarding.error.suspended.desc'),
|
|
||||||
primaryAction: t('onboarding.error.suspended.primaryAction'),
|
|
||||||
secondaryAction: t('onboarding.error.suspended.secondaryAction')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === TenantStatus.Expired) {
|
|
||||||
return {
|
|
||||||
type: 'warning',
|
|
||||||
icon: 'expired',
|
|
||||||
color: 'text-amber-600',
|
|
||||||
bgColor: 'bg-amber-50',
|
|
||||||
borderColor: 'border-amber-100',
|
|
||||||
title: t('onboarding.error.expired.title'),
|
|
||||||
desc: t('onboarding.error.expired.desc'),
|
|
||||||
primaryAction: t('onboarding.error.expired.primaryAction'),
|
|
||||||
secondaryAction: t('onboarding.error.expired.secondaryAction')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === TenantStatus.Closed) {
|
|
||||||
return {
|
|
||||||
type: 'gray',
|
|
||||||
icon: 'closed',
|
|
||||||
color: 'text-slate-600',
|
|
||||||
bgColor: 'bg-slate-100',
|
|
||||||
borderColor: 'border-slate-200',
|
|
||||||
title: t('onboarding.error.closed.title'),
|
|
||||||
desc: t('onboarding.error.closed.desc'),
|
|
||||||
primaryAction: t('onboarding.error.closed.primaryAction'),
|
|
||||||
secondaryAction: t('onboarding.error.closed.secondaryAction')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3.2 资质审核异常 (verificationStatus: 3)
|
|
||||||
if (verificationStatus === VerificationStatus.Rejected) {
|
|
||||||
return {
|
|
||||||
type: 'error',
|
|
||||||
icon: 'rejected',
|
|
||||||
color: 'text-orange-600',
|
|
||||||
bgColor: 'bg-orange-50',
|
|
||||||
borderColor: 'border-orange-100',
|
|
||||||
title: t('onboarding.error.rejected.title'),
|
|
||||||
desc: t('onboarding.error.rejected.desc'),
|
|
||||||
reason: rejectReason || t('onboarding.error.rejected.defaultReason'),
|
|
||||||
primaryAction: t('onboarding.error.rejected.primaryAction'),
|
|
||||||
secondaryAction: t('onboarding.error.rejected.secondaryAction')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3.3 兜底
|
|
||||||
return {
|
|
||||||
type: 'info',
|
|
||||||
icon: 'info',
|
|
||||||
color: 'text-indigo-600',
|
|
||||||
bgColor: 'bg-indigo-50',
|
|
||||||
borderColor: 'border-indigo-100',
|
|
||||||
title: t('onboarding.error.fallback.title'),
|
|
||||||
desc: t('onboarding.error.fallback.desc'),
|
|
||||||
primaryAction: t('onboarding.error.fallback.primaryAction'),
|
|
||||||
secondaryAction: ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 4. 操作处理(暂留给你后续补全)
|
|
||||||
const handlePrimaryAction = async () => {
|
|
||||||
if (pageConfig.value.icon === 'info') {
|
|
||||||
await loadTenantProgress()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadTenantProgress()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<div class="h-screen flex overflow-hidden bg-slate-50 text-slate-800 font-sans antialiased">
|
|
||||||
<!-- 左侧:品牌展示 (Left Side) -->
|
|
||||||
<!-- 保持与 AuditPending 一致的视觉风格 -->
|
|
||||||
<div
|
|
||||||
class="hidden lg:flex lg:w-4/12 relative bg-indigo-900 flex-col justify-between p-12 text-white"
|
|
||||||
>
|
|
||||||
<!-- 背景装饰 -->
|
|
||||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
|
||||||
<div
|
|
||||||
class="absolute top-0 right-0 w-96 h-96 bg-indigo-500/20 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="absolute bottom-0 left-0 w-80 h-80 bg-blue-600/20 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0"
|
|
||||||
style="
|
|
||||||
background-image: radial-gradient(rgb(255 255 255 / 10%) 1px, transparent 1px);
|
|
||||||
background-size: 24px 24px;
|
|
||||||
opacity: 0.3;
|
|
||||||
"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Logo -->
|
|
||||||
<div class="relative z-10 flex items-center gap-2 text-indigo-300">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
|
||||||
<line x1="3" x2="21" y1="9" y2="9" />
|
|
||||||
<line x1="9" x2="9" y1="21" y2="9" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-bold tracking-wide text-white">CloudSaaS</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 左侧文案 (根据状态可能会有变化,这里使用通用文案) -->
|
|
||||||
<div class="relative z-10">
|
|
||||||
<h2 class="text-3xl font-bold mb-6">连接无限可能<br />赋能商业增长</h2>
|
|
||||||
<p class="text-indigo-200 leading-relaxed mb-10 max-w-sm">
|
|
||||||
我们致力于为您提供最优质的 SaaS 服务体验。遇到问题不要担心,我们随时为您提供支持。
|
|
||||||
</p>
|
|
||||||
<!-- 客服联系方式小卡片 -->
|
|
||||||
<div
|
|
||||||
class="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 flex items-center gap-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center text-white"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs text-indigo-300">专属客服热线</div>
|
|
||||||
<div class="text-lg font-bold">400-123-4567</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative z-10 text-sm text-indigo-400/60"> © 2025 CloudSaaS Inc. </div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧:状态反馈内容区 (Right Side) -->
|
|
||||||
<div
|
|
||||||
class="flex-1 flex flex-col h-full bg-white overflow-y-auto relative transition-all duration-300"
|
|
||||||
>
|
|
||||||
<!-- 顶部功能栏 -->
|
|
||||||
<div class="h-16 px-6 md:px-12 flex items-center justify-end shrink-0">
|
|
||||||
<button class="text-sm text-slate-500 hover:text-indigo-600 font-medium transition-colors">
|
|
||||||
退出登录
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 核心内容垂直居中 -->
|
|
||||||
<div class="flex-1 flex flex-col items-center justify-center p-6 md:p-12 pb-32">
|
|
||||||
<div v-if="!isLoading" class="max-w-lg w-full text-center animate-fade-in-up">
|
|
||||||
<!-- 状态图标 -->
|
|
||||||
<div
|
|
||||||
class="relative w-24 h-24 mx-auto mb-6 flex items-center justify-center rounded-full transition-colors duration-300"
|
|
||||||
:class="pageConfig.bgColor"
|
|
||||||
>
|
|
||||||
<!-- Icon: Suspended -->
|
|
||||||
<svg
|
|
||||||
v-if="pageConfig.icon === 'suspended'"
|
|
||||||
:class="pageConfig.color"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="48"
|
|
||||||
height="48"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="4.93" x2="19.07" y1="4.93" y2="19.07" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Icon: Expired -->
|
|
||||||
<svg
|
|
||||||
v-else-if="pageConfig.icon === 'expired'"
|
|
||||||
:class="pageConfig.color"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="48"
|
|
||||||
height="48"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<polyline points="12 6 12 12 16 14" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Icon: Closed -->
|
|
||||||
<svg
|
|
||||||
v-else-if="pageConfig.icon === 'closed'"
|
|
||||||
:class="pageConfig.color"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="48"
|
|
||||||
height="48"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
|
||||||
<path d="m9 9 6 6" />
|
|
||||||
<path d="m15 9-6 6" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Icon: Rejected -->
|
|
||||||
<svg
|
|
||||||
v-else-if="pageConfig.icon === 'rejected'"
|
|
||||||
:class="pageConfig.color"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="48"
|
|
||||||
height="48"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
|
|
||||||
<path d="m15 9-6 6" />
|
|
||||||
<path d="m9 9 6 6" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Default -->
|
|
||||||
<svg
|
|
||||||
v-else
|
|
||||||
:class="pageConfig.color"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="48"
|
|
||||||
height="48"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="12" x2="12" y1="8" y2="12" />
|
|
||||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 标题与描述 -->
|
|
||||||
<h1 class="text-2xl md:text-3xl font-bold text-slate-900 mb-4 transition-all">
|
|
||||||
{{ pageConfig.title }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-slate-500 mb-8 px-4">
|
|
||||||
{{ pageConfig.desc }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- 特殊区域:驳回原因 (仅当 icon 为 rejected 时显示) -->
|
|
||||||
<div
|
|
||||||
v-if="pageConfig.icon === 'rejected'"
|
|
||||||
class="bg-orange-50 rounded-xl p-5 border border-orange-100 text-left mb-8 mx-auto max-w-md animate-fade-in-up-delay"
|
|
||||||
>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div class="mt-0.5 shrink-0 text-orange-500">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
|
||||||
/>
|
|
||||||
<line x1="12" y1="9" x2="12" y2="13" />
|
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="font-bold text-slate-800 text-sm mb-1">
|
|
||||||
{{ t('onboarding.error.rejected.reasonTitle') }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-sm text-slate-600 leading-relaxed">{{ pageConfig.reason }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 按钮组 -->
|
|
||||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
|
||||||
<button
|
|
||||||
@click="handlePrimaryAction"
|
|
||||||
class="px-8 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg shadow-lg shadow-indigo-500/20 transition-all transform hover:-translate-y-0.5 active:translate-y-0"
|
|
||||||
>
|
|
||||||
{{ pageConfig.primaryAction }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="pageConfig.secondaryAction"
|
|
||||||
class="px-8 py-3 bg-white border border-slate-200 text-slate-700 font-medium rounded-lg hover:bg-slate-50 hover:text-indigo-600 transition-all"
|
|
||||||
>
|
|
||||||
{{ pageConfig.secondaryAction }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading 状态 -->
|
|
||||||
<div v-else class="flex flex-col items-center">
|
|
||||||
<div
|
|
||||||
class="w-12 h-12 border-4 border-indigo-100 border-t-indigo-600 rounded-full animate-spin mb-4"
|
|
||||||
></div>
|
|
||||||
<p class="text-slate-400 text-sm">{{ t('onboarding.error.loading') }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.animate-spin {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in-up {
|
|
||||||
animation: fadeInUp 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in-up-delay {
|
|
||||||
opacity: 0;
|
|
||||||
animation: fadeInUp 0.6s ease-out 0.2s forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,513 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { StorageConfig } from '@/utils/storage'
|
|
||||||
import { fetchPublicTenantPackageList } from '@/api/tenant-package'
|
|
||||||
import ContactModal from '@/components/business/contact_modal/ContactModal.vue'
|
|
||||||
|
|
||||||
interface PlanFeature {
|
|
||||||
text: string
|
|
||||||
included: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Plan {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
price: number
|
|
||||||
currency: string
|
|
||||||
billingCycle: string
|
|
||||||
description: string
|
|
||||||
features: PlanFeature[]
|
|
||||||
isRecommended?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// 0. 套餐列表状态
|
|
||||||
const plans = reactive<Plan[]>([])
|
|
||||||
const isLoadingPlans = ref<boolean>(false)
|
|
||||||
|
|
||||||
// 1. 将后端套餐 DTO 映射到页面展示模型
|
|
||||||
const mapTenantPackageToPlan = (pkg: Api.Tenant.TenantPackageDto): Plan => {
|
|
||||||
// 1.1 价格展示优先年付,其次月付
|
|
||||||
const price = (pkg.yearlyPrice ?? pkg.monthlyPrice ?? 0) as number
|
|
||||||
const billingCycle = pkg.yearlyPrice !== null && pkg.yearlyPrice !== undefined ? '/年' : '/月'
|
|
||||||
|
|
||||||
// 1.2 根据配额字段生成展示用功能列表
|
|
||||||
const features: PlanFeature[] = []
|
|
||||||
if (pkg.maxStoreCount !== null && pkg.maxStoreCount !== undefined) {
|
|
||||||
features.push({ text: `门店数上限: ${pkg.maxStoreCount}`, included: true })
|
|
||||||
}
|
|
||||||
if (pkg.maxAccountCount !== null && pkg.maxAccountCount !== undefined) {
|
|
||||||
features.push({ text: `账号数上限: ${pkg.maxAccountCount}`, included: true })
|
|
||||||
}
|
|
||||||
if (pkg.maxStorageGb !== null && pkg.maxStorageGb !== undefined) {
|
|
||||||
features.push({ text: `存储空间: ${pkg.maxStorageGb}GB`, included: true })
|
|
||||||
}
|
|
||||||
if (pkg.maxSmsCredits !== null && pkg.maxSmsCredits !== undefined) {
|
|
||||||
features.push({ text: `短信额度: ${pkg.maxSmsCredits}`, included: true })
|
|
||||||
}
|
|
||||||
if (pkg.maxDeliveryOrders !== null && pkg.maxDeliveryOrders !== undefined) {
|
|
||||||
features.push({ text: `配送订单数上限: ${pkg.maxDeliveryOrders}`, included: true })
|
|
||||||
}
|
|
||||||
if (!features.length) {
|
|
||||||
features.push({ text: '暂无配额信息', included: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: pkg.id,
|
|
||||||
name: pkg.name,
|
|
||||||
price,
|
|
||||||
currency: '¥',
|
|
||||||
billingCycle,
|
|
||||||
description: pkg.description || '',
|
|
||||||
features
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 加载公共套餐列表(匿名可访问)
|
|
||||||
const loadPlans = async () => {
|
|
||||||
isLoadingPlans.value = true
|
|
||||||
try {
|
|
||||||
const res = await fetchPublicTenantPackageList({
|
|
||||||
IsActive: true,
|
|
||||||
Page: 1,
|
|
||||||
PageSize: 50
|
|
||||||
})
|
|
||||||
|
|
||||||
const mappedPlans = (res.items || []).map(mapTenantPackageToPlan)
|
|
||||||
plans.splice(0, plans.length, ...mappedPlans)
|
|
||||||
|
|
||||||
// 2.1 默认选中:优先 URL tenantPackageId,其次第一个套餐
|
|
||||||
const hasSelected =
|
|
||||||
selectedPlanId.value && mappedPlans.some((p) => p.id === selectedPlanId.value)
|
|
||||||
if (!hasSelected) {
|
|
||||||
selectedPlanId.value = mappedPlans[0]?.id || ''
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isLoadingPlans.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedPlanId = ref<string>('')
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
// 7. 联系销售弹窗
|
|
||||||
const isContactModalVisible = ref<boolean>(false)
|
|
||||||
const openContactModal = () => {
|
|
||||||
isContactModalVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 从 URL / 本地存储读取 tenantId
|
|
||||||
const tenantId = computed(
|
|
||||||
() =>
|
|
||||||
(route.query.tenantId as string) || localStorage.getItem(StorageConfig.TENANT_ID_KEY) || ''
|
|
||||||
)
|
|
||||||
|
|
||||||
// 4. 如果 URL 已带 tenantPackageId,则恢复默认选中
|
|
||||||
const initialTenantPackageId = route.query.tenantPackageId as string | undefined
|
|
||||||
if (initialTenantPackageId) {
|
|
||||||
selectedPlanId.value = initialTenantPackageId
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 进入资质审核页时,确保携带 tenantPackageId(以及 tenantId)到 URL
|
|
||||||
const goStatus = (tenantPackageId: string = selectedPlanId.value) => {
|
|
||||||
// 5.1 确保已加载且有有效套餐主键
|
|
||||||
if (isLoadingPlans.value || !tenantPackageId) return
|
|
||||||
const query: Record<string, string> = { tenantPackageId }
|
|
||||||
if (tenantId.value) {
|
|
||||||
query.tenantId = tenantId.value
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push({ name: 'TenantOnboardingStatus', query })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelectPlan = (plan: Plan) => {
|
|
||||||
selectedPlanId.value = plan.id
|
|
||||||
goStatus(plan.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNextStep = () => {
|
|
||||||
goStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 页面挂载后加载真实套餐列表
|
|
||||||
onMounted(() => {
|
|
||||||
loadPlans()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="h-screen flex overflow-hidden bg-slate-50 text-slate-800 font-sans antialiased">
|
|
||||||
<!-- 左侧:品牌与指引 -->
|
|
||||||
<div
|
|
||||||
class="hidden lg:flex lg:w-4/12 relative bg-indigo-900 flex-col justify-between p-12 text-white"
|
|
||||||
>
|
|
||||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
|
||||||
<div
|
|
||||||
class="absolute top-0 right-0 w-96 h-96 bg-purple-500/20 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="absolute bottom-0 left-0 w-80 h-80 bg-indigo-500/20 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0"
|
|
||||||
style="
|
|
||||||
background-image: radial-gradient(rgb(255 255 255 / 10%) 1px, transparent 1px);
|
|
||||||
background-size: 24px 24px;
|
|
||||||
opacity: 0.3;
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative z-10 flex items-center gap-2 text-indigo-300">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
|
||||||
<line x1="3" x2="21" y1="9" y2="9" />
|
|
||||||
<line x1="9" x2="9" y1="21" y2="9" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-bold tracking-wide text-white">CloudSaaS</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative z-10">
|
|
||||||
<h2 class="text-3xl font-bold mb-6">灵活定价,<br />助力业务起飞</h2>
|
|
||||||
<p class="text-indigo-200 leading-relaxed mb-10 max-w-sm">
|
|
||||||
超过 53,476 位信赖的开发者选择了我们,要构建他们的数字化平台。透明定价,无隐藏费用。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-0">
|
|
||||||
<div class="flex gap-4 relative pb-10">
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0 text-indigo-300"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="20 6 9 17 4 12" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="w-0.5 flex-1 bg-indigo-500/20 mt-2" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-indigo-200">注册账号</h4>
|
|
||||||
<p class="text-sm text-indigo-400/60 mt-0.5">已完成</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-4 relative pb-10">
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-white text-indigo-900 font-bold flex items-center justify-center shrink-0 shadow-[0_0_15px_rgba(255,255,255,0.3)]"
|
|
||||||
>
|
|
||||||
2
|
|
||||||
</div>
|
|
||||||
<div class="w-0.5 flex-1 bg-indigo-500/20 mt-2" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-white">选择套餐</h4>
|
|
||||||
<p class="text-sm text-indigo-300 mt-0.5">当前步骤</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-4 relative">
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0 text-indigo-400"
|
|
||||||
>
|
|
||||||
3
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="pt-1">
|
|
||||||
<h4 class="font-medium text-indigo-300/60">资质审核</h4>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative z-10 text-sm text-indigo-400/60"> © 2025 CloudSaaS Inc. </div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧:套餐选择区 -->
|
|
||||||
<div class="flex-1 flex flex-col h-full overflow-hidden relative">
|
|
||||||
<div
|
|
||||||
class="h-16 border-b border-slate-100 flex items-center justify-between px-6 shrink-0 bg-white z-20"
|
|
||||||
>
|
|
||||||
<span class="font-semibold text-slate-700">选择订阅方案</span>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-sm text-slate-500">遇到困难?</span>
|
|
||||||
<button
|
|
||||||
class="text-sm text-indigo-600 font-medium hover:underline"
|
|
||||||
@click="openContactModal"
|
|
||||||
>
|
|
||||||
联系销售
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-6 md:p-12 scroll-smooth bg-slate-50">
|
|
||||||
<div class="max-w-5xl mx-auto">
|
|
||||||
<div class="text-center mb-12">
|
|
||||||
<span
|
|
||||||
class="inline-block px-3 py-1 mb-4 text-xs font-semibold tracking-wider text-indigo-600 uppercase bg-indigo-50 rounded-full"
|
|
||||||
>
|
|
||||||
自助入驻
|
|
||||||
</span>
|
|
||||||
<h1 class="text-3xl md:text-4xl font-bold text-slate-900 mb-4"> 选择最适合您的方案 </h1>
|
|
||||||
<p class="text-slate-500 text-lg max-w-2xl mx-auto">
|
|
||||||
您可以随时升级套餐以获得更多资源。我们提供灵活的定价策略,满足不同阶段的发展需求。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap justify-center gap-8">
|
|
||||||
<div
|
|
||||||
v-for="plan in plans"
|
|
||||||
:key="plan.id"
|
|
||||||
@click="handleSelectPlan(plan)"
|
|
||||||
class="relative w-full max-w-sm bg-white rounded-2xl transition-all duration-300 cursor-pointer border-2 group hover:-translate-y-1 hover:shadow-xl flex flex-col"
|
|
||||||
:class="[
|
|
||||||
selectedPlanId === plan.id
|
|
||||||
? 'border-indigo-600 shadow-lg shadow-indigo-100 ring-4 ring-indigo-50'
|
|
||||||
: 'border-transparent shadow-md hover:border-indigo-200'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="plan.isRecommended"
|
|
||||||
class="absolute -top-4 left-1/2 -translate-x-1/2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white text-xs font-bold px-4 py-1.5 rounded-full shadow-md z-10"
|
|
||||||
>
|
|
||||||
热门推荐
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-8 text-center border-b border-slate-50">
|
|
||||||
<h3 class="text-xl font-bold text-slate-900 mb-2">{{ plan.name }}</h3>
|
|
||||||
<p class="text-slate-500 text-sm h-10 overflow-hidden">{{ plan.description }}</p>
|
|
||||||
|
|
||||||
<div class="mt-6 flex items-baseline justify-center text-slate-900">
|
|
||||||
<span class="text-2xl font-semibold mr-1">{{ plan.currency }}</span>
|
|
||||||
<span class="text-5xl font-extrabold tracking-tight">{{ plan.price }}</span>
|
|
||||||
<span class="text-slate-400 font-medium text-base ml-2">
|
|
||||||
{{ plan.billingCycle }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-8 flex-1 bg-slate-50/50 rounded-b-2xl">
|
|
||||||
<ul class="space-y-4 mb-8">
|
|
||||||
<li
|
|
||||||
v-for="(feature, index) in plan.features"
|
|
||||||
:key="index"
|
|
||||||
class="flex items-start"
|
|
||||||
>
|
|
||||||
<div class="shrink-0 mt-0.5">
|
|
||||||
<svg
|
|
||||||
v-if="feature.included"
|
|
||||||
class="w-5 h-5 text-indigo-600"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<svg
|
|
||||||
v-else
|
|
||||||
class="w-5 h-5 text-slate-300"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="ml-3 text-sm"
|
|
||||||
:class="
|
|
||||||
feature.included
|
|
||||||
? 'text-slate-700'
|
|
||||||
: 'text-slate-400 line-through decoration-slate-300'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ feature.text }}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="w-full py-3 px-6 rounded-xl font-semibold transition-all duration-200 flex items-center justify-center gap-2"
|
|
||||||
:class="[
|
|
||||||
selectedPlanId === plan.id
|
|
||||||
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-200 hover:bg-indigo-700 hover:shadow-indigo-300 transform scale-[1.02]'
|
|
||||||
: 'bg-indigo-50 text-indigo-700 hover:bg-indigo-100'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ selectedPlanId === plan.id ? '立即购买' : '选择此方案' }}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
:class="{ 'translate-x-1': selectedPlanId === plan.id }"
|
|
||||||
class="transition-transform"
|
|
||||||
>
|
|
||||||
<path d="M5 12h14" />
|
|
||||||
<path d="m12 5 7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p class="text-xs text-center text-slate-400 mt-4">
|
|
||||||
* 购买即代表同意
|
|
||||||
<RouterLink
|
|
||||||
:to="{ name: 'TermsOfService' }"
|
|
||||||
@click.stop
|
|
||||||
class="underline hover:text-indigo-500"
|
|
||||||
>
|
|
||||||
服务条款
|
|
||||||
</RouterLink>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-16 border-t border-slate-200 pt-8">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 text-center md:text-left">
|
|
||||||
<div class="flex flex-col items-center md:items-start gap-2">
|
|
||||||
<div class="p-2 bg-white rounded-lg shadow-sm text-indigo-600 mb-1">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
||||||
<path d="m9 12 2 2 4-4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h4 class="font-semibold text-slate-900">银行级安全保障</h4>
|
|
||||||
<p class="text-sm text-slate-500">所有交易数据均经过加密处理,保障资金安全。</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-center md:items-start gap-2">
|
|
||||||
<div class="p-2 bg-white rounded-lg shadow-sm text-indigo-600 mb-1">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
|
||||||
<path d="M12 17h.01" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h4 class="font-semibold text-slate-900">售前咨询支持</h4>
|
|
||||||
<p class="text-sm text-slate-500">
|
|
||||||
不确定哪个方案适合?我们的专家团队随时为您解答。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-center md:items-start gap-2">
|
|
||||||
<div class="p-2 bg-white rounded-lg shadow-sm text-indigo-600 mb-1">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
||||||
<polyline points="14 2 14 8 20 8" />
|
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
|
||||||
<line x1="16" y1="17" x2="8" y2="17" />
|
|
||||||
<polyline points="10 9 9 9 8 9" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h4 class="font-semibold text-slate-900">电子发票支持</h4>
|
|
||||||
<p class="text-sm text-slate-500">
|
|
||||||
支付完成后可立刻申请企业增值税电子普通/专用发票。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="md:hidden p-4 bg-white border-t border-slate-100 flex items-center justify-between shrink-0"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-slate-500">当前选择</p>
|
|
||||||
<p class="font-bold text-indigo-600"
|
|
||||||
>¥{{ plans.find((p) => p.id === selectedPlanId)?.price }}</p
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="handleNextStep"
|
|
||||||
class="bg-indigo-600 text-white px-6 py-2 rounded-lg font-medium shadow-lg shadow-indigo-200"
|
|
||||||
>
|
|
||||||
下一步
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 联系销售弹窗 -->
|
|
||||||
<ContactModal v-model="isContactModalVisible" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #cbd5e1;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,689 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, reactive, ref } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { fetchUploadFile } from '@/api/files'
|
|
||||||
import { bindInitialTenantSubscription, submitTenantVerification } from '@/api/tenant-onboarding'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
// 1. 从 URL 读取 tenantId / tenantPackageId,后续提交时携带
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const tenantId = computed(() => route.query.tenantId as string | undefined)
|
|
||||||
const tenantPackageId = computed(() => route.query.tenantPackageId as string | undefined)
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
// 定义表单数据接口
|
|
||||||
interface ProfileForm {
|
|
||||||
BusinessLicenseNumber: string
|
|
||||||
BusinessLicenseUrl: string
|
|
||||||
LegalPersonName: string
|
|
||||||
LegalPersonIdNumber: string
|
|
||||||
LegalPersonIdFrontUrl: string
|
|
||||||
LegalPersonIdBackUrl: string
|
|
||||||
BankAccountName: string
|
|
||||||
BankName: string
|
|
||||||
BankAccountNumber: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 reactive 定义响应式数据
|
|
||||||
const form = reactive<ProfileForm>({
|
|
||||||
BusinessLicenseNumber: '',
|
|
||||||
BusinessLicenseUrl: '',
|
|
||||||
LegalPersonName: '',
|
|
||||||
LegalPersonIdNumber: '',
|
|
||||||
LegalPersonIdFrontUrl: '',
|
|
||||||
LegalPersonIdBackUrl: '',
|
|
||||||
BankAccountName: '',
|
|
||||||
BankName: '',
|
|
||||||
BankAccountNumber: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
type UploadField = keyof Pick<
|
|
||||||
ProfileForm,
|
|
||||||
'BusinessLicenseUrl' | 'LegalPersonIdFrontUrl' | 'LegalPersonIdBackUrl'
|
|
||||||
>
|
|
||||||
|
|
||||||
// 2. 上传状态与文件名
|
|
||||||
const isUploading = reactive<Record<UploadField, boolean>>({
|
|
||||||
BusinessLicenseUrl: false,
|
|
||||||
LegalPersonIdFrontUrl: false,
|
|
||||||
LegalPersonIdBackUrl: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const uploadedFileName = reactive<Record<UploadField, string>>({
|
|
||||||
BusinessLicenseUrl: '',
|
|
||||||
LegalPersonIdFrontUrl: '',
|
|
||||||
LegalPersonIdBackUrl: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. 隐藏 input 引用
|
|
||||||
const businessLicenseInputRef = ref<HTMLInputElement | null>(null)
|
|
||||||
const legalPersonFrontInputRef = ref<HTMLInputElement | null>(null)
|
|
||||||
const legalPersonBackInputRef = ref<HTMLInputElement | null>(null)
|
|
||||||
|
|
||||||
const fileInputMap: Record<UploadField, typeof businessLicenseInputRef> = {
|
|
||||||
BusinessLicenseUrl: businessLicenseInputRef,
|
|
||||||
LegalPersonIdFrontUrl: legalPersonFrontInputRef,
|
|
||||||
LegalPersonIdBackUrl: legalPersonBackInputRef
|
|
||||||
}
|
|
||||||
|
|
||||||
const openFileDialog = (field: UploadField) => {
|
|
||||||
// 3.1 触发隐藏 input 选择文件
|
|
||||||
fileInputMap[field].value?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = async (field: UploadField, event: Event) => {
|
|
||||||
const target = event.target as HTMLInputElement
|
|
||||||
const file = target.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
// 3.2 清空 input,支持重复选择同一文件
|
|
||||||
target.value = ''
|
|
||||||
|
|
||||||
await uploadFile(field, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadFile = async (field: UploadField, file: File) => {
|
|
||||||
// 1. 设置上传中状态
|
|
||||||
isUploading[field] = true
|
|
||||||
try {
|
|
||||||
// 2. 调用上传接口并获取地址
|
|
||||||
// 2.1 入驻资质上传目前统一使用 business_license 类型
|
|
||||||
const res = await fetchUploadFile(file, 'business_license')
|
|
||||||
const url = res.url || ''
|
|
||||||
if (!url) {
|
|
||||||
alert('上传失败,请重试')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 写入表单地址与文件名
|
|
||||||
form[field] = url
|
|
||||||
uploadedFileName[field] = res.fileName || file.name
|
|
||||||
} catch (error) {
|
|
||||||
alert((error as Error).message || '上传失败,请重试')
|
|
||||||
} finally {
|
|
||||||
isUploading[field] = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交处理
|
|
||||||
const isSubmitting = ref<boolean>(false)
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
// 1. 表单基本校验
|
|
||||||
if (!form.BusinessLicenseUrl || !form.LegalPersonIdFrontUrl || !form.LegalPersonIdBackUrl) {
|
|
||||||
alert('请确保所有证件照片已上传')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 校验租户与套餐参数
|
|
||||||
if (!tenantId.value || !tenantPackageId.value) {
|
|
||||||
ElMessage.warning(t('onboarding.messages.missingTenantInfo'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSubmitting.value) return
|
|
||||||
|
|
||||||
// 3. 组装实名提交命令(字段转为后端 camelCase)
|
|
||||||
const verificationCommand: Api.Tenant.SubmitTenantVerificationCommand = {
|
|
||||||
tenantId: tenantId.value,
|
|
||||||
businessLicenseNumber: form.BusinessLicenseNumber,
|
|
||||||
businessLicenseUrl: form.BusinessLicenseUrl,
|
|
||||||
legalPersonName: form.LegalPersonName,
|
|
||||||
legalPersonIdNumber: form.LegalPersonIdNumber,
|
|
||||||
legalPersonIdFrontUrl: form.LegalPersonIdFrontUrl,
|
|
||||||
legalPersonIdBackUrl: form.LegalPersonIdBackUrl,
|
|
||||||
bankAccountName: form.BankAccountName,
|
|
||||||
bankAccountNumber: form.BankAccountNumber,
|
|
||||||
bankName: form.BankName
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 串行执行:提交实名资料 -> 绑定套餐订阅
|
|
||||||
isSubmitting.value = true
|
|
||||||
try {
|
|
||||||
await submitTenantVerification(tenantId.value, verificationCommand)
|
|
||||||
|
|
||||||
await bindInitialTenantSubscription(tenantId.value, {
|
|
||||||
tenantId: tenantId.value,
|
|
||||||
tenantPackageId: tenantPackageId.value,
|
|
||||||
autoRenew: false
|
|
||||||
})
|
|
||||||
|
|
||||||
ElMessage.success(t('onboarding.messages.submitSuccess'))
|
|
||||||
|
|
||||||
await router.push({
|
|
||||||
name: 'TenantOnboardingWaiting',
|
|
||||||
query: {
|
|
||||||
tenantId: tenantId.value,
|
|
||||||
tenantPackageId: tenantPackageId.value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[OnboardingStatus] 提交审核失败:', error)
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="h-screen flex overflow-hidden bg-slate-50 text-slate-800 font-sans antialiased">
|
|
||||||
<!-- 左侧:信息与指引 (Left Side - Context) -->
|
|
||||||
<div
|
|
||||||
class="hidden lg:flex lg:w-4/12 relative bg-slate-900 flex-col justify-between p-12 text-white"
|
|
||||||
>
|
|
||||||
<!-- 背景图案 -->
|
|
||||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
|
||||||
<div
|
|
||||||
class="absolute top-0 right-0 w-96 h-96 bg-indigo-600/20 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="absolute bottom-0 left-0 w-80 h-80 bg-blue-600/20 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2"
|
|
||||||
></div>
|
|
||||||
<!-- 网格纹理 -->
|
|
||||||
<div
|
|
||||||
class="absolute inset-0"
|
|
||||||
style="
|
|
||||||
background-image: radial-gradient(rgb(255 255 255 / 10%) 1px, transparent 1px);
|
|
||||||
background-size: 32px 32px;
|
|
||||||
opacity: 0.2;
|
|
||||||
"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 顶部 Logo -->
|
|
||||||
<div class="relative z-10 flex items-center gap-2 text-indigo-300">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
|
||||||
<line x1="3" x2="21" y1="9" y2="9" />
|
|
||||||
<line x1="9" x2="9" y1="21" y2="9" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-bold tracking-wide text-white">CloudSaaS</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 中间说明 -->
|
|
||||||
<div class="relative z-10">
|
|
||||||
<h2 class="text-3xl font-bold mb-6">最后一步:<br />实名认证与资质审核</h2>
|
|
||||||
<p class="text-slate-300 leading-relaxed mb-8">
|
|
||||||
依据相关法律法规,我们需要验证您的企业资质以开通正式服务。您提交的所有信息将通过银行级加密传输,仅用于资质审核。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0 text-indigo-400 mt-1"
|
|
||||||
>
|
|
||||||
1
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-white">注册账号</h4>
|
|
||||||
<p class="text-sm text-slate-400">已完成</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0 text-indigo-400 mt-1"
|
|
||||||
>
|
|
||||||
2
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-white">选择套餐</h4>
|
|
||||||
<p class="text-sm text-slate-400">已完成</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-white text-indigo-900 font-bold flex items-center justify-center shrink-0 mt-1 shadow-lg shadow-indigo-900/50"
|
|
||||||
>
|
|
||||||
3
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-white">资质审核</h4>
|
|
||||||
<p class="text-sm text-indigo-300">进行中...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 底部帮助 -->
|
|
||||||
<div class="relative z-10 text-sm text-slate-400">
|
|
||||||
遇到问题?<a href="#" class="text-white underline decoration-indigo-500 underline-offset-4"
|
|
||||||
>联系人工客服</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧:资料表单 (Right Side - Form) -->
|
|
||||||
<div class="flex-1 flex flex-col h-full bg-white overflow-hidden relative">
|
|
||||||
<!-- 顶部导航栏 (Mobile/Context) -->
|
|
||||||
<div
|
|
||||||
class="h-16 border-b border-slate-100 flex items-center justify-between px-6 shrink-0 bg-white/80 backdrop-blur z-20"
|
|
||||||
>
|
|
||||||
<span class="font-semibold text-slate-700">租户资料补全</span>
|
|
||||||
<div
|
|
||||||
class="text-xs text-slate-400 bg-slate-50 px-3 py-1 rounded-full border border-slate-100"
|
|
||||||
>
|
|
||||||
资料仅用于审核,不会泄露
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 可滚动区域 -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-6 md:p-10 scroll-smooth">
|
|
||||||
<div class="max-w-3xl mx-auto space-y-10 pb-20">
|
|
||||||
<form @submit.prevent="handleSubmit">
|
|
||||||
<!-- 模块 1: 企业资质 (Business License) -->
|
|
||||||
<section class="space-y-6">
|
|
||||||
<div class="flex items-center gap-3 pb-2 border-b border-slate-100">
|
|
||||||
<div class="p-2 bg-indigo-50 text-indigo-600 rounded-lg">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
|
||||||
<line x1="8" x2="16" y1="21" y2="21" />
|
|
||||||
<line x1="12" x2="12" y1="17" y2="21" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-bold text-slate-800">企业资质信息</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<!-- 营业执照编号 -->
|
|
||||||
<div class="col-span-1 md:col-span-2">
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
营业执照编号 <span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
v-model="form.BusinessLicenseNumber"
|
|
||||||
placeholder="请输入统一社会信用代码"
|
|
||||||
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 transition-all placeholder:text-slate-400"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 营业执照上传 -->
|
|
||||||
<div class="col-span-1 md:col-span-2">
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
营业执照照片 <span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<!-- 上传组件 -->
|
|
||||||
<div
|
|
||||||
class="upload-card relative border-2 border-dashed border-slate-300 rounded-xl p-8 flex flex-col items-center justify-center text-center cursor-pointer transition-all bg-slate-50 min-h-[160px]"
|
|
||||||
:class="{ 'opacity-60 pointer-events-none': isUploading.BusinessLicenseUrl }"
|
|
||||||
@click="openFileDialog('BusinessLicenseUrl')"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref="businessLicenseInputRef"
|
|
||||||
type="file"
|
|
||||||
accept="image/*,application/pdf"
|
|
||||||
class="hidden"
|
|
||||||
@change="(e) => handleFileChange('BusinessLicenseUrl', e)"
|
|
||||||
/>
|
|
||||||
<div v-if="!form.BusinessLicenseUrl" class="flex flex-col items-center">
|
|
||||||
<div
|
|
||||||
class="w-12 h-12 bg-white rounded-full shadow-sm flex items-center justify-center text-indigo-500 mb-3"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
||||||
<polyline points="17 8 12 3 7 8" />
|
|
||||||
<line x1="12" x2="12" y1="3" y2="15" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm font-medium text-slate-700">点击上传营业执照</p>
|
|
||||||
<p class="text-xs text-slate-400 mt-1">支持 JPG, PNG, PDF (最大 5MB)</p>
|
|
||||||
</div>
|
|
||||||
<!-- 上传成功后的预览状态 -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="w-full flex items-center justify-between bg-white p-3 rounded-lg border border-indigo-100 shadow-sm"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3 overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center text-indigo-600 shrink-0"
|
|
||||||
>IMG</div
|
|
||||||
>
|
|
||||||
<div class="flex flex-col text-left truncate">
|
|
||||||
<span class="text-sm font-medium text-slate-700 truncate">{{
|
|
||||||
uploadedFileName.BusinessLicenseUrl || 'license_scan_2025.jpg'
|
|
||||||
}}</span>
|
|
||||||
<span class="text-xs text-green-600">上传成功</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click.stop="form.BusinessLicenseUrl = ''"
|
|
||||||
class="text-slate-400 hover:text-rose-500 p-2"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M18 6 6 18" />
|
|
||||||
<path d="m6 6 12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 模块 2: 法人信息 (Legal Person) -->
|
|
||||||
<section class="space-y-6 pt-4">
|
|
||||||
<div class="flex items-center gap-3 pb-2 border-b border-slate-100">
|
|
||||||
<div class="p-2 bg-indigo-50 text-indigo-600 rounded-lg">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
|
||||||
<circle cx="9" cy="7" r="4" />
|
|
||||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-bold text-slate-800">法人身份信息</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<!-- 法人姓名 -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
法人姓名 <span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
v-model="form.LegalPersonName"
|
|
||||||
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 transition-all placeholder:text-slate-400"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 身份证号 -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
身份证号 <span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
v-model="form.LegalPersonIdNumber"
|
|
||||||
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 transition-all placeholder:text-slate-400"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 身份证正面 -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
身份证正面 (人像面) <span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
class="upload-card border-2 border-dashed border-slate-300 rounded-xl p-4 flex flex-col items-center justify-center text-center cursor-pointer bg-slate-50 h-32"
|
|
||||||
:class="{ 'opacity-60 pointer-events-none': isUploading.LegalPersonIdFrontUrl }"
|
|
||||||
@click="openFileDialog('LegalPersonIdFrontUrl')"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref="legalPersonFrontInputRef"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
|
||||||
@change="(e) => handleFileChange('LegalPersonIdFrontUrl', e)"
|
|
||||||
/>
|
|
||||||
<div v-if="!form.LegalPersonIdFrontUrl" class="text-slate-500 text-xs">
|
|
||||||
<svg
|
|
||||||
class="w-8 h-8 mx-auto mb-2 text-indigo-400 opacity-80"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
||||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
||||||
<polyline points="21 15 16 10 5 21" />
|
|
||||||
</svg>
|
|
||||||
点击上传人像面
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-green-600 flex items-center gap-2">
|
|
||||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm">已选择文件</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 身份证反面 -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
身份证反面 (国徽面) <span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
class="upload-card border-2 border-dashed border-slate-300 rounded-xl p-4 flex flex-col items-center justify-center text-center cursor-pointer bg-slate-50 h-32"
|
|
||||||
:class="{ 'opacity-60 pointer-events-none': isUploading.LegalPersonIdBackUrl }"
|
|
||||||
@click="openFileDialog('LegalPersonIdBackUrl')"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref="legalPersonBackInputRef"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
|
||||||
@change="(e) => handleFileChange('LegalPersonIdBackUrl', e)"
|
|
||||||
/>
|
|
||||||
<div v-if="!form.LegalPersonIdBackUrl" class="text-slate-500 text-xs">
|
|
||||||
<svg
|
|
||||||
class="w-8 h-8 mx-auto mb-2 text-indigo-400 opacity-80"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
||||||
<line x1="3" y1="9" x2="21" y2="9" />
|
|
||||||
<line x1="9" y1="21" x2="9" y2="9" />
|
|
||||||
</svg>
|
|
||||||
点击上传国徽面
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-green-600 flex items-center gap-2">
|
|
||||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm">已选择文件</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 模块 3: 对公账户 (Bank Account) -->
|
|
||||||
<section class="space-y-6 pt-4">
|
|
||||||
<div class="flex items-center gap-3 pb-2 border-b border-slate-100">
|
|
||||||
<div class="p-2 bg-indigo-50 text-indigo-600 rounded-lg">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M3 21h18" />
|
|
||||||
<path d="M5 21V7" />
|
|
||||||
<path d="M19 21V7" />
|
|
||||||
<path d="M4 7h16" />
|
|
||||||
<path d="M15 7 12 2 9 7" />
|
|
||||||
<line x1="10" x2="10" y1="14" y2="17" />
|
|
||||||
<line x1="14" x2="14" y1="14" y2="17" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-bold text-slate-800">对公结算账户</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<!-- 开户名 -->
|
|
||||||
<div class="col-span-1 md:col-span-2">
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
开户名 (公司名称) <span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
v-model="form.BankAccountName"
|
|
||||||
placeholder="需与营业执照名称一致"
|
|
||||||
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 transition-all placeholder:text-slate-400"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 开户行 -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
开户银行 <span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
v-model="form.BankName"
|
|
||||||
placeholder="如:招商银行北京分行"
|
|
||||||
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 transition-all placeholder:text-slate-400"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 银行账号 -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
银行账号 <span class="text-rose-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
v-model="form.BankAccountNumber"
|
|
||||||
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 transition-all placeholder:text-slate-400 font-mono"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 底部操作栏 -->
|
|
||||||
<div class="pt-8 flex items-center justify-end gap-4 border-t border-slate-100 mt-10">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-6 py-2.5 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-700 font-medium transition-colors"
|
|
||||||
>
|
|
||||||
上一步
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
:class="{ 'opacity-60 cursor-not-allowed': isSubmitting }"
|
|
||||||
class="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2.5 px-8 rounded-lg shadow-lg shadow-indigo-500/30 transition-all transform hover:-translate-y-0.5 active:translate-y-0 disabled:hover:bg-indigo-600"
|
|
||||||
>
|
|
||||||
提交审核
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M5 12h14" />
|
|
||||||
<path d="m12 5 7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 底部版权 (Mobile) -->
|
|
||||||
<div class="text-center text-xs text-slate-400 pb-6 md:hidden">
|
|
||||||
© 2025 CloudSaaS Inc.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 自定义滚动条,让右侧滚动更优雅 */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #cbd5e1;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-card:hover {
|
|
||||||
background-color: #f5f3ff; /* indigo-50 */
|
|
||||||
border-color: #6366f1; /* indigo-500 */
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
|
|
||||||
// 模拟最后更新时间
|
|
||||||
const lastUpdated = '2025年12月12日'
|
|
||||||
|
|
||||||
// 目录数据,用于生成侧边栏和锚点
|
|
||||||
const sections = [
|
|
||||||
{ id: 'introduction', title: '1. 服务协议的接受' },
|
|
||||||
{ id: 'account', title: '2. 账户条款' },
|
|
||||||
{ id: 'payment', title: '3. 支付、退款与升级' },
|
|
||||||
{ id: 'cancellation', title: '4. 取消与终止' },
|
|
||||||
{ id: 'changes', title: '5. 服务与价格变更' },
|
|
||||||
{ id: 'copyright', title: '6. 版权与内容所有权' },
|
|
||||||
{ id: 'privacy', title: '7. 隐私政策' },
|
|
||||||
{ id: 'disclaimer', title: '8. 免责声明' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const activeSection = ref('introduction')
|
|
||||||
|
|
||||||
// 简单的滚动监听,高亮当前阅读的章节
|
|
||||||
const handleScroll = () => {
|
|
||||||
const scrollPosition = window.scrollY + 100
|
|
||||||
|
|
||||||
for (const section of sections) {
|
|
||||||
const element = document.getElementById(section.id)
|
|
||||||
if (element && element.offsetTop <= scrollPosition) {
|
|
||||||
activeSection.value = section.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 目录导航点击:平滑滚动到指定章节
|
|
||||||
const handleSectionNavClick = (sectionId: string) => {
|
|
||||||
const element = document.getElementById(sectionId)
|
|
||||||
if (element) window.scrollTo({ top: element.offsetTop - 100, behavior: 'smooth' })
|
|
||||||
activeSection.value = sectionId
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('scroll', handleScroll)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-screen bg-slate-50 text-slate-700 font-sans">
|
|
||||||
<!-- 顶部简单的导航栏 (模拟) -->
|
|
||||||
<header class="bg-white border-b border-slate-200 sticky top-0 z-30">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
|
||||||
<div class="font-bold text-xl text-slate-900">Product Logo</div>
|
|
||||||
<!-- 返回按钮 -->
|
|
||||||
<button
|
|
||||||
@click="$router.back()"
|
|
||||||
class="text-sm font-medium text-slate-500 hover:text-indigo-600 transition-colors flex items-center"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
返回订阅页
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
||||||
<div class="lg:grid lg:grid-cols-12 lg:gap-8">
|
|
||||||
<!-- 左侧:悬浮目录 (仅在桌面端显示) -->
|
|
||||||
<aside class="hidden lg:block lg:col-span-3">
|
|
||||||
<nav class="sticky top-24 space-y-1">
|
|
||||||
<p class="uppercase text-xs font-bold text-slate-400 mb-4 tracking-wider pl-3"
|
|
||||||
>目录导航</p
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
v-for="section in sections"
|
|
||||||
:key="section.id"
|
|
||||||
:href="`#${section.id}`"
|
|
||||||
class="group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors"
|
|
||||||
:class="
|
|
||||||
activeSection === section.id
|
|
||||||
? 'bg-indigo-50 text-indigo-700'
|
|
||||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
|
||||||
"
|
|
||||||
@click.prevent="handleSectionNavClick(section.id)"
|
|
||||||
>
|
|
||||||
{{ section.title }}
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- 右侧:条款正文 -->
|
|
||||||
<article
|
|
||||||
class="lg:col-span-9 bg-white rounded-2xl shadow-sm border border-slate-200 p-8 sm:p-12"
|
|
||||||
>
|
|
||||||
<!-- 标题区 -->
|
|
||||||
<div class="border-b border-slate-100 pb-8 mb-8">
|
|
||||||
<h1 class="text-3xl sm:text-4xl font-extrabold text-slate-900 tracking-tight mb-4"
|
|
||||||
>服务条款</h1
|
|
||||||
>
|
|
||||||
<p class="text-slate-500">
|
|
||||||
最后更新于
|
|
||||||
<time :datetime="lastUpdated" class="font-medium text-slate-900">{{
|
|
||||||
lastUpdated
|
|
||||||
}}</time>
|
|
||||||
</p>
|
|
||||||
<p class="mt-4 text-slate-600 leading-relaxed">
|
|
||||||
欢迎使用我们的服务。请仔细阅读以下条款,这些条款规定了您对我们要提供的软件服务的使用。购买或使用本服务即表示您同意受这些条款的约束。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 正文内容区 -->
|
|
||||||
<div class="prose prose-slate prose-indigo max-w-none space-y-12">
|
|
||||||
<section :id="sections[0].id" class="scroll-mt-28">
|
|
||||||
<h2 class="text-xl font-bold text-slate-900 mb-4">{{ sections[0].title }}</h2>
|
|
||||||
<p
|
|
||||||
>一旦您访问我们的网站或使用服务,即表示您同意受这些服务条款、所有适用的法律和法规的约束,并同意您有责任遵守任何适用的当地法律。如果您不同意这些条款中的任何一项,则禁止您使用或访问本网站。</p
|
|
||||||
>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section :id="sections[1].id" class="scroll-mt-28">
|
|
||||||
<h2 class="text-xl font-bold text-slate-900 mb-4">{{ sections[1].title }}</h2>
|
|
||||||
<ul class="list-disc pl-5 space-y-2 marker:text-indigo-500">
|
|
||||||
<li>您必须年满18岁才能使用此服务。</li>
|
|
||||||
<li
|
|
||||||
>您必须提供您的法定全名、有效的电子邮件地址以及完成注册过程所需的任何其他信息。</li
|
|
||||||
>
|
|
||||||
<li>您的登录信息仅供您本人使用(除非购买了团队版),严禁多人共享一个账号。</li>
|
|
||||||
<li
|
|
||||||
>您有责任维护您的账户和密码的安全。对于因您未能遵守此安全义务而导致的任何损失或损害,我们要概不负责。</li
|
|
||||||
>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section :id="sections[2].id" class="scroll-mt-28">
|
|
||||||
<h2 class="text-xl font-bold text-slate-900 mb-4">{{ sections[2].title }}</h2>
|
|
||||||
<div class="bg-indigo-50 border-l-4 border-indigo-500 p-4 mb-4 rounded-r-md">
|
|
||||||
<p class="text-sm text-indigo-800 font-medium">
|
|
||||||
<strong>重点提示:</strong> 除非另有说明,所有费用均不含税。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
>使用本服务需要按月或按年支付订阅费。如果需要升级套餐,您的信用卡将在升级时立即扣费,新的费率将在下一个计费周期生效。</p
|
|
||||||
>
|
|
||||||
<p class="font-medium mt-2">退款政策:</p>
|
|
||||||
<p
|
|
||||||
>我们提供 <strong>7 天无理由退款保证</strong>。如果您在首次付款后的 7
|
|
||||||
天内对服务不满意,请联系支持团队,我们将全额退款。超过 7 天的请求将不予受理。</p
|
|
||||||
>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section :id="sections[3].id" class="scroll-mt-28">
|
|
||||||
<h2 class="text-xl font-bold text-slate-900 mb-4">{{ sections[3].title }}</h2>
|
|
||||||
<p
|
|
||||||
>您全权负责正确取消您的账户。您可以在任何时候通过账户设置页面点击“取消订阅”链接来取消您的账户。</p
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
>如果您在当前付费周期结束前取消服务,您的取消将立即生效,且不会再次收取费用,但剩余周期的费用不会退还。</p
|
|
||||||
>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section :id="sections[4].id" class="scroll-mt-28">
|
|
||||||
<h2 class="text-xl font-bold text-slate-900 mb-4">{{ sections[4].title }}</h2>
|
|
||||||
<p>我们要保留随时修改或终止服务(或其任何部分)的权利,无论是否通知。</p>
|
|
||||||
<p
|
|
||||||
>所有服务的价格,包括但不限于每月的订购计划费用,若有变更,我们将提前 30
|
|
||||||
天通知您。通知可以通过在我们的网站上发布或通过电子邮件发送给您。</p
|
|
||||||
>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section :id="sections[5].id" class="scroll-mt-28">
|
|
||||||
<h2 class="text-xl font-bold text-slate-900 mb-4">{{ sections[5].title }}</h2>
|
|
||||||
<p
|
|
||||||
>我们对您发布到服务的材料不主张任何知识产权。您的个人资料和上传的材料仍然属于您。但是,通过设置您的页面为公开共享,您同意允许其他人查看您的内容。</p
|
|
||||||
>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 更多章节占位... -->
|
|
||||||
<section :id="sections[6].id" class="scroll-mt-28">
|
|
||||||
<h2 class="text-xl font-bold text-slate-900 mb-4">{{ sections[6].title }}</h2>
|
|
||||||
<p>隐私政策内容占位符...</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section :id="sections[7].id" class="scroll-mt-28">
|
|
||||||
<h2 class="text-xl font-bold text-slate-900 mb-4">{{ sections[7].title }}</h2>
|
|
||||||
<p class="uppercase text-sm font-bold text-slate-500 mb-2">免责声明</p>
|
|
||||||
<p class="text-slate-600 italic"
|
|
||||||
>本服务按“原样”和“可用”的基础提供。我们不保证服务将满足您的具体要求,也不保证服务将不间断、及时、安全或无错误。</p
|
|
||||||
>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 底部联系方式 -->
|
|
||||||
<div class="mt-16 pt-8 border-t border-slate-100">
|
|
||||||
<h3 class="text-lg font-bold text-slate-900 mb-2">还有疑问?</h3>
|
|
||||||
<p class="text-slate-600">
|
|
||||||
如果您对本条款有任何疑问,请发送邮件至
|
|
||||||
<a
|
|
||||||
href="mailto:support@example.com"
|
|
||||||
class="text-indigo-600 hover:text-indigo-800 hover:underline"
|
|
||||||
>support@example.com</a
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 如果没有引入 tailwindcss/typography 插件,可以用简单的样式修补 */
|
|
||||||
.prose p {
|
|
||||||
margin-bottom: 1.25em;
|
|
||||||
line-height: 1.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose ul {
|
|
||||||
margin-bottom: 1.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose li {
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
// 模拟审核状态数据
|
|
||||||
const auditStatus = ref({
|
|
||||||
status: 'pending', // pending, success, rejected
|
|
||||||
submitTime: '2025-12-12 14:30:00',
|
|
||||||
estimatedTime: '2025-12-13 18:00:00',
|
|
||||||
companyName: '未来科技有限公司' // 回显刚才提交的公司名,增加确认感
|
|
||||||
})
|
|
||||||
|
|
||||||
const isRefreshing = ref(false)
|
|
||||||
|
|
||||||
// 模拟刷新状态功能
|
|
||||||
const handleRefresh = () => {
|
|
||||||
isRefreshing.value = true
|
|
||||||
setTimeout(() => {
|
|
||||||
isRefreshing.value = false
|
|
||||||
alert('状态已刷新,目前仍在审核队列中。')
|
|
||||||
}, 1500)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="h-screen flex overflow-hidden bg-slate-50 text-slate-800 font-sans antialiased">
|
|
||||||
<!-- 左侧:品牌与进度 (Left Side) -->
|
|
||||||
<!-- 注意:这里复用了之前的风格,但更新了进度条状态 -->
|
|
||||||
<div
|
|
||||||
class="hidden lg:flex lg:w-4/12 relative bg-indigo-900 flex-col justify-between p-12 text-white"
|
|
||||||
>
|
|
||||||
<!-- 背景装饰 -->
|
|
||||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
|
||||||
<div
|
|
||||||
class="absolute top-0 right-0 w-96 h-96 bg-indigo-500/20 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="absolute bottom-0 left-0 w-80 h-80 bg-blue-600/20 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0"
|
|
||||||
style="
|
|
||||||
background-image: radial-gradient(rgb(255 255 255 / 10%) 1px, transparent 1px);
|
|
||||||
background-size: 24px 24px;
|
|
||||||
opacity: 0.3;
|
|
||||||
"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Logo -->
|
|
||||||
<div class="relative z-10 flex items-center gap-2 text-indigo-300">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
|
||||||
<line x1="3" x2="21" y1="9" y2="9" />
|
|
||||||
<line x1="9" x2="9" y1="21" y2="9" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-bold tracking-wide text-white">CloudSaaS</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 进度指引 -->
|
|
||||||
<div class="relative z-10">
|
|
||||||
<h2 class="text-3xl font-bold mb-6">稍安勿躁,<br />精彩即将开启</h2>
|
|
||||||
<p class="text-indigo-200 leading-relaxed mb-10 max-w-sm">
|
|
||||||
我们的审核团队正在加班加点处理您的申请。一般情况下,审核将在 1 个工作日内完成。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-0">
|
|
||||||
<!-- Step 1: Done -->
|
|
||||||
<div class="flex gap-4 relative pb-10">
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0 text-indigo-300"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="20 6 9 17 4 12" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="w-0.5 flex-1 bg-indigo-500/20 mt-2"></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-indigo-200">注册账号</h4>
|
|
||||||
<p class="text-sm text-indigo-400/60 mt-0.5">已完成</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 2: Done -->
|
|
||||||
<div class="flex gap-4 relative pb-10">
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0 text-indigo-300"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="20 6 9 17 4 12" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="w-0.5 flex-1 bg-indigo-500/20 mt-2"></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-indigo-200">选择套餐</h4>
|
|
||||||
<p class="text-sm text-indigo-400/60 mt-0.5">已完成</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 3: Active / Processing -->
|
|
||||||
<div class="flex gap-4 relative">
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<!-- 呼吸灯效果 -->
|
|
||||||
<div class="relative w-8 h-8 flex items-center justify-center">
|
|
||||||
<div class="absolute inset-0 bg-white rounded-full animate-ping opacity-20"></div>
|
|
||||||
<div
|
|
||||||
class="relative w-8 h-8 rounded-full bg-white text-indigo-900 font-bold flex items-center justify-center shrink-0 shadow-[0_0_15px_rgba(255,255,255,0.3)]"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<polyline points="12 6 12 12 16 14" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="pt-1">
|
|
||||||
<h4 class="font-medium text-white">资质审核</h4>
|
|
||||||
<p class="text-sm text-indigo-300 mt-0.5">审核中...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 底部 -->
|
|
||||||
<div class="relative z-10 text-sm text-indigo-400/60"> © 2025 CloudSaaS Inc. </div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧:审核状态内容区 (Right Side) -->
|
|
||||||
<div class="flex-1 flex flex-col h-full bg-white overflow-y-auto relative">
|
|
||||||
<!-- 顶部功能栏 -->
|
|
||||||
<div class="h-16 px-6 md:px-12 flex items-center justify-end shrink-0">
|
|
||||||
<button class="text-sm text-slate-500 hover:text-indigo-600 font-medium transition-colors">
|
|
||||||
退出登录
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 核心内容垂直居中 -->
|
|
||||||
<div class="flex-1 flex flex-col items-center justify-center p-6 md:p-12 pb-24">
|
|
||||||
<div class="max-w-lg w-full text-center">
|
|
||||||
<!-- 状态插图 (CSS Animation) -->
|
|
||||||
<div class="relative w-32 h-32 mx-auto mb-8">
|
|
||||||
<!-- 外圈旋转 -->
|
|
||||||
<div class="absolute inset-0 border-4 border-indigo-100 rounded-full"></div>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 border-4 border-t-indigo-500 border-r-transparent border-b-transparent border-l-transparent rounded-full animate-spin"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- 中心图标 -->
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center text-indigo-600">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="48"
|
|
||||||
height="48"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
||||||
<polyline points="14 2 14 8 20 8" />
|
|
||||||
<path d="M12 18v-6" />
|
|
||||||
<path d="m9 15 3 3 3-3" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 class="text-2xl md:text-3xl font-bold text-slate-900 mb-4">
|
|
||||||
资料已提交,正在审核中
|
|
||||||
</h1>
|
|
||||||
<p class="text-slate-500 mb-8">
|
|
||||||
感谢您提交资料。您的申请编号为
|
|
||||||
<span class="font-mono text-slate-700 bg-slate-100 px-2 py-0.5 rounded"
|
|
||||||
>REQ-20251212-001</span
|
|
||||||
>。<br class="hidden md:block" />
|
|
||||||
审核结果将通过短信和邮件发送给您,请留意查收。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- 详细进度卡片 -->
|
|
||||||
<div class="bg-slate-50 rounded-2xl p-6 border border-slate-100 text-left mb-8">
|
|
||||||
<h3 class="text-sm font-semibold text-slate-900 mb-4 flex items-center justify-between">
|
|
||||||
<span>审核进度详情</span>
|
|
||||||
<span class="text-xs font-normal text-slate-400"
|
|
||||||
>预计完成:{{ auditStatus.estimatedTime.split(' ')[0] }}</span
|
|
||||||
>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="space-y-5">
|
|
||||||
<!-- Step 1 -->
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div class="mt-0.5">
|
|
||||||
<div
|
|
||||||
class="w-5 h-5 rounded-full bg-green-100 text-green-600 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="20 6 9 17 4 12" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="w-0.5 h-full bg-green-100 mx-auto mt-1 min-h-[16px]"></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-slate-800">资料提交成功</p>
|
|
||||||
<p class="text-xs text-slate-400 mt-0.5">{{ auditStatus.submitTime }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 2 -->
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div class="mt-0.5">
|
|
||||||
<div
|
|
||||||
class="w-5 h-5 rounded-full bg-green-100 text-green-600 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="20 6 9 17 4 12" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="w-0.5 h-full bg-indigo-100 mx-auto mt-1 min-h-[16px]"></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-slate-800">系统自动预审</p>
|
|
||||||
<p class="text-xs text-green-600 mt-0.5">已通过</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 3 -->
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div class="mt-0.5">
|
|
||||||
<div class="relative w-5 h-5 flex items-center justify-center">
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-indigo-500 rounded-full animate-ping opacity-20"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="w-5 h-5 rounded-full bg-indigo-600 text-white flex items-center justify-center relative z-10 text-[10px] font-bold"
|
|
||||||
>...</div
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-indigo-600">人工复核资质</p>
|
|
||||||
<p class="text-xs text-slate-400 mt-0.5">正在进行中,请耐心等待...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 按钮组 -->
|
|
||||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
|
||||||
<button
|
|
||||||
@click="handleRefresh"
|
|
||||||
class="flex items-center justify-center gap-2 px-6 py-2.5 bg-white border border-slate-200 text-slate-700 font-medium rounded-lg hover:bg-slate-50 hover:border-indigo-200 hover:text-indigo-600 transition-all active:scale-95"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
:class="{ 'animate-spin': isRefreshing }"
|
|
||||||
>
|
|
||||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
|
||||||
<path d="M3 3v5h5" />
|
|
||||||
</svg>
|
|
||||||
刷新状态
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="flex items-center justify-center gap-2 px-6 py-2.5 bg-indigo-50 text-indigo-700 font-medium rounded-lg hover:bg-indigo-100 transition-all"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
联系人工客服
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 确保加载动画平滑 */
|
|
||||||
.animate-spin {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
priority: 1,
|
priority: 1,
|
||||||
effectiveFrom: new Date().toISOString(),
|
effectiveFrom: new Date().toISOString(),
|
||||||
effectiveTo: null,
|
effectiveTo: null,
|
||||||
targetType: 'all',
|
targetType: 'All',
|
||||||
targetRules: undefined,
|
targetRules: undefined,
|
||||||
targetUserIds: [],
|
targetUserIds: [],
|
||||||
rowVersion: undefined
|
rowVersion: undefined
|
||||||
@@ -302,7 +302,7 @@
|
|||||||
formModel.priority = draft.priority ?? 1
|
formModel.priority = draft.priority ?? 1
|
||||||
formModel.effectiveFrom = draft.effectiveFrom ?? new Date().toISOString()
|
formModel.effectiveFrom = draft.effectiveFrom ?? new Date().toISOString()
|
||||||
formModel.effectiveTo = draft.effectiveTo ?? null
|
formModel.effectiveTo = draft.effectiveTo ?? null
|
||||||
formModel.targetType = draft.targetType ?? 'all'
|
formModel.targetType = draft.targetType ?? 'All'
|
||||||
formModel.targetRules = draft.targetRules
|
formModel.targetRules = draft.targetRules
|
||||||
formModel.targetUserIds = draft.targetUserIds ?? []
|
formModel.targetUserIds = draft.targetUserIds ?? []
|
||||||
}
|
}
|
||||||
@@ -327,12 +327,12 @@
|
|||||||
return t('announcement.validation.targetTypeRequired')
|
return t('announcement.validation.targetTypeRequired')
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((targetType === 'rules' || targetType === 'roles') && !formModel.targetRules) {
|
if ((targetType === 'Rules' || targetType === 'Roles') && !formModel.targetRules) {
|
||||||
return t('announcement.validation.targetRulesRequired')
|
return t('announcement.validation.targetRulesRequired')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(targetType === 'users' || targetType === 'manual') &&
|
(targetType === 'Users' || targetType === 'Manual') &&
|
||||||
(!formModel.targetUserIds || formModel.targetUserIds.length === 0)
|
(!formModel.targetUserIds || formModel.targetUserIds.length === 0)
|
||||||
) {
|
) {
|
||||||
return t('announcement.validation.targetUsersRequired')
|
return t('announcement.validation.targetUsersRequired')
|
||||||
|
|||||||
@@ -183,7 +183,7 @@
|
|||||||
priority: 1,
|
priority: 1,
|
||||||
effectiveFrom: new Date().toISOString(),
|
effectiveFrom: new Date().toISOString(),
|
||||||
effectiveTo: null,
|
effectiveTo: null,
|
||||||
targetType: 'all',
|
targetType: 'All',
|
||||||
targetRules: undefined,
|
targetRules: undefined,
|
||||||
targetUserIds: [],
|
targetUserIds: [],
|
||||||
rowVersion: undefined
|
rowVersion: undefined
|
||||||
@@ -288,12 +288,12 @@
|
|||||||
return t('announcement.validation.targetTypeRequired')
|
return t('announcement.validation.targetTypeRequired')
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((targetType === 'rules' || targetType === 'roles') && !formModel.targetRules) {
|
if ((targetType === 'Rules' || targetType === 'Roles') && !formModel.targetRules) {
|
||||||
return t('announcement.validation.targetRulesRequired')
|
return t('announcement.validation.targetRulesRequired')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(targetType === 'users' || targetType === 'manual') &&
|
(targetType === 'Users' || targetType === 'Manual') &&
|
||||||
(!formModel.targetUserIds || formModel.targetUserIds.length === 0)
|
(!formModel.targetUserIds || formModel.targetUserIds.length === 0)
|
||||||
) {
|
) {
|
||||||
return t('announcement.validation.targetUsersRequired')
|
return t('announcement.validation.targetUsersRequired')
|
||||||
@@ -477,11 +477,11 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as unknown
|
const parsed = JSON.parse(raw) as unknown
|
||||||
if ((targetType === 'rules' || targetType === 'roles') && isTargetRules(parsed)) {
|
if ((targetType === 'Rules' || targetType === 'Roles') && isTargetRules(parsed)) {
|
||||||
return { targetRules: parsed, targetUserIds: [] as string[] }
|
return { targetRules: parsed, targetUserIds: [] as string[] }
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((targetType === 'users' || targetType === 'manual') && isUserIdsPayload(parsed)) {
|
if ((targetType === 'Users' || targetType === 'Manual') && isUserIdsPayload(parsed)) {
|
||||||
return { targetRules: undefined, targetUserIds: parsed.userIds }
|
return { targetRules: undefined, targetUserIds: parsed.userIds }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<ElSelect v-model="formFilters.expired" class="w-40" clearable>
|
<ElSelect v-model="formFilters.expired" class="w-40" clearable>
|
||||||
<ElOption
|
<ElOption
|
||||||
v-for="option in expiredOptions"
|
v-for="option in expiredOptions"
|
||||||
:key="option.value"
|
:key="String(option.value)"
|
||||||
:label="option.label"
|
:label="option.label"
|
||||||
:value="option.value"
|
:value="option.value"
|
||||||
/>
|
/>
|
||||||
@@ -193,9 +193,9 @@
|
|||||||
|
|
||||||
// 4. 查询操作
|
// 4. 查询操作
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
searchParams.TenantId = formFilters.value.tenantId.trim() || undefined
|
searchParams.tenantId = formFilters.value.tenantId.trim() || undefined
|
||||||
searchParams.DaysThreshold = formFilters.value.daysThreshold || undefined
|
searchParams.daysThreshold = formFilters.value.daysThreshold || undefined
|
||||||
searchParams.Expired = formFilters.value.expired
|
searchParams.expired = formFilters.value.expired
|
||||||
refreshData()
|
refreshData()
|
||||||
}
|
}
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
@@ -204,9 +204,9 @@
|
|||||||
daysThreshold: undefined,
|
daysThreshold: undefined,
|
||||||
expired: undefined
|
expired: undefined
|
||||||
}
|
}
|
||||||
searchParams.TenantId = undefined
|
searchParams.tenantId = undefined
|
||||||
searchParams.DaysThreshold = undefined
|
searchParams.daysThreshold = undefined
|
||||||
searchParams.Expired = undefined
|
searchParams.expired = undefined
|
||||||
refreshData()
|
refreshData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -177,8 +177,8 @@
|
|||||||
apiParams: {
|
apiParams: {
|
||||||
Page: 1,
|
Page: 1,
|
||||||
PageSize: 20,
|
PageSize: 20,
|
||||||
SortBy: 'SubmittedAt',
|
sortBy: 'SubmittedAt',
|
||||||
SortDesc: true
|
sortDesc: true
|
||||||
},
|
},
|
||||||
paginationKey: {
|
paginationKey: {
|
||||||
current: 'Page',
|
current: 'Page',
|
||||||
@@ -217,11 +217,11 @@
|
|||||||
// 4. 查询与操作
|
// 4. 查询与操作
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
const [submittedFrom, submittedTo] = formFilters.value.dateRange || []
|
const [submittedFrom, submittedTo] = formFilters.value.dateRange || []
|
||||||
searchParams.Keyword = formFilters.value.keyword.trim() || undefined
|
searchParams.keyword = formFilters.value.keyword.trim() || undefined
|
||||||
searchParams.TenantId = formFilters.value.tenantId.trim() || undefined
|
searchParams.tenantId = formFilters.value.tenantId.trim() || undefined
|
||||||
searchParams.SubmittedFrom = submittedFrom || undefined
|
searchParams.submittedFrom = submittedFrom || undefined
|
||||||
searchParams.SubmittedTo = submittedTo || undefined
|
searchParams.submittedTo = submittedTo || undefined
|
||||||
searchParams.OverdueOnly = formFilters.value.overdueOnly
|
searchParams.overdueOnly = formFilters.value.overdueOnly
|
||||||
refreshData()
|
refreshData()
|
||||||
void loadStatistics()
|
void loadStatistics()
|
||||||
}
|
}
|
||||||
@@ -232,11 +232,11 @@
|
|||||||
dateRange: [],
|
dateRange: [],
|
||||||
overdueOnly: false
|
overdueOnly: false
|
||||||
}
|
}
|
||||||
searchParams.Keyword = undefined
|
searchParams.keyword = undefined
|
||||||
searchParams.TenantId = undefined
|
searchParams.tenantId = undefined
|
||||||
searchParams.SubmittedFrom = undefined
|
searchParams.submittedFrom = undefined
|
||||||
searchParams.SubmittedTo = undefined
|
searchParams.submittedTo = undefined
|
||||||
searchParams.OverdueOnly = false
|
searchParams.overdueOnly = false
|
||||||
refreshData()
|
refreshData()
|
||||||
void loadStatistics()
|
void loadStatistics()
|
||||||
}
|
}
|
||||||
@@ -256,8 +256,8 @@
|
|||||||
const loadStatistics = async () => {
|
const loadStatistics = async () => {
|
||||||
const [dateFrom, dateTo] = formFilters.value.dateRange || []
|
const [dateFrom, dateTo] = formFilters.value.dateRange || []
|
||||||
statistics.value = await fetchStoreAuditStatistics({
|
statistics.value = await fetchStoreAuditStatistics({
|
||||||
DateFrom: dateFrom || undefined,
|
dateFrom: dateFrom || undefined,
|
||||||
DateTo: dateTo || undefined
|
dateTo: dateTo || undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,12 +192,14 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!store.value || !formRef.value) {
|
const form = formRef.value
|
||||||
return
|
const currentStore = store.value
|
||||||
}
|
if (!currentStore || !form) return
|
||||||
|
|
||||||
|
const storeId = String(currentStore.id)
|
||||||
|
|
||||||
// 1. 校验表单
|
// 1. 校验表单
|
||||||
await formRef.value.validate(async (valid) => {
|
await form.validate(async (valid) => {
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -205,7 +207,7 @@
|
|||||||
// 2. 提交状态变更
|
// 2. 提交状态变更
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await fetchToggleBusinessStatus(String(store.value.id), buildPayload())
|
await fetchToggleBusinessStatus(storeId, buildPayload())
|
||||||
ElMessage.success(t('store.message.statusUpdated'))
|
ElMessage.success(t('store.message.statusUpdated'))
|
||||||
emit('saved')
|
emit('saved')
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
|
|||||||
@@ -201,11 +201,11 @@
|
|||||||
|
|
||||||
// 5. 搜索与操作
|
// 5. 搜索与操作
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
searchParams.Keyword = formFilters.value.keyword.trim() || undefined
|
searchParams.keyword = formFilters.value.keyword.trim() || undefined
|
||||||
searchParams.MerchantId = formFilters.value.merchantId.trim() || undefined
|
searchParams.merchantId = formFilters.value.merchantId.trim() || undefined
|
||||||
searchParams.AuditStatus = formFilters.value.auditStatus
|
searchParams.auditStatus = formFilters.value.auditStatus
|
||||||
searchParams.BusinessStatus = formFilters.value.businessStatus
|
searchParams.businessStatus = formFilters.value.businessStatus
|
||||||
searchParams.OwnershipType = formFilters.value.ownershipType
|
searchParams.ownershipType = formFilters.value.ownershipType
|
||||||
refreshData()
|
refreshData()
|
||||||
}
|
}
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
@@ -216,11 +216,11 @@
|
|||||||
businessStatus: undefined,
|
businessStatus: undefined,
|
||||||
ownershipType: undefined
|
ownershipType: undefined
|
||||||
}
|
}
|
||||||
searchParams.Keyword = undefined
|
searchParams.keyword = undefined
|
||||||
searchParams.MerchantId = undefined
|
searchParams.merchantId = undefined
|
||||||
searchParams.AuditStatus = undefined
|
searchParams.auditStatus = undefined
|
||||||
searchParams.BusinessStatus = undefined
|
searchParams.businessStatus = undefined
|
||||||
searchParams.OwnershipType = undefined
|
searchParams.ownershipType = undefined
|
||||||
refreshData()
|
refreshData()
|
||||||
}
|
}
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
|
|||||||
@@ -271,6 +271,7 @@
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useTable } from '@/hooks/core/useTable'
|
import { useTable } from '@/hooks/core/useTable'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
|
import type { ColumnOption } from '@/types/component'
|
||||||
import { formatDateTime } from '@/utils/billing'
|
import { formatDateTime } from '@/utils/billing'
|
||||||
import { isHttpError } from '@/utils/http/error'
|
import { isHttpError } from '@/utils/http/error'
|
||||||
import {
|
import {
|
||||||
@@ -392,7 +393,7 @@
|
|||||||
// 3. 正常状态映射
|
// 3. 正常状态映射
|
||||||
const statusMap: Record<
|
const statusMap: Record<
|
||||||
IdentityUserStatus,
|
IdentityUserStatus,
|
||||||
{ label: string; type: 'success' | 'danger' | 'info' }
|
{ label: string; type: 'success' | 'danger' | 'info' | 'warning' }
|
||||||
> = {
|
> = {
|
||||||
1: { label: t('user.status.active'), type: 'success' },
|
1: { label: t('user.status.active'), type: 'success' },
|
||||||
2: { label: t('user.status.disabled'), type: 'danger' },
|
2: { label: t('user.status.disabled'), type: 'danger' },
|
||||||
@@ -416,9 +417,9 @@
|
|||||||
permissionLabelMap.value.get(permission) || permission
|
permissionLabelMap.value.get(permission) || permission
|
||||||
|
|
||||||
// 4. 表格与查询逻辑
|
// 4. 表格与查询逻辑
|
||||||
const buildColumns = () => {
|
const buildColumns = (): ColumnOption<UserListItem>[] => {
|
||||||
// 1. 构建基础列
|
// 1. 构建基础列
|
||||||
const columns = [
|
const columns: ColumnOption<UserListItem>[] = [
|
||||||
{ type: 'selection', width: 50 },
|
{ type: 'selection', width: 50 },
|
||||||
{ type: 'globalIndex', width: 60, label: t('table.column.index') },
|
{ type: 'globalIndex', width: 60, label: t('table.column.index') },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -390,7 +390,7 @@
|
|||||||
const initFormData = () => {
|
const initFormData = () => {
|
||||||
// 1. 解析详情与默认租户
|
// 1. 解析详情与默认租户
|
||||||
const detail = props.userData
|
const detail = props.userData
|
||||||
const defaultTenantId = props.defaultTenantId || props.tenantOptions[0]?.value || ''
|
const defaultTenantId = String(props.defaultTenantId || props.tenantOptions[0]?.value || '')
|
||||||
|
|
||||||
// 2. 编辑模式填充
|
// 2. 编辑模式填充
|
||||||
if (isEdit.value && detail) {
|
if (isEdit.value && detail) {
|
||||||
@@ -411,7 +411,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 新增模式初始化
|
// 3. 新增模式初始化
|
||||||
formData.tenantId = detail?.tenantId || defaultTenantId
|
formData.tenantId = String(detail?.tenantId || defaultTenantId || '')
|
||||||
formData.account = ''
|
formData.account = ''
|
||||||
formData.displayName = ''
|
formData.displayName = ''
|
||||||
formData.password = ''
|
formData.password = ''
|
||||||
|
|||||||
@@ -1,404 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ElDrawer
|
|
||||||
v-model="visible"
|
|
||||||
:title="t('tenant.announcement.detailTitle')"
|
|
||||||
size="900px"
|
|
||||||
direction="rtl"
|
|
||||||
@closed="handleClosed"
|
|
||||||
>
|
|
||||||
<div class="px-4 pb-4" v-loading="loading">
|
|
||||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="text-lg font-semibold">{{ t('tenant.announcement.detailTitle') }}</div>
|
|
||||||
<ElTag v-if="detail" :type="getStatusTagType(detail.status)">
|
|
||||||
{{ getStatusText(detail.status) }}
|
|
||||||
</ElTag>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<ElButton
|
|
||||||
v-if="detail && resolveStatus(detail.status) === 'Draft'"
|
|
||||||
@click="handleEdit"
|
|
||||||
v-ripple
|
|
||||||
>
|
|
||||||
{{ t('tenant.announcement.action.edit') }}
|
|
||||||
</ElButton>
|
|
||||||
<ElButton
|
|
||||||
v-if="detail && resolveStatus(detail.status) === 'Draft'"
|
|
||||||
type="primary"
|
|
||||||
:loading="actionLoading"
|
|
||||||
@click="handlePublish"
|
|
||||||
v-ripple
|
|
||||||
>
|
|
||||||
{{ t('tenant.announcement.action.publish') }}
|
|
||||||
</ElButton>
|
|
||||||
<ElButton
|
|
||||||
v-if="detail && resolveStatus(detail.status) === 'Published'"
|
|
||||||
type="warning"
|
|
||||||
:loading="actionLoading"
|
|
||||||
@click="handleRevoke"
|
|
||||||
v-ripple
|
|
||||||
>
|
|
||||||
{{ t('tenant.announcement.action.revoke') }}
|
|
||||||
</ElButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ElAlert v-if="errorMessage" type="error" show-icon :title="errorMessage" class="mb-4" />
|
|
||||||
|
|
||||||
<ElDescriptions :column="2" border>
|
|
||||||
<ElDescriptionsItem :label="t('common.id')">
|
|
||||||
{{ detail?.id || '-' }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem :label="t('tenant.announcement.field.title')">
|
|
||||||
{{ detail?.title || '-' }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem :label="t('tenant.announcement.field.announcementType')">
|
|
||||||
{{ detail ? getTypeText(detail.announcementType) : '-' }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem :label="t('tenant.announcement.field.priority')">
|
|
||||||
{{ detail?.priority ?? '-' }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem :label="t('tenant.announcement.field.status')">
|
|
||||||
<ElTag v-if="detail" :type="getStatusTagType(detail.status)">
|
|
||||||
{{ getStatusText(detail.status) }}
|
|
||||||
</ElTag>
|
|
||||||
<span v-else>-</span>
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem :label="t('tenant.announcement.field.targetType')">
|
|
||||||
{{ detail ? getTargetTypeText(detail.targetType) : '-' }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem :label="t('tenant.announcement.field.effectiveFrom')">
|
|
||||||
{{ formatDateTime(detail?.effectiveFrom) }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem :label="t('tenant.announcement.field.effectiveTo')">
|
|
||||||
{{ formatDateTime(detail?.effectiveTo) }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem :label="t('tenant.announcement.field.publishedAt')">
|
|
||||||
{{ formatDateTime(detail?.publishedAt) }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem :label="t('tenant.announcement.field.revokedAt')">
|
|
||||||
{{ formatDateTime(detail?.revokedAt) }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem :label="t('tenant.announcement.field.isActive')">
|
|
||||||
{{ detail ? (detail.isActive ? t('common.yes') : t('common.no')) : '-' }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem :label="t('tenant.announcement.field.rowVersion')">
|
|
||||||
<span class="font-mono text-xs">{{ detail?.rowVersion || '-' }}</span>
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
</ElDescriptions>
|
|
||||||
|
|
||||||
<ElDivider class="my-4" />
|
|
||||||
|
|
||||||
<div class="mb-3 text-sm font-semibold">
|
|
||||||
{{ t('tenant.announcement.section.content') }}
|
|
||||||
</div>
|
|
||||||
<div class="rounded-md border border-[var(--el-border-color-lighter)] p-3 text-sm">
|
|
||||||
<div class="whitespace-pre-wrap text-[var(--el-text-color-primary)]">
|
|
||||||
{{ detail?.content || '-' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ElDivider class="my-4" />
|
|
||||||
|
|
||||||
<div class="mb-3 text-sm font-semibold">
|
|
||||||
{{ t('tenant.announcement.section.audience') }}
|
|
||||||
</div>
|
|
||||||
<div class="rounded-md border border-[var(--el-border-color-lighter)] p-3 text-sm">
|
|
||||||
<div class="mb-2 text-[var(--el-text-color-secondary)]">
|
|
||||||
{{ t('tenant.announcement.field.targetParameters') }}
|
|
||||||
</div>
|
|
||||||
<pre class="whitespace-pre-wrap text-xs">{{ formattedTargetParameters }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ElDrawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import {
|
|
||||||
ElAlert,
|
|
||||||
ElButton,
|
|
||||||
ElDescriptions,
|
|
||||||
ElDescriptionsItem,
|
|
||||||
ElDivider,
|
|
||||||
ElDrawer,
|
|
||||||
ElMessage,
|
|
||||||
ElMessageBox,
|
|
||||||
ElTag
|
|
||||||
} from 'element-plus'
|
|
||||||
import { tenantAnnouncementApi } from '@/api/announcement'
|
|
||||||
import { isHttpError } from '@/utils/http/error'
|
|
||||||
import { ApiStatus } from '@/utils/http/status'
|
|
||||||
import { normalizeAnnouncementStatus } from '@/utils/announcementStatus'
|
|
||||||
import type { AnnouncementTargetType, TenantAnnouncementDto } from '@/types/announcement'
|
|
||||||
import { TenantAnnouncementType } from '@/types/announcement'
|
|
||||||
|
|
||||||
defineOptions({ name: 'TenantAnnouncementDetailDrawer' })
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: 'updated'): void
|
|
||||||
(event: 'edit', payload: TenantAnnouncementDto): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const visible = ref(false)
|
|
||||||
const loading = ref(false)
|
|
||||||
const actionLoading = ref(false)
|
|
||||||
const tenantId = ref('')
|
|
||||||
const announcementId = ref('')
|
|
||||||
const detail = ref<TenantAnnouncementDto | null>(null)
|
|
||||||
const errorMessage = ref<string | null>(null)
|
|
||||||
const currentLoadToken = ref(0)
|
|
||||||
|
|
||||||
// 1. 文案转换
|
|
||||||
const typeOptions = computed(() => [
|
|
||||||
{ label: t('tenant.announcement.type.system'), value: TenantAnnouncementType.System },
|
|
||||||
{ label: t('tenant.announcement.type.billing'), value: TenantAnnouncementType.Billing },
|
|
||||||
{ label: t('tenant.announcement.type.operation'), value: TenantAnnouncementType.Operation },
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.systemPlatformUpdate'),
|
|
||||||
value: TenantAnnouncementType.SYSTEM_PLATFORM_UPDATE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.systemSecurityNotice'),
|
|
||||||
value: TenantAnnouncementType.SYSTEM_SECURITY_NOTICE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.systemCompliance'),
|
|
||||||
value: TenantAnnouncementType.SYSTEM_COMPLIANCE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantInternal'),
|
|
||||||
value: TenantAnnouncementType.TENANT_INTERNAL
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantFinance'),
|
|
||||||
value: TenantAnnouncementType.TENANT_FINANCE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantOperation'),
|
|
||||||
value: TenantAnnouncementType.TENANT_OPERATION
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const targetTypeMap = computed<Record<string, string>>(() => ({
|
|
||||||
All: t('tenant.announcement.targetType.all'),
|
|
||||||
all: t('tenant.announcement.targetType.all'),
|
|
||||||
Roles: t('tenant.announcement.targetType.roles'),
|
|
||||||
roles: t('tenant.announcement.targetType.roles'),
|
|
||||||
Users: t('tenant.announcement.targetType.users'),
|
|
||||||
users: t('tenant.announcement.targetType.users'),
|
|
||||||
Rules: t('tenant.announcement.targetType.rules'),
|
|
||||||
rules: t('tenant.announcement.targetType.rules'),
|
|
||||||
Manual: t('tenant.announcement.targetType.manual'),
|
|
||||||
manual: t('tenant.announcement.targetType.manual')
|
|
||||||
}))
|
|
||||||
|
|
||||||
const getTypeText = (type: TenantAnnouncementType) => {
|
|
||||||
const found = typeOptions.value.find((item) => item.value === type)
|
|
||||||
return found?.label || '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTargetTypeText = (type: AnnouncementTargetType) => {
|
|
||||||
return targetTypeMap.value[String(type)] || '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 状态映射
|
|
||||||
const resolveStatus = (status: unknown) => normalizeAnnouncementStatus(status)
|
|
||||||
const getStatusText = (status: TenantAnnouncementDto['status']) => {
|
|
||||||
const map = {
|
|
||||||
Draft: t('tenant.announcement.status.draft'),
|
|
||||||
Published: t('tenant.announcement.status.published'),
|
|
||||||
Revoked: t('tenant.announcement.status.revoked')
|
|
||||||
} as const
|
|
||||||
const normalized = resolveStatus(status)
|
|
||||||
return normalized ? map[normalized] : t('tenant.announcement.status.unknown')
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusTagType = (status: TenantAnnouncementDto['status']) => {
|
|
||||||
const normalized = resolveStatus(status)
|
|
||||||
switch (normalized) {
|
|
||||||
case 'Draft':
|
|
||||||
return 'info'
|
|
||||||
case 'Published':
|
|
||||||
return 'success'
|
|
||||||
case 'Revoked':
|
|
||||||
return 'danger'
|
|
||||||
default:
|
|
||||||
return 'info'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 时间格式化
|
|
||||||
const formatDateTime = (value?: string | null) => {
|
|
||||||
if (!value) return '-'
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) return '-'
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 受众参数展示
|
|
||||||
const formattedTargetParameters = computed(() => {
|
|
||||||
if (!detail.value?.targetParameters) return '-'
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(detail.value.targetParameters)
|
|
||||||
return JSON.stringify(parsed, null, 2)
|
|
||||||
} catch {
|
|
||||||
return detail.value.targetParameters
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolveDetailErrorMessage = (error: unknown) => {
|
|
||||||
if (isHttpError(error)) {
|
|
||||||
if (error.code === ApiStatus.notFound) {
|
|
||||||
return t('tenant.announcement.message.detailNotFound')
|
|
||||||
}
|
|
||||||
return error.message
|
|
||||||
}
|
|
||||||
if (error instanceof Error && error.message) {
|
|
||||||
return error.message
|
|
||||||
}
|
|
||||||
return t('tenant.announcement.message.loadDetailFailed')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 拉取详情
|
|
||||||
const loadDetail = async (token: number) => {
|
|
||||||
// 1. 校验租户与公告ID
|
|
||||||
if (!tenantId.value) {
|
|
||||||
errorMessage.value = t('tenant.announcement.message.tenantIdMissing')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!announcementId.value) {
|
|
||||||
errorMessage.value = t('tenant.announcement.message.loadDetailFailed')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 清理错误并拉取详情
|
|
||||||
errorMessage.value = null
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const result = await tenantAnnouncementApi.detail(tenantId.value, announcementId.value)
|
|
||||||
if (token !== currentLoadToken.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
detail.value = result
|
|
||||||
} catch (error) {
|
|
||||||
if (token !== currentLoadToken.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
errorMessage.value = resolveDetailErrorMessage(error)
|
|
||||||
detail.value = null
|
|
||||||
} finally {
|
|
||||||
if (token === currentLoadToken.value) {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 操作处理
|
|
||||||
const handleEdit = () => {
|
|
||||||
if (!detail.value) return
|
|
||||||
emit('edit', detail.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePublish = async () => {
|
|
||||||
if (!tenantId.value || !detail.value) {
|
|
||||||
ElMessage.error(t('tenant.announcement.message.tenantIdMissing'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 二次确认发布
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
t('tenant.announcement.message.publishConfirm'),
|
|
||||||
t('common.tips'),
|
|
||||||
{
|
|
||||||
confirmButtonText: t('common.confirm'),
|
|
||||||
cancelButtonText: t('common.cancel'),
|
|
||||||
type: 'warning'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 2. 发起发布请求
|
|
||||||
actionLoading.value = true
|
|
||||||
const result = await tenantAnnouncementApi.publish(tenantId.value, detail.value.id, {
|
|
||||||
rowVersion: detail.value.rowVersion
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. 更新详情并通知刷新
|
|
||||||
detail.value = result
|
|
||||||
ElMessage.success(t('tenant.announcement.message.publishSuccess'))
|
|
||||||
emit('updated')
|
|
||||||
} catch (error) {
|
|
||||||
if (error !== 'cancel') {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
actionLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRevoke = async () => {
|
|
||||||
if (!tenantId.value || !detail.value) {
|
|
||||||
ElMessage.error(t('tenant.announcement.message.tenantIdMissing'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 二次确认撤销
|
|
||||||
await ElMessageBox.confirm(t('tenant.announcement.message.revokeConfirm'), t('common.tips'), {
|
|
||||||
confirmButtonText: t('common.confirm'),
|
|
||||||
cancelButtonText: t('common.cancel'),
|
|
||||||
type: 'warning'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 2. 发起撤销请求
|
|
||||||
actionLoading.value = true
|
|
||||||
const result = await tenantAnnouncementApi.revoke(tenantId.value, detail.value.id, {
|
|
||||||
rowVersion: detail.value.rowVersion
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. 更新详情并通知刷新
|
|
||||||
detail.value = result
|
|
||||||
ElMessage.success(t('tenant.announcement.message.revokeSuccess'))
|
|
||||||
emit('updated')
|
|
||||||
} catch (error) {
|
|
||||||
if (error !== 'cancel') {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
actionLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. 抽屉控制
|
|
||||||
const open = async (id: string, tenant: string) => {
|
|
||||||
visible.value = true
|
|
||||||
|
|
||||||
// 1. 初始化抽屉状态
|
|
||||||
tenantId.value = tenant
|
|
||||||
announcementId.value = String(id)
|
|
||||||
detail.value = null
|
|
||||||
errorMessage.value = null
|
|
||||||
|
|
||||||
// 2. 拉取详情数据
|
|
||||||
const token = ++currentLoadToken.value
|
|
||||||
await loadDetail(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClosed = () => {
|
|
||||||
// 1. 关闭时清理状态,防止残留
|
|
||||||
currentLoadToken.value += 1
|
|
||||||
tenantId.value = ''
|
|
||||||
announcementId.value = ''
|
|
||||||
detail.value = null
|
|
||||||
errorMessage.value = null
|
|
||||||
loading.value = false
|
|
||||||
actionLoading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ open })
|
|
||||||
</script>
|
|
||||||
@@ -1,463 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ElDialog
|
|
||||||
v-model="dialogVisible"
|
|
||||||
:title="
|
|
||||||
isEditMode ? $t('tenant.announcement.editTitle') : $t('tenant.announcement.createTitle')
|
|
||||||
"
|
|
||||||
width="900px"
|
|
||||||
:close-on-click-modal="false"
|
|
||||||
@close="handleClose"
|
|
||||||
>
|
|
||||||
<ElForm ref="formRef" :model="formState" :rules="rules" label-width="110px">
|
|
||||||
<!-- 基础信息 -->
|
|
||||||
<ElRow :gutter="16">
|
|
||||||
<ElCol :span="12">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.title')" prop="title">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.title"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.title')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="6">
|
|
||||||
<ElFormItem
|
|
||||||
:label="$t('tenant.announcement.field.announcementType')"
|
|
||||||
prop="announcementType"
|
|
||||||
>
|
|
||||||
<ElSelect
|
|
||||||
v-model="formState.announcementType"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.type')"
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
<ElOption
|
|
||||||
v-for="item in typeOptions"
|
|
||||||
:key="item.value"
|
|
||||||
:label="item.label"
|
|
||||||
:value="item.value"
|
|
||||||
/>
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="6">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.priority')" prop="priority">
|
|
||||||
<ElInputNumber
|
|
||||||
v-model="formState.priority"
|
|
||||||
:min="1"
|
|
||||||
:max="10"
|
|
||||||
controls-position="right"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
|
|
||||||
<ElRow :gutter="16">
|
|
||||||
<ElCol :span="12">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.effectiveRange')" prop="effectiveRange">
|
|
||||||
<ElDatePicker
|
|
||||||
v-model="formState.effectiveRange"
|
|
||||||
type="datetimerange"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.effectiveRange')"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="12">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.targetType')" prop="targetType">
|
|
||||||
<ElSelect
|
|
||||||
v-model="formState.targetType"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.targetType')"
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
<ElOption
|
|
||||||
v-for="item in targetTypeOptions"
|
|
||||||
:key="item.value"
|
|
||||||
:label="item.label"
|
|
||||||
:value="item.value"
|
|
||||||
/>
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
|
|
||||||
<!-- 公告内容 -->
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.content')" prop="content">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.content"
|
|
||||||
type="textarea"
|
|
||||||
:rows="6"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.content')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
|
|
||||||
<!-- 目标受众 -->
|
|
||||||
<ElRow :gutter="16" v-if="formState.targetType !== 'all'">
|
|
||||||
<ElCol :span="24" v-if="formState.targetType === 'roles'">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.targetParameters')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.roleIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.roleIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="24" v-if="formState.targetType === 'users'">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.targetParameters')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.userIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.userIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="24" v-if="formState.targetType === 'manual'">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.targetParameters')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.userIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.userIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
|
|
||||||
<ElRow :gutter="16" v-if="formState.targetType === 'rules'">
|
|
||||||
<ElCol :span="8">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.departmentIds')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.departmentIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.departmentIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="8">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.roleIds')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.roleIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.roleIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="8">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.tagIds')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.tagIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.tagIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
</ElForm>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<ElButton @click="handleClose">{{ $t('common.cancel') }}</ElButton>
|
|
||||||
<ElButton type="primary" :loading="loading" @click="handleSubmit">
|
|
||||||
{{ isEditMode ? $t('common.update') : $t('common.create') }}
|
|
||||||
</ElButton>
|
|
||||||
</template>
|
|
||||||
</ElDialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import {
|
|
||||||
ElDialog,
|
|
||||||
ElForm,
|
|
||||||
ElFormItem,
|
|
||||||
ElInput,
|
|
||||||
ElInputNumber,
|
|
||||||
ElSelect,
|
|
||||||
ElOption,
|
|
||||||
ElDatePicker,
|
|
||||||
ElRow,
|
|
||||||
ElCol,
|
|
||||||
ElButton,
|
|
||||||
ElMessage,
|
|
||||||
type FormInstance,
|
|
||||||
type FormRules
|
|
||||||
} from 'element-plus'
|
|
||||||
import { tenantAnnouncementApi } from '@/api/announcement'
|
|
||||||
import {
|
|
||||||
TenantAnnouncementType,
|
|
||||||
type TenantAnnouncementDto,
|
|
||||||
type AnnouncementTargetType
|
|
||||||
} from '@/types/announcement'
|
|
||||||
|
|
||||||
// 1. Props 定义
|
|
||||||
interface Props {
|
|
||||||
modelValue: boolean
|
|
||||||
tenantId: string
|
|
||||||
editData?: TenantAnnouncementDto | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
modelValue: false,
|
|
||||||
editData: null
|
|
||||||
})
|
|
||||||
|
|
||||||
// 2. Emits 定义
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: boolean]
|
|
||||||
success: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// 3. 国际化
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
// 4. 弹窗显示状态
|
|
||||||
const dialogVisible = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (val) => emit('update:modelValue', val)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 5. 是否编辑模式
|
|
||||||
const isEditMode = computed(() => !!props.editData)
|
|
||||||
|
|
||||||
// 6. 表单引用
|
|
||||||
const formRef = ref<FormInstance>()
|
|
||||||
|
|
||||||
// 7. 加载状态
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// 8. 表单状态
|
|
||||||
const formState = reactive({
|
|
||||||
title: '',
|
|
||||||
content: '',
|
|
||||||
announcementType: TenantAnnouncementType.System,
|
|
||||||
priority: 5,
|
|
||||||
effectiveRange: [] as (Date | null)[],
|
|
||||||
targetType: 'all' as AnnouncementTargetType,
|
|
||||||
roleIds: '',
|
|
||||||
userIds: '',
|
|
||||||
departmentIds: '',
|
|
||||||
tagIds: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 9. 表单校验规则
|
|
||||||
const rules: FormRules = {
|
|
||||||
title: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('tenant.announcement.validation.titleRequired'),
|
|
||||||
trigger: 'blur'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('tenant.announcement.validation.contentRequired'),
|
|
||||||
trigger: 'blur'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
announcementType: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('tenant.announcement.validation.typeRequired'),
|
|
||||||
trigger: 'change'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
priority: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('tenant.announcement.validation.priorityRequired'),
|
|
||||||
trigger: 'blur'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
effectiveRange: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('tenant.announcement.validation.effectiveRangeRequired'),
|
|
||||||
trigger: 'change'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
targetType: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('tenant.announcement.validation.targetTypeRequired'),
|
|
||||||
trigger: 'change'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10. 类型选项
|
|
||||||
const typeOptions = computed(() => [
|
|
||||||
{ label: t('tenant.announcement.type.system'), value: TenantAnnouncementType.System },
|
|
||||||
{ label: t('tenant.announcement.type.billing'), value: TenantAnnouncementType.Billing },
|
|
||||||
{ label: t('tenant.announcement.type.operation'), value: TenantAnnouncementType.Operation },
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantInternal'),
|
|
||||||
value: TenantAnnouncementType.TENANT_INTERNAL
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantFinance'),
|
|
||||||
value: TenantAnnouncementType.TENANT_FINANCE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantOperation'),
|
|
||||||
value: TenantAnnouncementType.TENANT_OPERATION
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
// 11. 目标受众类型选项
|
|
||||||
const targetTypeOptions = computed(() => [
|
|
||||||
{ label: t('tenant.announcement.targetType.all'), value: 'all' as AnnouncementTargetType },
|
|
||||||
{ label: t('tenant.announcement.targetType.roles'), value: 'roles' as AnnouncementTargetType },
|
|
||||||
{ label: t('tenant.announcement.targetType.users'), value: 'users' as AnnouncementTargetType },
|
|
||||||
{ label: t('tenant.announcement.targetType.rules'), value: 'rules' as AnnouncementTargetType },
|
|
||||||
{ label: t('tenant.announcement.targetType.manual'), value: 'manual' as AnnouncementTargetType }
|
|
||||||
])
|
|
||||||
|
|
||||||
// 12. 目标受众辅助处理
|
|
||||||
const normalizeTargetType = (value?: string | null): AnnouncementTargetType => {
|
|
||||||
const normalized = String(value || '')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
if (normalized === 'roles') return 'roles' as AnnouncementTargetType
|
|
||||||
if (normalized === 'users') return 'users' as AnnouncementTargetType
|
|
||||||
if (normalized === 'rules') return 'rules' as AnnouncementTargetType
|
|
||||||
if (normalized === 'manual') return 'manual' as AnnouncementTargetType
|
|
||||||
return 'all' as AnnouncementTargetType
|
|
||||||
}
|
|
||||||
|
|
||||||
const splitIds = (value: string) => {
|
|
||||||
return value
|
|
||||||
.split(/[,,\s]+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyTargetParameters = (targetType: AnnouncementTargetType, raw?: string | null) => {
|
|
||||||
// 1. 先清空已有数据
|
|
||||||
formState.roleIds = ''
|
|
||||||
formState.userIds = ''
|
|
||||||
formState.departmentIds = ''
|
|
||||||
formState.tagIds = ''
|
|
||||||
|
|
||||||
// 2. 无参数直接返回
|
|
||||||
if (!raw) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const params = JSON.parse(raw) as Record<string, unknown>
|
|
||||||
|
|
||||||
// 3. 用户/手选用户模式
|
|
||||||
if (targetType === 'users' || targetType === 'manual') {
|
|
||||||
const userIds = Array.isArray(params.userIds) ? params.userIds : []
|
|
||||||
formState.userIds = userIds.map(String).join(',')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 角色模式
|
|
||||||
if (targetType === 'roles') {
|
|
||||||
const roles = Array.isArray(params.roles) ? params.roles : []
|
|
||||||
formState.roleIds = roles.map(String).join(',')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 规则模式
|
|
||||||
if (targetType === 'rules') {
|
|
||||||
const departments = Array.isArray(params.departments) ? params.departments : []
|
|
||||||
const roles = Array.isArray(params.roles) ? params.roles : []
|
|
||||||
const tags = Array.isArray(params.tags) ? params.tags : []
|
|
||||||
|
|
||||||
formState.departmentIds = departments.map(String).join(',')
|
|
||||||
formState.roleIds = roles.map(String).join(',')
|
|
||||||
formState.tagIds = tags.map(String).join(',')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[AnnouncementFormDialog] 目标参数解析失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildTargetParameters = () => {
|
|
||||||
// 1. 角色模式
|
|
||||||
if (formState.targetType === 'roles') {
|
|
||||||
return JSON.stringify({ roles: splitIds(formState.roleIds) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 用户/手选用户模式
|
|
||||||
if (formState.targetType === 'users' || formState.targetType === 'manual') {
|
|
||||||
return JSON.stringify({ userIds: splitIds(formState.userIds) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 规则模式
|
|
||||||
if (formState.targetType === 'rules') {
|
|
||||||
return JSON.stringify({
|
|
||||||
departments: splitIds(formState.departmentIds),
|
|
||||||
roles: splitIds(formState.roleIds),
|
|
||||||
tags: splitIds(formState.tagIds)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 默认返回空参数
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 13. 监听编辑数据变化
|
|
||||||
watch(
|
|
||||||
() => props.editData,
|
|
||||||
(data) => {
|
|
||||||
if (data) {
|
|
||||||
formState.title = data.title
|
|
||||||
formState.content = data.content
|
|
||||||
formState.announcementType = data.announcementType
|
|
||||||
formState.priority = data.priority
|
|
||||||
// 将字符串转换为 Date 对象
|
|
||||||
formState.effectiveRange = [
|
|
||||||
data.effectiveFrom ? new Date(data.effectiveFrom) : null,
|
|
||||||
data.effectiveTo ? new Date(data.effectiveTo) : null
|
|
||||||
]
|
|
||||||
formState.targetType = normalizeTargetType(data.targetType)
|
|
||||||
applyTargetParameters(formState.targetType, data.targetParameters)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
// 14. 提交表单
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!formRef.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await formRef.value.validate()
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
// 将 Date 对象转换为 ISO UTC 字符串
|
|
||||||
const effectiveFrom = formState.effectiveRange[0]
|
|
||||||
const effectiveTo = formState.effectiveRange[1]
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
title: formState.title,
|
|
||||||
content: formState.content,
|
|
||||||
announcementType: formState.announcementType,
|
|
||||||
priority: formState.priority,
|
|
||||||
effectiveFrom: effectiveFrom ? effectiveFrom.toISOString() : '',
|
|
||||||
effectiveTo: effectiveTo ? effectiveTo.toISOString() : null,
|
|
||||||
targetType: formState.targetType,
|
|
||||||
targetParameters: buildTargetParameters()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditMode.value && props.editData) {
|
|
||||||
// 编辑
|
|
||||||
await tenantAnnouncementApi.update(props.tenantId, props.editData.id, {
|
|
||||||
...payload,
|
|
||||||
rowVersion: props.editData.rowVersion
|
|
||||||
})
|
|
||||||
ElMessage.success(t('tenant.announcement.message.updateSuccess'))
|
|
||||||
} else {
|
|
||||||
// 新建
|
|
||||||
await tenantAnnouncementApi.create(props.tenantId, payload)
|
|
||||||
ElMessage.success(t('tenant.announcement.message.createSuccess'))
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('success')
|
|
||||||
handleClose()
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[AnnouncementFormDialog] 提交失败:', error)
|
|
||||||
ElMessage.error(error.message || t('tenant.announcement.message.submitFailed'))
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 15. 关闭弹窗
|
|
||||||
const handleClose = () => {
|
|
||||||
formRef.value?.resetFields()
|
|
||||||
dialogVisible.value = false
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,746 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="art-page-view">
|
|
||||||
<!-- 1. 顶部操作区 -->
|
|
||||||
<ElCard class="mb-4" shadow="never">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div class="text-lg font-semibold">
|
|
||||||
{{
|
|
||||||
isEditMode ? $t('tenant.announcement.editTitle') : $t('tenant.announcement.createTitle')
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<ElTag v-if="draftStatus" type="info">{{ draftStatus }}</ElTag>
|
|
||||||
<ElButton @click="handleBack">{{ $t('tenant.announcement.action.back') }}</ElButton>
|
|
||||||
<ElButton type="primary" :loading="loading" @click="handleSubmit" v-ripple>
|
|
||||||
{{
|
|
||||||
isEditMode
|
|
||||||
? $t('tenant.announcement.action.update')
|
|
||||||
: $t('tenant.announcement.action.create')
|
|
||||||
}}
|
|
||||||
</ElButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ElCard>
|
|
||||||
|
|
||||||
<ElAlert v-if="errorMessage" type="error" show-icon :title="errorMessage" class="mb-4" />
|
|
||||||
<ElAlert
|
|
||||||
v-else
|
|
||||||
type="info"
|
|
||||||
show-icon
|
|
||||||
:title="$t('tenant.announcement.tip.tenantScope')"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ElAlert
|
|
||||||
v-if="draftLoaded"
|
|
||||||
type="success"
|
|
||||||
show-icon
|
|
||||||
:title="$t('tenant.announcement.tip.draftLoaded')"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 2. 表单区 -->
|
|
||||||
<ElCard shadow="never" v-loading="loading || loadingData">
|
|
||||||
<ElForm ref="formRef" :model="formState" :rules="rules" label-width="110px">
|
|
||||||
<!-- 2.1 基础信息 -->
|
|
||||||
<ElRow :gutter="16">
|
|
||||||
<ElCol :span="12">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.title')" prop="title">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.title"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.title')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="6">
|
|
||||||
<ElFormItem
|
|
||||||
:label="$t('tenant.announcement.field.announcementType')"
|
|
||||||
prop="announcementType"
|
|
||||||
>
|
|
||||||
<ElSelect
|
|
||||||
v-model="formState.announcementType"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.type')"
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
<ElOption
|
|
||||||
v-for="item in typeOptions"
|
|
||||||
:key="item.value"
|
|
||||||
:label="item.label"
|
|
||||||
:value="item.value"
|
|
||||||
/>
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="6">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.priority')" prop="priority">
|
|
||||||
<ElInputNumber
|
|
||||||
v-model="formState.priority"
|
|
||||||
:min="1"
|
|
||||||
:max="10"
|
|
||||||
controls-position="right"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
|
|
||||||
<ElRow :gutter="16">
|
|
||||||
<ElCol :span="12">
|
|
||||||
<ElFormItem
|
|
||||||
:label="$t('tenant.announcement.field.effectiveRange')"
|
|
||||||
prop="effectiveRange"
|
|
||||||
>
|
|
||||||
<ElDatePicker
|
|
||||||
v-model="formState.effectiveRange"
|
|
||||||
type="datetimerange"
|
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.effectiveRange')"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="12">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.targetType')" prop="targetType">
|
|
||||||
<ElSelect
|
|
||||||
v-model="formState.targetType"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.targetType')"
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
<ElOption
|
|
||||||
v-for="item in targetTypeOptions"
|
|
||||||
:key="item.value"
|
|
||||||
:label="item.label"
|
|
||||||
:value="item.value"
|
|
||||||
/>
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
|
|
||||||
<ElDivider class="my-4" />
|
|
||||||
|
|
||||||
<!-- 2.2 公告内容 -->
|
|
||||||
<div class="mb-3 text-sm font-semibold">
|
|
||||||
{{ $t('tenant.announcement.section.content') }}
|
|
||||||
</div>
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.content')" prop="content">
|
|
||||||
<RichTextEditor
|
|
||||||
v-model="formState.content"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.content')"
|
|
||||||
height="320px"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
|
|
||||||
<ElDivider class="my-4" />
|
|
||||||
|
|
||||||
<!-- 2.3 目标受众 -->
|
|
||||||
<div class="mb-3 text-sm font-semibold">
|
|
||||||
{{ $t('tenant.announcement.section.audience') }}
|
|
||||||
</div>
|
|
||||||
<ElRow :gutter="16">
|
|
||||||
<ElCol :span="12" v-if="formState.targetType === 'roles'">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.targetParameters')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.roleIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.roleIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="12" v-if="formState.targetType === 'users'">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.targetParameters')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.userIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.userIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="12" v-if="formState.targetType === 'manual'">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.targetParameters')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.userIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.userIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
|
|
||||||
<ElRow :gutter="16" v-if="formState.targetType === 'rules'">
|
|
||||||
<ElCol :span="8">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.departmentIds')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.departmentIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.departmentIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="8">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.roleIds')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.roleIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.roleIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="8">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.tagIds')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.tagIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.tagIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
</ElForm>
|
|
||||||
</ElCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import {
|
|
||||||
ElAlert,
|
|
||||||
ElButton,
|
|
||||||
ElCard,
|
|
||||||
ElCol,
|
|
||||||
ElDatePicker,
|
|
||||||
ElDivider,
|
|
||||||
ElForm,
|
|
||||||
ElFormItem,
|
|
||||||
ElInput,
|
|
||||||
ElInputNumber,
|
|
||||||
ElMessage,
|
|
||||||
ElOption,
|
|
||||||
ElRow,
|
|
||||||
ElSelect,
|
|
||||||
ElTag,
|
|
||||||
type FormInstance,
|
|
||||||
type FormRules
|
|
||||||
} from 'element-plus'
|
|
||||||
import { useAnnouncementStore } from '@/store/modules/announcement'
|
|
||||||
import { useUserStore } from '@/store/modules/user'
|
|
||||||
import { normalizeAnnouncementStatus } from '@/utils/announcementStatus'
|
|
||||||
import type {
|
|
||||||
AnnouncementFormData,
|
|
||||||
AnnouncementTargetType,
|
|
||||||
TargetRules
|
|
||||||
} from '@/types/announcement'
|
|
||||||
import { TenantAnnouncementType } from '@/types/announcement'
|
|
||||||
import RichTextEditor from '@/components/common/RichTextEditor.vue'
|
|
||||||
import { tenantAnnouncementApi } from '@/api/announcement'
|
|
||||||
import type { TenantAnnouncementDto } from '@/types/announcement'
|
|
||||||
|
|
||||||
defineOptions({ name: 'TenantAnnouncementCreate' })
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const announcementStore = useAnnouncementStore()
|
|
||||||
|
|
||||||
const { loading, currentDraft } = storeToRefs(announcementStore)
|
|
||||||
|
|
||||||
// 1. 租户ID(优先路由参数,其次用户信息)
|
|
||||||
const tenantId = computed(() => {
|
|
||||||
const paramId = route.params.tenantId
|
|
||||||
const queryId = route.query.tenantId
|
|
||||||
const rawId = Array.isArray(paramId)
|
|
||||||
? paramId[0]
|
|
||||||
: paramId || (Array.isArray(queryId) ? queryId[0] : queryId)
|
|
||||||
return String(rawId || userStore.info?.tenantId || '')
|
|
||||||
})
|
|
||||||
|
|
||||||
// 1.5 编辑模式检测与数据加载
|
|
||||||
const announcementId = computed(() => {
|
|
||||||
const routeId = route.params.id
|
|
||||||
return Array.isArray(routeId) ? routeId[0] : routeId
|
|
||||||
})
|
|
||||||
|
|
||||||
const isEditMode = computed(() => !!announcementId.value)
|
|
||||||
const loadingData = ref(false)
|
|
||||||
const currentRowVersion = ref<string | null>(null)
|
|
||||||
|
|
||||||
// 加载公告数据(编辑模式)
|
|
||||||
const loadAnnouncementData = async () => {
|
|
||||||
if (!isEditMode.value || !tenantId.value || !announcementId.value) return
|
|
||||||
|
|
||||||
loadingData.value = true
|
|
||||||
errorMessage.value = null
|
|
||||||
try {
|
|
||||||
const data = await tenantAnnouncementApi.detail(tenantId.value, announcementId.value)
|
|
||||||
|
|
||||||
// 检查是否只有草稿可编辑
|
|
||||||
if (normalizeAnnouncementStatus(data.status) !== 'Draft') {
|
|
||||||
errorMessage.value = t('tenant.announcement.message.onlyDraftEditable')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存 RowVersion
|
|
||||||
currentRowVersion.value = data.rowVersion || null
|
|
||||||
|
|
||||||
// 映射 DTO 到表单数据
|
|
||||||
const dtoToFormData = (dto: TenantAnnouncementDto): Partial<AnnouncementFormData> => {
|
|
||||||
let targetRules: TargetRules | undefined
|
|
||||||
let targetUserIds: string[] | undefined
|
|
||||||
|
|
||||||
// 解析 targetParameters JSON 字符串
|
|
||||||
if (dto.targetParameters) {
|
|
||||||
try {
|
|
||||||
const params = JSON.parse(dto.targetParameters)
|
|
||||||
if (dto.targetType === 'rules') {
|
|
||||||
targetRules = params
|
|
||||||
} else if (dto.targetType === 'users' || dto.targetType === 'manual') {
|
|
||||||
targetUserIds = params.userIds || []
|
|
||||||
} else if (dto.targetType === 'roles') {
|
|
||||||
targetRules = { roles: params.roles || [] }
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse targetParameters:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: dto.title,
|
|
||||||
content: dto.content,
|
|
||||||
announcementType: dto.announcementType,
|
|
||||||
priority: dto.priority,
|
|
||||||
effectiveFrom: dto.effectiveFrom,
|
|
||||||
effectiveTo: dto.effectiveTo,
|
|
||||||
targetType: dto.targetType,
|
|
||||||
targetRules,
|
|
||||||
targetUserIds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyDraftToForm(dtoToFormData(data))
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to load announcement:', error)
|
|
||||||
errorMessage.value =
|
|
||||||
error?.response?.data?.message || t('tenant.announcement.message.loadFailed')
|
|
||||||
} finally {
|
|
||||||
loadingData.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 错误提示与草稿状态
|
|
||||||
const errorMessage = ref<string | null>(null)
|
|
||||||
const draftLoaded = ref(false)
|
|
||||||
|
|
||||||
const draftStatus = computed(() => {
|
|
||||||
if (!currentDraft.value?.lastSaved) {
|
|
||||||
return t('tenant.announcement.tip.draftAutoSave')
|
|
||||||
}
|
|
||||||
return t('tenant.announcement.tip.draftSaved', {
|
|
||||||
time: formatDateTime(currentDraft.value.lastSaved)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. 表单数据(UI 形态)
|
|
||||||
interface AnnouncementFormState {
|
|
||||||
title: string
|
|
||||||
content: string
|
|
||||||
announcementType: TenantAnnouncementType
|
|
||||||
priority: number
|
|
||||||
effectiveRange: string[]
|
|
||||||
targetType: AnnouncementTargetType
|
|
||||||
roleIds: string
|
|
||||||
userIds: string
|
|
||||||
departmentIds: string
|
|
||||||
tagIds: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const formState = reactive<AnnouncementFormState>({
|
|
||||||
title: '',
|
|
||||||
content: '',
|
|
||||||
announcementType: TenantAnnouncementType.TENANT_INTERNAL,
|
|
||||||
priority: 3,
|
|
||||||
effectiveRange: [],
|
|
||||||
targetType: 'all',
|
|
||||||
roleIds: '',
|
|
||||||
userIds: '',
|
|
||||||
departmentIds: '',
|
|
||||||
tagIds: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 4. 草稿数据(对接 Store)
|
|
||||||
const draftState = reactive<AnnouncementFormData>({
|
|
||||||
title: '',
|
|
||||||
content: '',
|
|
||||||
announcementType: TenantAnnouncementType.TENANT_INTERNAL,
|
|
||||||
priority: 3,
|
|
||||||
effectiveFrom: '',
|
|
||||||
effectiveTo: null,
|
|
||||||
targetType: 'all'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 5. 选项数据
|
|
||||||
const typeOptions = computed(() => [
|
|
||||||
{ label: t('tenant.announcement.type.system'), value: TenantAnnouncementType.System },
|
|
||||||
{ label: t('tenant.announcement.type.billing'), value: TenantAnnouncementType.Billing },
|
|
||||||
{ label: t('tenant.announcement.type.operation'), value: TenantAnnouncementType.Operation },
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.systemPlatformUpdate'),
|
|
||||||
value: TenantAnnouncementType.SYSTEM_PLATFORM_UPDATE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.systemSecurityNotice'),
|
|
||||||
value: TenantAnnouncementType.SYSTEM_SECURITY_NOTICE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.systemCompliance'),
|
|
||||||
value: TenantAnnouncementType.SYSTEM_COMPLIANCE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantInternal'),
|
|
||||||
value: TenantAnnouncementType.TENANT_INTERNAL
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantFinance'),
|
|
||||||
value: TenantAnnouncementType.TENANT_FINANCE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantOperation'),
|
|
||||||
value: TenantAnnouncementType.TENANT_OPERATION
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const targetTypeOptions = computed(() => [
|
|
||||||
{ label: t('tenant.announcement.targetType.all'), value: 'all' },
|
|
||||||
{ label: t('tenant.announcement.targetType.roles'), value: 'roles' },
|
|
||||||
{ label: t('tenant.announcement.targetType.users'), value: 'users' },
|
|
||||||
{ label: t('tenant.announcement.targetType.rules'), value: 'rules' },
|
|
||||||
{ label: t('tenant.announcement.targetType.manual'), value: 'manual' }
|
|
||||||
])
|
|
||||||
|
|
||||||
// 6. 表单验证
|
|
||||||
const rules: FormRules = {
|
|
||||||
title: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('tenant.announcement.validation.titleRequired'),
|
|
||||||
trigger: 'blur'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
min: 2,
|
|
||||||
max: 128,
|
|
||||||
message: t('tenant.announcement.validation.titleLength'),
|
|
||||||
trigger: 'blur'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('tenant.announcement.validation.contentRequired'),
|
|
||||||
trigger: 'blur'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
announcementType: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('tenant.announcement.validation.typeRequired'),
|
|
||||||
trigger: 'change'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
priority: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('tenant.announcement.validation.priorityRequired'),
|
|
||||||
trigger: 'change'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
effectiveRange: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
type: 'array',
|
|
||||||
min: 2,
|
|
||||||
message: t('tenant.announcement.validation.effectiveRangeRequired'),
|
|
||||||
trigger: 'change'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
targetType: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('tenant.announcement.validation.targetTypeRequired'),
|
|
||||||
trigger: 'change'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const formRef = ref<FormInstance>()
|
|
||||||
|
|
||||||
// 7. 时间处理
|
|
||||||
const formatDateTime = (value?: string | null) => {
|
|
||||||
if (!value) return '-'
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) return '-'
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const toIsoDateTime = (value: string) => {
|
|
||||||
if (!value) return ''
|
|
||||||
const normalized = value.includes('T') ? value : value.replace(' ', 'T')
|
|
||||||
const date = new Date(normalized)
|
|
||||||
if (Number.isNaN(date.getTime())) return ''
|
|
||||||
return date.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDateTimeInput = (value?: string | null) => {
|
|
||||||
if (!value) return ''
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) return ''
|
|
||||||
const pad = (num: number) => String(num).padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
|
||||||
date.getHours()
|
|
||||||
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. 目标受众处理
|
|
||||||
const splitIds = (value: string) => {
|
|
||||||
return value
|
|
||||||
.split(/[,,\s]+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildTargetRules = () => {
|
|
||||||
if (formState.targetType === 'roles') {
|
|
||||||
return { roles: splitIds(formState.roleIds) }
|
|
||||||
}
|
|
||||||
if (formState.targetType === 'rules') {
|
|
||||||
return {
|
|
||||||
departments: splitIds(formState.departmentIds),
|
|
||||||
roles: splitIds(formState.roleIds),
|
|
||||||
tags: splitIds(formState.tagIds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildTargetUserIds = () => {
|
|
||||||
if (formState.targetType === 'users' || formState.targetType === 'manual') {
|
|
||||||
return splitIds(formState.userIds)
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateTargetInputs = () => {
|
|
||||||
if (formState.targetType === 'roles' && splitIds(formState.roleIds).length === 0) {
|
|
||||||
ElMessage.warning(t('tenant.announcement.validation.roleIdsRequired'))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(formState.targetType === 'users' || formState.targetType === 'manual') &&
|
|
||||||
splitIds(formState.userIds).length === 0
|
|
||||||
) {
|
|
||||||
ElMessage.warning(t('tenant.announcement.validation.userIdsRequired'))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formState.targetType === 'rules') {
|
|
||||||
const hasRule =
|
|
||||||
splitIds(formState.departmentIds).length > 0 ||
|
|
||||||
splitIds(formState.roleIds).length > 0 ||
|
|
||||||
splitIds(formState.tagIds).length > 0
|
|
||||||
if (!hasRule) {
|
|
||||||
ElMessage.warning(t('tenant.announcement.validation.targetRuleRequired'))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. 同步草稿数据
|
|
||||||
const syncDraftState = () => {
|
|
||||||
// 1. 同步基础字段
|
|
||||||
const [start, end] = formState.effectiveRange
|
|
||||||
draftState.title = formState.title
|
|
||||||
draftState.content = formState.content
|
|
||||||
draftState.announcementType = formState.announcementType
|
|
||||||
draftState.priority = formState.priority
|
|
||||||
|
|
||||||
// 2. 同步有效期
|
|
||||||
draftState.effectiveFrom = toIsoDateTime(start)
|
|
||||||
draftState.effectiveTo = end ? toIsoDateTime(end) : null
|
|
||||||
|
|
||||||
// 3. 同步受众参数
|
|
||||||
draftState.targetType = formState.targetType
|
|
||||||
draftState.targetRules = buildTargetRules()
|
|
||||||
draftState.targetUserIds = buildTargetUserIds()
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyDraftToForm = (draft: Partial<AnnouncementFormData>) => {
|
|
||||||
// 1. 回填基础字段
|
|
||||||
formState.title = draft.title || ''
|
|
||||||
formState.content = draft.content || ''
|
|
||||||
formState.announcementType = draft.announcementType ?? TenantAnnouncementType.TENANT_INTERNAL
|
|
||||||
formState.priority = draft.priority ?? 3
|
|
||||||
formState.targetType = draft.targetType ?? 'all'
|
|
||||||
|
|
||||||
// 2. 回填有效期
|
|
||||||
const start = formatDateTimeInput(draft.effectiveFrom)
|
|
||||||
const end = formatDateTimeInput(draft.effectiveTo ?? null)
|
|
||||||
formState.effectiveRange = start && end ? [start, end] : []
|
|
||||||
|
|
||||||
// 3. 回填受众参数
|
|
||||||
formState.roleIds = draft.targetRules?.roles?.join(',') || ''
|
|
||||||
formState.departmentIds = draft.targetRules?.departments?.join(',') || ''
|
|
||||||
formState.tagIds = draft.targetRules?.tags?.join(',') || ''
|
|
||||||
formState.userIds = draft.targetUserIds?.join(',') || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10. 草稿自动保存
|
|
||||||
const stopAutoSave = ref<(() => void) | null>(null)
|
|
||||||
|
|
||||||
const initDraft = () => {
|
|
||||||
// 1. 同步草稿数据
|
|
||||||
syncDraftState()
|
|
||||||
|
|
||||||
// 2. 若无草稿则先写入本地
|
|
||||||
if (!currentDraft.value) {
|
|
||||||
announcementStore.saveDraft(draftState)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 启动自动保存
|
|
||||||
stopAutoSave.value = announcementStore.autoSaveDraft(draftState, 30000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 11. 提交创建/更新
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!formRef.value) return
|
|
||||||
|
|
||||||
if (!tenantId.value) {
|
|
||||||
ElMessage.error(t('tenant.announcement.message.tenantIdMissing'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 表单校验
|
|
||||||
await formRef.value.validate()
|
|
||||||
if (!validateTargetInputs()) return
|
|
||||||
|
|
||||||
// 2. 组装请求参数
|
|
||||||
syncDraftState()
|
|
||||||
const payload: AnnouncementFormData = {
|
|
||||||
...draftState,
|
|
||||||
title: formState.title.trim(),
|
|
||||||
content: formState.content.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 编辑模式:包含 RowVersion
|
|
||||||
if (isEditMode.value && announcementId.value) {
|
|
||||||
if (currentRowVersion.value) {
|
|
||||||
payload.rowVersion = currentRowVersion.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await tenantAnnouncementApi.update(
|
|
||||||
tenantId.value,
|
|
||||||
announcementId.value,
|
|
||||||
payload
|
|
||||||
)
|
|
||||||
ElMessage.success(t('tenant.announcement.message.updateSuccess'))
|
|
||||||
|
|
||||||
// 跳转详情页
|
|
||||||
router.push({
|
|
||||||
path: `${listPath.value}/${result.id}`,
|
|
||||||
query: tenantId.value ? { tenantId: tenantId.value } : undefined
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 创建模式
|
|
||||||
const result = await announcementStore.createAnnouncement(payload, {
|
|
||||||
scope: 'tenant',
|
|
||||||
tenantId: tenantId.value
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
ElMessage.success(t('tenant.announcement.message.createSuccess'))
|
|
||||||
|
|
||||||
// 5. 清理草稿
|
|
||||||
if (currentDraft.value?.draftId) {
|
|
||||||
announcementStore.clearDraft(currentDraft.value.draftId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 跳转详情页
|
|
||||||
router.push({
|
|
||||||
path: `${listPath.value}/${result.id}`,
|
|
||||||
query: tenantId.value ? { tenantId: tenantId.value } : undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error)
|
|
||||||
// 处理并发冲突
|
|
||||||
if (error?.response?.status === 409) {
|
|
||||||
ElMessage.error(t('tenant.announcement.message.concurrentConflict'))
|
|
||||||
} else {
|
|
||||||
ElMessage.error(
|
|
||||||
error?.response?.data?.message || t('tenant.announcement.message.operationFailed')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 12. 返回列表
|
|
||||||
const listPath = computed(() => route.path.replace(/\/(create|edit\/[^/]+)$/, ''))
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
router.push({
|
|
||||||
path: listPath.value,
|
|
||||||
query: tenantId.value ? { tenantId: tenantId.value } : undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 13. 初始化草稿与错误提示
|
|
||||||
watch(
|
|
||||||
tenantId,
|
|
||||||
(value) => {
|
|
||||||
if (!value) {
|
|
||||||
errorMessage.value = t('tenant.announcement.message.tenantIdMissing')
|
|
||||||
} else {
|
|
||||||
errorMessage.value = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
formState,
|
|
||||||
() => {
|
|
||||||
// 1. 表单变化时同步草稿数据
|
|
||||||
syncDraftState()
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const draftIdFromRoute = computed(() => {
|
|
||||||
const draftId = route.query.draftId
|
|
||||||
return Array.isArray(draftId) ? draftId[0] : draftId
|
|
||||||
})
|
|
||||||
|
|
||||||
// 14. 初始化逻辑
|
|
||||||
if (isEditMode.value) {
|
|
||||||
// 编辑模式:加载公告数据
|
|
||||||
loadAnnouncementData()
|
|
||||||
} else if (draftIdFromRoute.value) {
|
|
||||||
// 创建模式:加载草稿
|
|
||||||
const draft = announcementStore.loadDraft(String(draftIdFromRoute.value))
|
|
||||||
if (draft) {
|
|
||||||
applyDraftToForm(draft)
|
|
||||||
draftLoaded.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建模式下初始化草稿自动保存
|
|
||||||
if (!isEditMode.value) {
|
|
||||||
initDraft()
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
// 1. 组件卸载时停止自动保存
|
|
||||||
stopAutoSave.value?.()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,621 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="art-page-view">
|
|
||||||
<!-- 1. 顶部操作区 -->
|
|
||||||
<ElCard class="mb-4" shadow="never">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="text-lg font-semibold">{{ $t('tenant.announcement.editTitle') }}</div>
|
|
||||||
<ElTag v-if="detail" :type="getStatusTagType(detail.status)">
|
|
||||||
{{ getStatusText(detail.status) }}
|
|
||||||
</ElTag>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<ElButton @click="handleBack">{{ $t('tenant.announcement.action.back') }}</ElButton>
|
|
||||||
<ElButton
|
|
||||||
type="primary"
|
|
||||||
:loading="loading"
|
|
||||||
:disabled="!isDraft"
|
|
||||||
@click="handleSubmit"
|
|
||||||
v-ripple
|
|
||||||
>
|
|
||||||
{{ $t('tenant.announcement.action.save') }}
|
|
||||||
</ElButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ElCard>
|
|
||||||
|
|
||||||
<ElAlert v-if="errorMessage" type="error" show-icon :title="errorMessage" class="mb-4" />
|
|
||||||
<ElAlert
|
|
||||||
v-if="!isDraft && detail"
|
|
||||||
type="warning"
|
|
||||||
show-icon
|
|
||||||
:title="$t('tenant.announcement.tip.draftNotEditable')"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 2. 表单区 -->
|
|
||||||
<ElCard shadow="never" v-loading="loading">
|
|
||||||
<ElForm ref="formRef" :model="formState" :rules="rules" label-width="110px">
|
|
||||||
<!-- 2.1 基础信息(部分字段只读) -->
|
|
||||||
<ElRow :gutter="16">
|
|
||||||
<ElCol :span="12">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.title')" prop="title">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.title"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.title')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="6">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.announcementType')">
|
|
||||||
<ElSelect v-model="formState.announcementType" disabled class="w-full">
|
|
||||||
<ElOption
|
|
||||||
v-for="item in typeOptions"
|
|
||||||
:key="item.value"
|
|
||||||
:label="item.label"
|
|
||||||
:value="item.value"
|
|
||||||
/>
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="6">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.priority')">
|
|
||||||
<ElInputNumber
|
|
||||||
v-model="formState.priority"
|
|
||||||
:min="1"
|
|
||||||
:max="10"
|
|
||||||
disabled
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
|
|
||||||
<ElRow :gutter="16">
|
|
||||||
<ElCol :span="12">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.effectiveRange')">
|
|
||||||
<ElDatePicker
|
|
||||||
v-model="formState.effectiveRange"
|
|
||||||
type="datetimerange"
|
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
|
||||||
disabled
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="12">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.targetType')" prop="targetType">
|
|
||||||
<ElSelect
|
|
||||||
v-model="formState.targetType"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.targetType')"
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
<ElOption
|
|
||||||
v-for="item in targetTypeOptions"
|
|
||||||
:key="item.value"
|
|
||||||
:label="item.label"
|
|
||||||
:value="item.value"
|
|
||||||
/>
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
|
|
||||||
<ElDivider class="my-4" />
|
|
||||||
|
|
||||||
<!-- 2.2 公告内容 -->
|
|
||||||
<div class="mb-3 text-sm font-semibold">
|
|
||||||
{{ $t('tenant.announcement.section.content') }}
|
|
||||||
</div>
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.content')" prop="content">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.content"
|
|
||||||
type="textarea"
|
|
||||||
:rows="8"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.content')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
|
|
||||||
<ElDivider class="my-4" />
|
|
||||||
|
|
||||||
<!-- 2.3 目标受众 -->
|
|
||||||
<div class="mb-3 text-sm font-semibold">
|
|
||||||
{{ $t('tenant.announcement.section.audience') }}
|
|
||||||
</div>
|
|
||||||
<ElRow :gutter="16">
|
|
||||||
<ElCol :span="12" v-if="formState.targetType === 'roles'">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.targetParameters')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.roleIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.roleIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="12" v-if="formState.targetType === 'users'">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.targetParameters')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.userIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.userIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="12" v-if="formState.targetType === 'manual'">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.targetParameters')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.userIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.userIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
|
|
||||||
<ElRow :gutter="16" v-if="formState.targetType === 'rules'">
|
|
||||||
<ElCol :span="8">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.departmentIds')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.departmentIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.departmentIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="8">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.roleIds')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.roleIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.roleIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="8">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.field.tagIds')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formState.tagIds"
|
|
||||||
:placeholder="$t('tenant.announcement.placeholder.tagIds')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
</ElForm>
|
|
||||||
</ElCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import {
|
|
||||||
ElAlert,
|
|
||||||
ElButton,
|
|
||||||
ElCard,
|
|
||||||
ElCol,
|
|
||||||
ElDatePicker,
|
|
||||||
ElDivider,
|
|
||||||
ElForm,
|
|
||||||
ElFormItem,
|
|
||||||
ElInput,
|
|
||||||
ElInputNumber,
|
|
||||||
ElMessage,
|
|
||||||
ElOption,
|
|
||||||
ElRow,
|
|
||||||
ElSelect,
|
|
||||||
ElTag,
|
|
||||||
type FormInstance,
|
|
||||||
type FormRules
|
|
||||||
} from 'element-plus'
|
|
||||||
import { useAnnouncementStore } from '@/store/modules/announcement'
|
|
||||||
import { useUserStore } from '@/store/modules/user'
|
|
||||||
import { isHttpError } from '@/utils/http/error'
|
|
||||||
import { ApiStatus } from '@/utils/http/status'
|
|
||||||
import { normalizeAnnouncementStatus } from '@/utils/announcementStatus'
|
|
||||||
import type {
|
|
||||||
AnnouncementFormData,
|
|
||||||
AnnouncementTargetType,
|
|
||||||
TenantAnnouncementDto
|
|
||||||
} from '@/types/announcement'
|
|
||||||
import { TenantAnnouncementType } from '@/types/announcement'
|
|
||||||
|
|
||||||
defineOptions({ name: 'TenantAnnouncementEdit' })
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const announcementStore = useAnnouncementStore()
|
|
||||||
|
|
||||||
const { loading } = storeToRefs(announcementStore)
|
|
||||||
|
|
||||||
// 1. 路由参数
|
|
||||||
const tenantId = computed(() => {
|
|
||||||
const paramId = route.params.tenantId
|
|
||||||
const queryId = route.query.tenantId
|
|
||||||
const rawId = Array.isArray(paramId)
|
|
||||||
? paramId[0]
|
|
||||||
: paramId || (Array.isArray(queryId) ? queryId[0] : queryId)
|
|
||||||
return String(rawId || userStore.info?.tenantId || '')
|
|
||||||
})
|
|
||||||
|
|
||||||
const announcementId = computed(() => {
|
|
||||||
const paramId = route.params.announcementId || route.params.id
|
|
||||||
const queryId = route.query.announcementId
|
|
||||||
const rawId = Array.isArray(paramId)
|
|
||||||
? paramId[0]
|
|
||||||
: paramId || (Array.isArray(queryId) ? queryId[0] : queryId)
|
|
||||||
return String(rawId || '')
|
|
||||||
})
|
|
||||||
|
|
||||||
// 2. 详情数据
|
|
||||||
const detail = ref<TenantAnnouncementDto | null>(null)
|
|
||||||
const errorMessage = ref<string | null>(null)
|
|
||||||
const rowVersion = ref('')
|
|
||||||
|
|
||||||
// 2.5 状态判断
|
|
||||||
const resolveStatus = (status: unknown) => normalizeAnnouncementStatus(status)
|
|
||||||
const isDraft = computed(() => resolveStatus(detail.value?.status) === 'Draft')
|
|
||||||
|
|
||||||
// 3. 表单数据
|
|
||||||
interface AnnouncementFormState {
|
|
||||||
title: string
|
|
||||||
content: string
|
|
||||||
announcementType: TenantAnnouncementType
|
|
||||||
priority: number
|
|
||||||
effectiveRange: string[]
|
|
||||||
targetType: AnnouncementTargetType
|
|
||||||
roleIds: string
|
|
||||||
userIds: string
|
|
||||||
departmentIds: string
|
|
||||||
tagIds: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const formState = reactive<AnnouncementFormState>({
|
|
||||||
title: '',
|
|
||||||
content: '',
|
|
||||||
announcementType: TenantAnnouncementType.TENANT_INTERNAL,
|
|
||||||
priority: 3,
|
|
||||||
effectiveRange: [],
|
|
||||||
targetType: 'all',
|
|
||||||
roleIds: '',
|
|
||||||
userIds: '',
|
|
||||||
departmentIds: '',
|
|
||||||
tagIds: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 4. 选项数据
|
|
||||||
const typeOptions = computed(() => [
|
|
||||||
{ label: t('tenant.announcement.type.system'), value: TenantAnnouncementType.System },
|
|
||||||
{ label: t('tenant.announcement.type.billing'), value: TenantAnnouncementType.Billing },
|
|
||||||
{ label: t('tenant.announcement.type.operation'), value: TenantAnnouncementType.Operation },
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.systemPlatformUpdate'),
|
|
||||||
value: TenantAnnouncementType.SYSTEM_PLATFORM_UPDATE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.systemSecurityNotice'),
|
|
||||||
value: TenantAnnouncementType.SYSTEM_SECURITY_NOTICE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.systemCompliance'),
|
|
||||||
value: TenantAnnouncementType.SYSTEM_COMPLIANCE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantInternal'),
|
|
||||||
value: TenantAnnouncementType.TENANT_INTERNAL
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantFinance'),
|
|
||||||
value: TenantAnnouncementType.TENANT_FINANCE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantOperation'),
|
|
||||||
value: TenantAnnouncementType.TENANT_OPERATION
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const targetTypeOptions = computed(() => [
|
|
||||||
{ label: t('tenant.announcement.targetType.all'), value: 'all' },
|
|
||||||
{ label: t('tenant.announcement.targetType.roles'), value: 'roles' },
|
|
||||||
{ label: t('tenant.announcement.targetType.users'), value: 'users' },
|
|
||||||
{ label: t('tenant.announcement.targetType.rules'), value: 'rules' },
|
|
||||||
{ label: t('tenant.announcement.targetType.manual'), value: 'manual' }
|
|
||||||
])
|
|
||||||
|
|
||||||
// 5. 表单验证
|
|
||||||
const rules: FormRules = {
|
|
||||||
title: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('tenant.announcement.validation.titleRequired'),
|
|
||||||
trigger: 'blur'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
min: 2,
|
|
||||||
max: 128,
|
|
||||||
message: t('tenant.announcement.validation.titleLength'),
|
|
||||||
trigger: 'blur'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('tenant.announcement.validation.contentRequired'),
|
|
||||||
trigger: 'blur'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
targetType: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('tenant.announcement.validation.targetTypeRequired'),
|
|
||||||
trigger: 'change'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const formRef = ref<FormInstance>()
|
|
||||||
|
|
||||||
// 6. 时间处理
|
|
||||||
const formatDateTimeInput = (value?: string | null) => {
|
|
||||||
if (!value) return ''
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) return ''
|
|
||||||
const pad = (num: number) => String(num).padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
|
||||||
date.getHours()
|
|
||||||
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. 状态显示
|
|
||||||
const getStatusText = (status: TenantAnnouncementDto['status']) => {
|
|
||||||
const map = {
|
|
||||||
Draft: t('tenant.announcement.status.draft'),
|
|
||||||
Published: t('tenant.announcement.status.published'),
|
|
||||||
Revoked: t('tenant.announcement.status.revoked')
|
|
||||||
} as const
|
|
||||||
const normalized = resolveStatus(status)
|
|
||||||
return normalized ? map[normalized] : t('tenant.announcement.status.unknown')
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusTagType = (status: TenantAnnouncementDto['status']) => {
|
|
||||||
const normalized = resolveStatus(status)
|
|
||||||
switch (normalized) {
|
|
||||||
case 'Draft':
|
|
||||||
return 'info'
|
|
||||||
case 'Published':
|
|
||||||
return 'success'
|
|
||||||
case 'Revoked':
|
|
||||||
return 'danger'
|
|
||||||
default:
|
|
||||||
return 'info'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. 目标受众处理
|
|
||||||
const splitIds = (value: string) => {
|
|
||||||
return value
|
|
||||||
.split(/[,,\s]+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildTargetRules = () => {
|
|
||||||
if (formState.targetType === 'roles') {
|
|
||||||
return { roles: splitIds(formState.roleIds) }
|
|
||||||
}
|
|
||||||
if (formState.targetType === 'rules') {
|
|
||||||
return {
|
|
||||||
departments: splitIds(formState.departmentIds),
|
|
||||||
roles: splitIds(formState.roleIds),
|
|
||||||
tags: splitIds(formState.tagIds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildTargetUserIds = () => {
|
|
||||||
if (formState.targetType === 'users' || formState.targetType === 'manual') {
|
|
||||||
return splitIds(formState.userIds)
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateTargetInputs = () => {
|
|
||||||
if (formState.targetType === 'roles' && splitIds(formState.roleIds).length === 0) {
|
|
||||||
ElMessage.warning(t('tenant.announcement.validation.roleIdsRequired'))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(formState.targetType === 'users' || formState.targetType === 'manual') &&
|
|
||||||
splitIds(formState.userIds).length === 0
|
|
||||||
) {
|
|
||||||
ElMessage.warning(t('tenant.announcement.validation.userIdsRequired'))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formState.targetType === 'rules') {
|
|
||||||
const hasRule =
|
|
||||||
splitIds(formState.departmentIds).length > 0 ||
|
|
||||||
splitIds(formState.roleIds).length > 0 ||
|
|
||||||
splitIds(formState.tagIds).length > 0
|
|
||||||
if (!hasRule) {
|
|
||||||
ElMessage.warning(t('tenant.announcement.validation.targetRuleRequired'))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyTargetParameters = (targetType: AnnouncementTargetType, raw?: string | null) => {
|
|
||||||
if (!raw) return
|
|
||||||
try {
|
|
||||||
// 1. 解析 JSON 目标参数
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (targetType === 'users' || targetType === 'manual') {
|
|
||||||
const ids = Array.isArray(parsed) ? parsed : parsed.userIds
|
|
||||||
formState.userIds = Array.isArray(ids) ? ids.join(',') : ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 规则/角色模式映射字段
|
|
||||||
if (targetType === 'roles' || targetType === 'rules') {
|
|
||||||
formState.roleIds = Array.isArray(parsed.roles) ? parsed.roles.join(',') : ''
|
|
||||||
formState.departmentIds = Array.isArray(parsed.departments)
|
|
||||||
? parsed.departments.join(',')
|
|
||||||
: ''
|
|
||||||
formState.tagIds = Array.isArray(parsed.tags) ? parsed.tags.join(',') : ''
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 3. 解析失败时回填原始字符串
|
|
||||||
if (targetType === 'users' || targetType === 'manual') {
|
|
||||||
formState.userIds = raw
|
|
||||||
} else {
|
|
||||||
formState.roleIds = raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. 填充详情数据
|
|
||||||
const applyDetail = (data: TenantAnnouncementDto) => {
|
|
||||||
// 1. 更新详情与并发版本
|
|
||||||
detail.value = data
|
|
||||||
rowVersion.value = data.rowVersion
|
|
||||||
|
|
||||||
// 2. 回填基础字段
|
|
||||||
formState.title = data.title
|
|
||||||
formState.content = data.content
|
|
||||||
formState.announcementType = data.announcementType
|
|
||||||
formState.priority = data.priority
|
|
||||||
formState.targetType = data.targetType
|
|
||||||
|
|
||||||
// 3. 回填有效期
|
|
||||||
const start = formatDateTimeInput(data.effectiveFrom)
|
|
||||||
const end = formatDateTimeInput(data.effectiveTo ?? null)
|
|
||||||
formState.effectiveRange = start && end ? [start, end] : []
|
|
||||||
|
|
||||||
// 4. 回填受众参数
|
|
||||||
formState.roleIds = ''
|
|
||||||
formState.userIds = ''
|
|
||||||
formState.departmentIds = ''
|
|
||||||
formState.tagIds = ''
|
|
||||||
applyTargetParameters(data.targetType, data.targetParameters)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveDetailErrorMessage = (error: unknown) => {
|
|
||||||
if (isHttpError(error)) {
|
|
||||||
if (error.code === ApiStatus.notFound) {
|
|
||||||
return t('tenant.announcement.message.detailNotFound')
|
|
||||||
}
|
|
||||||
return error.message
|
|
||||||
}
|
|
||||||
if (error instanceof Error && error.message) {
|
|
||||||
return error.message
|
|
||||||
}
|
|
||||||
return t('tenant.announcement.message.loadDetailFailed')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10. 拉取详情
|
|
||||||
const loadDetail = async () => {
|
|
||||||
// 1. 校验租户与公告ID
|
|
||||||
if (!tenantId.value) {
|
|
||||||
errorMessage.value = t('tenant.announcement.message.tenantIdMissing')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!announcementId.value) {
|
|
||||||
errorMessage.value = t('tenant.announcement.message.loadDetailFailed')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 清理错误并拉取详情
|
|
||||||
errorMessage.value = null
|
|
||||||
try {
|
|
||||||
const result = await announcementStore.fetchAnnouncementDetail(announcementId.value, {
|
|
||||||
scope: 'tenant',
|
|
||||||
tenantId: tenantId.value,
|
|
||||||
silent: true
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. 同步详情数据
|
|
||||||
if (!result) {
|
|
||||||
errorMessage.value = t('tenant.announcement.message.detailNotFound')
|
|
||||||
detail.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
applyDetail(result)
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = resolveDetailErrorMessage(error)
|
|
||||||
detail.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 11. 提交更新
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!formRef.value) return
|
|
||||||
|
|
||||||
if (!tenantId.value) {
|
|
||||||
ElMessage.error(t('tenant.announcement.message.tenantIdMissing'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!announcementId.value) {
|
|
||||||
ElMessage.error(t('tenant.announcement.message.loadDetailFailed'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isDraft.value) {
|
|
||||||
ElMessage.warning(t('tenant.announcement.tip.draftNotEditable'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 表单校验
|
|
||||||
await formRef.value.validate()
|
|
||||||
if (!validateTargetInputs()) return
|
|
||||||
|
|
||||||
// 2. 组装更新参数(仅传可编辑字段)
|
|
||||||
const payload: AnnouncementFormData = {
|
|
||||||
title: formState.title.trim(),
|
|
||||||
content: formState.content.trim(),
|
|
||||||
announcementType: formState.announcementType,
|
|
||||||
priority: formState.priority,
|
|
||||||
effectiveFrom: detail.value?.effectiveFrom || '',
|
|
||||||
effectiveTo: detail.value?.effectiveTo ?? null,
|
|
||||||
targetType: formState.targetType,
|
|
||||||
targetRules: buildTargetRules(),
|
|
||||||
targetUserIds: buildTargetUserIds(),
|
|
||||||
rowVersion: rowVersion.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await announcementStore.updateAnnouncement(announcementId.value, payload, {
|
|
||||||
scope: 'tenant',
|
|
||||||
tenantId: tenantId.value
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
ElMessage.success(t('tenant.announcement.message.updateSuccess'))
|
|
||||||
applyDetail(result)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 12. 返回列表
|
|
||||||
const handleBack = () => {
|
|
||||||
router.push({
|
|
||||||
name: 'TenantAnnouncementList',
|
|
||||||
query: tenantId.value ? { tenantId: tenantId.value } : undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 13. 监听路由变化加载数据
|
|
||||||
watch(
|
|
||||||
[tenantId, announcementId],
|
|
||||||
() => {
|
|
||||||
void loadDetail()
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
@@ -1,698 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="art-page-view">
|
|
||||||
<!-- 1. 筛选区 -->
|
|
||||||
<ElCard v-if="showSearchBar" class="art-search-card" shadow="never">
|
|
||||||
<ElForm :model="formFilters" label-width="80px">
|
|
||||||
<ElRow :gutter="16">
|
|
||||||
<ElCol :span="6">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.search.status')">
|
|
||||||
<ElSelect
|
|
||||||
v-model="formFilters.status"
|
|
||||||
:placeholder="$t('tenant.announcement.search.statusPlaceholder')"
|
|
||||||
clearable
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
<ElOption
|
|
||||||
v-for="item in statusOptions"
|
|
||||||
:key="item.value"
|
|
||||||
:label="item.label"
|
|
||||||
:value="item.value"
|
|
||||||
/>
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="6">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.search.keyword')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formFilters.keyword"
|
|
||||||
:placeholder="$t('tenant.announcement.search.keywordPlaceholder')"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="8">
|
|
||||||
<ElFormItem :label="$t('tenant.announcement.search.dateRange')">
|
|
||||||
<ElDatePicker
|
|
||||||
v-model="formFilters.dateRange"
|
|
||||||
type="daterange"
|
|
||||||
value-format="YYYY-MM-DD"
|
|
||||||
:placeholder="$t('tenant.announcement.search.dateRangePlaceholder')"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="4" class="text-right">
|
|
||||||
<ElButton @click="handleReset">{{ $t('table.searchBar.reset') }}</ElButton>
|
|
||||||
<ElButton type="primary" @click="handleSearch" v-ripple>
|
|
||||||
{{ $t('table.searchBar.search') }}
|
|
||||||
</ElButton>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
</ElForm>
|
|
||||||
</ElCard>
|
|
||||||
|
|
||||||
<ElAlert v-if="errorMessage" type="error" show-icon :title="errorMessage" class="mb-4" />
|
|
||||||
|
|
||||||
<!-- 2. 表格区 -->
|
|
||||||
<ElCard class="art-table-card" shadow="never">
|
|
||||||
<ArtTableHeader
|
|
||||||
v-model:columns="columns"
|
|
||||||
v-model:showSearchBar="showSearchBar"
|
|
||||||
:loading="loading"
|
|
||||||
@refresh="refreshData"
|
|
||||||
>
|
|
||||||
<template #left>
|
|
||||||
<ElButton type="primary" @click="handleCreate" v-ripple>
|
|
||||||
<ArtSvgIcon icon="ri:add-line" />
|
|
||||||
{{ $t('tenant.announcement.action.create') }}
|
|
||||||
</ElButton>
|
|
||||||
</template>
|
|
||||||
</ArtTableHeader>
|
|
||||||
|
|
||||||
<ArtTable
|
|
||||||
:data="announcements"
|
|
||||||
:columns="columns"
|
|
||||||
:loading="loading"
|
|
||||||
:pagination="tablePagination"
|
|
||||||
row-key="id"
|
|
||||||
:empty-text="$t('tenant.announcement.message.empty')"
|
|
||||||
@pagination:current-change="handleCurrentChange"
|
|
||||||
@pagination:size-change="handleSizeChange"
|
|
||||||
>
|
|
||||||
<template #announcementType="{ row }">
|
|
||||||
<ElTag type="info">{{ getTypeText(row.announcementType) }}</ElTag>
|
|
||||||
</template>
|
|
||||||
<template #priority="{ row }">
|
|
||||||
<ElTag type="warning">{{ row.priority }}</ElTag>
|
|
||||||
</template>
|
|
||||||
<template #status="{ row }">
|
|
||||||
<ElTag :type="getStatusTagType(row.status)">{{ getStatusText(row.status) }}</ElTag>
|
|
||||||
</template>
|
|
||||||
<template #effectiveRange="{ row }">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span>{{ formatDateTime(row.effectiveFrom) }}</span>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
{{ row.effectiveTo ? formatDateTime(row.effectiveTo) : '-' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #targetType="{ row }">
|
|
||||||
{{ getTargetTypeText(row.targetType) }}
|
|
||||||
</template>
|
|
||||||
<template #publishedAt="{ row }">
|
|
||||||
{{ formatDateTime(row.publishedAt) }}
|
|
||||||
</template>
|
|
||||||
<template #action="{ row }">
|
|
||||||
<div class="action-wrap">
|
|
||||||
<button class="art-action-btn" @click="handleDetail(row)">
|
|
||||||
<ArtSvgIcon icon="ri:information-line" class="art-action-icon" />
|
|
||||||
<span>{{ t('dictionary.common.detail') }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<template v-if="resolveStatus(row.status) === 'Draft'">
|
|
||||||
<button class="art-action-btn primary" @click="handleEdit(row)">
|
|
||||||
<ArtSvgIcon icon="ri:edit-2-line" class="art-action-icon" />
|
|
||||||
<span>{{ $t('tenant.announcement.action.edit') }}</span>
|
|
||||||
</button>
|
|
||||||
<ElDropdown @command="(cmd) => handleMoreCommand(cmd, row)">
|
|
||||||
<button class="art-action-btn info">
|
|
||||||
<ArtSvgIcon icon="ri:more-2-line" class="art-action-icon" />
|
|
||||||
<span>{{ $t('tenant.announcement.action.more') }}</span>
|
|
||||||
</button>
|
|
||||||
<template #dropdown>
|
|
||||||
<ElDropdownMenu>
|
|
||||||
<ElDropdownItem command="publish">
|
|
||||||
<div class="flex items-center gap-2 text-success">
|
|
||||||
<ArtSvgIcon icon="ri:send-plane-2-line" />
|
|
||||||
<span>{{ $t('tenant.announcement.action.publish') }}</span>
|
|
||||||
</div>
|
|
||||||
</ElDropdownItem>
|
|
||||||
<ElDropdownItem command="delete" divided>
|
|
||||||
<div class="flex items-center gap-2 text-danger">
|
|
||||||
<ArtSvgIcon icon="ri:delete-bin-6-line" />
|
|
||||||
<span>{{ $t('tenant.announcement.action.delete') }}</span>
|
|
||||||
</div>
|
|
||||||
</ElDropdownItem>
|
|
||||||
</ElDropdownMenu>
|
|
||||||
</template>
|
|
||||||
</ElDropdown>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="resolveStatus(row.status) === 'Published'"
|
|
||||||
class="art-action-btn warning"
|
|
||||||
@click="handleRevoke(row)"
|
|
||||||
>
|
|
||||||
<ArtSvgIcon icon="ri:pause-circle-line" class="art-action-icon" />
|
|
||||||
<span>{{ $t('tenant.announcement.action.revoke') }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="
|
|
||||||
resolveStatus(row.status) !== 'Published' && resolveStatus(row.status) !== 'Draft'
|
|
||||||
"
|
|
||||||
class="art-action-btn danger"
|
|
||||||
@click="handleDelete(row)"
|
|
||||||
>
|
|
||||||
<ArtSvgIcon icon="ri:delete-bin-6-line" class="art-action-icon" />
|
|
||||||
<span>{{ $t('tenant.announcement.action.delete') }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ArtTable>
|
|
||||||
</ElCard>
|
|
||||||
|
|
||||||
<!-- 3. 公告表单对话框 -->
|
|
||||||
<AnnouncementFormDialog
|
|
||||||
v-model="dialogVisible"
|
|
||||||
:tenant-id="tenantId"
|
|
||||||
:edit-data="editData"
|
|
||||||
@success="handleDialogSuccess"
|
|
||||||
/>
|
|
||||||
<AnnouncementDetailDrawer
|
|
||||||
ref="detailDrawerRef"
|
|
||||||
@updated="handleDetailUpdated"
|
|
||||||
@edit="handleDetailEdit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import {
|
|
||||||
ElAlert,
|
|
||||||
ElButton,
|
|
||||||
ElCard,
|
|
||||||
ElCol,
|
|
||||||
ElDatePicker,
|
|
||||||
ElForm,
|
|
||||||
ElFormItem,
|
|
||||||
ElInput,
|
|
||||||
ElMessage,
|
|
||||||
ElMessageBox,
|
|
||||||
ElOption,
|
|
||||||
ElRow,
|
|
||||||
ElSelect,
|
|
||||||
ElTag,
|
|
||||||
ElDropdown,
|
|
||||||
ElDropdownMenu,
|
|
||||||
ElDropdownItem
|
|
||||||
} from 'element-plus'
|
|
||||||
import ArtTable from '@/components/core/tables/art-table/index.vue'
|
|
||||||
import ArtTableHeader from '@/components/core/tables/art-table-header/index.vue'
|
|
||||||
import ArtSvgIcon from '@/components/core/base/art-svg-icon/index.vue'
|
|
||||||
import AnnouncementFormDialog from './components/AnnouncementFormDialog.vue'
|
|
||||||
import AnnouncementDetailDrawer from './components/AnnouncementDetailDrawer.vue'
|
|
||||||
import { useAnnouncementStore } from '@/store/modules/announcement'
|
|
||||||
import { useUserStore } from '@/store/modules/user'
|
|
||||||
import { isHttpError } from '@/utils/http/error'
|
|
||||||
import { normalizeAnnouncementStatus } from '@/utils/announcementStatus'
|
|
||||||
import type {
|
|
||||||
AnnouncementQueryParams,
|
|
||||||
AnnouncementStatus,
|
|
||||||
AnnouncementTargetType,
|
|
||||||
TenantAnnouncementDto
|
|
||||||
} from '@/types/announcement'
|
|
||||||
import { TenantAnnouncementType } from '@/types/announcement'
|
|
||||||
import type { ColumnOption } from '@/types/component'
|
|
||||||
|
|
||||||
defineOptions({ name: 'TenantAnnouncementList' })
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const route = useRoute()
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const announcementStore = useAnnouncementStore()
|
|
||||||
|
|
||||||
const { announcements, pagination, loading } = storeToRefs(announcementStore)
|
|
||||||
|
|
||||||
// 1. 搜索栏显示状态
|
|
||||||
const showSearchBar = ref(true)
|
|
||||||
|
|
||||||
// 2. 错误提示
|
|
||||||
const errorMessage = ref<string | null>(null)
|
|
||||||
|
|
||||||
// 3. 对话框状态
|
|
||||||
const dialogVisible = ref(false)
|
|
||||||
const editData = ref<TenantAnnouncementDto | null>(null)
|
|
||||||
const detailDrawerRef = ref<InstanceType<typeof AnnouncementDetailDrawer>>()
|
|
||||||
|
|
||||||
// 4. 租户ID(优先路由参数,其次用户信息)
|
|
||||||
const tenantId = computed(() => {
|
|
||||||
const paramId = route.params.tenantId
|
|
||||||
const queryId = route.query.tenantId
|
|
||||||
const rawId = Array.isArray(paramId)
|
|
||||||
? paramId[0]
|
|
||||||
: paramId || (Array.isArray(queryId) ? queryId[0] : queryId)
|
|
||||||
return String(rawId || userStore.info?.tenantId || '')
|
|
||||||
})
|
|
||||||
|
|
||||||
// 5. 筛选表单
|
|
||||||
const formFilters = reactive({
|
|
||||||
status: undefined as AnnouncementStatus | undefined,
|
|
||||||
keyword: '',
|
|
||||||
dateRange: [] as string[]
|
|
||||||
})
|
|
||||||
|
|
||||||
// 6. 状态/类型选项
|
|
||||||
const statusOptions = computed(() => [
|
|
||||||
{ label: t('tenant.announcement.status.draft'), value: 'Draft' },
|
|
||||||
{ label: t('tenant.announcement.status.published'), value: 'Published' },
|
|
||||||
{ label: t('tenant.announcement.status.revoked'), value: 'Revoked' }
|
|
||||||
])
|
|
||||||
|
|
||||||
const typeOptions = computed(() => [
|
|
||||||
{ label: t('tenant.announcement.type.system'), value: TenantAnnouncementType.System },
|
|
||||||
{ label: t('tenant.announcement.type.billing'), value: TenantAnnouncementType.Billing },
|
|
||||||
{ label: t('tenant.announcement.type.operation'), value: TenantAnnouncementType.Operation },
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.systemPlatformUpdate'),
|
|
||||||
value: TenantAnnouncementType.SYSTEM_PLATFORM_UPDATE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.systemSecurityNotice'),
|
|
||||||
value: TenantAnnouncementType.SYSTEM_SECURITY_NOTICE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.systemCompliance'),
|
|
||||||
value: TenantAnnouncementType.SYSTEM_COMPLIANCE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantInternal'),
|
|
||||||
value: TenantAnnouncementType.TENANT_INTERNAL
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantFinance'),
|
|
||||||
value: TenantAnnouncementType.TENANT_FINANCE
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('tenant.announcement.type.tenantOperation'),
|
|
||||||
value: TenantAnnouncementType.TENANT_OPERATION
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const targetTypeMap = computed<Record<string, string>>(() => ({
|
|
||||||
All: t('tenant.announcement.targetType.all'),
|
|
||||||
all: t('tenant.announcement.targetType.all'),
|
|
||||||
Roles: t('tenant.announcement.targetType.roles'),
|
|
||||||
roles: t('tenant.announcement.targetType.roles'),
|
|
||||||
Users: t('tenant.announcement.targetType.users'),
|
|
||||||
users: t('tenant.announcement.targetType.users'),
|
|
||||||
Rules: t('tenant.announcement.targetType.rules'),
|
|
||||||
rules: t('tenant.announcement.targetType.rules'),
|
|
||||||
Manual: t('tenant.announcement.targetType.manual'),
|
|
||||||
manual: t('tenant.announcement.targetType.manual')
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 7. 表格列
|
|
||||||
const columns = ref<ColumnOption<TenantAnnouncementDto>[]>([
|
|
||||||
{ type: 'globalIndex', label: t('table.column.index'), width: 70, fixed: 'left' },
|
|
||||||
{
|
|
||||||
prop: 'title',
|
|
||||||
label: t('tenant.announcement.field.title'),
|
|
||||||
minWidth: 200,
|
|
||||||
showOverflowTooltip: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'announcementType',
|
|
||||||
label: t('tenant.announcement.field.announcementType'),
|
|
||||||
width: 160,
|
|
||||||
useSlot: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'priority',
|
|
||||||
label: t('tenant.announcement.field.priority'),
|
|
||||||
width: 110,
|
|
||||||
useSlot: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'status',
|
|
||||||
label: t('tenant.announcement.field.status'),
|
|
||||||
width: 110,
|
|
||||||
useSlot: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'effectiveRange',
|
|
||||||
label: t('tenant.announcement.field.effectiveRange'),
|
|
||||||
minWidth: 200,
|
|
||||||
useSlot: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'targetType',
|
|
||||||
label: t('tenant.announcement.field.targetType'),
|
|
||||||
width: 120,
|
|
||||||
useSlot: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'publishedAt',
|
|
||||||
label: t('tenant.announcement.field.publishedAt'),
|
|
||||||
width: 170,
|
|
||||||
useSlot: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'action',
|
|
||||||
label: t('table.column.action'),
|
|
||||||
width: 360,
|
|
||||||
fixed: 'right',
|
|
||||||
useSlot: true
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
// 8. 分页映射(对接 ArtTable)
|
|
||||||
const tablePagination = computed(() => ({
|
|
||||||
current: pagination.value.page,
|
|
||||||
size: pagination.value.pageSize,
|
|
||||||
total: pagination.value.totalCount
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 9. 时间格式化
|
|
||||||
const formatDateTime = (value?: string | null) => {
|
|
||||||
if (!value) return '-'
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) return '-'
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 11. 文案转换
|
|
||||||
const resolveStatus = (status: unknown) => normalizeAnnouncementStatus(status)
|
|
||||||
const getStatusText = (status: AnnouncementStatus) => {
|
|
||||||
const map: Record<AnnouncementStatus, string> = {
|
|
||||||
Draft: t('tenant.announcement.status.draft'),
|
|
||||||
Published: t('tenant.announcement.status.published'),
|
|
||||||
Revoked: t('tenant.announcement.status.revoked')
|
|
||||||
}
|
|
||||||
const normalized = resolveStatus(status)
|
|
||||||
return normalized ? map[normalized] : '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusTagType = (status: AnnouncementStatus) => {
|
|
||||||
const normalized = resolveStatus(status)
|
|
||||||
switch (normalized) {
|
|
||||||
case 'Draft':
|
|
||||||
return 'info'
|
|
||||||
case 'Published':
|
|
||||||
return 'success'
|
|
||||||
case 'Revoked':
|
|
||||||
return 'danger'
|
|
||||||
default:
|
|
||||||
return 'info'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTypeText = (type: TenantAnnouncementType) => {
|
|
||||||
const found = typeOptions.value.find((item) => item.value === type)
|
|
||||||
return found?.label || '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTargetTypeText = (type: AnnouncementTargetType) => {
|
|
||||||
return targetTypeMap.value[String(type)] || '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 12. 日期过滤转换
|
|
||||||
const toIsoDateTime = (value: string) => {
|
|
||||||
if (!value) return undefined
|
|
||||||
const normalized = value.includes('T') ? value : value.replace(' ', 'T')
|
|
||||||
const date = new Date(normalized)
|
|
||||||
if (Number.isNaN(date.getTime())) return undefined
|
|
||||||
return date.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const toIsoDateStart = (value: string) => toIsoDateTime(`${value} 00:00:00`)
|
|
||||||
const toIsoDateEnd = (value: string) => toIsoDateTime(`${value} 23:59:59`)
|
|
||||||
|
|
||||||
// 13. 组装查询参数
|
|
||||||
const buildQueryParams = (overrides?: Partial<AnnouncementQueryParams>) => {
|
|
||||||
const params: AnnouncementQueryParams = {
|
|
||||||
page: pagination.value.page,
|
|
||||||
pageSize: pagination.value.pageSize,
|
|
||||||
status: formFilters.status,
|
|
||||||
keyword: formFilters.keyword.trim() || undefined,
|
|
||||||
tenantId: tenantId.value || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formFilters.dateRange.length === 2) {
|
|
||||||
params.dateFrom = toIsoDateStart(formFilters.dateRange[0])
|
|
||||||
params.dateTo = toIsoDateEnd(formFilters.dateRange[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...params, ...overrides }
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveRequestErrorMessage = (error: unknown, fallback: string) => {
|
|
||||||
if (isHttpError(error)) {
|
|
||||||
return error.message
|
|
||||||
}
|
|
||||||
if (error instanceof Error && error.message) {
|
|
||||||
return error.message
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// 14. 拉取列表
|
|
||||||
const fetchList = async (overrides?: Partial<AnnouncementQueryParams>) => {
|
|
||||||
// 1. 校验租户ID
|
|
||||||
if (!tenantId.value) {
|
|
||||||
errorMessage.value = t('tenant.announcement.message.tenantIdMissing')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 清理错误并组装查询参数
|
|
||||||
errorMessage.value = null
|
|
||||||
const params = buildQueryParams(overrides)
|
|
||||||
|
|
||||||
// 3. 发起请求并处理结果
|
|
||||||
try {
|
|
||||||
const result = await announcementStore.fetchAnnouncements(params, {
|
|
||||||
scope: 'tenant',
|
|
||||||
tenantId: tenantId.value,
|
|
||||||
silent: true
|
|
||||||
})
|
|
||||||
if (!result) {
|
|
||||||
errorMessage.value = t('tenant.announcement.message.loadListFailed')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = resolveRequestErrorMessage(
|
|
||||||
error,
|
|
||||||
t('tenant.announcement.message.loadListFailed')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 15. 搜索与重置
|
|
||||||
const handleSearch = () => {
|
|
||||||
// 1. 查询回到第一页
|
|
||||||
void fetchList({ page: 1 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
// 1. 重置筛选条件
|
|
||||||
formFilters.status = undefined
|
|
||||||
formFilters.keyword = ''
|
|
||||||
formFilters.dateRange = []
|
|
||||||
|
|
||||||
// 2. 重新查询
|
|
||||||
handleSearch()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 16. 分页操作
|
|
||||||
const handleCurrentChange = (page: number) => {
|
|
||||||
// 1. 切换页码
|
|
||||||
void fetchList({ page })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSizeChange = (pageSize: number) => {
|
|
||||||
// 1. 切换每页数量并回到第一页
|
|
||||||
void fetchList({ page: 1, pageSize })
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshData = () => {
|
|
||||||
// 1. 刷新列表
|
|
||||||
void fetchList()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 17. 对话框操作
|
|
||||||
const handleCreate = () => {
|
|
||||||
// 1. 清空编辑数据
|
|
||||||
editData.value = null
|
|
||||||
|
|
||||||
// 2. 打开对话框
|
|
||||||
dialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEdit = (row: TenantAnnouncementDto) => {
|
|
||||||
// 1. 设置编辑数据
|
|
||||||
editData.value = row
|
|
||||||
|
|
||||||
// 2. 打开对话框
|
|
||||||
dialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDialogSuccess = () => {
|
|
||||||
// 1. 刷新列表
|
|
||||||
void fetchList()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 18. 详情抽屉
|
|
||||||
const handleDetail = (row: TenantAnnouncementDto) => {
|
|
||||||
detailDrawerRef.value?.open(row.id, tenantId.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDetailEdit = (payload: TenantAnnouncementDto) => {
|
|
||||||
// 1. 设置编辑数据
|
|
||||||
editData.value = payload
|
|
||||||
|
|
||||||
// 2. 打开对话框
|
|
||||||
dialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDetailUpdated = () => {
|
|
||||||
// 1. 刷新列表
|
|
||||||
void fetchList()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 19. 发布/撤销/删除
|
|
||||||
const handlePublish = async (row: TenantAnnouncementDto) => {
|
|
||||||
if (!tenantId.value) {
|
|
||||||
ElMessage.error(t('tenant.announcement.message.tenantIdMissing'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 二次确认发布
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
t('tenant.announcement.message.publishConfirm'),
|
|
||||||
t('common.tips'),
|
|
||||||
{
|
|
||||||
confirmButtonText: t('common.confirm'),
|
|
||||||
cancelButtonText: t('common.cancel'),
|
|
||||||
type: 'warning'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 2. 发起发布请求
|
|
||||||
const result = await announcementStore.publishAnnouncement(row.id, row.rowVersion, {
|
|
||||||
scope: 'tenant',
|
|
||||||
tenantId: tenantId.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. 刷新列表
|
|
||||||
if (result) {
|
|
||||||
ElMessage.success(t('tenant.announcement.message.publishSuccess'))
|
|
||||||
void fetchList()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error !== 'cancel') {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRevoke = async (row: TenantAnnouncementDto) => {
|
|
||||||
if (!tenantId.value) {
|
|
||||||
ElMessage.error(t('tenant.announcement.message.tenantIdMissing'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 二次确认撤销
|
|
||||||
await ElMessageBox.confirm(t('tenant.announcement.message.revokeConfirm'), t('common.tips'), {
|
|
||||||
confirmButtonText: t('common.confirm'),
|
|
||||||
cancelButtonText: t('common.cancel'),
|
|
||||||
type: 'warning'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 2. 发起撤销请求
|
|
||||||
const result = await announcementStore.revokeAnnouncement(row.id, row.rowVersion, {
|
|
||||||
scope: 'tenant',
|
|
||||||
tenantId: tenantId.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. 刷新列表
|
|
||||||
if (result) {
|
|
||||||
ElMessage.success(t('tenant.announcement.message.revokeSuccess'))
|
|
||||||
void fetchList()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error !== 'cancel') {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (row: TenantAnnouncementDto) => {
|
|
||||||
if (!tenantId.value) {
|
|
||||||
ElMessage.error(t('tenant.announcement.message.tenantIdMissing'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 二次确认删除
|
|
||||||
await ElMessageBox.confirm(t('tenant.announcement.message.deleteConfirm'), t('common.tips'), {
|
|
||||||
confirmButtonText: t('common.confirm'),
|
|
||||||
cancelButtonText: t('common.cancel'),
|
|
||||||
type: 'warning'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 2. 调用 Store 删除公告
|
|
||||||
const result = await announcementStore.deleteAnnouncement(row.id, {
|
|
||||||
scope: 'tenant',
|
|
||||||
tenantId: tenantId.value
|
|
||||||
})
|
|
||||||
if (result) {
|
|
||||||
ElMessage.success(t('tenant.announcement.message.deleteSuccess'))
|
|
||||||
void fetchList()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error !== 'cancel') {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMoreCommand = (command: string, row: TenantAnnouncementDto) => {
|
|
||||||
switch (command) {
|
|
||||||
case 'publish':
|
|
||||||
handlePublish(row)
|
|
||||||
break
|
|
||||||
case 'delete':
|
|
||||||
handleDelete(row)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 20. 监听租户变更自动刷新
|
|
||||||
watch(
|
|
||||||
tenantId,
|
|
||||||
(value) => {
|
|
||||||
if (value) {
|
|
||||||
void fetchList({ page: 1 })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.art-page-view {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.art-search-card {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.art-table-card {
|
|
||||||
:deep(.el-card__body) {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ElCard class="panel-card" shadow="never" :class="{ 'panel-disabled': disabled }">
|
|
||||||
<template #header>
|
|
||||||
<div class="panel-header">
|
|
||||||
<div class="header-title">
|
|
||||||
<span>{{ t('dictionary.override.customView') }}</span>
|
|
||||||
<ElTag size="small" type="info">{{ localItems.length }}</ElTag>
|
|
||||||
</div>
|
|
||||||
<ElSpace>
|
|
||||||
<ElButton type="primary" :disabled="disabled" @click="openCreate">
|
|
||||||
{{ t('dictionary.override.newCustomItem') }}
|
|
||||||
</ElButton>
|
|
||||||
<ElButton
|
|
||||||
:type="hasPendingSort ? 'warning' : 'default'"
|
|
||||||
:disabled="disabled || !hasPendingSort"
|
|
||||||
@click="saveSortOrder"
|
|
||||||
>
|
|
||||||
{{ t('dictionary.override.saveSortOrder') }}
|
|
||||||
</ElButton>
|
|
||||||
</ElSpace>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<SortableDragDrop
|
|
||||||
:items="localItems"
|
|
||||||
:disabled="disabled"
|
|
||||||
@update:items="handleSortUpdate"
|
|
||||||
@edit="openEdit"
|
|
||||||
@delete="handleDelete"
|
|
||||||
/>
|
|
||||||
</ElCard>
|
|
||||||
|
|
||||||
<ItemFormDialog
|
|
||||||
v-model="itemDialogVisible"
|
|
||||||
:mode="itemDialogMode"
|
|
||||||
:group-id="tenantGroupId || undefined"
|
|
||||||
:item="editingItem"
|
|
||||||
@success="handleItemSaved"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import SortableDragDrop from './SortableDragDrop.vue'
|
|
||||||
import ItemFormDialog from '@/views/system/dictionary/components/ItemFormDialog.vue'
|
|
||||||
import { useDictionaryItemStore } from '@/store/modules/dictionaryItem'
|
|
||||||
import { useDictionaryGroupStore } from '@/store/modules/dictionaryGroup'
|
|
||||||
import { useDictionaryOverrideStore } from '@/store/modules/dictionaryOverride'
|
|
||||||
import { DictionaryScope } from '@/enums/Dictionary'
|
|
||||||
|
|
||||||
defineOptions({ name: 'CustomItemsPanel' })
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
groupCode: string
|
|
||||||
systemGroup?: Api.Dictionary.DictionaryGroupDto | null
|
|
||||||
tenantGroupId?: string | null
|
|
||||||
items: Api.Dictionary.DictionaryItemDto[]
|
|
||||||
overrideEnabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
systemGroup: null,
|
|
||||||
tenantGroupId: null,
|
|
||||||
items: () => [],
|
|
||||||
overrideEnabled: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: 'refresh'): void
|
|
||||||
(event: 'tenant-group-created', group: Api.Dictionary.DictionaryGroupDto): void
|
|
||||||
(event: 'sort-pending', value: boolean): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const itemStore = useDictionaryItemStore()
|
|
||||||
const groupStore = useDictionaryGroupStore()
|
|
||||||
const overrideStore = useDictionaryOverrideStore()
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const localItems = ref<Api.Dictionary.DictionaryItemDto[]>([])
|
|
||||||
const hasPendingSort = ref(false)
|
|
||||||
const itemDialogVisible = ref(false)
|
|
||||||
const itemDialogMode = ref<'create' | 'edit'>('create')
|
|
||||||
const editingItem = ref<Api.Dictionary.DictionaryItemDto | null>(null)
|
|
||||||
|
|
||||||
const disabled = ref(!props.overrideEnabled)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.items,
|
|
||||||
(value) => {
|
|
||||||
localItems.value = value ? [...value] : []
|
|
||||||
},
|
|
||||||
{ deep: true, immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.overrideEnabled,
|
|
||||||
(value) => {
|
|
||||||
disabled.value = !value
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const ensureTenantGroup = async () => {
|
|
||||||
if (props.tenantGroupId) return props.tenantGroupId
|
|
||||||
if (!props.systemGroup) return null
|
|
||||||
|
|
||||||
const result = await groupStore.createGroup({
|
|
||||||
code: props.systemGroup.code,
|
|
||||||
name: props.systemGroup.name,
|
|
||||||
scope: DictionaryScope.Business,
|
|
||||||
allowOverride: false,
|
|
||||||
description: props.systemGroup.description ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
emit('tenant-group-created', result)
|
|
||||||
return result.id
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const openCreate = async () => {
|
|
||||||
if (disabled.value) return
|
|
||||||
const groupId = await ensureTenantGroup()
|
|
||||||
if (!groupId) {
|
|
||||||
ElMessage.error(t('dictionary.override.tenantGroupNotReady'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
itemDialogMode.value = 'create'
|
|
||||||
editingItem.value = null
|
|
||||||
itemDialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEdit = (item: Api.Dictionary.DictionaryItemDto) => {
|
|
||||||
if (disabled.value || item.source !== 'tenant') return
|
|
||||||
itemDialogMode.value = 'edit'
|
|
||||||
editingItem.value = item
|
|
||||||
itemDialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (item: Api.Dictionary.DictionaryItemDto) => {
|
|
||||||
if (disabled.value || item.source !== 'tenant') return
|
|
||||||
const success = await itemStore.deleteItem(item.groupId, item.id)
|
|
||||||
if (success) {
|
|
||||||
ElMessage.success(t('dictionary.messages.deleted'))
|
|
||||||
emit('refresh')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleItemSaved = () => {
|
|
||||||
emit('refresh')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSortUpdate = (items: Api.Dictionary.DictionaryItemDto[]) => {
|
|
||||||
localItems.value = [...items]
|
|
||||||
hasPendingSort.value = true
|
|
||||||
emit('sort-pending', true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveSortOrder = async () => {
|
|
||||||
if (disabled.value) return
|
|
||||||
const sortOrder: Record<string, number> = {}
|
|
||||||
localItems.value.forEach((item, index) => {
|
|
||||||
sortOrder[item.id] = (index + 1) * 10
|
|
||||||
})
|
|
||||||
|
|
||||||
await overrideStore.updateCustomSortOrder(props.groupCode, sortOrder)
|
|
||||||
hasPendingSort.value = false
|
|
||||||
emit('sort-pending', false)
|
|
||||||
ElMessage.success(t('dictionary.override.sortSaved'))
|
|
||||||
emit('refresh')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.panel-card {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-disabled {
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ElRow :gutter="16" class="dual-pane">
|
|
||||||
<ElCol :span="12">
|
|
||||||
<SystemItemsPanel
|
|
||||||
:group-code="groupCode"
|
|
||||||
:items="systemItems"
|
|
||||||
:hidden-ids="hiddenIds"
|
|
||||||
:disabled="!overrideEnabled"
|
|
||||||
@updated="$emit('refresh')"
|
|
||||||
/>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="12">
|
|
||||||
<CustomItemsPanel
|
|
||||||
:group-code="groupCode"
|
|
||||||
:system-group="systemGroup"
|
|
||||||
:tenant-group-id="tenantGroupId"
|
|
||||||
:items="customItems"
|
|
||||||
:override-enabled="overrideEnabled"
|
|
||||||
@refresh="$emit('refresh')"
|
|
||||||
@tenant-group-created="$emit('tenant-group-created', $event)"
|
|
||||||
@sort-pending="$emit('sort-pending', $event)"
|
|
||||||
/>
|
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import SystemItemsPanel from './SystemItemsPanel.vue'
|
|
||||||
import CustomItemsPanel from './CustomItemsPanel.vue'
|
|
||||||
|
|
||||||
defineOptions({ name: 'DualPaneView' })
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
groupCode: string
|
|
||||||
systemGroup?: Api.Dictionary.DictionaryGroupDto | null
|
|
||||||
tenantGroupId?: string | null
|
|
||||||
systemItems: Api.Dictionary.DictionaryItemDto[]
|
|
||||||
customItems: Api.Dictionary.DictionaryItemDto[]
|
|
||||||
hiddenIds: string[]
|
|
||||||
overrideEnabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
systemGroup: null,
|
|
||||||
tenantGroupId: null,
|
|
||||||
systemItems: () => [],
|
|
||||||
customItems: () => [],
|
|
||||||
hiddenIds: () => [],
|
|
||||||
overrideEnabled: false
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
(event: 'refresh'): void
|
|
||||||
(event: 'tenant-group-created', group: Api.Dictionary.DictionaryGroupDto): void
|
|
||||||
(event: 'sort-pending', value: boolean): void
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.dual-pane {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ElDialog
|
|
||||||
v-model="visible"
|
|
||||||
:title="
|
|
||||||
isEdit
|
|
||||||
? t('dictionary.labelOverride.editOverride')
|
|
||||||
: t('dictionary.labelOverride.newOverride')
|
|
||||||
"
|
|
||||||
width="560px"
|
|
||||||
:close-on-click-modal="false"
|
|
||||||
@closed="handleClosed"
|
|
||||||
>
|
|
||||||
<ElForm
|
|
||||||
ref="formRef"
|
|
||||||
:model="formData"
|
|
||||||
:rules="formRules"
|
|
||||||
label-width="100px"
|
|
||||||
label-position="top"
|
|
||||||
>
|
|
||||||
<!-- 1. 字典项选择(新建时显示) -->
|
|
||||||
<ElFormItem
|
|
||||||
v-if="!isEdit"
|
|
||||||
:label="t('dictionary.labelOverride.dictionaryItem')"
|
|
||||||
prop="dictionaryItemId"
|
|
||||||
>
|
|
||||||
<ElSelect
|
|
||||||
v-model="formData.dictionaryItemId"
|
|
||||||
:placeholder="t('dictionary.labelOverride.selectDictionaryItem')"
|
|
||||||
filterable
|
|
||||||
style="width: 100%"
|
|
||||||
@change="handleItemSelect"
|
|
||||||
>
|
|
||||||
<ElOption
|
|
||||||
v-for="item in availableItems"
|
|
||||||
:key="item.id"
|
|
||||||
:label="`${item.key} - ${getDisplayValue(item.value)}`"
|
|
||||||
:value="item.id"
|
|
||||||
/>
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
|
|
||||||
<!-- 2. 显示原始值(编辑时) -->
|
|
||||||
<ElFormItem
|
|
||||||
v-if="isEdit || selectedItem"
|
|
||||||
:label="t('dictionary.labelOverride.originalValue')"
|
|
||||||
>
|
|
||||||
<div class="original-value-display">
|
|
||||||
<div v-for="(val, lang) in selectedItem?.value || {}" :key="lang" class="i18n-row">
|
|
||||||
<span class="lang-label">{{ getLangLabel(lang) }}:</span>
|
|
||||||
<span class="lang-value">{{ val }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ElFormItem>
|
|
||||||
|
|
||||||
<!-- 3. 覆盖值输入(多语言) -->
|
|
||||||
<ElFormItem :label="t('dictionary.labelOverride.overrideValue')" prop="overrideValue">
|
|
||||||
<I18nValueEditor v-model="formData.overrideValue" />
|
|
||||||
</ElFormItem>
|
|
||||||
|
|
||||||
<!-- 4. 覆盖原因 -->
|
|
||||||
<ElFormItem :label="t('dictionary.labelOverride.reason')">
|
|
||||||
<ElInput
|
|
||||||
v-model="formData.reason"
|
|
||||||
type="textarea"
|
|
||||||
:rows="2"
|
|
||||||
:placeholder="t('dictionary.labelOverride.reasonPlaceholder')"
|
|
||||||
/>
|
|
||||||
<div class="form-hint">{{ t('dictionary.labelOverride.reasonHint') }}</div>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElForm>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<ElButton @click="visible = false">{{ t('common.cancel') }}</ElButton>
|
|
||||||
<ElButton type="primary" :loading="loading" @click="handleSubmit">
|
|
||||||
{{ t('common.confirm') }}
|
|
||||||
</ElButton>
|
|
||||||
</template>
|
|
||||||
</ElDialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
|
||||||
import I18nValueEditor from '@/views/system/dictionary/components/I18nValueEditor.vue'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
modelValue: boolean
|
|
||||||
editItem?: Api.Dictionary.LabelOverrideDto | null
|
|
||||||
systemItems?: Api.Dictionary.DictionaryItemDto[]
|
|
||||||
existingOverrideIds?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
editItem: null,
|
|
||||||
systemItems: () => [],
|
|
||||||
existingOverrideIds: () => []
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: boolean]
|
|
||||||
submit: [data: Api.Dictionary.UpsertLabelOverrideRequest]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
// 1. 对话框状态
|
|
||||||
const visible = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (val) => emit('update:modelValue', val)
|
|
||||||
})
|
|
||||||
|
|
||||||
const formRef = ref<FormInstance>()
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// 2. 表单数据
|
|
||||||
const formData = reactive<{
|
|
||||||
dictionaryItemId: string
|
|
||||||
overrideValue: Record<string, string>
|
|
||||||
reason: string
|
|
||||||
}>({
|
|
||||||
dictionaryItemId: '',
|
|
||||||
overrideValue: {},
|
|
||||||
reason: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. 计算属性
|
|
||||||
const isEdit = computed(() => !!props.editItem)
|
|
||||||
|
|
||||||
const selectedItem = computed(() => {
|
|
||||||
if (isEdit.value && props.editItem) {
|
|
||||||
return {
|
|
||||||
id: props.editItem.dictionaryItemId,
|
|
||||||
key: props.editItem.dictionaryItemKey,
|
|
||||||
value: props.editItem.originalValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return props.systemItems.find((item) => item.id === formData.dictionaryItemId)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 4. 可选择的字典项(排除已有覆盖的)
|
|
||||||
const availableItems = computed(() => {
|
|
||||||
return props.systemItems.filter((item) => !props.existingOverrideIds.includes(item.id))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 5. 表单校验规则
|
|
||||||
const formRules = computed<FormRules>(() => ({
|
|
||||||
dictionaryItemId: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('dictionary.labelOverride.selectDictionaryItem'),
|
|
||||||
trigger: 'change'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
overrideValue: [
|
|
||||||
{
|
|
||||||
validator: (_rule, value, callback) => {
|
|
||||||
const hasValue = Object.values(value || {}).some((v) => !!v)
|
|
||||||
if (!hasValue) {
|
|
||||||
callback(new Error(t('dictionary.labelOverride.overrideValueRequired')))
|
|
||||||
} else {
|
|
||||||
callback()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trigger: 'blur'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 6. 辅助方法
|
|
||||||
const getDisplayValue = (value: Record<string, string>) => {
|
|
||||||
return value['zh'] || value['en'] || Object.values(value)[0] || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const getLangLabel = (lang: string) => {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
zh: t('dictionary.i18n.zh'),
|
|
||||||
en: t('dictionary.i18n.en')
|
|
||||||
}
|
|
||||||
return labels[lang] || lang
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleItemSelect = () => {
|
|
||||||
// 选择字典项后,预填覆盖值为原始值
|
|
||||||
if (selectedItem.value) {
|
|
||||||
formData.overrideValue = { ...selectedItem.value.value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. 提交处理
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!formRef.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await formRef.value.validate()
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
const request: Api.Dictionary.UpsertLabelOverrideRequest = {
|
|
||||||
dictionaryItemId: isEdit.value
|
|
||||||
? props.editItem!.dictionaryItemId
|
|
||||||
: formData.dictionaryItemId,
|
|
||||||
overrideValue: formData.overrideValue,
|
|
||||||
reason: formData.reason || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('submit', request)
|
|
||||||
} catch {
|
|
||||||
// 校验失败
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. 对话框关闭时重置
|
|
||||||
const handleClosed = () => {
|
|
||||||
formData.dictionaryItemId = ''
|
|
||||||
formData.overrideValue = {}
|
|
||||||
formData.reason = ''
|
|
||||||
formRef.value?.resetFields()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. 编辑模式初始化
|
|
||||||
watch(
|
|
||||||
() => props.editItem,
|
|
||||||
(item) => {
|
|
||||||
if (item) {
|
|
||||||
formData.dictionaryItemId = item.dictionaryItemId
|
|
||||||
formData.overrideValue = { ...item.overrideValue }
|
|
||||||
formData.reason = item.reason || ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
// 10. 暴露关闭加载状态的方法
|
|
||||||
defineExpose({
|
|
||||||
setLoading: (val: boolean) => {
|
|
||||||
loading.value = val
|
|
||||||
},
|
|
||||||
close: () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.original-value-display {
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--el-fill-color-light);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.i18n-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-label {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 40px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-value {
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-hint {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="label-override-panel">
|
|
||||||
<!-- 1. 工具栏 -->
|
|
||||||
<div class="panel-toolbar">
|
|
||||||
<ElButton type="primary" :icon="Plus" @click="handleAdd">
|
|
||||||
{{ t('dictionary.labelOverride.newOverride') }}
|
|
||||||
</ElButton>
|
|
||||||
<ElButton :icon="Refresh" @click="handleRefresh">
|
|
||||||
{{ t('dictionary.common.refresh') }}
|
|
||||||
</ElButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 2. 覆盖列表表格 -->
|
|
||||||
<ElTable
|
|
||||||
v-loading="loading"
|
|
||||||
:data="overrides"
|
|
||||||
stripe
|
|
||||||
border
|
|
||||||
class="override-table"
|
|
||||||
empty-text=" "
|
|
||||||
>
|
|
||||||
<!-- 2.1 字典项键 -->
|
|
||||||
<ElTableColumn
|
|
||||||
prop="dictionaryItemKey"
|
|
||||||
:label="t('dictionary.labelOverride.dictionaryItemKey')"
|
|
||||||
min-width="120"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 2.2 原始值 -->
|
|
||||||
<ElTableColumn :label="t('dictionary.labelOverride.originalValue')" min-width="180">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="i18n-cell">
|
|
||||||
<div v-for="(val, lang) in row.originalValue" :key="lang" class="i18n-item">
|
|
||||||
<span class="lang-tag">{{ lang }}</span>
|
|
||||||
<span class="lang-text">{{ val }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ElTableColumn>
|
|
||||||
|
|
||||||
<!-- 2.3 覆盖值 -->
|
|
||||||
<ElTableColumn :label="t('dictionary.labelOverride.overrideValue')" min-width="180">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="i18n-cell override-value">
|
|
||||||
<div v-for="(val, lang) in row.overrideValue" :key="lang" class="i18n-item">
|
|
||||||
<span class="lang-tag">{{ lang }}</span>
|
|
||||||
<span class="lang-text">{{ val }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ElTableColumn>
|
|
||||||
|
|
||||||
<!-- 2.4 覆盖类型 -->
|
|
||||||
<ElTableColumn :label="t('dictionary.labelOverride.overrideType')" width="120">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<ElTag :type="getOverrideTypeTag(row.overrideType)">
|
|
||||||
{{ row.overrideTypeName }}
|
|
||||||
</ElTag>
|
|
||||||
</template>
|
|
||||||
</ElTableColumn>
|
|
||||||
|
|
||||||
<!-- 2.5 原因 -->
|
|
||||||
<ElTableColumn prop="reason" :label="t('dictionary.labelOverride.reason')" min-width="150">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span v-if="row.reason">{{ row.reason }}</span>
|
|
||||||
<span v-else class="text-secondary">-</span>
|
|
||||||
</template>
|
|
||||||
</ElTableColumn>
|
|
||||||
|
|
||||||
<!-- 2.6 更新时间 -->
|
|
||||||
<ElTableColumn :label="t('dictionary.labelOverride.updatedAt')" width="160">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ formatDateTime(row.updatedAt || row.createdAt) }}
|
|
||||||
</template>
|
|
||||||
</ElTableColumn>
|
|
||||||
|
|
||||||
<!-- 2.7 操作 -->
|
|
||||||
<ElTableColumn :label="t('dictionary.common.actions')" width="140" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="action-wrap">
|
|
||||||
<template v-if="row.overrideType === 2">
|
|
||||||
<span class="text-secondary">-</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<button class="art-action-btn primary" @click="handleEdit(row)">
|
|
||||||
<ArtSvgIcon icon="ri:edit-2-line" class="art-action-icon" />
|
|
||||||
<span>{{ t('dictionary.common.edit') }}</span>
|
|
||||||
</button>
|
|
||||||
<ElPopconfirm
|
|
||||||
:title="t('dictionary.labelOverride.deleteConfirm')"
|
|
||||||
@confirm="handleDelete(row)"
|
|
||||||
>
|
|
||||||
<template #reference>
|
|
||||||
<button class="art-action-btn danger">
|
|
||||||
<ArtSvgIcon icon="ri:delete-bin-line" class="art-action-icon" />
|
|
||||||
<span>{{ t('dictionary.common.delete') }}</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</ElPopconfirm>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ElTableColumn>
|
|
||||||
|
|
||||||
<!-- 2.8 空状态 -->
|
|
||||||
<template #empty>
|
|
||||||
<ElEmpty :description="t('dictionary.labelOverride.noOverrides')" />
|
|
||||||
</template>
|
|
||||||
</ElTable>
|
|
||||||
|
|
||||||
<!-- 3. 表单对话框 -->
|
|
||||||
<LabelOverrideFormDialog
|
|
||||||
ref="formDialogRef"
|
|
||||||
v-model="dialogVisible"
|
|
||||||
:edit-item="editingItem"
|
|
||||||
:system-items="systemItems"
|
|
||||||
:existing-override-ids="existingOverrideIds"
|
|
||||||
@submit="handleSubmit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { Plus, Refresh } from '@element-plus/icons-vue'
|
|
||||||
import ArtSvgIcon from '@/components/core/base/art-svg-icon/index.vue'
|
|
||||||
import { useLabelOverrideStore } from '@/store/modules/labelOverride'
|
|
||||||
import LabelOverrideFormDialog from './LabelOverrideFormDialog.vue'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
systemItems?: Api.Dictionary.DictionaryItemDto[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const { systemItems } = withDefaults(defineProps<Props>(), {
|
|
||||||
systemItems: () => []
|
|
||||||
})
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const labelOverrideStore = useLabelOverrideStore()
|
|
||||||
|
|
||||||
// 1. 状态
|
|
||||||
const dialogVisible = ref(false)
|
|
||||||
const editingItem = ref<Api.Dictionary.LabelOverrideDto | null>(null)
|
|
||||||
const formDialogRef = ref<InstanceType<typeof LabelOverrideFormDialog>>()
|
|
||||||
|
|
||||||
// 2. 计算属性
|
|
||||||
const loading = computed(() => labelOverrideStore.loading)
|
|
||||||
const overrides = computed(() => labelOverrideStore.tenantOverrides)
|
|
||||||
const existingOverrideIds = computed(() => overrides.value.map((o) => o.dictionaryItemId))
|
|
||||||
|
|
||||||
// 3. 辅助方法
|
|
||||||
const formatDateTime = (dateStr: string) => {
|
|
||||||
if (!dateStr) return '-'
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
return date.toLocaleString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getOverrideTypeTag = (type: number) => {
|
|
||||||
// TenantCustomization = 1, PlatformEnforcement = 2
|
|
||||||
return type === 2 ? 'danger' : 'success'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 操作方法
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
await labelOverrideStore.fetchTenantOverrides()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
editingItem.value = null
|
|
||||||
dialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEdit = (row: Api.Dictionary.LabelOverrideDto) => {
|
|
||||||
editingItem.value = row
|
|
||||||
dialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (row: Api.Dictionary.LabelOverrideDto) => {
|
|
||||||
await labelOverrideStore.deleteTenantOverride(row.dictionaryItemId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (data: Api.Dictionary.UpsertLabelOverrideRequest) => {
|
|
||||||
formDialogRef.value?.setLoading(true)
|
|
||||||
const result = await labelOverrideStore.upsertTenantOverride(data)
|
|
||||||
formDialogRef.value?.setLoading(false)
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
formDialogRef.value?.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 生命周期
|
|
||||||
onMounted(async () => {
|
|
||||||
await labelOverrideStore.fetchTenantOverrides()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.label-override-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-toolbar {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-table {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.i18n-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.i18n-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-tag {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 2px 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
background: var(--el-fill-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-text {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-value .lang-text {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-secondary {
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.art-action-icon {
|
|
||||||
display: flex;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="override-toggle">
|
|
||||||
<ElSwitch v-model="localEnabled" :disabled="!groupCode || loading" @change="handleToggle" />
|
|
||||||
<ElTag :type="localEnabled ? 'success' : 'info'" size="small" class="status-tag">
|
|
||||||
{{
|
|
||||||
localEnabled
|
|
||||||
? t('dictionary.override.toggleEnabled')
|
|
||||||
: t('dictionary.override.toggleDisabled')
|
|
||||||
}}
|
|
||||||
</ElTag>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useDictionaryOverrideStore } from '@/store/modules/dictionaryOverride'
|
|
||||||
|
|
||||||
defineOptions({ name: 'OverrideToggle' })
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
groupCode?: string
|
|
||||||
enabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
groupCode: undefined,
|
|
||||||
enabled: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: 'change', value: boolean): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const overrideStore = useDictionaryOverrideStore()
|
|
||||||
const { t } = useI18n()
|
|
||||||
const loading = computed(() => overrideStore.loading)
|
|
||||||
const localEnabled = ref(props.enabled)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.enabled,
|
|
||||||
(value) => {
|
|
||||||
localEnabled.value = value
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleToggle = async (value: boolean) => {
|
|
||||||
if (!props.groupCode) return
|
|
||||||
if (value) {
|
|
||||||
await overrideStore.enableOverride(props.groupCode)
|
|
||||||
} else {
|
|
||||||
await overrideStore.disableOverride(props.groupCode)
|
|
||||||
}
|
|
||||||
emit('change', value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.override-toggle {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-tag {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ElTable
|
|
||||||
ref="tableRef"
|
|
||||||
:data="localItems"
|
|
||||||
:row-key="(row) => row.id"
|
|
||||||
class="sortable-table"
|
|
||||||
v-loading="loading"
|
|
||||||
>
|
|
||||||
<ElTableColumn width="40">
|
|
||||||
<template #default>
|
|
||||||
<span class="drag-handle"
|
|
||||||
><ElIcon><Rank /></ElIcon
|
|
||||||
></span>
|
|
||||||
</template>
|
|
||||||
</ElTableColumn>
|
|
||||||
<ElTableColumn prop="key" :label="t('dictionary.common.key')" min-width="140" />
|
|
||||||
<ElTableColumn :label="t('dictionary.common.value')" min-width="160">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ extractI18nText(row.value, locale) }}
|
|
||||||
</template>
|
|
||||||
</ElTableColumn>
|
|
||||||
<ElTableColumn :label="t('dictionary.common.source')" width="110">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<ElTag size="small" :type="row.source === 'tenant' ? 'success' : 'info'">
|
|
||||||
{{
|
|
||||||
row.source === 'tenant'
|
|
||||||
? t('dictionary.common.tenant')
|
|
||||||
: t('dictionary.common.systemLabel')
|
|
||||||
}}
|
|
||||||
</ElTag>
|
|
||||||
</template>
|
|
||||||
</ElTableColumn>
|
|
||||||
<ElTableColumn :label="t('dictionary.common.actions')" width="140">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="action-wrap">
|
|
||||||
<button
|
|
||||||
v-if="row.source === 'tenant'"
|
|
||||||
class="art-action-btn primary"
|
|
||||||
:disabled="disabled"
|
|
||||||
@click="$emit('edit', row)"
|
|
||||||
>
|
|
||||||
<ArtSvgIcon icon="ri:edit-2-line" class="art-action-icon" />
|
|
||||||
<span>{{ t('dictionary.common.edit') }}</span>
|
|
||||||
</button>
|
|
||||||
<ElPopconfirm
|
|
||||||
v-if="row.source === 'tenant'"
|
|
||||||
:title="t('dictionary.item.deleteConfirm')"
|
|
||||||
@confirm="$emit('delete', row)"
|
|
||||||
>
|
|
||||||
<template #reference>
|
|
||||||
<button class="art-action-btn danger" :disabled="disabled">
|
|
||||||
<ArtSvgIcon icon="ri:delete-bin-line" class="art-action-icon" />
|
|
||||||
<span>{{ t('dictionary.common.delete') }}</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</ElPopconfirm>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ElTableColumn>
|
|
||||||
</ElTable>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { Rank } from '@element-plus/icons-vue'
|
|
||||||
import { useDraggable } from 'vue-draggable-plus'
|
|
||||||
import { extractI18nText } from '@/types/dictionary'
|
|
||||||
|
|
||||||
defineOptions({ name: 'SortableDragDrop' })
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
items: Api.Dictionary.DictionaryItemDto[]
|
|
||||||
disabled?: boolean
|
|
||||||
loading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
items: () => [],
|
|
||||||
disabled: false,
|
|
||||||
loading: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: 'update:items', value: Api.Dictionary.DictionaryItemDto[]): void
|
|
||||||
(event: 'edit', value: Api.Dictionary.DictionaryItemDto): void
|
|
||||||
(event: 'delete', value: Api.Dictionary.DictionaryItemDto): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { locale, t } = useI18n()
|
|
||||||
const tableRef = ref()
|
|
||||||
const tableBodyRef = ref<HTMLElement | null>(null)
|
|
||||||
const localItems = ref<Api.Dictionary.DictionaryItemDto[]>([])
|
|
||||||
|
|
||||||
const syncItems = () => {
|
|
||||||
localItems.value = props.items ? [...props.items] : []
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
syncItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
const { start, destroy, option } = useDraggable(tableBodyRef, localItems, {
|
|
||||||
handle: '.drag-handle',
|
|
||||||
animation: 150,
|
|
||||||
ghostClass: 'drag-ghost',
|
|
||||||
immediate: false,
|
|
||||||
onEnd: () => {
|
|
||||||
if (tableBodyRef.value) {
|
|
||||||
option('disabled', props.disabled)
|
|
||||||
}
|
|
||||||
emit('update:items', [...localItems.value])
|
|
||||||
handleDragEnd()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const initDraggable = async () => {
|
|
||||||
await nextTick()
|
|
||||||
tableBodyRef.value = tableRef.value?.$el?.querySelector('.el-table__body-wrapper tbody') || null
|
|
||||||
if (tableBodyRef.value) {
|
|
||||||
destroy()
|
|
||||||
start(tableBodyRef.value)
|
|
||||||
option('disabled', props.disabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.items,
|
|
||||||
() => {
|
|
||||||
syncItems()
|
|
||||||
initDraggable()
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.disabled,
|
|
||||||
(value) => {
|
|
||||||
if (tableBodyRef.value) {
|
|
||||||
option('disabled', value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
syncItems()
|
|
||||||
initDraggable()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
destroy()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.sortable-table {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-handle {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #909399;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.drag-ghost) {
|
|
||||||
background: #ecf5ff;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ElCard class="panel-card" shadow="never">
|
|
||||||
<template #header>
|
|
||||||
<div class="panel-header">
|
|
||||||
<span>{{ t('dictionary.override.systemItems') }}</span>
|
|
||||||
<ElTag size="small" type="info">{{ items.length }}</ElTag>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<ElTable :data="items" size="small" :row-key="(row) => row.id">
|
|
||||||
<ElTableColumn prop="key" :label="t('dictionary.common.key')" min-width="120" />
|
|
||||||
<ElTableColumn :label="t('dictionary.common.value')" min-width="160">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ extractI18nText(row.value, locale) }}
|
|
||||||
</template>
|
|
||||||
</ElTableColumn>
|
|
||||||
<ElTableColumn prop="sortOrder" :label="t('dictionary.common.order')" width="80" />
|
|
||||||
<ElTableColumn :label="t('dictionary.common.hidden')" width="90">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<ElCheckbox
|
|
||||||
:model-value="isHidden(row.id)"
|
|
||||||
:disabled="disabled"
|
|
||||||
@change="(value) => toggleHidden(row.id, value)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</ElTableColumn>
|
|
||||||
</ElTable>
|
|
||||||
</ElCard>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useDictionaryOverrideStore } from '@/store/modules/dictionaryOverride'
|
|
||||||
import { extractI18nText } from '@/types/dictionary'
|
|
||||||
|
|
||||||
defineOptions({ name: 'SystemItemsPanel' })
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
groupCode: string
|
|
||||||
items: Api.Dictionary.DictionaryItemDto[]
|
|
||||||
hiddenIds: string[]
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
items: () => [],
|
|
||||||
hiddenIds: () => [],
|
|
||||||
disabled: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: 'updated'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { locale, t } = useI18n()
|
|
||||||
const overrideStore = useDictionaryOverrideStore()
|
|
||||||
const localHiddenIds = ref<string[]>([...props.hiddenIds])
|
|
||||||
const pendingTimer = ref<number | null>(null)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.hiddenIds,
|
|
||||||
(value) => {
|
|
||||||
localHiddenIds.value = [...value]
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const isHidden = (id: string) => localHiddenIds.value.includes(id)
|
|
||||||
|
|
||||||
const toggleHidden = (id: string, checked: boolean | string | number) => {
|
|
||||||
const next = new Set(localHiddenIds.value)
|
|
||||||
if (checked) {
|
|
||||||
next.add(id)
|
|
||||||
} else {
|
|
||||||
next.delete(id)
|
|
||||||
}
|
|
||||||
localHiddenIds.value = Array.from(next)
|
|
||||||
|
|
||||||
if (pendingTimer.value) {
|
|
||||||
window.clearTimeout(pendingTimer.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingTimer.value = window.setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await overrideStore.updateHiddenItems(props.groupCode, localHiddenIds.value)
|
|
||||||
ElMessage.success(t('dictionary.override.hiddenSaved'))
|
|
||||||
emit('updated')
|
|
||||||
} catch {
|
|
||||||
// rollback is handled by store fetch
|
|
||||||
}
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.panel-card {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="dictionary-override-page art-full-height">
|
|
||||||
<ElCard class="override-card" shadow="never">
|
|
||||||
<!-- 1. 选项卡切换 -->
|
|
||||||
<ElTabs v-model="activeTab" class="override-tabs">
|
|
||||||
<!-- 1.1 视图覆盖(原有功能) -->
|
|
||||||
<ElTabPane :label="t('dictionary.override.customView')" name="view">
|
|
||||||
<div class="override-toolbar">
|
|
||||||
<ElSelect
|
|
||||||
v-model="selectedGroupCode"
|
|
||||||
:placeholder="t('dictionary.override.selectSystemDictionary')"
|
|
||||||
filterable
|
|
||||||
style="min-width: 260px"
|
|
||||||
>
|
|
||||||
<ElOption
|
|
||||||
v-for="group in systemGroups"
|
|
||||||
:key="group.id"
|
|
||||||
:label="`${group.name} (${group.code})`"
|
|
||||||
:value="group.code"
|
|
||||||
/>
|
|
||||||
</ElSelect>
|
|
||||||
<OverrideToggle
|
|
||||||
:group-code="selectedGroupCode"
|
|
||||||
:enabled="overrideConfig.overrideEnabled"
|
|
||||||
@change="handleOverrideChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DualPaneView
|
|
||||||
v-if="selectedGroup"
|
|
||||||
:group-code="selectedGroup.code"
|
|
||||||
:system-group="selectedGroup"
|
|
||||||
:tenant-group-id="tenantGroupId"
|
|
||||||
:system-items="systemItems"
|
|
||||||
:custom-items="mergedItems"
|
|
||||||
:hidden-ids="overrideConfig.hiddenSystemItemIds"
|
|
||||||
:override-enabled="overrideConfig.overrideEnabled"
|
|
||||||
@refresh="refreshMergedItems"
|
|
||||||
@tenant-group-created="handleTenantGroupCreated"
|
|
||||||
@sort-pending="handleSortPending"
|
|
||||||
/>
|
|
||||||
<div v-else class="override-empty">{{ t('dictionary.override.selectGroupHint') }}</div>
|
|
||||||
</ElTabPane>
|
|
||||||
|
|
||||||
<!-- 1.2 标签覆盖(新功能) -->
|
|
||||||
<ElTabPane :label="t('dictionary.labelOverride.tenantTitle')" name="label">
|
|
||||||
<LabelOverridePanel :system-items="allSystemItems" />
|
|
||||||
</ElTabPane>
|
|
||||||
</ElTabs>
|
|
||||||
</ElCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
|
||||||
import { ElMessageBox } from 'element-plus'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { onBeforeRouteLeave } from 'vue-router'
|
|
||||||
import { getGroups } from '@/api/dictionary/group'
|
|
||||||
import { getItems } from '@/api/dictionary/item'
|
|
||||||
import { getDictionary } from '@/api/dictionary/query'
|
|
||||||
import { useDictionaryOverrideStore } from '@/store/modules/dictionaryOverride'
|
|
||||||
import { useUserStore } from '@/store/modules/user'
|
|
||||||
import { DictionaryScope } from '@/enums/Dictionary'
|
|
||||||
import DualPaneView from './components/DualPaneView.vue'
|
|
||||||
import OverrideToggle from './components/OverrideToggle.vue'
|
|
||||||
import LabelOverridePanel from './components/LabelOverridePanel.vue'
|
|
||||||
|
|
||||||
defineOptions({ name: 'DictionaryOverride' })
|
|
||||||
|
|
||||||
const overrideStore = useDictionaryOverrideStore()
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
// 1. 选项卡状态
|
|
||||||
const activeTab = ref('view')
|
|
||||||
|
|
||||||
const systemGroups = ref<Api.Dictionary.DictionaryGroupDto[]>([])
|
|
||||||
const selectedGroupCode = ref('')
|
|
||||||
const systemItems = ref<Api.Dictionary.DictionaryItemDto[]>([])
|
|
||||||
const mergedItems = ref<Api.Dictionary.DictionaryItemDto[]>([])
|
|
||||||
const tenantGroupId = ref<string | null>(null)
|
|
||||||
const hasPendingSort = ref(false)
|
|
||||||
|
|
||||||
// 2. 所有系统字典项(用于标签覆盖面板)
|
|
||||||
const allSystemItems = ref<Api.Dictionary.DictionaryItemDto[]>([])
|
|
||||||
|
|
||||||
const selectedGroup = computed(() =>
|
|
||||||
systemGroups.value.find((group) => group.code === selectedGroupCode.value)
|
|
||||||
)
|
|
||||||
|
|
||||||
const overrideConfig = computed<Api.Dictionary.OverrideConfigDto>(() => {
|
|
||||||
const key = selectedGroupCode.value.trim().toLowerCase()
|
|
||||||
const stored = overrideStore.overrides[key]
|
|
||||||
if (stored) return stored
|
|
||||||
return {
|
|
||||||
tenantId: userStore.info?.tenantId || '0',
|
|
||||||
systemDictionaryGroupCode: selectedGroupCode.value,
|
|
||||||
overrideEnabled: false,
|
|
||||||
hiddenSystemItemIds: [],
|
|
||||||
customSortOrder: {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadSystemGroups = async () => {
|
|
||||||
const result = await getGroups({
|
|
||||||
scope: DictionaryScope.System,
|
|
||||||
Page: 1,
|
|
||||||
PageSize: 200,
|
|
||||||
isEnabled: true
|
|
||||||
})
|
|
||||||
|
|
||||||
systemGroups.value = result.items.filter((group) => group.allowOverride)
|
|
||||||
if (!selectedGroupCode.value && systemGroups.value.length) {
|
|
||||||
selectedGroupCode.value = systemGroups.value[0].code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadTenantGroupId = async (code: string) => {
|
|
||||||
if (!code) {
|
|
||||||
tenantGroupId.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = await getGroups({
|
|
||||||
scope: DictionaryScope.Business,
|
|
||||||
Page: 1,
|
|
||||||
PageSize: 200,
|
|
||||||
keyword: code
|
|
||||||
})
|
|
||||||
const match = result.items.find((group) => group.code.toLowerCase() === code.toLowerCase())
|
|
||||||
tenantGroupId.value = match?.id ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadItems = async () => {
|
|
||||||
if (!selectedGroup.value) return
|
|
||||||
systemItems.value = await getItems(selectedGroup.value.id)
|
|
||||||
mergedItems.value = await getDictionary(selectedGroup.value.code)
|
|
||||||
await loadTenantGroupId(selectedGroup.value.code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 加载所有系统字典项(用于标签覆盖)
|
|
||||||
const loadAllSystemItems = async () => {
|
|
||||||
if (allSystemItems.value.length > 0) return // 已加载则跳过
|
|
||||||
|
|
||||||
const items: Api.Dictionary.DictionaryItemDto[] = []
|
|
||||||
for (const group of systemGroups.value) {
|
|
||||||
const groupItems = await getItems(group.id)
|
|
||||||
items.push(...groupItems)
|
|
||||||
}
|
|
||||||
allSystemItems.value = items
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshMergedItems = async () => {
|
|
||||||
if (!selectedGroup.value) return
|
|
||||||
mergedItems.value = await getDictionary(selectedGroup.value.code)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOverrideChange = async () => {
|
|
||||||
await refreshMergedItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTenantGroupCreated = (group: Api.Dictionary.DictionaryGroupDto) => {
|
|
||||||
tenantGroupId.value = group.id
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSortPending = (pending: boolean) => {
|
|
||||||
hasPendingSort.value = pending
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeRouteLeave(async (_to, _from, next) => {
|
|
||||||
if (!hasPendingSort.value) {
|
|
||||||
next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
t('dictionary.override.unsavedSortConfirm'),
|
|
||||||
t('dictionary.common.warning'),
|
|
||||||
{
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: t('dictionary.common.confirm'),
|
|
||||||
cancelButtonText: t('dictionary.common.cancel')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
next()
|
|
||||||
} catch {
|
|
||||||
next(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => selectedGroupCode.value,
|
|
||||||
async () => {
|
|
||||||
if (!selectedGroupCode.value) return
|
|
||||||
hasPendingSort.value = false
|
|
||||||
await loadItems()
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
// 4. 监听选项卡切换,按需加载数据
|
|
||||||
watch(
|
|
||||||
() => activeTab.value,
|
|
||||||
async (tab) => {
|
|
||||||
if (tab === 'label') {
|
|
||||||
await loadAllSystemItems()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await overrideStore.fetchOverrides()
|
|
||||||
await loadSystemGroups()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.dictionary-override-page {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-tabs {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.override-tabs .el-tabs__content) {
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.override-tabs .el-tab-pane) {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-empty {
|
|
||||||
padding: 32px;
|
|
||||||
color: #909399;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="dictionary-page art-full-height">
|
|
||||||
<ElContainer class="dictionary-layout">
|
|
||||||
<ElAside width="320px">
|
|
||||||
<GroupList :scope="DictionaryScope.Business" :allow-scope-selection="false" />
|
|
||||||
</ElAside>
|
|
||||||
<ElMain>
|
|
||||||
<ItemTable :group="currentGroup" />
|
|
||||||
</ElMain>
|
|
||||||
</ElContainer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useDictionaryGroupStore } from '@/store/modules/dictionaryGroup'
|
|
||||||
import { DictionaryScope } from '@/enums/Dictionary'
|
|
||||||
import GroupList from '@/views/system/dictionary/components/GroupList.vue'
|
|
||||||
import ItemTable from '@/views/system/dictionary/components/ItemTable.vue'
|
|
||||||
|
|
||||||
defineOptions({ name: 'TenantDictionary' })
|
|
||||||
|
|
||||||
const groupStore = useDictionaryGroupStore()
|
|
||||||
const currentGroup = computed(() => groupStore.currentGroup)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.dictionary-page {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dictionary-layout {
|
|
||||||
gap: 16px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-aside) {
|
|
||||||
padding-right: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-main) {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -505,7 +505,7 @@
|
|||||||
rejectReasonKey: '' as RejectReasonKey | '',
|
rejectReasonKey: '' as RejectReasonKey | '',
|
||||||
customRejectReason: '',
|
customRejectReason: '',
|
||||||
renewMonths: 1 as number | null,
|
renewMonths: 1 as number | null,
|
||||||
operatingMode: OperatingMode.SameEntity as OperatingMode | null
|
operatingMode: OperatingMode.SameEntity as OperatingMode | undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const rules = computed<FormRules>(() => ({
|
const rules = computed<FormRules>(() => ({
|
||||||
@@ -613,7 +613,7 @@
|
|||||||
form.operatingMode = form.operatingMode ?? OperatingMode.SameEntity
|
form.operatingMode = form.operatingMode ?? OperatingMode.SameEntity
|
||||||
} else {
|
} else {
|
||||||
form.renewMonths = null
|
form.renewMonths = null
|
||||||
form.operatingMode = null
|
form.operatingMode = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 触发表单校验刷新
|
// 2. 触发表单校验刷新
|
||||||
|
|||||||
Reference in New Issue
Block a user