feat: add public purchase portal and admin tools
Deploy Backend via Git / deploy (push) Failing after 1m0s
Deploy Frontend via Git / build-and-deploy (push) Failing after 1m0s

This commit is contained in:
leeexin committed 2026-03-28 17:55:03 +08:00
1 parent c0a6ee3cb0
commit eb1f68b423
29 files changed
+2851 -163

No files matched your search

+24 -1
View File
@@ -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 }
})
}
+107
View File
@@ -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>
+19 -3
View File
@@ -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"
}
}
}
}
+19 -3
View File
@@ -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": "配列"
}
}
}
}
+19 -3
View File
@@ -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": "数组"
}
}
}
}
+35 -1
View File
@@ -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;
+346
View File
@@ -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) || []
}))
}
+36
View File
@@ -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>
+146
View File
@@ -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>
+5
View File
@@ -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 {
+73
View File
@@ -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) => {
+151 -23
View File
@@ -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;
}
+192
View File
@@ -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>
+59
View File
@@ -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>
+558
View File
@@ -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
View File
@@ -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
View File
@@ -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>