diff --git a/app/Http/Controllers/IndexController.php b/app/Http/Controllers/IndexController.php index d88c84b..2554caf 100644 --- a/app/Http/Controllers/IndexController.php +++ b/app/Http/Controllers/IndexController.php @@ -11,6 +11,7 @@ use Modules\AnnoRoute\Attribute\GetRoute; use Modules\AnnoRoute\Attribute\PostRoute; use Modules\AnnoRoute\Attribute\RequestAttribute; use Modules\Common\Trait\RequestJson; +use Modules\SystemTool\Models\SysActivityModel; use Modules\SystemTool\Models\SysCarouselModel; use Modules\SystemTool\Models\SysCategoryItemModel; use Modules\SystemTool\Models\SysCategoryModel; @@ -65,7 +66,7 @@ class IndexController return $this->error('创建用户失败'); } - /** 获取首页数据(公开接口):轮播图 + 宫格导航 */ + /** 获取首页数据(公开接口):轮播图 + 宫格导航 + 活动专区 */ #[GetRoute('/home', false)] public function home(): JsonResponse { @@ -80,8 +81,13 @@ class IndexController ->orderBy('sort', 'desc') ->orderBy('id', 'desc') ->get(); + $activity = SysActivityModel::query() + ->where('status', 0) + ->orderBy('sort', 'desc') + ->orderBy('id', 'desc') + ->get(); - return $this->success(compact('carousel', 'gridNav', 'site_title')); + return $this->success(compact('carousel', 'gridNav', 'activity', 'site_title')); } /** diff --git a/database/migrations/2026_08_09_000001_create_activity_table.php b/database/migrations/2026_08_09_000001_create_activity_table.php new file mode 100644 index 0000000..c0f1de1 --- /dev/null +++ b/database/migrations/2026_08_09_000001_create_activity_table.php @@ -0,0 +1,118 @@ +increments('id'); + $table->string('title', 100)->comment('活动标题'); + $table->integer('image_id')->comment('活动图片信息'); + $table->string('link', 500)->nullable()->default('')->comment('跳转链接'); + $table->unsignedTinyInteger('link_type')->default(0)->comment('跳转类型:0=链接,1=分类'); + $table->integer('category_id')->nullable()->default(0)->comment('分类ID(link_type=1 时生效)'); + $table->text('description')->nullable()->comment('活动描述'); + $table->unsignedTinyInteger('status')->default(0)->comment('状态:0=启用,1=禁用'); + $table->integer('sort')->default(0)->comment('排序(数字越大越靠前)'); + $table->timestamps(); + $table->comment('首页活动专区管理表'); + }); + } + + $this->insertMenuRules(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sys_activity'); + + $this->deleteMenuRules(); + } + + /** + * 向权限菜单表插入「活动专区」菜单(幂等,已存在则跳过) + */ + protected function insertMenuRules(): void + { + if (! Schema::hasTable('sys_rule')) { + return; + } + + $exists = DB::table('sys_rule')->where('key', 'system.activity')->exists(); + if ($exists) { + return; + } + + $date = date('Y-m-d H:i:s'); + + $menuId = DB::table('sys_rule')->insertGetId([ + 'parent_id' => 0, + 'type' => 'route', + 'key' => 'system.activity', + 'name' => '活动专区管理', + 'path' => '/system/activity', + 'icon' => '', + 'order' => 0, + 'local' => 'menu.system.activity', + 'status' => 1, + 'hidden' => 1, + 'link' => 0, + 'created_at' => $date, + 'updated_at' => $date, + ]); + + $children = [ + ['name' => '查询活动列表', 'key' => 'system.activity.query'], + ['name' => '新增活动', 'key' => 'system.activity.create'], + ['name' => '编辑活动', 'key' => 'system.activity.update'], + ['name' => '删除活动', 'key' => 'system.activity.delete'], + ]; + + foreach ($children as $i => $child) { + DB::table('sys_rule')->insert([ + 'parent_id' => $menuId, + 'type' => 'rule', + 'key' => $child['key'], + 'name' => $child['name'], + 'path' => '', + 'icon' => '', + 'order' => $i, + 'local' => '', + 'status' => 1, + 'hidden' => 1, + 'link' => 0, + 'created_at' => $date, + 'updated_at' => $date, + ]); + } + } + + /** + * 删除「活动专区」菜单及其子规则 + */ + protected function deleteMenuRules(): void + { + if (! Schema::hasTable('sys_rule')) { + return; + } + + $menuId = DB::table('sys_rule')->where('key', 'system.activity')->value('id'); + if ($menuId) { + DB::table('sys_rule')->where('key', 'system.activity')->delete(); + DB::table('sys_rule')->where('parent_id', $menuId)->delete(); + } + } +}; diff --git a/modules/SystemTool/Http/Controllers/SysActivityController.php b/modules/SystemTool/Http/Controllers/SysActivityController.php new file mode 100644 index 0000000..ed80857 --- /dev/null +++ b/modules/SystemTool/Http/Controllers/SysActivityController.php @@ -0,0 +1,85 @@ + '=', + ]; + + protected array $quickSearchField = ['title']; + + /** 查询活动列表 */ + #[GetRoute(authorize: 'query')] + public function query(Request $request): JsonResponse + { + $query = SysActivityModel::query(); + $data = $this->buildSearch($request->all(), $query) + ->orderBy('sort', 'desc') + ->orderBy('id', 'desc') + ->paginate($request->input('pageSize', 10)) + ->toArray(); + return $this->success($data); + } + + /** 创建活动 */ + #[PostRoute(authorize: 'create')] + public function create(SysActivityFormRequest $request): JsonResponse + { + $validated = $request->validated(); + $model = SysActivityModel::create($validated); + if (empty($model)) { + return $this->error(); + } + return $this->success(); + } + + /** 编辑活动 */ + #[PutRoute( + route: '/{id}', + authorize: 'update', + where: ['id' => '[0-9]+'] + )] + public function update(int $id, SysActivityFormRequest $request): JsonResponse + { + $validated = $request->validated(); + $model = SysActivityModel::find($id); + if (empty($model)) { + return $this->error('活动不存在'); + } + $model->update($validated); + return $this->success(); + } + + /** 删除活动 */ + #[DeleteRoute( + route: '/{id}', + authorize: 'delete', + where: ['id' => '[0-9]+'] + )] + public function delete(int $id): JsonResponse + { + $model = SysActivityModel::find($id); + if (empty($model)) { + return $this->error('活动不存在'); + } + $model->delete(); + return $this->success(); + } +} diff --git a/modules/SystemTool/Http/Requests/SysActivityFormRequest.php b/modules/SystemTool/Http/Requests/SysActivityFormRequest.php new file mode 100644 index 0000000..957dc0c --- /dev/null +++ b/modules/SystemTool/Http/Requests/SysActivityFormRequest.php @@ -0,0 +1,54 @@ + 'required|string|max:100', + 'image_id' => ['required', 'integer', new Exists(SysFileModel::class, 'id')], + 'link_type' => 'nullable|integer|in:0,1', + 'link' => [ + 'nullable', + 'string', + 'max:500', + Rule::requiredIf(fn () => $this->input('link_type', 0) == 0), + ], + 'category_id' => [ + 'nullable', + 'integer', + Rule::requiredIf(fn () => $this->input('link_type', 0) == 1), + new Exists(SysCategoryModel::class, 'id'), + ], + 'description' => 'nullable|string|max:5000', + 'status' => 'nullable|integer|in:0,1', + 'sort' => 'nullable|integer', + ]; + } + + public function messages(): array + { + return [ + 'title.required' => '活动标题是必填的', + 'title.max' => '活动标题不能超过 :max 个字符', + 'image_id.required' => '活动图片是必填的', + 'link_type.in' => '跳转类型只能是 0(链接)或 1(分类)', + 'link.required' => '链接模式下跳转链接是必填的', + 'link.max' => '跳转链接不能超过 :max 个字符', + 'category_id.required' => '分类模式下请选择分类', + 'description.max' => '活动描述不能超过 :max 个字符', + 'status.in' => '状态值只能是 0(启用)或 1(禁用)', + 'sort.integer' => '排序必须是整数', + ]; + } +} diff --git a/modules/SystemTool/Models/SysActivityModel.php b/modules/SystemTool/Models/SysActivityModel.php new file mode 100644 index 0000000..da15dc6 --- /dev/null +++ b/modules/SystemTool/Models/SysActivityModel.php @@ -0,0 +1,55 @@ + 'int', + 'sort' => 'int', + 'image_id' => 'array', + 'link_type' => 'int', + 'category_id' => 'int', + ]; + + protected $fillable = [ + 'title', + 'image_id', + 'link', + 'link_type', + 'category_id', + 'description', + 'status', + 'sort', + ]; + + protected $with = ['image', 'category']; + + /** + * 关联图片 + * @return HasOne + */ + public function image(): HasOne + { + return $this->hasOne(SysFileModel::class, 'id', 'image_id'); + } + + /** + * 关联所属分类(link_type=1 时生效) + * @return BelongsTo + */ + public function category(): BelongsTo + { + return $this->belongsTo(SysCategoryModel::class, 'category_id', 'id'); + } + +} diff --git a/web/domain/iActivity.ts b/web/domain/iActivity.ts new file mode 100644 index 0000000..4f9761c --- /dev/null +++ b/web/domain/iActivity.ts @@ -0,0 +1,27 @@ +import type { ICategory } from '@/domain/iCategory'; +import type { ISysFileInfo } from '@/domain/iSysFile'; + +/** 首页活动专区 */ +export interface IActivity { + id?: number; + /** 活动标题 */ + title: string; + /** 活动图片信息(ISysFileInfo 对象) */ + image: ISysFileInfo | string; + /** 跳转类型:0=链接,1=分类 */ + link_type?: number; + /** 跳转链接(link_type=0 时生效) */ + link?: string; + /** 分类ID(link_type=1 时生效) */ + category_id?: number; + /** 所属分类(后端关联返回) */ + category?: ICategory; + /** 活动描述 */ + description?: string; + /** 状态:0=启用,1=禁用 */ + status: number; + /** 排序 */ + sort: number; + created_at?: string; + updated_at?: string; +} diff --git a/web/locales/en_US/index.ts b/web/locales/en_US/index.ts index 5c67b1d..3fba689 100644 --- a/web/locales/en_US/index.ts +++ b/web/locales/en_US/index.ts @@ -19,6 +19,7 @@ import systemAi from "./system/ai"; import systemCarousel from "./system/carousel"; import systemGridNav from "./system/gridNav"; import systemCategory from "./system/category"; +import systemActivity from "./system/activity"; import aiChat from "./ai/chat"; import aiConversation from "./ai/conversation"; @@ -52,6 +53,7 @@ export default { ...systemCarousel, ...systemGridNav, ...systemCategory, + ...systemActivity, ...aiChat, ...aiConversation, ...aiAgent, diff --git a/web/locales/en_US/menu.ts b/web/locales/en_US/menu.ts index 758a415..eab6570 100644 --- a/web/locales/en_US/menu.ts +++ b/web/locales/en_US/menu.ts @@ -47,5 +47,6 @@ export default { "menu.system.carousel": "Carousel", "menu.system.grid-nav": "Grid Navigation", "menu.system.category": "Categories", + "menu.system.activity": "Activity Zone", "menu.xin-admin": "XinAdmin", } diff --git a/web/locales/en_US/system/activity.ts b/web/locales/en_US/system/activity.ts new file mode 100644 index 0000000..106cb45 --- /dev/null +++ b/web/locales/en_US/system/activity.ts @@ -0,0 +1,29 @@ +export default { + // Page Title + "system.activity.page.title": "Activity Zone", + "system.activity.page.description": "Manage homepage activity zone images, descriptions and links", + + // Fields + "system.activity.id": "ID", + "system.activity.title": "Activity Title", + "system.activity.title.required": "Activity title is required", + "system.activity.image": "Activity Image", + "system.activity.image.required": "Activity image is required", + "system.activity.description": "Description", + "system.activity.description.placeholder": "Enter activity description", + "system.activity.linkType": "Link Type", + "system.activity.linkType.required": "Link type is required", + "system.activity.linkType.link": "Link", + "system.activity.linkType.category": "Category", + "system.activity.link": "Redirect Link", + "system.activity.link.placeholder": "Enter redirect link", + "system.activity.category": "Target Category", + "system.activity.category.placeholder": "Select a category", + "system.activity.status": "Status", + "system.activity.status.required": "Status is required", + "system.activity.status.normal": "Active", + "system.activity.status.disabled": "Disabled", + "system.activity.sort": "Sort", + "system.activity.createdAt": "Created At", + "system.activity.updatedAt": "Updated At", +}; diff --git a/web/locales/zh_CN/index.ts b/web/locales/zh_CN/index.ts index 5c67b1d..3fba689 100644 --- a/web/locales/zh_CN/index.ts +++ b/web/locales/zh_CN/index.ts @@ -19,6 +19,7 @@ import systemAi from "./system/ai"; import systemCarousel from "./system/carousel"; import systemGridNav from "./system/gridNav"; import systemCategory from "./system/category"; +import systemActivity from "./system/activity"; import aiChat from "./ai/chat"; import aiConversation from "./ai/conversation"; @@ -52,6 +53,7 @@ export default { ...systemCarousel, ...systemGridNav, ...systemCategory, + ...systemActivity, ...aiChat, ...aiConversation, ...aiAgent, diff --git a/web/locales/zh_CN/menu.ts b/web/locales/zh_CN/menu.ts index fe5963a..373a881 100644 --- a/web/locales/zh_CN/menu.ts +++ b/web/locales/zh_CN/menu.ts @@ -47,5 +47,6 @@ export default { "menu.system.carousel": "轮播图管理", "menu.system.grid-nav": "宫格导航管理", "menu.system.category": "分类管理", + "menu.system.activity": "活动专区管理", "menu.xin-admin": "XinAdmin", }; diff --git a/web/locales/zh_CN/system/activity.ts b/web/locales/zh_CN/system/activity.ts new file mode 100644 index 0000000..f70f69a --- /dev/null +++ b/web/locales/zh_CN/system/activity.ts @@ -0,0 +1,29 @@ +export default { + // 页面标题 + "system.activity.page.title": "活动专区管理", + "system.activity.page.description": "管理首页活动专区图片、描述及跳转链接", + + // 字段 + "system.activity.id": "ID", + "system.activity.title": "活动标题", + "system.activity.title.required": "活动标题不能为空", + "system.activity.image": "活动图片", + "system.activity.image.required": "活动图片不能为空", + "system.activity.description": "活动描述", + "system.activity.description.placeholder": "请输入活动描述", + "system.activity.linkType": "跳转类型", + "system.activity.linkType.required": "跳转类型不能为空", + "system.activity.linkType.link": "链接", + "system.activity.linkType.category": "分类", + "system.activity.link": "跳转链接", + "system.activity.link.placeholder": "请输入跳转链接", + "system.activity.category": "跳转分类", + "system.activity.category.placeholder": "请选择分类", + "system.activity.status": "状态", + "system.activity.status.required": "状态不能为空", + "system.activity.status.normal": "启用", + "system.activity.status.disabled": "禁用", + "system.activity.sort": "排序", + "system.activity.createdAt": "创建时间", + "system.activity.updatedAt": "更新时间", +}; diff --git a/web/pages/system/activity/index.tsx b/web/pages/system/activity/index.tsx new file mode 100644 index 0000000..529bcd7 --- /dev/null +++ b/web/pages/system/activity/index.tsx @@ -0,0 +1,221 @@ +import XinTable from '@/components/XinTable'; +import { Badge, Image, Tag, Typography } from 'antd'; +import { useEffect, useState } from 'react'; +import type { IActivity } from '@/domain/iActivity'; +import type { ICategory } from '@/domain/iCategory'; +import type { XinTableColumn } from '@/components/XinTable/typings'; +import type { ISysFileInfo } from '@/domain/iSysFile'; +import { List } from '@/api/common/table'; +import { useTranslation } from 'react-i18next'; +import dayjs from 'dayjs'; + +const { Title, Text } = Typography; + +/** + * 从 image 字段中提取预览 URL + * image 字段可能是 ISysFileInfo 对象或 URL 字符串 + */ +function getImageUrl(image: ISysFileInfo | string | null | undefined): string { + if (!image) return ''; + if (typeof image === 'string') return image; + return image.preview_url || image.file_url || ''; +} + +/** 首页活动专区管理 */ +export default function ActivityPage() { + const { t } = useTranslation(); + + // 分类下拉选项 + const [categoryOptions, setCategoryOptions] = useState<{ label: string; value: number }[]>([]); + useEffect(() => { + List('/system/category', { pageSize: 1000 }).then((res) => { + const list = res.data?.data?.data ?? []; + setCategoryOptions(list.map((item) => ({ + label: item.name, + value: item.id as number, + }))); + }).catch(() => { + // 分类加载失败不影响页面使用 + }); + }, []); + + const columns: XinTableColumn[] = [ + { + title: t('system.activity.id'), + dataIndex: 'id', + hideInForm: true, + width: 80, + sorter: true, + align: 'center', + }, + { + title: t('system.activity.title'), + dataIndex: 'title', + valueType: 'text', + colProps: { span: 12 }, + rules: [{ required: true, message: t('system.activity.title.required') }], + }, + { + title: t('system.activity.image'), + dataIndex: 'image_id', + valueType: 'image', + colProps: { span: 24 }, + rules: [{ required: true, message: t('system.activity.image.required') }], + hideInSearch: true, + fieldProps: { + action: '/system/file/list/upload', + mode: 'single', + maxCount: 1, + changeType: "id" + }, + render: (_value: ISysFileInfo | string, record: IActivity) => { + const url = getImageUrl(record.image); + if (!url) return '-'; + return ( + + ); + }, + }, + { + title: t('system.activity.description'), + dataIndex: 'description', + valueType: 'textarea', + colProps: { span: 24 }, + hideInSearch: true, + fieldProps: { + rows: 3, + placeholder: t('system.activity.description.placeholder'), + }, + render: (value: string) => value || '-', + }, + { + title: t('system.activity.linkType'), + dataIndex: 'link_type', + valueType: 'select', + colProps: { span: 12 }, + initialValue: 0, + rules: [{ required: true, message: t('system.activity.linkType.required') }], + fieldProps: { + options: [ + { label: t('system.activity.linkType.link'), value: 0 }, + { label: t('system.activity.linkType.category'), value: 1 }, + ], + }, + render: (value: number) => { + return value === 1 + ? {t('system.activity.linkType.category')} + : {t('system.activity.linkType.link')}; + }, + }, + { + title: t('system.activity.link'), + dataIndex: 'link', + valueType: 'text', + colProps: { span: 12 }, + hideInSearch: true, + fieldProps: { + placeholder: t('system.activity.link.placeholder'), + }, + render: (_, record) => { + if(record.link_type === 1) { + const name = record.category?.name; + return name ? {name} : '-'; + } + return record.link || '-' + }, + dependency: { + dependencies: ['link_type'], + visible: (values) => values.link_type !== 1, + }, + }, + { + title: t('system.activity.category'), + dataIndex: 'category_id', + valueType: 'select', + colProps: { span: 12 }, + hideInSearch: true, + hideInTable: true, + fieldProps: { + options: categoryOptions, + showSearch: true, + optionFilterProp: 'label', + placeholder: t('system.activity.category.placeholder'), + }, + dependency: { + dependencies: ['link_type'], + visible: (values) => values.link_type === 1, + }, + }, + { + title: t('system.activity.status'), + dataIndex: 'status', + valueType: 'select', + filters: [ + { text: t('system.activity.status.normal'), value: 0 }, + { text: t('system.activity.status.disabled'), value: 1 }, + ], + colProps: { span: 12 }, + rules: [{ required: true, message: t('system.activity.status.required') }], + fieldProps: { + options: [ + { label: t('system.activity.status.normal'), value: 0 }, + { label: t('system.activity.status.disabled'), value: 1 }, + ], + }, + render: (value: number) => { + return value === 0 + ? + : ; + }, + }, + { + title: t('system.activity.sort'), + dataIndex: 'sort', + valueType: 'digit', + colProps: { span: 12 }, + hideInSearch: true, + initialValue: 0, + fieldProps: { + min: 0, + style: { width: '100%' }, + }, + }, + { + title: t('system.activity.createdAt'), + dataIndex: 'created_at', + render: (value: string) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'), + hideInForm: true, + hideInSearch: true, + width: 160, + }, + ]; + + return ( + <> +
+ {t('system.activity.page.title')} + {t('system.activity.page.description')} +
+ + api="/system/activity" + columns={columns} + rowKey="id" + accessName="system.activity" + formProps={{ + grid: true, + colProps: { span: 12 }, + rowProps: { gutter: [30, 0] }, + layout: 'vertical', + }} + modalProps={{ width: 800 }} + searchProps={false} + scroll={{ x: 1000 }} + /> + + ); +}