feat: add public purchase portal and admin tools
This commit is contained in:
1 parent
c0a6ee3cb0
commit
eb1f68b423
29 files changed
+2851
-163
No files matched your search
@@ -7,7 +7,7 @@ export interface Package {
|
||||
price: number;
|
||||
price_description?: string;
|
||||
duration_description: string;
|
||||
status: number; // 1: enabled, 2: disabled
|
||||
status: number; // 1: enabled, 0: disabled
|
||||
type: string;
|
||||
sort_order: number;
|
||||
description?: string;
|
||||
@@ -51,3 +51,26 @@ export function getPackageDetail(id: string | number): Promise<ApiResponse<Packa
|
||||
export function updatePackage(id: string | number, data: Partial<Package>): Promise<ApiResponse<Package>> {
|
||||
return Axios.put(`/api/packages/${id}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建套餐
|
||||
*/
|
||||
export function createPackage(data: Partial<Package>): Promise<ApiResponse<Package>> {
|
||||
return Axios.post('/api/packages', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除套餐
|
||||
*/
|
||||
export function deletePackage(id: string | number): Promise<ApiResponse<null>> {
|
||||
return Axios.delete(`/api/packages/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新套餐状态
|
||||
*/
|
||||
export function updatePackageStatus(id: string | number, status: 0 | 1): Promise<ApiResponse<Package>> {
|
||||
return Axios.put(`/api/packages/${id}/status`, null, {
|
||||
params: { status }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Axios from './https'
|
||||
|
||||
export interface PublicApiResponse<T = any> {
|
||||
code: string
|
||||
message: string
|
||||
timestamp: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export interface AuthorizationCodeInfo {
|
||||
authorization_code_id: string
|
||||
authorization_code: string
|
||||
customer_id: string
|
||||
customer_name: string
|
||||
contact_person: string
|
||||
contact_phone: string
|
||||
contact_email: string
|
||||
status: 'normal' | 'expired' | 'locked'
|
||||
is_locked: boolean
|
||||
start_date: string
|
||||
end_date: string
|
||||
max_activations: number
|
||||
active_licenses: number
|
||||
}
|
||||
|
||||
export interface RenewalOrderRequest {
|
||||
authorization_code: string
|
||||
package_id: string
|
||||
license_count: number
|
||||
renew_days: number
|
||||
payment_method?: string
|
||||
contact_name: string
|
||||
contact_phone: string
|
||||
contact_email?: string
|
||||
company_name?: string
|
||||
}
|
||||
|
||||
export interface RenewalOrderResponse {
|
||||
order_id?: string
|
||||
order_no?: string
|
||||
package_name?: string
|
||||
authorization_code?: string
|
||||
payment_no?: string
|
||||
payment_url?: string
|
||||
total_amount: number
|
||||
expire_time?: string
|
||||
renew_days?: number
|
||||
license_count?: number
|
||||
}
|
||||
|
||||
export interface PublicPaymentStatus {
|
||||
payment_no: string
|
||||
status: 'pending' | 'paid' | 'cancelled' | 'expired' | 'failed'
|
||||
amount: number
|
||||
payment_time?: string
|
||||
trade_no?: string
|
||||
authorization_code_id?: string
|
||||
authorization_end_date?: string
|
||||
business_order?: {
|
||||
order_no: string
|
||||
authorization_code?: string
|
||||
status: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface PublicPackageItem {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
price: number
|
||||
display_price: string
|
||||
price_description: string
|
||||
}
|
||||
|
||||
export function queryAuthorizationCode(authorizationCode: string): Promise<PublicApiResponse<AuthorizationCodeInfo>> {
|
||||
return Axios.post('/api/public/authorization-codes/query', {
|
||||
authorization_code: authorizationCode
|
||||
})
|
||||
}
|
||||
|
||||
export function createRenewalOrder(payload: RenewalOrderRequest): Promise<PublicApiResponse<RenewalOrderResponse>> {
|
||||
return Axios.post('/api/public/renewals/order', payload)
|
||||
}
|
||||
|
||||
export interface PurchaseOrderRequest {
|
||||
package_id: string
|
||||
license_count: number
|
||||
payment_method?: string
|
||||
contact_name: string
|
||||
contact_phone: string
|
||||
contact_email?: string
|
||||
company_name?: string
|
||||
customer_type?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function createPurchaseOrder(payload: PurchaseOrderRequest): Promise<PublicApiResponse<RenewalOrderResponse>> {
|
||||
return Axios.post('/api/public/orders', payload)
|
||||
}
|
||||
|
||||
export function getPublicPaymentStatus(paymentNo: string): Promise<PublicApiResponse<PublicPaymentStatus>> {
|
||||
return Axios.get(`/api/public/payment/${paymentNo}/status`)
|
||||
}
|
||||
|
||||
export function getPublicPackages(): Promise<PublicApiResponse<PublicPackageItem[]>> {
|
||||
return Axios.get('/api/cu/packages')
|
||||
}
|
||||
@@ -38,7 +38,11 @@ const iconMap = {
|
||||
'invoices': InvoicesIcon,
|
||||
'enterprise-leads': EnterpriseLeadsIcon,
|
||||
'roles': RolesIcon,
|
||||
'users': UsersIcon
|
||||
'users': UsersIcon,
|
||||
'packages': InvoicesIcon,
|
||||
'payments': InvoicesIcon,
|
||||
'product': InvoicesIcon,
|
||||
'system': RolesIcon
|
||||
}
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
@@ -73,4 +77,4 @@ const iconClass = computed(() => ({
|
||||
.sidebar-icon--active :deep(path) {
|
||||
fill: #019C7C;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -30,10 +30,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import Sidebar from './Sidebar.vue'
|
||||
import NavContent from './NavContent.vue'
|
||||
import { buildSidebarMenus } from '@/utils/adminLocalData'
|
||||
|
||||
// 导航项和面包屑项接口定义
|
||||
interface NavItem {
|
||||
@@ -57,22 +57,6 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
pageTitle: ''
|
||||
})
|
||||
|
||||
// 使用国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 默认导航配置
|
||||
const defaultNavItems = computed(() => [
|
||||
{ id: "dashboard", label: t('navigation.menu.dashboard'), href: "/dashboard", icon: "dashboard" },
|
||||
{ id: "customers", label: t('navigation.menu.customers'), href: "/customers", icon: "customers" },
|
||||
{ id: "enterprise-leads", label: t('navigation.menu.enterpriseLeads'), href: "/enterprise-leads", icon: "enterprise-leads" },
|
||||
{ id: "licenses", label: t('navigation.menu.licenses'), href: "/licenses", icon: "licenses" },
|
||||
{ id: "invoices", label: t('navigation.menu.invoices'), href: "/invoices", icon: "invoices" },
|
||||
{ id: "packages", label: t('navigation.menu.packages'), href: "/packages", icon: "packages" }
|
||||
// 暂时隐藏角色和系统用户菜单,后续版本再开启
|
||||
// { id: "roles", label: t('navigation.menu.roles'), href: "/roles", icon: "roles" },
|
||||
// { id: "users", label: t('navigation.menu.users'), href: "/users", icon: "users" }
|
||||
])
|
||||
|
||||
// 使用 store 和路由
|
||||
const appStore = useAppStore()
|
||||
const route = useRoute()
|
||||
@@ -80,10 +64,13 @@ const router = useRouter()
|
||||
|
||||
// 计算当前激活的导航项
|
||||
const navItems = computed(() => {
|
||||
return defaultNavItems.value.map(item => ({
|
||||
return buildSidebarMenus().map(item => ({
|
||||
...item,
|
||||
// 对于有子路由的项目,匹配路径前缀
|
||||
active: route.path === item.href || route.path.startsWith(item.href + '/')
|
||||
active: route.path === item.href || route.path.startsWith(item.href + '/'),
|
||||
children: item.children?.map(child => ({
|
||||
...child,
|
||||
active: route.path === child.href || route.path.startsWith(child.href + '/')
|
||||
})) || []
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -274,4 +261,4 @@ const handleMobileOverlay = () => {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -201,14 +201,30 @@
|
||||
"remarkPlaceholder": "Please enter remark",
|
||||
"featurePlaceholder": "Please enter feature content"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Package",
|
||||
"save": "Create Package"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "Edit"
|
||||
"create": "Create",
|
||||
"edit": "Edit",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"messages": {
|
||||
"fetchError": "Failed to fetch list",
|
||||
"fetchDetailError": "Failed to fetch details",
|
||||
"createSuccess": "Created successfully",
|
||||
"createError": "Failed to create",
|
||||
"updateSuccess": "Updated successfully",
|
||||
"updateError": "Failed to update"
|
||||
"updateError": "Failed to update",
|
||||
"toggleSuccess": "Status updated successfully",
|
||||
"toggleError": "Failed to update status",
|
||||
"toggleConfirm": "Are you sure to {action} package \"{name}\"?",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"deleteError": "Failed to delete",
|
||||
"deleteConfirm": "Are you sure you want to delete package \"{name}\"? This action cannot be undone."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1002,4 +1018,4 @@
|
||||
"array": "Array"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,14 +201,30 @@
|
||||
"remarkPlaceholder": "備考を入力してください",
|
||||
"featurePlaceholder": "機能内容を入力してください"
|
||||
},
|
||||
"create": {
|
||||
"title": "パッケージ新規作成",
|
||||
"save": "パッケージを作成"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "編集"
|
||||
"create": "新規作成",
|
||||
"edit": "編集",
|
||||
"enable": "有効化",
|
||||
"disable": "無効化",
|
||||
"delete": "削除"
|
||||
},
|
||||
"messages": {
|
||||
"fetchError": "一覧の取得に失敗しました",
|
||||
"fetchDetailError": "詳細の取得に失敗しました",
|
||||
"createSuccess": "作成しました",
|
||||
"createError": "作成に失敗しました",
|
||||
"updateSuccess": "更新しました",
|
||||
"updateError": "更新に失敗しました"
|
||||
"updateError": "更新に失敗しました",
|
||||
"toggleSuccess": "ステータスを更新しました",
|
||||
"toggleError": "ステータス更新に失敗しました",
|
||||
"toggleConfirm": "パッケージ「{name}」を{action}してもよろしいですか?",
|
||||
"deleteSuccess": "削除しました",
|
||||
"deleteError": "削除に失敗しました",
|
||||
"deleteConfirm": "パッケージ「{name}」を削除しますか?この操作は元に戻せません。"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1001,4 +1017,4 @@
|
||||
"array": "配列"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,14 +234,30 @@
|
||||
"remarkPlaceholder": "请输入备注",
|
||||
"featurePlaceholder": "请输入特性内容"
|
||||
},
|
||||
"create": {
|
||||
"title": "新增套餐",
|
||||
"save": "创建套餐"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "编辑"
|
||||
"create": "新增套餐",
|
||||
"edit": "编辑",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"delete": "删除"
|
||||
},
|
||||
"messages": {
|
||||
"fetchError": "获取列表失败",
|
||||
"fetchDetailError": "获取详情失败",
|
||||
"createSuccess": "创建成功",
|
||||
"createError": "创建失败",
|
||||
"updateSuccess": "更新成功",
|
||||
"updateError": "更新失败"
|
||||
"updateError": "更新失败",
|
||||
"toggleSuccess": "状态更新成功",
|
||||
"toggleError": "状态更新失败",
|
||||
"toggleConfirm": "确认{action}套餐“{name}”吗?",
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteError": "删除失败",
|
||||
"deleteConfirm": "确认删除套餐“{name}”吗?删除后不可恢复。"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1035,4 +1051,4 @@
|
||||
"array": "数组"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,11 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () => import("@/views/Login.vue"),
|
||||
meta: { title: "登录" }
|
||||
},
|
||||
{
|
||||
path: "/public-renewal",
|
||||
component: () => import("@/views/PublicRenewal.vue"),
|
||||
meta: { title: "授权续期购买" }
|
||||
},
|
||||
{
|
||||
path: "/dashboard",
|
||||
component: () => import("@/views/Dashboard.vue"),
|
||||
@@ -29,6 +34,11 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () => import("@/views/Customers/index.vue"),
|
||||
meta: { title: "客户管理", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/customer-archives",
|
||||
component: () => import("@/views/Customers/index.vue"),
|
||||
meta: { title: "客户档案", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/licenses",
|
||||
name: "licenses",
|
||||
@@ -76,6 +86,21 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () => import("@/views/Packages/index.vue"),
|
||||
meta: { title: "套餐管理", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/product-items",
|
||||
component: () => import("@/views/ProductItems.vue"),
|
||||
meta: { title: "产品项管理", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/feature-templates",
|
||||
component: () => import("@/views/FeatureTemplates.vue"),
|
||||
meta: { title: "功能模板", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/payment-settings",
|
||||
component: () => import("@/views/PaymentSettings.vue"),
|
||||
meta: { title: "支付配置", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/invoices/detail/:id",
|
||||
name: "invoice-detail",
|
||||
@@ -92,6 +117,16 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () => import("@/views/Users.vue"),
|
||||
meta: { title: "用户管理", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/menu-management",
|
||||
component: () => import("@/views/MenuManagement.vue"),
|
||||
meta: { title: "菜单管理", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/audit-logs",
|
||||
component: () => import("@/views/AuditLogs.vue"),
|
||||
meta: { title: "审计日志", requiresAuth: true }
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
@@ -117,4 +152,3 @@ router.beforeEach((to, _from, next) => {
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
export interface AdminRole {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
description: string
|
||||
permissions: string[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: string
|
||||
username: string
|
||||
roleCode: string
|
||||
phone: string
|
||||
email: string
|
||||
status: 'active' | 'disabled'
|
||||
lastLoginAt: string
|
||||
}
|
||||
|
||||
export interface FeatureTemplate {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
description: string
|
||||
content: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AuditLogItem {
|
||||
id: string
|
||||
module: string
|
||||
action: string
|
||||
operator: string
|
||||
detail: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PaymentProviderSetting {
|
||||
enabled: boolean
|
||||
appId: string
|
||||
gatewayUrl: string
|
||||
notifyUrl: string
|
||||
returnUrl: string
|
||||
signType: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface PaymentSettings {
|
||||
defaultMethod: 'alipay' | 'wechat'
|
||||
expireMinutes: number
|
||||
retryTimes: number
|
||||
enableLog: boolean
|
||||
alipay: PaymentProviderSetting
|
||||
wechat: PaymentProviderSetting
|
||||
}
|
||||
|
||||
export interface AdminMenuItem {
|
||||
id: string
|
||||
label: string
|
||||
href: string
|
||||
icon?: string
|
||||
visible: boolean
|
||||
children?: AdminMenuItem[]
|
||||
}
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
roles: 'cedar-admin-roles',
|
||||
users: 'cedar-admin-users',
|
||||
templates: 'cedar-feature-templates',
|
||||
menus: 'cedar-admin-menus',
|
||||
logs: 'cedar-admin-logs',
|
||||
paymentSettings: 'cedar-payment-settings'
|
||||
}
|
||||
|
||||
const now = () => new Date().toISOString()
|
||||
const uid = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
const defaultRoles: AdminRole[] = [
|
||||
{
|
||||
id: uid('role'),
|
||||
name: '超级管理员',
|
||||
code: 'admin',
|
||||
description: '拥有全部功能访问与配置权限',
|
||||
permissions: ['dashboard', 'customers', 'licenses', 'system'],
|
||||
createdAt: now()
|
||||
},
|
||||
{
|
||||
id: uid('role'),
|
||||
name: '运营人员',
|
||||
code: 'operator',
|
||||
description: '可处理客户、授权、发票与线索',
|
||||
permissions: ['dashboard', 'customers', 'licenses', 'invoices', 'leads'],
|
||||
createdAt: now()
|
||||
}
|
||||
]
|
||||
|
||||
const defaultUsers: AdminUser[] = [
|
||||
{
|
||||
id: uid('user'),
|
||||
username: 'admin',
|
||||
roleCode: 'admin',
|
||||
phone: '13800000000',
|
||||
email: 'admin@cedarv.local',
|
||||
status: 'active',
|
||||
lastLoginAt: now()
|
||||
},
|
||||
{
|
||||
id: uid('user'),
|
||||
username: 'operator',
|
||||
roleCode: 'operator',
|
||||
phone: '13900000000',
|
||||
email: 'operator@cedarv.local',
|
||||
status: 'active',
|
||||
lastLoginAt: now()
|
||||
}
|
||||
]
|
||||
|
||||
const defaultTemplates: FeatureTemplate[] = [
|
||||
{
|
||||
id: uid('tpl'),
|
||||
name: '标准授权模板',
|
||||
category: '授权',
|
||||
description: '适用于标准商业授权场景',
|
||||
content: JSON.stringify({ deployment: 'cloud', activations: 5, support: 'standard' }, null, 2),
|
||||
updatedAt: now()
|
||||
},
|
||||
{
|
||||
id: uid('tpl'),
|
||||
name: '企业续期模板',
|
||||
category: '续期',
|
||||
description: '适用于企业客户统一续期',
|
||||
content: JSON.stringify({ renew_days: 365, invoice: true, priority: 'high' }, null, 2),
|
||||
updatedAt: now()
|
||||
}
|
||||
]
|
||||
|
||||
const defaultMenus: AdminMenuItem[] = [
|
||||
{ id: 'dashboard', label: '仪表盘', href: '/dashboard', icon: 'dashboard', visible: true },
|
||||
{
|
||||
id: 'customer-center',
|
||||
label: '客户管理',
|
||||
href: '/customer-archives',
|
||||
icon: 'customers',
|
||||
visible: true,
|
||||
children: [
|
||||
{ id: 'customer-archives', label: '客户档案', href: '/customer-archives', visible: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'product-center',
|
||||
label: '产品管理',
|
||||
href: '/product-items',
|
||||
icon: 'invoices',
|
||||
visible: true,
|
||||
children: [
|
||||
{ id: 'product-items', label: '产品项管理', href: '/product-items', visible: true },
|
||||
{ id: 'feature-templates', label: '功能模板', href: '/feature-templates', visible: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'license-center',
|
||||
label: '授权管理',
|
||||
href: '/licenses/list',
|
||||
icon: 'licenses',
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
id: 'payment-center',
|
||||
label: '支付管理',
|
||||
href: '/payment-settings',
|
||||
icon: 'payments',
|
||||
visible: true,
|
||||
children: [
|
||||
{ id: 'payment-settings', label: '支付配置', href: '/payment-settings', visible: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'system-center',
|
||||
label: '系统管理',
|
||||
href: '/roles',
|
||||
icon: 'roles',
|
||||
visible: true,
|
||||
children: [
|
||||
{ id: 'roles', label: '角色权限', href: '/roles', visible: true },
|
||||
{ id: 'users', label: '系统用户', href: '/users', visible: true },
|
||||
{ id: 'menu-management', label: '菜单管理', href: '/menu-management', visible: true },
|
||||
{ id: 'audit-logs', label: '审计日志', href: '/audit-logs', visible: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'user-side-center',
|
||||
label: '用户端管理',
|
||||
href: '/enterprise-leads',
|
||||
icon: 'users',
|
||||
visible: true,
|
||||
children: [
|
||||
{ id: 'enterprise-leads', label: '企业线索', href: '/enterprise-leads', visible: true },
|
||||
{ id: 'invoices', label: '发票管理', href: '/invoices', visible: true },
|
||||
{ id: 'packages', label: '套餐管理', href: '/packages', visible: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const defaultLogs: AuditLogItem[] = [
|
||||
{
|
||||
id: uid('log'),
|
||||
module: 'system',
|
||||
action: '初始化',
|
||||
operator: 'system',
|
||||
detail: '初始化后台配置数据',
|
||||
createdAt: now()
|
||||
}
|
||||
]
|
||||
|
||||
const defaultPaymentSettings: PaymentSettings = {
|
||||
defaultMethod: 'alipay',
|
||||
expireMinutes: 30,
|
||||
retryTimes: 3,
|
||||
enableLog: true,
|
||||
alipay: {
|
||||
enabled: true,
|
||||
appId: '9021000159611786',
|
||||
gatewayUrl: 'https://openapi-sandbox.dl.alipaydev.com/gateway.do',
|
||||
notifyUrl: 'http://localhost:18888/api/payment/alipay/callback',
|
||||
returnUrl: 'http://localhost:18080/public-renewal',
|
||||
signType: 'RSA2',
|
||||
remark: '默认使用支付宝沙箱配置,部署生产环境前请替换正式应用参数。'
|
||||
},
|
||||
wechat: {
|
||||
enabled: false,
|
||||
appId: '',
|
||||
gatewayUrl: '',
|
||||
notifyUrl: '',
|
||||
returnUrl: '',
|
||||
signType: 'HMAC-SHA256',
|
||||
remark: '预留微信支付配置位,当前项目后端默认支付链路仍以支付宝为主。'
|
||||
}
|
||||
}
|
||||
|
||||
function readStorage<T>(key: string, fallback: T): T {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) {
|
||||
localStorage.setItem(key, JSON.stringify(fallback))
|
||||
return fallback
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
localStorage.setItem(key, JSON.stringify(fallback))
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage<T>(key: string, value: T) {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
|
||||
function mergeMenuDefaults(current: AdminMenuItem[], defaults: AdminMenuItem[]) {
|
||||
const currentMap = new Map(current.map(item => [item.id, item]))
|
||||
|
||||
return defaults.map(defaultItem => {
|
||||
const existing = currentMap.get(defaultItem.id)
|
||||
if (!existing) {
|
||||
return defaultItem
|
||||
}
|
||||
|
||||
const existingChildren = existing.children || []
|
||||
const defaultChildren = defaultItem.children || []
|
||||
const mergedChildren = defaultChildren.map(defaultChild => {
|
||||
const existingChild = existingChildren.find(child => child.id === defaultChild.id)
|
||||
return existingChild ? { ...defaultChild, ...existingChild } : defaultChild
|
||||
})
|
||||
|
||||
const customChildren = existingChildren.filter(child => !defaultChildren.some(defaultChild => defaultChild.id === child.id))
|
||||
|
||||
return {
|
||||
...defaultItem,
|
||||
...existing,
|
||||
children: [...mergedChildren, ...customChildren]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getRoles() {
|
||||
return readStorage(STORAGE_KEYS.roles, defaultRoles)
|
||||
}
|
||||
|
||||
export function saveRoles(data: AdminRole[]) {
|
||||
writeStorage(STORAGE_KEYS.roles, data)
|
||||
}
|
||||
|
||||
export function getUsers() {
|
||||
return readStorage(STORAGE_KEYS.users, defaultUsers)
|
||||
}
|
||||
|
||||
export function saveUsers(data: AdminUser[]) {
|
||||
writeStorage(STORAGE_KEYS.users, data)
|
||||
}
|
||||
|
||||
export function getFeatureTemplates() {
|
||||
return readStorage(STORAGE_KEYS.templates, defaultTemplates)
|
||||
}
|
||||
|
||||
export function saveFeatureTemplates(data: FeatureTemplate[]) {
|
||||
writeStorage(STORAGE_KEYS.templates, data)
|
||||
}
|
||||
|
||||
export function getAdminMenus() {
|
||||
const menus = readStorage(STORAGE_KEYS.menus, defaultMenus)
|
||||
const merged = mergeMenuDefaults(menus, defaultMenus)
|
||||
writeStorage(STORAGE_KEYS.menus, merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
export function saveAdminMenus(data: AdminMenuItem[]) {
|
||||
writeStorage(STORAGE_KEYS.menus, data)
|
||||
}
|
||||
|
||||
export function getAuditLogs() {
|
||||
return readStorage(STORAGE_KEYS.logs, defaultLogs)
|
||||
}
|
||||
|
||||
export function getPaymentSettings() {
|
||||
return readStorage(STORAGE_KEYS.paymentSettings, defaultPaymentSettings)
|
||||
}
|
||||
|
||||
export function savePaymentSettings(data: PaymentSettings) {
|
||||
writeStorage(STORAGE_KEYS.paymentSettings, data)
|
||||
}
|
||||
|
||||
export function appendAuditLog(log: Omit<AuditLogItem, 'id' | 'createdAt'>) {
|
||||
const logs = getAuditLogs()
|
||||
logs.unshift({
|
||||
id: uid('log'),
|
||||
createdAt: now(),
|
||||
...log
|
||||
})
|
||||
writeStorage(STORAGE_KEYS.logs, logs.slice(0, 200))
|
||||
}
|
||||
|
||||
export function buildSidebarMenus() {
|
||||
return getAdminMenus().filter(item => item.visible).map(item => ({
|
||||
...item,
|
||||
children: item.children?.filter(child => child.visible) || []
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<Layout app-name="Cedar-V" page-title="审计日志">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>审计日志</h1>
|
||||
<p>记录后台关键配置与管理动作,便于回溯操作轨迹。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="logs" class="data-table">
|
||||
<el-table-column prop="module" label="模块" min-width="140" />
|
||||
<el-table-column prop="action" label="动作" min-width="120" />
|
||||
<el-table-column prop="operator" label="操作人" min-width="120" />
|
||||
<el-table-column prop="detail" label="详情" min-width="320" />
|
||||
<el-table-column prop="createdAt" label="时间" min-width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import { getAuditLogs, type AuditLogItem } from '@/utils/adminLocalData'
|
||||
|
||||
const logs = ref<AuditLogItem[]>(getAuditLogs())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell { padding: 24px; }
|
||||
.page-header { margin-bottom: 20px; }
|
||||
.page-header h1 { margin: 0; font-size: 28px; }
|
||||
.page-header p { margin: 8px 0 0; color: #6b7280; }
|
||||
.data-table { background: #fff; border-radius: 18px; overflow: hidden; }
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<Layout app-name="Cedar-V" page-title="功能模板">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>功能模板</h1>
|
||||
<p>维护产品功能模板,供授权、续期和定制方案复用。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">新增模板</el-button>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col v-for="item in templates" :key="item.id" :xs="24" :md="12" :xl="8">
|
||||
<div class="template-card">
|
||||
<div class="template-top">
|
||||
<h3>{{ item.name }}</h3>
|
||||
<el-tag>{{ item.category }}</el-tag>
|
||||
</div>
|
||||
<p>{{ item.description }}</p>
|
||||
<pre>{{ item.content }}</pre>
|
||||
<div class="template-actions">
|
||||
<el-button link type="primary" @click="openEdit(item)">编辑</el-button>
|
||||
<el-button link type="danger" @click="removeTemplate(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑模板' : '新增模板'" width="640px">
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="模板名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="分类"><el-input v-model="form.category" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="模板内容"><el-input v-model="form.content" type="textarea" :rows="10" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveTemplate">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import { appendAuditLog, getFeatureTemplates, saveFeatureTemplates, type FeatureTemplate } from '@/utils/adminLocalData'
|
||||
|
||||
const templates = ref<FeatureTemplate[]>(getFeatureTemplates())
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
category: '',
|
||||
description: '',
|
||||
content: '{}'
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
form.category = ''
|
||||
form.description = ''
|
||||
form.content = '{}'
|
||||
editingId.value = ''
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: FeatureTemplate) {
|
||||
editingId.value = item.id
|
||||
form.name = item.name
|
||||
form.category = item.category
|
||||
form.description = item.description
|
||||
form.content = item.content
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function saveTemplate() {
|
||||
if (!form.name.trim()) {
|
||||
ElMessage.warning('请输入模板名称')
|
||||
return
|
||||
}
|
||||
const nextItems = [...templates.value]
|
||||
if (editingId.value) {
|
||||
const index = nextItems.findIndex(item => item.id === editingId.value)
|
||||
if (index >= 0) {
|
||||
nextItems[index] = { ...nextItems[index], ...form, updatedAt: new Date().toLocaleString() }
|
||||
appendAuditLog({ module: '功能模板', action: '编辑', operator: 'admin', detail: `编辑模板 ${form.name}` })
|
||||
}
|
||||
} else {
|
||||
nextItems.unshift({
|
||||
id: `tpl_${Date.now()}`,
|
||||
updatedAt: new Date().toLocaleString(),
|
||||
...form
|
||||
})
|
||||
appendAuditLog({ module: '功能模板', action: '新增', operator: 'admin', detail: `新增模板 ${form.name}` })
|
||||
}
|
||||
templates.value = nextItems
|
||||
saveFeatureTemplates(nextItems)
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('模板已保存')
|
||||
}
|
||||
|
||||
async function removeTemplate(id: string) {
|
||||
const item = templates.value.find(template => template.id === id)
|
||||
if (!item) return
|
||||
await ElMessageBox.confirm(`确认删除模板“${item.name}”吗?`, '提示')
|
||||
templates.value = templates.value.filter(template => template.id !== id)
|
||||
saveFeatureTemplates(templates.value)
|
||||
appendAuditLog({ module: '功能模板', action: '删除', operator: 'admin', detail: `删除模板 ${item.name}` })
|
||||
ElMessage.success('模板已删除')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell { padding: 24px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.page-header h1 { margin: 0; font-size: 28px; }
|
||||
.page-header p { margin: 8px 0 0; color: #6b7280; }
|
||||
.template-card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.template-top { display: flex; justify-content: space-between; align-items: center; }
|
||||
.template-card h3 { margin: 0; }
|
||||
.template-card p { color: #6b7280; min-height: 44px; }
|
||||
pre {
|
||||
background: #0f172a;
|
||||
color: #cbd5e1;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
min-height: 140px;
|
||||
}
|
||||
.template-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
</style>
|
||||
@@ -132,6 +132,7 @@
|
||||
</el-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<el-link type="primary" :underline="false" @click="goPublicRenewal">免登录续期购买</el-link>
|
||||
<p>{{ t('login.copyright') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,6 +328,10 @@ function handleForgotPassword() {
|
||||
ElMessage.info(t('login.forgotPasswordAlert'))
|
||||
}
|
||||
|
||||
function goPublicRenewal() {
|
||||
router.push('/public-renewal')
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!loginFormRef.value) return
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<Layout app-name="Cedar-V" page-title="菜单管理">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>菜单管理</h1>
|
||||
<p>控制后台导航分组与菜单显示状态,保存后侧边栏立即生效。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-card">
|
||||
<div v-for="group in menus" :key="group.id" class="menu-group">
|
||||
<div class="menu-group-head">
|
||||
<div>
|
||||
<strong>{{ group.label }}</strong>
|
||||
<span class="route">{{ group.href }}</span>
|
||||
</div>
|
||||
<el-switch v-model="group.visible" />
|
||||
</div>
|
||||
<div v-if="group.children?.length" class="menu-children">
|
||||
<div v-for="child in group.children" :key="child.id" class="menu-child">
|
||||
<span>{{ child.label }}</span>
|
||||
<span class="route">{{ child.href }}</span>
|
||||
<el-switch v-model="child.visible" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<el-button type="primary" @click="saveMenus">保存菜单配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import { appendAuditLog, getAdminMenus, saveAdminMenus, type AdminMenuItem } from '@/utils/adminLocalData'
|
||||
|
||||
const menus = ref<AdminMenuItem[]>(JSON.parse(JSON.stringify(getAdminMenus())))
|
||||
|
||||
function saveMenus() {
|
||||
saveAdminMenus(menus.value)
|
||||
appendAuditLog({ module: '菜单管理', action: '保存', operator: 'admin', detail: '更新后台菜单显示配置' })
|
||||
ElMessage.success('菜单配置已保存')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell { padding: 24px; }
|
||||
.page-header { margin-bottom: 20px; }
|
||||
.page-header h1 { margin: 0; font-size: 28px; }
|
||||
.page-header p { margin: 8px 0 0; color: #6b7280; }
|
||||
.menu-card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.menu-group { border-bottom: 1px solid #eef2f7; padding: 16px 0; }
|
||||
.menu-group:last-child { border-bottom: none; }
|
||||
.menu-group-head, .menu-child {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px 60px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.menu-children { margin-top: 10px; padding-left: 22px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.route { color: #94a3b8; font-size: 13px; }
|
||||
.footer { display: flex; justify-content: flex-end; padding-top: 16px; }
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="loading_box">
|
||||
<el-dialog v-model="visible" :title="t('packages.edit.title')" width="700px" class="package-edit-dialog"
|
||||
<el-dialog v-model="visible" :title="dialogTitle" width="700px" class="package-edit-dialog"
|
||||
destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="edit-form">
|
||||
<div class="form-row">
|
||||
@@ -59,7 +59,7 @@
|
||||
<el-form-item :label="t('packages.edit.status')" prop="status" class="flex-1">
|
||||
<el-select v-model="form.status" class="w-full">
|
||||
<el-option :label="t('packages.status.enabled')" :value="1" />
|
||||
<el-option :label="t('packages.status.disabled')" :value="2" />
|
||||
<el-option :label="t('packages.status.disabled')" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('packages.edit.sort')" prop="sort_order" class="flex-1">
|
||||
@@ -77,7 +77,7 @@
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" class="btn-save" @click="handleSave">
|
||||
{{ t('packages.edit.save') }}
|
||||
{{ saveButtonText }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -86,7 +86,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, reactive } from 'vue'
|
||||
import { ref, watch, reactive, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Plus, Minus } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
@@ -94,6 +94,7 @@ import type { FormInstance, FormRules } from 'element-plus'
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
data: any
|
||||
mode?: 'create' | 'edit'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save'])
|
||||
@@ -101,6 +102,8 @@ const emit = defineEmits(['update:modelValue', 'save'])
|
||||
const { t } = useI18n()
|
||||
const visible = ref(props.modelValue)
|
||||
const formRef = ref<FormInstance>()
|
||||
const dialogTitle = computed(() => props.mode === 'create' ? t('packages.create.title') : t('packages.edit.title'))
|
||||
const saveButtonText = computed(() => props.mode === 'create' ? t('packages.create.save') : t('packages.edit.save'))
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
@@ -115,6 +118,19 @@ const form = reactive({
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
form.name = ''
|
||||
form.type = 'trial'
|
||||
form.price = 0
|
||||
form.price_description = ''
|
||||
form.duration_description = ''
|
||||
form.description = ''
|
||||
form.features = []
|
||||
form.status = 1
|
||||
form.sort_order = 1
|
||||
form.remark = ''
|
||||
}
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
name: [{ required: true, message: t('common.required'), trigger: 'blur' }],
|
||||
type: [{ required: true, message: t('common.required'), trigger: 'change' }],
|
||||
@@ -127,7 +143,12 @@ const rules = reactive<FormRules>({
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val && props.data) {
|
||||
if (!val) return
|
||||
if (props.mode === 'create') {
|
||||
resetForm()
|
||||
return
|
||||
}
|
||||
if (props.data) {
|
||||
form.name = props.data.name || ''
|
||||
form.type = props.data.type || 'trial'
|
||||
form.price = Number(props.data.price) || 0
|
||||
@@ -135,10 +156,12 @@ watch(() => props.modelValue, (val) => {
|
||||
form.duration_description = props.data.duration_description || ''
|
||||
form.description = props.data.description || ''
|
||||
form.features = Array.isArray(props.data.features) ? [...props.data.features] : []
|
||||
form.status = props.data.status === 1 ? 1 : 2
|
||||
form.status = props.data.status === 1 ? 1 : 0
|
||||
form.sort_order = props.data.sort_order || 1
|
||||
form.remark = props.data.remark || ''
|
||||
return
|
||||
}
|
||||
resetForm()
|
||||
})
|
||||
|
||||
watch(visible, (val) => {
|
||||
|
||||
@@ -7,13 +7,18 @@
|
||||
<span class="title-bar"></span>
|
||||
<span>{{ t('packages.table.title') }}</span>
|
||||
</div>
|
||||
<el-button
|
||||
class="refresh-btn"
|
||||
:icon="Refresh"
|
||||
circle
|
||||
@click="fetchData"
|
||||
:loading="loading"
|
||||
/>
|
||||
<div class="header-actions">
|
||||
<el-button class="create-btn" type="primary" @click="handleCreate">
|
||||
{{ t('packages.actions.create') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
class="refresh-btn"
|
||||
:icon="Refresh"
|
||||
circle
|
||||
@click="fetchData"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
@@ -54,11 +59,23 @@
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('packages.table.actions')" width="100" fixed="right">
|
||||
<el-table-column :label="t('packages.table.actions')" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" class="btn-edit" @click="handleEdit(row)">
|
||||
{{ t('packages.actions.edit') }}
|
||||
</el-button>
|
||||
<div class="action-buttons">
|
||||
<el-button size="small" class="btn-edit" @click="handleEdit(row)">
|
||||
{{ t('packages.actions.edit') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
class="btn-toggle"
|
||||
@click="handleToggleStatus(row)"
|
||||
>
|
||||
{{ row.status === 1 ? t('packages.actions.disable') : t('packages.actions.enable') }}
|
||||
</el-button>
|
||||
<el-button size="small" class="btn-delete" @click="handleDelete(row)">
|
||||
{{ t('packages.actions.delete') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -66,9 +83,10 @@
|
||||
</div>
|
||||
|
||||
<PackageEditDialog
|
||||
v-model="editVisible"
|
||||
v-model="dialogVisible"
|
||||
:data="selectedPackage"
|
||||
@save="handleUpdate"
|
||||
:mode="dialogMode"
|
||||
@save="handleSavePackage"
|
||||
/>
|
||||
</Layout>
|
||||
</template>
|
||||
@@ -78,8 +96,8 @@ import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import PackageEditDialog from './components/PackageEditDialog.vue'
|
||||
import { getPackages, updatePackage, getPackageDetail, type Package } from '@/api/package'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getPackages, updatePackage, getPackageDetail, createPackage, deletePackage, updatePackageStatus, type Package } from '@/api/package'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
|
||||
@@ -126,9 +144,16 @@ onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
const editVisible = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('edit')
|
||||
const selectedPackage = ref<any>(null)
|
||||
|
||||
const handleCreate = () => {
|
||||
dialogMode.value = 'create'
|
||||
selectedPackage.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = async (row: any) => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -151,7 +176,8 @@ const handleEdit = async (row: any) => {
|
||||
...detail,
|
||||
features
|
||||
}
|
||||
editVisible.value = true
|
||||
dialogMode.value = 'edit'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Fetch package detail error:', error)
|
||||
@@ -161,12 +187,27 @@ const handleEdit = async (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (updatedData: any) => {
|
||||
const normalizePackagePayload = (rawData: any) => {
|
||||
const { id, created_at, updated_at, ...data } = rawData
|
||||
if (Array.isArray(data.features)) {
|
||||
data.features = JSON.stringify(data.features.filter((item: string) => item && item.trim()))
|
||||
}
|
||||
if (data.status !== 1) {
|
||||
data.status = 0
|
||||
}
|
||||
return { id, data }
|
||||
}
|
||||
|
||||
const handleSavePackage = async (savedData: any) => {
|
||||
try {
|
||||
const { id, created_at, updated_at, ...data } = updatedData
|
||||
// 将 features 数组转回字符串供后端存储
|
||||
if (Array.isArray(data.features)) {
|
||||
data.features = JSON.stringify(data.features)
|
||||
const { id, data } = normalizePackagePayload(savedData)
|
||||
if (dialogMode.value === 'create') {
|
||||
const createRes = await createPackage(data)
|
||||
if (createRes.code === '000000') {
|
||||
ElMessage.success(t('packages.messages.createSuccess'))
|
||||
fetchData()
|
||||
}
|
||||
return
|
||||
}
|
||||
const res = await updatePackage(id, data)
|
||||
if (res.code === '000000') {
|
||||
@@ -175,7 +216,58 @@ const handleUpdate = async (updatedData: any) => {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Update package error:', error)
|
||||
ElMessage.error(error.backendMessage || t('packages.messages.updateError'))
|
||||
ElMessage.error(
|
||||
error.backendMessage ||
|
||||
(dialogMode.value === 'create' ? t('packages.messages.createError') : t('packages.messages.updateError'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (row: Package) => {
|
||||
const nextStatus = row.status === 1 ? 0 : 1
|
||||
const actionText = nextStatus === 1 ? t('packages.actions.enable') : t('packages.actions.disable')
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('packages.messages.toggleConfirm', { action: actionText, name: row.name }),
|
||||
t('common.confirm'),
|
||||
{
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
const res = await updatePackageStatus(row.id, nextStatus as 0 | 1)
|
||||
if (res.code === '000000') {
|
||||
ElMessage.success(t('packages.messages.toggleSuccess'))
|
||||
fetchData()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.backendMessage || t('packages.messages.toggleError'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row: Package) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('packages.messages.deleteConfirm', { name: row.name }),
|
||||
t('common.confirm'),
|
||||
{
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
const res = await deletePackage(row.id)
|
||||
if (res.code === '000000') {
|
||||
ElMessage.success(t('packages.messages.deleteSuccess'))
|
||||
fetchData()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.backendMessage || t('packages.messages.deleteError'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +294,17 @@ const handleUpdate = async (updatedData: any) => {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background: #00a870;
|
||||
border-color: #00a870;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -253,6 +356,31 @@ const handleUpdate = async (updatedData: any) => {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-toggle {
|
||||
border: none;
|
||||
background: #eef3ff !important;
|
||||
color: #5678d6 !important;
|
||||
padding: 4px 12px;
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
border: none;
|
||||
background: #fff1f0 !important;
|
||||
color: #f56c6c !important;
|
||||
padding: 4px 12px;
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.price-free {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<Layout app-name="Cedar-V" page-title="支付配置">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>支付配置</h1>
|
||||
<p>统一维护公开购买、授权续期与订单支付的渠道参数和超时策略。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="saveSettings">保存配置</el-button>
|
||||
</div>
|
||||
|
||||
<div class="page-grid">
|
||||
<div class="card">
|
||||
<div class="card-title">基础策略</div>
|
||||
<el-form :model="settings" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="默认支付方式">
|
||||
<el-select v-model="settings.defaultMethod">
|
||||
<el-option label="支付宝" value="alipay" />
|
||||
<el-option label="微信支付" value="wechat" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="支付过期时间(分钟)">
|
||||
<el-input-number v-model="settings.expireMinutes" :min="5" :max="180" />
|
||||
</el-form-item>
|
||||
<el-form-item label="重试次数">
|
||||
<el-input-number v-model="settings.retryTimes" :min="0" :max="10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用支付日志">
|
||||
<el-switch v-model="settings.enableLog" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">支付宝</div>
|
||||
<p>当前公开购买与续期页面默认使用该渠道。</p>
|
||||
</div>
|
||||
<el-switch v-model="settings.alipay.enabled" />
|
||||
</div>
|
||||
<el-form :model="settings.alipay" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="App ID">
|
||||
<el-input v-model="settings.alipay.appId" />
|
||||
</el-form-item>
|
||||
<el-form-item label="签名方式">
|
||||
<el-input v-model="settings.alipay.signType" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网关地址" class="span-2">
|
||||
<el-input v-model="settings.alipay.gatewayUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="回调地址" class="span-2">
|
||||
<el-input v-model="settings.alipay.notifyUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="支付完成跳转" class="span-2">
|
||||
<el-input v-model="settings.alipay.returnUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="span-2">
|
||||
<el-input v-model="settings.alipay.remark" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">微信支付</div>
|
||||
<p>先把配置位补齐,后续接入真实渠道时可直接替换参数。</p>
|
||||
</div>
|
||||
<el-switch v-model="settings.wechat.enabled" />
|
||||
</div>
|
||||
<el-form :model="settings.wechat" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="应用标识">
|
||||
<el-input v-model="settings.wechat.appId" placeholder="例如商户号或 AppID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="签名方式">
|
||||
<el-input v-model="settings.wechat.signType" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网关地址" class="span-2">
|
||||
<el-input v-model="settings.wechat.gatewayUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="回调地址" class="span-2">
|
||||
<el-input v-model="settings.wechat.notifyUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="支付完成跳转" class="span-2">
|
||||
<el-input v-model="settings.wechat.returnUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="span-2">
|
||||
<el-input v-model="settings.wechat.remark" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">部署提示</div>
|
||||
<ul class="tips">
|
||||
<li>运行时支付参数以后端配置文件为准,该页面用于运营可视化维护和部署前确认。</li>
|
||||
<li>开发环境已接支付宝沙箱,生产环境请同步修改 <code>backend/configs/config.prod.yaml</code>。</li>
|
||||
<li>公开页面入口为 <code>/public-renewal</code>,免登录用户会直接创建支付单并写入客户信息。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import { appendAuditLog, getPaymentSettings, savePaymentSettings, type PaymentSettings } from '@/utils/adminLocalData'
|
||||
|
||||
const settings = reactive<PaymentSettings>(JSON.parse(JSON.stringify(getPaymentSettings())))
|
||||
|
||||
function saveSettings() {
|
||||
savePaymentSettings(JSON.parse(JSON.stringify(settings)))
|
||||
appendAuditLog({
|
||||
module: '支付配置',
|
||||
action: '保存',
|
||||
operator: 'admin',
|
||||
detail: `更新支付配置,默认方式 ${settings.defaultMethod},过期时间 ${settings.expireMinutes} 分钟`
|
||||
})
|
||||
ElMessage.success('支付配置已保存')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell { padding: 24px; }
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-header h1 { margin: 0; font-size: 28px; }
|
||||
.page-header p { margin: 8px 0 0; color: #6b7280; }
|
||||
.page-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-head p { margin: 6px 0 0; color: #6b7280; }
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.span-2 { grid-column: span 2; }
|
||||
.tips {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #475569;
|
||||
line-height: 1.7;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<Layout app-name="Cedar-V" page-title="产品项管理">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>产品项管理</h1>
|
||||
<p>按产品视角整理当前可售套餐与续期策略,便于业务统一维护。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" class="data-table">
|
||||
<el-table-column prop="name" label="产品项" min-width="160" />
|
||||
<el-table-column prop="type" label="类型" min-width="120" />
|
||||
<el-table-column prop="duration_description" label="周期说明" min-width="180" />
|
||||
<el-table-column prop="priceText" label="定价" min-width="120" />
|
||||
<el-table-column prop="statusText" label="状态" min-width="100" />
|
||||
<el-table-column prop="description" label="业务说明" min-width="240" />
|
||||
</el-table>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import { getPackages, type Package } from '@/api/package'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<Array<Package & { priceText: string; statusText: string }>>([])
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getPackages()
|
||||
if (res.code === '000000' && res.data) {
|
||||
tableData.value = res.data.packages.map(item => ({
|
||||
...item,
|
||||
priceText: item.type === 'custom' ? '定制报价' : `¥${item.price}`,
|
||||
statusText: item.status === 1 ? '启用' : '停用'
|
||||
}))
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.backendMessage || '加载产品项失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell { padding: 24px; }
|
||||
.page-header { margin-bottom: 20px; }
|
||||
.page-header h1 { margin: 0; font-size: 28px; }
|
||||
.page-header p { margin: 8px 0 0; color: #6b7280; }
|
||||
.data-table { background: #fff; border-radius: 18px; overflow: hidden; }
|
||||
</style>
|
||||
@@ -0,0 +1,558 @@
|
||||
<template>
|
||||
<div class="public-page">
|
||||
<div class="hero-panel">
|
||||
<div>
|
||||
<div class="eyebrow">Cedar-V Public Portal</div>
|
||||
<h1>免登录购买、续期与授权码查询</h1>
|
||||
<p>企业客户可直接完成新购、通过授权码续期,并实时回写联系人信息。</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<el-button plain @click="goLogin">进入后台</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="portal-card">
|
||||
<el-tabs v-model="activeTab" class="portal-tabs">
|
||||
<el-tab-pane label="新购授权" name="purchase" />
|
||||
<el-tab-pane label="授权续期" name="renewal" />
|
||||
<el-tab-pane label="查询授权码" name="query" />
|
||||
</el-tabs>
|
||||
|
||||
<div v-if="activeTab === 'purchase'" class="tab-body">
|
||||
<div class="section-title">新购授权</div>
|
||||
<el-form :model="purchaseForm" label-position="top">
|
||||
<div class="grid">
|
||||
<el-form-item label="企业名称"><el-input v-model="purchaseForm.company_name" /></el-form-item>
|
||||
<el-form-item label="联系人"><el-input v-model="purchaseForm.contact_name" /></el-form-item>
|
||||
<el-form-item label="联系电话"><el-input v-model="purchaseForm.contact_phone" /></el-form-item>
|
||||
<el-form-item label="邮箱"><el-input v-model="purchaseForm.contact_email" /></el-form-item>
|
||||
<el-form-item label="客户类型">
|
||||
<el-select v-model="purchaseForm.customer_type">
|
||||
<el-option label="企业客户" value="enterprise" />
|
||||
<el-option label="个人客户" value="individual" />
|
||||
<el-option label="政府客户" value="government" />
|
||||
<el-option label="教育客户" value="education" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="套餐">
|
||||
<el-select v-model="purchaseForm.package_id" filterable>
|
||||
<el-option v-for="pkg in packages" :key="pkg.id" :label="`${pkg.name} · ${pkg.display_price || `¥${pkg.price}`}`" :value="pkg.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="许可数量"><el-input-number v-model="purchaseForm.license_count" :min="1" :max="1000" /></el-form-item>
|
||||
<el-form-item label="业务说明" class="span-2"><el-input v-model="purchaseForm.description" type="textarea" :rows="3" /></el-form-item>
|
||||
</div>
|
||||
<el-button type="primary" :loading="purchaseLoading" @click="submitPurchase">创建支付单</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'renewal'" class="tab-body">
|
||||
<div class="section-title">授权续期</div>
|
||||
<el-form :model="queryForm" @submit.prevent class="query-inline">
|
||||
<el-input v-model="queryForm.authorizationCode" placeholder="请输入授权码先查询" clearable />
|
||||
<el-button type="primary" :loading="queryLoading" @click="handleQuery">查询授权码</el-button>
|
||||
</el-form>
|
||||
|
||||
<div v-if="authInfo" class="auth-card">
|
||||
<div class="auth-head">
|
||||
<div>
|
||||
<h3>{{ authInfo.customer_name || '未命名客户' }}</h3>
|
||||
<p>{{ authInfo.authorization_code }}</p>
|
||||
</div>
|
||||
<el-tag :type="statusTagType(authInfo.status)">{{ statusLabel(authInfo.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="auth-meta">
|
||||
<span>到期时间:{{ formatDate(authInfo.end_date) }}</span>
|
||||
<span>激活数:{{ authInfo.active_licenses }}/{{ authInfo.max_activations }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form :model="renewalForm" label-position="top">
|
||||
<div class="grid">
|
||||
<el-form-item label="联系人"><el-input v-model="renewalForm.contact_name" /></el-form-item>
|
||||
<el-form-item label="联系电话"><el-input v-model="renewalForm.contact_phone" /></el-form-item>
|
||||
<el-form-item label="邮箱"><el-input v-model="renewalForm.contact_email" /></el-form-item>
|
||||
<el-form-item label="企业名称"><el-input v-model="renewalForm.company_name" /></el-form-item>
|
||||
<el-form-item label="套餐">
|
||||
<el-select v-model="renewalForm.package_id" filterable>
|
||||
<el-option v-for="pkg in packages" :key="pkg.id" :label="`${pkg.name} · ${pkg.display_price || `¥${pkg.price}`}`" :value="pkg.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="许可数量"><el-input-number v-model="renewalForm.license_count" :min="1" :max="1000" /></el-form-item>
|
||||
<el-form-item label="续期天数">
|
||||
<el-segmented v-model="renewalForm.renew_days" :options="renewDayOptions" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-button type="success" :loading="renewalLoading" @click="submitRenewal">创建续期支付单</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'query'" class="tab-body">
|
||||
<div class="section-title">授权码查询</div>
|
||||
<el-form :model="queryForm" @submit.prevent class="query-inline">
|
||||
<el-input v-model="queryForm.authorizationCode" placeholder="请输入授权码" clearable />
|
||||
<el-button type="primary" :loading="queryLoading" @click="handleQuery">查询</el-button>
|
||||
</el-form>
|
||||
|
||||
<el-descriptions v-if="authInfo" :column="1" border class="query-result">
|
||||
<el-descriptions-item label="客户名称">{{ authInfo.customer_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="授权码">{{ authInfo.authorization_code }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusLabel(authInfo.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="到期时间">{{ formatDate(authInfo.end_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系人">{{ authInfo.contact_person || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系电话">{{ authInfo.contact_phone || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ authInfo.contact_email || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div v-if="paymentResult" class="payment-panel">
|
||||
<div class="payment-main">
|
||||
<div>
|
||||
<div class="payment-title">支付单已创建</div>
|
||||
<p v-if="paymentResult.payment_no">支付单号:{{ paymentResult.payment_no }}</p>
|
||||
<p>支付金额:¥{{ paymentResult.total_amount }}</p>
|
||||
<p v-if="paymentResult.expire_time">过期时间:{{ formatDate(paymentResult.expire_time) }}</p>
|
||||
</div>
|
||||
<div class="payment-buttons" v-if="paymentResult.payment_no">
|
||||
<el-button type="primary" @click="openPaymentUrl">去支付</el-button>
|
||||
<el-button :loading="statusLoading" @click="checkPaymentStatus">刷新状态</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="paymentResult.authorization_code" class="result-box">
|
||||
新授权码:{{ paymentResult.authorization_code }}
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="paymentStatus"
|
||||
:type="paymentStatus.status === 'paid' ? 'success' : 'info'"
|
||||
:title="paymentStatus.status === 'paid' ? '支付成功' : `当前状态:${paymentStatus.status}`"
|
||||
:closable="false"
|
||||
/>
|
||||
<div v-if="paymentStatus?.business_order?.authorization_code" class="result-box">
|
||||
新授权码:{{ paymentStatus.business_order.authorization_code }}
|
||||
</div>
|
||||
<div v-if="paymentStatus?.authorization_end_date" class="result-box">
|
||||
最新到期时间:{{ formatDate(paymentStatus.authorization_end_date) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
createPurchaseOrder,
|
||||
createRenewalOrder,
|
||||
getPublicPackages,
|
||||
getPublicPaymentStatus,
|
||||
queryAuthorizationCode,
|
||||
type AuthorizationCodeInfo,
|
||||
type PublicPackageItem,
|
||||
type PublicPaymentStatus,
|
||||
type RenewalOrderResponse
|
||||
} from '@/api/publicRenewal'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = ref<'purchase' | 'renewal' | 'query'>('purchase')
|
||||
const queryLoading = ref(false)
|
||||
const purchaseLoading = ref(false)
|
||||
const renewalLoading = ref(false)
|
||||
const statusLoading = ref(false)
|
||||
|
||||
const packages = ref<PublicPackageItem[]>([])
|
||||
const authInfo = ref<AuthorizationCodeInfo | null>(null)
|
||||
const paymentResult = ref<RenewalOrderResponse | null>(null)
|
||||
const paymentStatus = ref<PublicPaymentStatus | null>(null)
|
||||
const statusTimer = ref<number | null>(null)
|
||||
|
||||
const renewDayOptions = [
|
||||
{ label: '30天', value: 30 },
|
||||
{ label: '90天', value: 90 },
|
||||
{ label: '180天', value: 180 },
|
||||
{ label: '365天', value: 365 }
|
||||
]
|
||||
|
||||
const queryForm = reactive({
|
||||
authorizationCode: ''
|
||||
})
|
||||
|
||||
const purchaseForm = reactive({
|
||||
company_name: '',
|
||||
contact_name: '',
|
||||
contact_phone: '',
|
||||
contact_email: '',
|
||||
customer_type: 'enterprise',
|
||||
package_id: '',
|
||||
license_count: 1,
|
||||
description: ''
|
||||
})
|
||||
|
||||
const renewalForm = reactive({
|
||||
package_id: '',
|
||||
license_count: 1,
|
||||
renew_days: 365,
|
||||
contact_name: '',
|
||||
contact_phone: '',
|
||||
contact_email: '',
|
||||
company_name: ''
|
||||
})
|
||||
|
||||
function goLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
async function loadPackages() {
|
||||
try {
|
||||
const res = await getPublicPackages()
|
||||
if (res.code === '000000' && res.data) {
|
||||
packages.value = res.data
|
||||
if (!purchaseForm.package_id && res.data[0]) {
|
||||
purchaseForm.package_id = res.data[0].id
|
||||
}
|
||||
if (!renewalForm.package_id && res.data[0]) {
|
||||
renewalForm.package_id = res.data[0].id
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.backendMessage || '加载套餐失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuery() {
|
||||
if (!queryForm.authorizationCode.trim()) {
|
||||
ElMessage.warning('请输入授权码')
|
||||
return
|
||||
}
|
||||
queryLoading.value = true
|
||||
try {
|
||||
const res = await queryAuthorizationCode(queryForm.authorizationCode.trim())
|
||||
if (res.code === '000000' && res.data) {
|
||||
authInfo.value = res.data
|
||||
renewalForm.contact_name = res.data.contact_person || ''
|
||||
renewalForm.contact_phone = res.data.contact_phone || ''
|
||||
renewalForm.contact_email = res.data.contact_email || ''
|
||||
renewalForm.company_name = res.data.customer_name || ''
|
||||
activeTab.value = activeTab.value === 'query' ? 'query' : 'renewal'
|
||||
ElMessage.success('查询成功')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.backendMessage || '授权码查询失败')
|
||||
} finally {
|
||||
queryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPurchase() {
|
||||
if (!purchaseForm.contact_name || !purchaseForm.contact_phone || !purchaseForm.package_id) {
|
||||
ElMessage.warning('请先完善购买信息')
|
||||
return
|
||||
}
|
||||
purchaseLoading.value = true
|
||||
try {
|
||||
const res = await createPurchaseOrder({
|
||||
...purchaseForm,
|
||||
payment_method: 'alipay'
|
||||
})
|
||||
if (res.code === '000000' && res.data) {
|
||||
applyPaymentResult(res.data)
|
||||
ElMessage.success('新购支付单创建成功')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.backendMessage || '新购支付单创建失败')
|
||||
} finally {
|
||||
purchaseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRenewal() {
|
||||
if (!authInfo.value) {
|
||||
ElMessage.warning('请先查询授权码')
|
||||
return
|
||||
}
|
||||
renewalLoading.value = true
|
||||
try {
|
||||
const res = await createRenewalOrder({
|
||||
authorization_code: authInfo.value.authorization_code,
|
||||
...renewalForm,
|
||||
payment_method: 'alipay'
|
||||
})
|
||||
if (res.code === '000000' && res.data) {
|
||||
applyPaymentResult(res.data)
|
||||
ElMessage.success('续期支付单创建成功')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.backendMessage || '续期支付单创建失败')
|
||||
} finally {
|
||||
renewalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyPaymentResult(result: RenewalOrderResponse) {
|
||||
paymentResult.value = result
|
||||
paymentStatus.value = null
|
||||
if (result.payment_no) {
|
||||
startPolling()
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPaymentStatus() {
|
||||
if (!paymentResult.value?.payment_no) return
|
||||
statusLoading.value = true
|
||||
try {
|
||||
const res = await getPublicPaymentStatus(paymentResult.value.payment_no)
|
||||
if (res.code === '000000' && res.data) {
|
||||
paymentStatus.value = res.data
|
||||
if (res.data.status === 'paid') {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.backendMessage || '支付状态查询失败')
|
||||
} finally {
|
||||
statusLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPaymentUrl() {
|
||||
if (paymentResult.value?.payment_url) {
|
||||
window.open(paymentResult.value.payment_url, '_blank')
|
||||
} else {
|
||||
ElMessage.warning('当前支付方式未返回跳转地址')
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
statusTimer.value = window.setInterval(() => {
|
||||
checkPaymentStatus()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (statusTimer.value) {
|
||||
window.clearInterval(statusTimer.value)
|
||||
statusTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function statusTagType(status: string) {
|
||||
if (status === 'normal') return 'success'
|
||||
if (status === 'expired') return 'warning'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'normal') return '正常'
|
||||
if (status === 'expired') return '已过期'
|
||||
if (status === 'locked') return '已锁定'
|
||||
return status
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString() : '-'
|
||||
}
|
||||
|
||||
onMounted(loadPackages)
|
||||
onBeforeUnmount(stopPolling)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.public-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(0, 168, 112, 0.16), transparent 30%),
|
||||
radial-gradient(circle at bottom right, rgba(23, 92, 255, 0.14), transparent 26%),
|
||||
linear-gradient(135deg, #f7fbff 0%, #f3fbf7 100%);
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.portal-card {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
background: linear-gradient(135deg, #0f8b77 0%, #39c6a0 55%, #6fe0b7 100%);
|
||||
color: #fff;
|
||||
border-radius: 24px;
|
||||
padding: 28px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
box-shadow: 0 20px 50px rgba(15, 139, 119, 0.22);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
opacity: 0.86;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-panel h1 {
|
||||
margin: 8px 0 10px;
|
||||
font-size: 34px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-panel p {
|
||||
margin: 0;
|
||||
max-width: 640px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.portal-card {
|
||||
margin-top: 20px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.85);
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.08);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 18px;
|
||||
color: #102a43;
|
||||
}
|
||||
|
||||
.tab-body {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.query-inline {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: linear-gradient(135deg, #f7fffc 0%, #eef8ff 100%);
|
||||
border: 1px solid #d6efe8;
|
||||
border-radius: 18px;
|
||||
padding: 16px 18px;
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.auth-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-head h3 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.auth-head p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.auth-meta {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 12px;
|
||||
color: #334155;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.payment-panel {
|
||||
margin-top: 24px;
|
||||
padding-top: 22px;
|
||||
border-top: 1px solid #e5eef5;
|
||||
}
|
||||
|
||||
.payment-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.payment-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.payment-main p {
|
||||
margin: 4px 0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.payment-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.result-box {
|
||||
margin-top: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: #f0fdf8;
|
||||
color: #047857;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.query-result {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.public-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-panel h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.portal-card {
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.grid,
|
||||
.query-inline {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.payment-main {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+136
-48
@@ -1,59 +1,147 @@
|
||||
<!--
|
||||
* @Author: 13895237362 2205451508@qq.com
|
||||
* @Date: 2025-08-12 00:00:00
|
||||
* @LastEditors: 13895237362 2205451508@qq.com
|
||||
* @LastEditTime: 2025-08-12 00:00:00
|
||||
* @FilePath: /frontend/src/views/Roles.vue
|
||||
* @Description: 角色权限页面
|
||||
-->
|
||||
<template>
|
||||
<Layout app-name="Cedar-V" :page-title="t('pages.roles.title')">
|
||||
<!-- 页面内容 -->
|
||||
<div class="content-container">
|
||||
<h1>{{ t('pages.roles.welcome') }}</h1>
|
||||
<Layout app-name="Cedar-V" page-title="角色权限">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>角色权限</h1>
|
||||
<p>维护后台角色、权限边界和职责说明。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">新增角色</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="roles" class="data-table">
|
||||
<el-table-column prop="name" label="角色名称" min-width="160" />
|
||||
<el-table-column prop="code" label="角色编码" min-width="120" />
|
||||
<el-table-column prop="description" label="描述" min-width="220" />
|
||||
<el-table-column label="权限范围" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="permission in row.permissions" :key="permission" class="tag">{{ permission }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="180" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="removeRole(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑角色' : '新增角色'" width="520px">
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="角色名称">
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色编码">
|
||||
<el-input v-model="form.code" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="权限范围">
|
||||
<el-select v-model="form.permissions" multiple>
|
||||
<el-option v-for="item in permissionOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveRole">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Layout from '@/components/common/layout/Layout.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import { appendAuditLog, getRoles, saveRoles, type AdminRole } from '@/utils/adminLocalData'
|
||||
|
||||
const { t } = useI18n();
|
||||
const roles = ref<AdminRole[]>(getRoles())
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref('')
|
||||
const permissionOptions = ['dashboard', 'customers', 'licenses', 'invoices', 'leads', 'system']
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
permissions: [] as string[]
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
form.code = ''
|
||||
form.description = ''
|
||||
form.permissions = []
|
||||
editingId.value = ''
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(role: AdminRole) {
|
||||
editingId.value = role.id
|
||||
form.name = role.name
|
||||
form.code = role.code
|
||||
form.description = role.description
|
||||
form.permissions = [...role.permissions]
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function saveRole() {
|
||||
if (!form.name.trim() || !form.code.trim()) {
|
||||
ElMessage.warning('请填写角色名称和编码')
|
||||
return
|
||||
}
|
||||
|
||||
const nextRoles = [...roles.value]
|
||||
if (editingId.value) {
|
||||
const index = nextRoles.findIndex(item => item.id === editingId.value)
|
||||
if (index >= 0) {
|
||||
nextRoles[index] = { ...nextRoles[index], ...form }
|
||||
appendAuditLog({ module: '角色权限', action: '编辑', operator: 'admin', detail: `编辑角色 ${form.name}` })
|
||||
}
|
||||
} else {
|
||||
nextRoles.unshift({
|
||||
id: `role_${Date.now()}`,
|
||||
createdAt: new Date().toLocaleString(),
|
||||
...form
|
||||
})
|
||||
appendAuditLog({ module: '角色权限', action: '新增', operator: 'admin', detail: `新增角色 ${form.name}` })
|
||||
}
|
||||
|
||||
roles.value = nextRoles
|
||||
saveRoles(nextRoles)
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('角色已保存')
|
||||
}
|
||||
|
||||
async function removeRole(id: string) {
|
||||
const role = roles.value.find(item => item.id === id)
|
||||
if (!role) return
|
||||
await ElMessageBox.confirm(`确认删除角色“${role.name}”吗?`, '提示')
|
||||
roles.value = roles.value.filter(item => item.id !== id)
|
||||
saveRoles(roles.value)
|
||||
appendAuditLog({ module: '角色权限', action: '删除', operator: 'admin', detail: `删除角色 ${role.name}` })
|
||||
ElMessage.success('角色已删除')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-container {
|
||||
min-height: calc(100vh - 80px);
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.content-container {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) {
|
||||
.content-container {
|
||||
height: calc(100vh - 4.17vw);
|
||||
padding: 1.25vw;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
<style scoped lang="scss">
|
||||
.page-shell { padding: 24px; }
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-header h1 { margin: 0; font-size: 28px; }
|
||||
.page-header p { margin: 8px 0 0; color: #6b7280; }
|
||||
.data-table { background: #fff; border-radius: 16px; overflow: hidden; }
|
||||
.tag { margin-right: 8px; margin-bottom: 6px; }
|
||||
</style>
|
||||
|
||||
<style scoped></style>
|
||||
+146
-47
@@ -1,57 +1,156 @@
|
||||
<!--
|
||||
* @Author: 13895237362 2205451508@qq.com
|
||||
* @Date: 2025-08-12 00:00:00
|
||||
* @LastEditors: 13895237362 2205451508@qq.com
|
||||
* @LastEditTime: 2025-08-12 00:00:00
|
||||
* @FilePath: /frontend/src/views/Users.vue
|
||||
* @Description: 系统用户页面
|
||||
-->
|
||||
<template>
|
||||
<Layout app-name="Cedar-V" :page-title="t('pages.users.title')">
|
||||
<!-- 页面内容 -->
|
||||
<div class="content-container">
|
||||
<h1>{{ t('pages.users.welcome') }}</h1>
|
||||
<Layout app-name="Cedar-V" page-title="系统用户">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>系统用户</h1>
|
||||
<p>管理后台使用人员、角色归属和账号状态。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">新增用户</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="users" class="data-table">
|
||||
<el-table-column prop="username" label="用户名" min-width="140" />
|
||||
<el-table-column prop="roleCode" label="角色" min-width="120" />
|
||||
<el-table-column prop="phone" label="手机号" min-width="150" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="220" />
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ row.status === 'active' ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lastLoginAt" label="最近登录" min-width="180" />
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link @click="toggleStatus(row)">{{ row.status === 'active' ? '停用' : '启用' }}</el-button>
|
||||
<el-button link type="danger" @click="removeUser(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑用户' : '新增用户'" width="520px">
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.roleCode">
|
||||
<el-option v-for="role in roles" :key="role.code" :label="role.name" :value="role.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveUser">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Layout from '@/components/common/layout/Layout.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import { appendAuditLog, getRoles, getUsers, saveUsers, type AdminUser } from '@/utils/adminLocalData'
|
||||
|
||||
const { t } = useI18n();
|
||||
const users = ref<AdminUser[]>(getUsers())
|
||||
const roles = getRoles()
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref('')
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
roleCode: roles[0]?.code || 'admin',
|
||||
phone: '',
|
||||
email: ''
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.username = ''
|
||||
form.roleCode = roles[0]?.code || 'admin'
|
||||
form.phone = ''
|
||||
form.email = ''
|
||||
editingId.value = ''
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(user: AdminUser) {
|
||||
editingId.value = user.id
|
||||
form.username = user.username
|
||||
form.roleCode = user.roleCode
|
||||
form.phone = user.phone
|
||||
form.email = user.email
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function saveUser() {
|
||||
if (!form.username.trim()) {
|
||||
ElMessage.warning('请输入用户名')
|
||||
return
|
||||
}
|
||||
|
||||
const nextUsers = [...users.value]
|
||||
if (editingId.value) {
|
||||
const index = nextUsers.findIndex(item => item.id === editingId.value)
|
||||
if (index >= 0) {
|
||||
nextUsers[index] = { ...nextUsers[index], ...form }
|
||||
appendAuditLog({ module: '系统用户', action: '编辑', operator: 'admin', detail: `编辑用户 ${form.username}` })
|
||||
}
|
||||
} else {
|
||||
nextUsers.unshift({
|
||||
id: `user_${Date.now()}`,
|
||||
status: 'active',
|
||||
lastLoginAt: new Date().toLocaleString(),
|
||||
...form
|
||||
})
|
||||
appendAuditLog({ module: '系统用户', action: '新增', operator: 'admin', detail: `新增用户 ${form.username}` })
|
||||
}
|
||||
|
||||
users.value = nextUsers
|
||||
saveUsers(nextUsers)
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('用户已保存')
|
||||
}
|
||||
|
||||
function toggleStatus(user: AdminUser) {
|
||||
user.status = user.status === 'active' ? 'disabled' : 'active'
|
||||
saveUsers(users.value)
|
||||
appendAuditLog({ module: '系统用户', action: '状态变更', operator: 'admin', detail: `${user.username} -> ${user.status}` })
|
||||
ElMessage.success('状态已更新')
|
||||
}
|
||||
|
||||
async function removeUser(id: string) {
|
||||
const user = users.value.find(item => item.id === id)
|
||||
if (!user) return
|
||||
await ElMessageBox.confirm(`确认删除用户“${user.username}”吗?`, '提示')
|
||||
users.value = users.value.filter(item => item.id !== id)
|
||||
saveUsers(users.value)
|
||||
appendAuditLog({ module: '系统用户', action: '删除', operator: 'admin', detail: `删除用户 ${user.username}` })
|
||||
ElMessage.success('用户已删除')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-container {
|
||||
min-height: calc(100vh - 80px);
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
<style scoped lang="scss">
|
||||
.page-shell { padding: 24px; }
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.content-container {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) {
|
||||
.content-container {
|
||||
height: calc(100vh - 4.17vw);
|
||||
padding: 1.25vw;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
.page-header h1 { margin: 0; font-size: 28px; }
|
||||
.page-header p { margin: 8px 0 0; color: #6b7280; }
|
||||
.data-table { background: #fff; border-radius: 16px; overflow: hidden; }
|
||||
</style>
|
||||
Reference in new issue
Block a user