diff --git a/.env.base b/.env.base index 17a0c79..6357a92 100644 --- a/.env.base +++ b/.env.base @@ -8,13 +8,13 @@ VITE_API_BASE_PATH= VITE_BASE_PATH=/ # 标题 -VITE_APP_TITLE=ReelShort +VITE_APP_TITLE=FruitShorts # 是否全量引入element-plus样式 VITE_USE_ALL_ELEMENT_PLUS_STYLE=true # 是否开启mock -VITE_USE_MOCK=true +VITE_USE_MOCK=false # 是否使用在线图标 VITE_USE_ONLINE_ICON=true diff --git a/.env.dev b/.env.dev index a4d6b3a..7de1c6f 100644 --- a/.env.dev +++ b/.env.dev @@ -20,7 +20,7 @@ VITE_SOURCEMAP=true VITE_OUT_DIR=dist-dev # 标题 -VITE_APP_TITLE=ElementAdmin +VITE_APP_TITLE=FruitShorts # 是否包分析 VITE_USE_BUNDLE_ANALYZER=false diff --git a/.env.pro b/.env.pro index d3d823f..bf5d37d 100644 --- a/.env.pro +++ b/.env.pro @@ -20,7 +20,7 @@ VITE_SOURCEMAP=false VITE_OUT_DIR=dist-pro # 标题 -VITE_APP_TITLE=ElementAdmin +VITE_APP_TITLE=FruitShorts # 是否包分析 VITE_USE_BUNDLE_ANALYZER=true diff --git a/.env.test b/.env.test index b53f347..be7aae9 100644 --- a/.env.test +++ b/.env.test @@ -20,7 +20,7 @@ VITE_SOURCEMAP=true VITE_OUT_DIR=dist-test # 标题 -VITE_APP_TITLE=ElementAdmin +VITE_APP_TITLE=FruitShorts # 是否包分析 VITE_USE_BUNDLE_ANALYZER=false diff --git a/README.md b/README.md index a8b2f63..aae9365 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,38 @@ -# ReelShort 内容管理后台 +# FruitShorts 内容管理后台 -短剧内容管理后台。界面和工程结构使用 [vue-element-plus-admin](https://element-plus-admin-doc.cn/) 的 `main` 分支,在上面接入了短剧、剧集、角色、菜单和管理员页面。 +短剧内容管理后台。界面与工程结构基于 [vue-element-plus-admin](https://element-plus-admin-doc.cn/),在其上接入了短剧、剧集、App 用户、角色、菜单和管理员页面,数据全部来自 Java 后端服务。 + +界面名称由 `VITE_APP_TITLE` 控制(`.env.base` 及 dev / test / pro 三个环境文件),当前值为 `FruitShorts`,同时作用于浏览器标题、侧边栏品牌与登录页。 + +后端工程在同级目录 `../FruitServer`,接口与启动方式见 [../FruitServer/README.md](../FruitServer/README.md)。 ## 环境 - Node.js 18 或更高版本 - pnpm 8 或更高版本 +- 已启动的后端服务(默认 `http://127.0.0.1:8080`) ## 开始 +先启动后端(见 `../FruitServer/README.md`),再启动前端: + ```sh pnpm install pnpm dev ``` -开发服务地址是 [http://localhost:3005](http://localhost:3005)。同一局域网的设备可以使用终端里的 Network 地址打开。 +开发服务地址是 http://localhost:3005 。`vite.config.mts` 已把 `/admin` 代理到 `http://127.0.0.1:8080`、`/files` 代理到后端本地媒资目录,因此前端不需要额外配置跨域。 -演示账号: +演示账号(密码均为 `admin123`,由后端 `sql/data.sql` 初始化): -| 账号 | 密码 | -| ------- | ---------- | -| `admin` | `admin123` | -| `test` | `test` | +| 账号 | 角色 | 说明 | +| -------------------- | ---------- | ------------------------------ | +| `admin` | 超级管理员 | 拥有全部菜单与操作权限 | +| `content.editor01` | 内容运营 | 只能看到内容管理,且无审核权限 | +| `content.reviewer01` | 审核专员 | 可审核发布 | +| `data.analyst01` | 数据分析师 | 只读内容数据 | -`admin` 拥有全部菜单。`test` 只用于对照框架自带的权限示例。 +用不同账号登录可以直观看到菜单与按钮随权限变化。 ## 命令 @@ -39,22 +48,26 @@ pnpm dev 登录后菜单包括: -- 工作台 -- 内容管理:短剧管理、剧集管理 +- 工作台:短剧总数、累计播放量、待审核数、已上线数与最近更新 +- 内容管理:短剧管理(含封面分片上传、提交审核/通过/驳回/上线/下线)、剧集管理(含视频分片上传、自动读取时长、播放顺序调整)、轮播图管理(含图片上传、跳转配置、排序与上下线)、类型管理(短剧类型的增删改) +- 用户管理 → App 用户:按 uid / 昵称 / 手机号 / 邮箱检索 C 端用户,查看资料、观看流水与处置记录,支持封禁 / 限制 / 解封(处置后该用户全部会话立即失效) - 后台管理:角色权限配置、管理员用户列表、菜单管理、角色分类、系统设置 +- 后台管理 → 操作日志:查看后台写操作的请求与响应、操作人、耗时 -这些页面的数据仍是前端演示数据,没有接后端。登录和菜单由框架的 mock 接口提供。 +左侧菜单由后端 `GET /admin/auth/menus` 下发,属于服务端动态路由;在菜单管理页改动菜单后,重新登录即可看到新的菜单结构。 ## 目录 -框架原有代码保持 [vue-element-plus-admin](https://github.com/kailong321200875/vue-element-plus-admin/tree/mini) 的结构。本项目新增的业务代码在: +框架原有代码保持 vue-element-plus-admin 的结构。本项目新增的业务代码: +- `src/api/admin/`:管理端接口封装(短剧、剧集、角色、菜单、管理员、配置) - `src/views/common/DashboardView.vue` - `src/views/contentManagement/` -- `src/views/permission/` -- `src/views/userManagement/` +- `src/views/permission/`、`src/views/permission/composables/` +- `src/views/userManagement/`、`src/views/userManagement/composables/` - `src/views/systemManagement/` -- `src/data/`、`src/permissions/`、`src/stores/` +- `src/stores/rbac.js`:基于登录下发权限码的按钮级权限判断 +- `src/utils/status.js`、`src/utils/format.js` - `src/styles/business/` -路由写在 `src/router/index.ts` 的 `asyncRouterMap`。 +路由写在 `src/router/index.ts` 的 `asyncRouterMap`(服务端动态路由不可用时的兜底)。 diff --git a/package.json b/package.json index a89c5f0..e441b28 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "reelshort-admin", + "name": "fruitshorts-admin", "version": "1.0.0", - "description": "ReelShort 内容管理后台,基于 vue-element-plus-admin。", + "description": "FruitShorts 内容管理后台,基于 vue-element-plus-admin。", "author": "Archer <502431556@qq.com>", "private": true, "scripts": { diff --git a/src/api/admin/appUser.ts b/src/api/admin/appUser.ts new file mode 100644 index 0000000..0d5578f --- /dev/null +++ b/src/api/admin/appUser.ts @@ -0,0 +1,39 @@ +import request from '@/axios' +import type { AppUserBanRecord, AppUserItem, PageResult, WatchHistoryItem } from './types' + +export interface AppUserPageParams { + pageIndex?: number + pageSize?: number + /** uid / 昵称 / 手机号 / 邮箱 */ + keyword?: string + status?: string + countryCode?: string + registerSource?: string +} + +export const pageAppUsersApi = (params: AppUserPageParams) => { + return request.get>({ url: '/admin/app-users/page', params }) +} + +export const getAppUserApi = (id: number) => { + return request.get({ url: `/admin/app-users/${id}` }) +} + +export const listAppUserBanRecordsApi = (id: number) => { + return request.get({ url: `/admin/app-users/${id}/ban-records` }) +} + +export const listAppUserWatchHistoryApi = (id: number, limit = 50) => { + return request.get({ + url: `/admin/app-users/${id}/watch-history`, + params: { limit } + }) +} + +/** normal / limited / banned,封禁与限制会立即吊销该用户全部会话 */ +export const updateAppUserStatusApi = (id: number, status: string, reason?: string) => { + return request.put({ + url: `/admin/app-users/${id}/status`, + params: { status, reason } + }) +} diff --git a/src/api/admin/banner.ts b/src/api/admin/banner.ts new file mode 100644 index 0000000..dbf558f --- /dev/null +++ b/src/api/admin/banner.ts @@ -0,0 +1,37 @@ +import request from '@/axios' +import type { BannerItem, BannerSaveData, PageResult } from './types' + +export interface BannerPageParams { + pageIndex?: number + pageSize?: number + position?: string + status?: number +} + +export const pageBannersApi = (params: BannerPageParams) => { + return request.get>({ url: '/admin/banners/page', params }) +} + +export const getBannerApi = (id: number) => { + return request.get({ url: `/admin/banners/${id}` }) +} + +export const createBannerApi = (data: BannerSaveData) => { + return request.post({ url: '/admin/banners', data }) +} + +export const updateBannerApi = (id: number, data: BannerSaveData) => { + return request.put({ url: `/admin/banners/${id}`, data }) +} + +export const deleteBannerApi = (id: number) => { + return request.delete({ url: `/admin/banners/${id}` }) +} + +export const updateBannerStatusApi = (id: number, status: number) => { + return request.put({ url: `/admin/banners/${id}/status`, params: { status } }) +} + +export const sortBannersApi = (ids: number[]) => { + return request.put({ url: '/admin/banners/sort', data: { ids } }) +} diff --git a/src/api/admin/config.ts b/src/api/admin/config.ts new file mode 100644 index 0000000..0739013 --- /dev/null +++ b/src/api/admin/config.ts @@ -0,0 +1,10 @@ +import request from '@/axios' +import type { ConfigItem } from './types' + +export const listConfigsApi = () => { + return request.get({ url: '/admin/configs' }) +} + +export const updateConfigsApi = (values: Record) => { + return request.put({ url: '/admin/configs', data: values }) +} diff --git a/src/api/admin/drama.ts b/src/api/admin/drama.ts new file mode 100644 index 0000000..884393b --- /dev/null +++ b/src/api/admin/drama.ts @@ -0,0 +1,70 @@ +import request from '@/axios' +import type { + DramaCategory, + DramaItem, + DramaReviewAction, + DramaSaveData, + PageResult +} from './types' + +export interface DramaPageParams { + pageIndex?: number + pageSize?: number + keyword?: string + status?: string + categoryId?: number + isHot?: boolean +} + +export const pageDramasApi = (params: DramaPageParams) => { + return request.get>({ url: '/admin/dramas/page', params }) +} + +export const getDramaApi = (id: number) => { + return request.get({ url: `/admin/dramas/${id}` }) +} + +export const createDramaApi = (data: DramaSaveData) => { + return request.post({ url: '/admin/dramas', data }) +} + +export const updateDramaApi = (id: number, data: DramaSaveData) => { + return request.put({ url: `/admin/dramas/${id}`, data }) +} + +export const deleteDramaApi = (id: number) => { + return request.delete({ url: `/admin/dramas/${id}` }) +} + +/** 审核与发布:submit / approve / reject / publish / offline */ +export const changeDramaStatusApi = (id: number, action: DramaReviewAction, reason?: string) => { + return request.post({ url: `/admin/dramas/${id}/${action}`, data: { reason } }) +} + +/** 下拉选项(短剧表单用),只返回 id/name */ +export const listDramaCategoryOptionsApi = () => { + return request.get({ url: '/admin/dicts/drama-category' }) +} + +export interface DramaCategorySaveData { + name: string + sort?: number + status?: number +} + +/** 类型管理列表,带短剧数量 */ +export const listDramaCategoriesApi = () => { + return request.get({ url: '/admin/drama-categories' }) +} + +export const createDramaCategoryApi = (data: DramaCategorySaveData) => { + return request.post({ url: '/admin/drama-categories', data }) +} + +export const updateDramaCategoryApi = (id: number, data: DramaCategorySaveData) => { + return request.put({ url: `/admin/drama-categories/${id}`, data }) +} + +export const deleteDramaCategoryApi = (id: number) => { + return request.delete({ url: `/admin/drama-categories/${id}` }) +} diff --git a/src/api/admin/episode.ts b/src/api/admin/episode.ts new file mode 100644 index 0000000..2cf8b89 --- /dev/null +++ b/src/api/admin/episode.ts @@ -0,0 +1,22 @@ +import request from '@/axios' +import type { EpisodeItem, EpisodeSaveData } from './types' + +export const listEpisodesApi = (dramaId: number, status?: string) => { + return request.get({ url: '/admin/episodes', params: { dramaId, status } }) +} + +export const createEpisodeApi = (data: EpisodeSaveData) => { + return request.post({ url: '/admin/episodes', data }) +} + +export const updateEpisodeApi = (id: number, data: EpisodeSaveData) => { + return request.put({ url: `/admin/episodes/${id}`, data }) +} + +export const deleteEpisodeApi = (id: number) => { + return request.delete({ url: `/admin/episodes/${id}` }) +} + +export const sortEpisodesApi = (ids: number[]) => { + return request.put({ url: '/admin/episodes/sort', data: { ids } }) +} diff --git a/src/api/admin/log.ts b/src/api/admin/log.ts new file mode 100644 index 0000000..f67cfcb --- /dev/null +++ b/src/api/admin/log.ts @@ -0,0 +1,21 @@ +import request from '@/axios' +import type { OperationLogItem, PageResult } from './types' + +export interface OperationLogPageParams { + pageIndex?: number + pageSize?: number + username?: string + module?: string + action?: string + status?: string + startTime?: string + endTime?: string +} + +export const pageOperationLogsApi = (params: OperationLogPageParams) => { + return request.get>({ url: '/admin/operation-logs/page', params }) +} + +export const getOperationLogApi = (id: number) => { + return request.get({ url: `/admin/operation-logs/${id}` }) +} diff --git a/src/api/admin/media.ts b/src/api/admin/media.ts new file mode 100644 index 0000000..f2d0dad --- /dev/null +++ b/src/api/admin/media.ts @@ -0,0 +1,41 @@ +import request from '@/axios' +import type { MediaAsset } from './types' + +export interface UploadInitData { + fileName: string + fileSize: number + /** DRAMA_COVER / EPISODE / AVATAR */ + bizType?: string + chunkSize?: number +} + +export interface UploadInitResult { + uploadId: string + chunkSize: number + totalChunks: number + objectKey: string +} + +export const initUploadApi = (data: UploadInitData) => { + return request.post({ url: '/admin/media/upload/init', data }) +} + +/** 不设置 Content-Type,交给浏览器带 boundary */ +export const uploadChunkApi = (uploadId: string, index: number, chunk: Blob, fileName: string) => { + const form = new FormData() + form.append('uploadId', uploadId) + form.append('index', String(index)) + form.append('file', chunk, fileName) + return request.post({ url: '/admin/media/upload/chunk', data: form }) +} + +export const completeUploadApi = (uploadId: string, durationSec?: number) => { + return request.post({ + url: '/admin/media/upload/complete', + data: { uploadId, durationSec } + }) +} + +export const getMediaApi = (id: number) => { + return request.get({ url: `/admin/media/${id}` }) +} diff --git a/src/api/admin/role.ts b/src/api/admin/role.ts new file mode 100644 index 0000000..17a64ee --- /dev/null +++ b/src/api/admin/role.ts @@ -0,0 +1,85 @@ +import request from '@/axios' +import type { + MenuNode, + MenuSaveData, + PageResult, + PermissionGroup, + RoleCategoryItem, + RoleItem, + RoleSaveData +} from './types' + +export interface RolePageParams { + keyword?: string + pageIndex?: number + pageSize?: number +} + +export const pageRolesApi = (params: RolePageParams) => { + return request.get>({ url: '/admin/roles/page', params }) +} + +export const listRolesApi = () => { + return request.get({ url: '/admin/roles/list' }) +} + +export const createRoleApi = (data: RoleSaveData) => { + return request.post({ url: '/admin/roles', data }) +} + +export const updateRoleApi = (id: number, data: RoleSaveData) => { + return request.put({ url: `/admin/roles/${id}`, data }) +} + +export const deleteRoleApi = (id: number) => { + return request.delete({ url: `/admin/roles/${id}` }) +} + +export const getRolePermissionsApi = (id: number) => { + return request.get({ url: `/admin/roles/${id}/permissions` }) +} + +export const updateRolePermissionsApi = (id: number, permissions: string[]) => { + return request.put({ url: `/admin/roles/${id}/permissions`, data: { permissions } }) +} + +export const listPermissionGroupsApi = () => { + return request.get({ url: '/admin/permissions' }) +} + +export interface RoleCategorySaveData { + name: string + description?: string +} + +export const listRoleCategoriesApi = () => { + return request.get({ url: '/admin/role-categories' }) +} + +export const createRoleCategoryApi = (data: RoleCategorySaveData) => { + return request.post({ url: '/admin/role-categories', data }) +} + +export const updateRoleCategoryApi = (id: number, data: RoleCategorySaveData) => { + return request.put({ url: `/admin/role-categories/${id}`, data }) +} + +export const deleteRoleCategoryApi = (id: number) => { + return request.delete({ url: `/admin/role-categories/${id}` }) +} + +export const getMenuTreeApi = () => { + return request.get({ url: '/admin/menus/tree' }) +} + +export const createMenuApi = (data: MenuSaveData) => { + return request.post({ url: '/admin/menus', data }) +} + +export const updateMenuApi = (id: number, data: MenuSaveData) => { + return request.put({ url: `/admin/menus/${id}`, data }) +} + +export const deleteMenuApi = (id: number) => { + return request.delete({ url: `/admin/menus/${id}` }) +} diff --git a/src/api/admin/types.ts b/src/api/admin/types.ts new file mode 100644 index 0000000..522282f --- /dev/null +++ b/src/api/admin/types.ts @@ -0,0 +1,353 @@ +export interface PageResult { + total: number + list: T[] +} + +export interface DramaCategory { + id: number + name: string + sort?: number + status?: number + /** 该类型下的短剧数量,管理列表才有 */ + dramaCount?: number +} + +export interface DramaItem { + id: number + code?: string + title: string + subtitle?: string + categoryId?: number + category?: string + genre?: string + description?: string + cover?: string + language?: string + episodes?: number + freeEpisodes?: number + unlockCoin?: number + /** 是否热门 */ + isHot?: boolean + status: string + statusText: string + views?: number + sort?: number + publishAt?: string + createdAt?: string + updatedAt?: string +} + +export interface DramaSaveData { + title: string + subtitle?: string + categoryId?: number + genre?: string + description?: string + cover?: string + language?: string + freeEpisodes?: number + unlockCoin?: number + isHot?: boolean + sort?: number +} + +export type DramaReviewAction = 'submit' | 'approve' | 'reject' | 'publish' | 'offline' + +export interface EpisodeItem { + id: number + dramaId: number + number: number + title: string + duration: string + durationSec: number + cover?: string + videoUrl?: string + isFree: boolean + unlockCoin: number + status: string + statusText: string + views?: number + sort?: number +} + +export interface EpisodeSaveData { + dramaId: number + number: number + title?: string + durationSec?: number + cover?: string + videoUrl?: string + isFree?: boolean + unlockCoin?: number + status?: string + sort?: number +} + +export interface AdminUserItem { + id: number + username: string + name: string + roleId: number | null + role: string + ipWhitelist: string + status: number + statusText: string + twoFactor: number + twoFactorText: string + createdAt?: string + lastLogin?: string +} + +export interface AdminUserSaveData { + username?: string + password?: string + name: string + roleId: number + ipWhitelist?: string + status?: number + twoFactor?: number +} + +export interface RoleItem { + id: number + name: string + code: string + description: string + categoryId: number + category: string + level: number + status: number + statusText: string + builtin: boolean + memberCount: number + permissions: string[] +} + +export interface RoleSaveData { + name: string + code?: string + description?: string + categoryId?: number + level?: number + status?: number + permissions?: string[] +} + +export interface RoleCategoryItem { + id: number + name: string + description: string + roleCount: number +} + +export interface PermissionItem { + code: string + name: string +} + +export interface PermissionGroup { + name: string + items: PermissionItem[] +} + +export interface MenuNode { + id: number + parentId?: number + path?: string + name?: string + component?: string + redirect?: string + icon?: string + type?: string + permissionCode?: string + sort?: number + visible?: number + status?: number + meta?: { title?: string; icon?: string; alwaysShow?: boolean; affix?: boolean } + children?: MenuNode[] +} + +export interface MenuSaveData { + parentId?: number + title: string + routePath?: string + routeName?: string + component?: string + redirect?: string + icon?: string + permissionCode?: string + type?: string + sort?: number + visible?: number + status?: number +} + +export interface ConfigItem { + key: string + value: string + remark?: string +} + +export interface MediaAsset { + id: number + bizType: string + fileName: string + objectKey: string + url: string + sizeBytes: number + md5: string + durationSec?: number + /** uploading / uploaded / transcoding / ready / failed */ + status: string + createdAt?: string +} + +export interface BannerItem { + id: number + imageUrl: string + /** HOME_TOP / DISCOVER */ + position: string + positionText: string + /** NONE / DRAMA / URL */ + linkType: string + linkTypeText: string + linkValue: string + /** 跳转目标的展示文案 */ + linkText: string + sort: number + status: number + statusText: string + updatedAt?: string +} + +export interface BannerSaveData { + imageUrl: string + position: string + linkType: string + linkValue?: string + sort?: number + status?: number +} + +export interface OperationLogItem { + id: number + userId: number + username: string + module: string + action: string + requestMethod: string + requestUrl: string + requestParams?: string + requestBody?: string + responseBody?: string + /** SUCCESS / FAIL */ + status: string + statusText: string + errorMsg?: string + durationMs: number + ip: string + createdAt?: string +} + +/** C 端用户(后台视图,比客户端多一个自增 id,用于后台按 id 寻址) */ +export interface AppUserItem { + id: number + uid: string + nickname: string + avatarUrl: string + gender: string + countryCode: string + regionCode: string + language: string + timezone: string + currency: string + /** normal / limited / banned / deactivated */ + status: string + /** 脱敏后的手机号或邮箱 */ + maskedTarget: string + createdAt?: string + lastLoginAt?: string + /** 预留:会员等级与到期(第二阶段) */ + vipLevel?: number + vipExpireAt?: string +} + +export interface AppUserBanRecord { + id: number + userId: number + /** BAN / LIMIT / MUTE */ + type: string + reasonCode: string + reason: string + refType: string + refId: number + startAt?: string + endAt?: string + operatorId: number + /** none / pending / approved / rejected */ + appealStatus: string + appealContent: string + handleNote: string +} + +export interface WatchHistoryItem { + id: number + dramaId: number + dramaTitle?: string + dramaPublished?: boolean + episodeId: number + episodeNo?: number + positionMs: number + durationMs: number + finished?: boolean + source: string + deviceId: string + watchedAt?: string +} + +/** 评论(后台视图,字段与 /admin/comments/page 一一对应) */ +export interface CommentItem { + id: number + dramaId: number + episodeId: number + parentId: number + rootId: number + userId: number + uid: string + nickname: string + content: string + /** pending / published / rejected / deleted */ + status: string + auditReason: string + likeCount: number + replyCount: number + reportCount: number + createdAt?: string +} + +export interface ReportItem { + id: number + reporterId: number + /** COMMENT / USER / DRAMA */ + targetType: string + targetId: number + reasonCode: string + description: string + /** pending / handled / rejected */ + status: string + handleBy: number + handleAt?: string + handleNote: string + createdAt?: string +} + +export interface SensitiveWordItem { + id: number + word: string + category: string + /** BLOCK / REVIEW / REPLACE */ + level: string + replaceText: string + language: string + status: number +} diff --git a/src/api/admin/user.ts b/src/api/admin/user.ts new file mode 100644 index 0000000..0a3ad9b --- /dev/null +++ b/src/api/admin/user.ts @@ -0,0 +1,34 @@ +import request from '@/axios' +import type { AdminUserItem, AdminUserSaveData, PageResult } from './types' + +export interface AdminUserPageParams { + pageIndex?: number + pageSize?: number + username?: string + roleId?: number + status?: number +} + +export const pageAdminUsersApi = (params: AdminUserPageParams) => { + return request.get>({ url: '/admin/admin-users/page', params }) +} + +export const createAdminUserApi = (data: AdminUserSaveData) => { + return request.post({ url: '/admin/admin-users', data }) +} + +export const updateAdminUserApi = (id: number, data: AdminUserSaveData) => { + return request.put({ url: `/admin/admin-users/${id}`, data }) +} + +export const deleteAdminUserApi = (id: number) => { + return request.delete({ url: `/admin/admin-users/${id}` }) +} + +export const updateAdminUserStatusApi = (id: number, status: number) => { + return request.put({ url: `/admin/admin-users/${id}/status`, params: { status } }) +} + +export const resetAdminUserPasswordApi = (id: number, password: string) => { + return request.put({ url: `/admin/admin-users/${id}/password`, data: { password } }) +} diff --git a/src/api/login/index.ts b/src/api/login/index.ts index dff73a0..072ef09 100644 --- a/src/api/login/index.ts +++ b/src/api/login/index.ts @@ -6,17 +6,22 @@ interface RoleParams { } export const loginApi = (data: UserType): Promise> => { - return request.post({ url: '/mock/user/login', data }) + return request.post({ url: '/admin/auth/login', data }) } export const loginOutApi = (): Promise => { - return request.get({ url: '/mock/user/loginOut' }) + return request.post({ url: '/admin/auth/logout' }) +} + +/** 当前登录人信息(含实时权限) */ +export const getProfileApi = (): Promise> => { + return request.get({ url: '/admin/auth/profile' }) } export const getAdminRoleApi = ( params: RoleParams ): Promise> => { - return request.get({ url: '/mock/role/list', params }) + return request.get({ url: '/admin/auth/menus', params }) } export const getTestRoleApi = (params: RoleParams): Promise> => { diff --git a/src/api/login/types.ts b/src/api/login/types.ts index 446a023..23e888f 100644 --- a/src/api/login/types.ts +++ b/src/api/login/types.ts @@ -9,4 +9,10 @@ export type UserType = { role: string roleId: string permissions: string | string[] + /** 登录成功后由后端下发 */ + token?: string + userId?: number + name?: string + avatar?: string + roleName?: string } diff --git a/src/axios/index.ts b/src/axios/index.ts index 94cbfc3..07ec0a1 100644 --- a/src/axios/index.ts +++ b/src/axios/index.ts @@ -5,6 +5,8 @@ import { useUserStoreWithOut } from '@/store/modules/user' const request = (option: AxiosConfig) => { const { url, method, params, data, headers, responseType } = option const userStore = useUserStoreWithOut() + // FormData 不能带 JSON 的 Content-Type,否则 axios 会把表单转成 JSON,浏览器也无法补上 boundary + const isFormData = typeof FormData !== 'undefined' && data instanceof FormData return service.request({ url: url, method, @@ -12,8 +14,10 @@ const request = (option: AxiosConfig) => { data, responseType: responseType, headers: { - 'Content-Type': CONTENT_TYPE, - [userStore.getTokenKey ?? 'Authorization']: userStore.getToken ?? '', + ...(isFormData ? {} : { 'Content-Type': CONTENT_TYPE }), + [userStore.getTokenKey ?? 'Authorization']: userStore.getToken + ? `Bearer ${userStore.getToken}` + : '', ...headers } }) diff --git a/src/data/customMenus.js b/src/data/customMenus.js deleted file mode 100644 index b0fdb34..0000000 --- a/src/data/customMenus.js +++ /dev/null @@ -1,23 +0,0 @@ -import { reactive } from 'vue' - -const storageKey = 'reelshort-custom-menus' -let savedMenus -try { - savedMenus = JSON.parse(localStorage.getItem(storageKey) || '[]') -} catch { - savedMenus = [] -} - -export const customMenus = reactive(Array.isArray(savedMenus) ? savedMenus : []) -export const menuIconNames = [ - 'Document', - 'DataAnalysis', - 'Picture', - 'User', - 'Setting', - 'VideoCamera' -] - -export function saveCustomMenus() { - localStorage.setItem(storageKey, JSON.stringify(customMenus)) -} diff --git a/src/data/dramas.js b/src/data/dramas.js deleted file mode 100644 index 01342c7..0000000 --- a/src/data/dramas.js +++ /dev/null @@ -1,59 +0,0 @@ -import { reactive } from 'vue' - -export const dramas = reactive([ - { - id: 1, - title: '替嫁新娘:总裁的掌心宠', - genre: '都市情感', - status: '已上线', - episodes: 68, - views: '2.4M', - updatedAt: '2025-06-12', - description: '一场意外替嫁,让她成为冷面总裁的契约新娘。', - cover: '' - }, - { - id: 2, - title: '重生之我在豪门当千金', - genre: '重生逆袭', - status: '审核中', - episodes: 42, - views: '—', - updatedAt: '2025-06-18', - description: '重活一世,她要拿回属于自己的一切。', - cover: '' - }, - { - id: 3, - title: '心动信号:请和我恋爱', - genre: '甜宠爱情', - status: '已上线', - episodes: 56, - views: '1.8M', - updatedAt: '2025-05-29', - description: '恋爱观察室里,心动正在悄悄发生。', - cover: '' - }, - { - id: 4, - title: '她从深渊中走来', - genre: '悬疑复仇', - status: '草稿', - episodes: 24, - views: '—', - updatedAt: '2025-06-20', - description: '真相只有一个,而她亲手揭开迷雾。', - cover: '' - }, - { - id: 5, - title: '闪婚后,傅先生每天都在撒糖', - genre: '都市情感', - status: '已上线', - episodes: 72, - views: '3.1M', - updatedAt: '2025-05-21', - description: '一纸婚约,开启了甜蜜又心动的同居生活。', - cover: '' - } -]) diff --git a/src/data/episodes.js b/src/data/episodes.js deleted file mode 100644 index 1141d01..0000000 --- a/src/data/episodes.js +++ /dev/null @@ -1,40 +0,0 @@ -import { reactive } from 'vue' - -export const episodeData = reactive([ - { - id: 1, - dramaId: 1, - number: 1, - title: '命运的相遇', - duration: '02:18', - status: '已上线', - views: '48,201' - }, - { - id: 2, - dramaId: 1, - number: 2, - title: '意外的婚约', - duration: '01:56', - status: '已上线', - views: '42,817' - }, - { - id: 3, - dramaId: 1, - number: 3, - title: '心动的开始', - duration: '02:04', - status: '已上线', - views: '39,506' - }, - { - id: 4, - dramaId: 1, - number: 4, - title: '他的秘密', - duration: '02:31', - status: '待发布', - views: '—' - } -]) diff --git a/src/data/roles.js b/src/data/roles.js deleted file mode 100644 index c297378..0000000 --- a/src/data/roles.js +++ /dev/null @@ -1,40 +0,0 @@ -import { reactive } from 'vue' - -export const roleData = reactive([ - { - id: 1, - name: '超级管理员', - description: '拥有系统全部管理权限', - memberCount: 2, - category: '系统管理员', - level: 1, - status: '启用' - }, - { - id: 2, - name: '内容运营', - description: '管理短剧、剧集及封面素材', - memberCount: 8, - category: '内容管理', - level: 2, - status: '启用' - }, - { - id: 3, - name: '审核专员', - description: '审核短剧内容与发布状态', - memberCount: 5, - category: '内容管理', - level: 3, - status: '启用' - }, - { - id: 4, - name: '数据分析师', - description: '查看内容数据与运营报表', - memberCount: 3, - category: '数据分析', - level: 4, - status: '启用' - } -]) diff --git a/src/permission.ts b/src/permission.ts index a14c3a8..756b65a 100644 --- a/src/permission.ts +++ b/src/permission.ts @@ -7,6 +7,7 @@ import { usePermissionStoreWithOut } from '@/store/modules/permission' import { usePageLoading } from '@/hooks/web/usePageLoading' import { NO_REDIRECT_WHITE_LIST } from '@/constants' import { useUserStoreWithOut } from '@/store/modules/user' +import { getAdminRoleApi, getProfileApi } from '@/api/login' const { start, done } = useNProgress() @@ -32,9 +33,29 @@ router.beforeEach(async (to, from, next) => { // 是否使用动态路由 if (appStore.getDynamicRouter) { - appStore.serverDynamicRouter - ? await permissionStore.generateRoutes('server', roleRouters as AppCustomRouteRecordRaw[]) - : await permissionStore.generateRoutes('frontEnd', roleRouters as string[]) + if (appStore.serverDynamicRouter) { + // 每次进入应用都重新拉菜单与权限:后台改了菜单或角色权限,刷新即可生效,不必重新登录 + let routers = (userStore.getRoleRouters || []) as AppCustomRouteRecordRaw[] + try { + const username = userStore.getUserInfo?.username || '' + const [profileRes, menuRes] = await Promise.all([ + getProfileApi(), + getAdminRoleApi({ roleName: username }) + ]) + if (profileRes?.data) { + userStore.setUserInfo({ ...userStore.getUserInfo, ...profileRes.data }) + } + if (menuRes?.data?.length) { + routers = menuRes.data + userStore.setRoleRouters(routers) + } + } catch { + // 拉取失败时退回上次缓存的菜单,不阻塞进入 + } + await permissionStore.generateRoutes('server', routers) + } else { + await permissionStore.generateRoutes('frontEnd', roleRouters as string[]) + } } else { await permissionStore.generateRoutes('static') } diff --git a/src/permissions/permissions.js b/src/permissions/permissions.js deleted file mode 100644 index c83feaf..0000000 --- a/src/permissions/permissions.js +++ /dev/null @@ -1,24 +0,0 @@ -export const permissionGroups = [ - { name: '短剧管理', items: ['查看短剧列表', '创建短剧', '编辑短剧信息', '删除短剧'] }, - { name: '剧集管理', items: ['查看剧集列表', '上传剧集视频', '管理剧集排序'] }, - { name: '封面管理', items: ['上传封面素材'] }, - { name: '审核与系统', items: ['审核并发布', '查看数据报表'] }, - { name: '后台管理', items: ['角色与权限配置', '管理员用户列表', '菜单管理', '角色分类管理'] } -] - -export const allPermissions = permissionGroups.flatMap((group) => group.items) -export const defaultRolePermissions = { - 超级管理员: [...allPermissions], - 内容运营: [ - '查看短剧列表', - '创建短剧', - '编辑短剧信息', - '删除短剧', - '查看剧集列表', - '上传剧集视频', - '管理剧集排序', - '上传封面素材' - ], - 审核专员: ['查看短剧列表', '查看剧集列表', '审核并发布', '查看数据报表'], - 数据分析师: ['查看短剧列表', '查看数据报表'] -} diff --git a/src/router/index.ts b/src/router/index.ts index 70b565a..eec41a4 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -2,7 +2,6 @@ import { createRouter, createWebHashHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' import type { App } from 'vue' import { Layout } from '@/utils/routerHelper' -import { customMenus } from '@/data/customMenus' export const constantRouterMap: AppRouteRecordRaw[] = [ { @@ -102,6 +101,43 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [ meta: { title: '剧集管理' } + }, + { + path: 'banners', + name: 'banners', + component: () => import('@/views/contentManagement/BannerManagementView.vue'), + meta: { + title: '轮播图管理' + } + }, + { + path: 'categories', + name: 'categories', + component: () => import('@/views/contentManagement/CategoryManagementView.vue'), + meta: { + title: '类型管理' + } + } + ] + }, + { + path: '/user', + component: Layout, + redirect: '/user/app-users', + name: 'UserCenter', + meta: { + title: '用户管理', + icon: 'ep:user', + alwaysShow: true + }, + children: [ + { + path: 'app-users', + name: 'app-users', + component: () => import('@/views/userManagement/AppUsersView.vue'), + meta: { + title: 'App 用户' + } } ] }, @@ -155,42 +191,19 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [ meta: { title: '系统设置' } + }, + { + path: 'operation-logs', + name: 'operation-logs', + component: () => import('@/views/systemManagement/OperationLogView.vue'), + meta: { + title: '操作日志' + } } ] } ] -export function registerCustomMenu(menu: { - key: string - title: string - route?: string - type?: string -}) { - const name = `custom-menu-${menu.key}` - if (router.hasRoute(name)) return - if (menu.type === '按钮') return - const path = `/${String(menu.route || `custom/${menu.key}`).replace(/^\/+/, '')}` - router.addRoute({ - path, - name, - component: Layout, - meta: { - title: menu.title, - hidden: true - }, - children: [ - { - path: '', - name: `${name}-page`, - component: () => import('@/views/permission/CustomMenuView.vue'), - meta: { - title: menu.title - } - } - ] - }) -} - const router = createRouter({ history: createWebHashHistory(), strict: true, @@ -198,8 +211,6 @@ const router = createRouter({ scrollBehavior: () => ({ left: 0, top: 0 }) }) -customMenus.forEach(registerCustomMenu) - export const resetRouter = (): void => { const resetWhiteNameList = ['Redirect', 'RedirectParent', 'Login', 'NoFind', 'Root'] router.getRoutes().forEach((route) => { diff --git a/src/store/modules/app.ts b/src/store/modules/app.ts index 140fd22..4ee50d2 100644 --- a/src/store/modules/app.ts +++ b/src/store/modules/app.ts @@ -330,7 +330,19 @@ export const useAppStore = defineStore('app', { isDark.value = this.getIsDark } }, - persist: true + /** + * 界面偏好整体持久化,但 title 例外。 + * + * title 来自构建期常量(.env 的 VITE_APP_TITLE),不是用户设置。 + * 如果跟着 localStorage 走,产品改名后老用户浏览器里存着旧值, + * 刷新多少次都还是旧名称(浏览器标题和侧边栏品牌都读它)。 + * 所以恢复之后再强制覆盖一次。 + */ + persist: { + afterRestore: (context) => { + context.store.title = import.meta.env.VITE_APP_TITLE + } + } }) export const useAppStoreWithOut = () => { diff --git a/src/stores/auth.js b/src/stores/auth.js deleted file mode 100644 index c1fd0ac..0000000 --- a/src/stores/auth.js +++ /dev/null @@ -1,24 +0,0 @@ -import { computed, ref } from 'vue' - -const authenticated = ref(localStorage.getItem('reelshort-auth') === '1') -const currentRole = ref('超级管理员') - -export function useAuth() { - const isAuthenticated = computed(() => authenticated.value) - function login(username, password) { - if (username !== 'admin' || password !== 'admin123') return false - localStorage.setItem('reelshort-auth', '1') - currentRole.value = '超级管理员' - authenticated.value = true - return true - } - function logout() { - localStorage.removeItem('reelshort-auth') - currentRole.value = '超级管理员' - authenticated.value = false - } - function setRole(role) { - currentRole.value = role - } - return { isAuthenticated, currentRole, login, logout, setRole } -} diff --git a/src/stores/rbac.js b/src/stores/rbac.js index 558d6bc..bf57ed7 100644 --- a/src/stores/rbac.js +++ b/src/stores/rbac.js @@ -1,44 +1,17 @@ -import { reactive, computed } from 'vue' -import { defaultRolePermissions } from '../permissions/permissions' -import { useAuth } from './auth' -import { customMenus } from '../data/customMenus' - -const rolePermissions = reactive( - Object.fromEntries( - Object.entries(defaultRolePermissions).map(([role, list]) => [role, [...list]]) - ) -) -rolePermissions['超级管理员'].push(...customMenus.map((menu) => menu.permission)) +import { computed } from 'vue' +import { useUserStoreWithOut } from '@/store/modules/user' +/** + * 权限判断:权限码由登录接口下发并存在 user store 中。 + * 用法:const { can, hasAny } = useRbac(); can('drama:create') + */ export function useRbac() { - const { currentRole } = useAuth() - const permissions = computed(() => rolePermissions[currentRole.value] || []) - const can = (permission) => permissions.value.includes(permission) - function updateRolePermission(role, permission, enabled) { - const current = rolePermissions[role] || (rolePermissions[role] = []) - rolePermissions[role] = enabled - ? [...new Set([...current, permission])] - : current.filter((item) => item !== permission) - } - function createRole(name) { - if (!rolePermissions[name]) rolePermissions[name] = [] - } - function createPermission(permission) { - rolePermissions['超级管理员'] = [ - ...new Set([...(rolePermissions['超级管理员'] || []), permission]) - ] - } - function removePermission(permission) { - for (const role of Object.keys(rolePermissions)) - rolePermissions[role] = rolePermissions[role].filter((item) => item !== permission) - } - return { - permissions, - can, - rolePermissions, - updateRolePermission, - createRole, - createPermission, - removePermission - } + const userStore = useUserStoreWithOut() + const permissions = computed(() => { + const list = userStore.getUserInfo?.permissions + return Array.isArray(list) ? list : [] + }) + const can = (code) => permissions.value.includes(code) + const hasAny = (...codes) => codes.some((code) => permissions.value.includes(code)) + return { permissions, can, hasAny } } diff --git a/src/styles/business/app-user-management.css b/src/styles/business/app-user-management.css new file mode 100644 index 0000000..9640d79 --- /dev/null +++ b/src/styles/business/app-user-management.css @@ -0,0 +1,135 @@ +/** + * App 用户管理页。 + * 窄窗口下的重点是两件事:筛选控件换行而不是被挤出可视区;长昵称在单元格里截断而不是撑破表格。 + */ + +.app-user-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 14px; +} + +.app-user-toolbar .app-user-keyword { + flex: 1 1 240px; + min-width: 200px; + max-width: 320px; +} + +.app-user-toolbar .app-user-status, +.app-user-toolbar .app-user-country { + width: 150px; + flex: 0 0 150px; +} + +.app-user-count { + margin-left: auto; + color: #858b95; + font-size: 13px; + white-space: nowrap; +} + +/* 用户单元格:头像固定宽度,文字块 min-width:0 才能让省略号生效 */ +.app-user-cell { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.app-user-avatar { + display: grid; + place-content: center; + flex: 0 0 34px; + width: 34px; + height: 34px; + overflow: hidden; + border-radius: 50%; + background: #6f7cf0; + color: #fff; + font-size: 13px; + font-weight: 600; + line-height: 1; +} + +.app-user-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.app-user-meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.app-user-name, +.app-user-uid { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-user-name { + color: #302d44; + font-size: 13px; + font-weight: 600; +} + +.app-user-uid { + color: #9a97a8; + font-size: 12px; +} + +.app-user-mono { + font-variant-numeric: tabular-nums; +} + +/* 详情抽屉 */ +.app-user-drawer .el-drawer__header { + margin-bottom: 4px; +} + +.app-user-summary { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 0 16px; + border-bottom: 1px solid #f0eef4; +} + +.app-user-summary .app-user-name { + font-size: 15px; +} + +.app-user-section { + padding-top: 16px; +} + +.app-user-section-title { + margin: 0 0 8px; + color: #302d44; + font-size: 13px; + font-weight: 650; +} + +/* 抽屉内的表格:内容多时由 .el-drawer__body 统一滚动,这里不再套一层滚动条 */ +.app-user-drawer .el-table { + font-size: 12px; +} + +.app-user-drawer .el-table .el-table__cell { + padding: 6px 0; +} + +html.dark .app-user-name, +html.dark .app-user-section-title { + color: #e8e6ef; +} + +html.dark .app-user-summary { + border-bottom-color: #2c2c37; +} diff --git a/src/styles/business/banner-management.css b/src/styles/business/banner-management.css new file mode 100644 index 0000000..fb5bf08 --- /dev/null +++ b/src/styles/business/banner-management.css @@ -0,0 +1,49 @@ +.banner-thumb { + display: block; + width: 160px; + height: 90px; + object-fit: cover; + border: 1px solid var(--el-border-color-lighter); + border-radius: 4px; + background: #f0f2f4 !important; +} + +.banner-thumb-empty { + display: grid; + place-content: center; + width: 160px; + height: 90px; + border-radius: 4px; + background: #f0f2f4 !important; + color: #7e8791; + font-size: 12px; +} + +.banner-field { + display: flex; + gap: 12px; + align-items: flex-start; + width: 100%; +} +.banner-field-actions { + flex: 1 1 auto; + min-width: 0; +} +/* 编辑弹窗里的轮播图预览:和上传控件并排,控制弹窗高度 */ +.form-banner-preview { + display: block; + width: 200px; + height: 112px; + flex: 0 0 200px; + object-fit: contain; + border: 1px solid var(--el-border-color-lighter); + border-radius: 6px; + background: #f0f2f4 !important; +} + +.dark .banner-thumb, +.dark .banner-thumb-empty, +.dark .form-banner-preview { + background: #353b45 !important; + color: #b9c1cb; +} diff --git a/src/styles/business/drama-management.css b/src/styles/business/drama-management.css index 981feaa..e1cf3ea 100644 --- a/src/styles/business/drama-management.css +++ b/src/styles/business/drama-management.css @@ -3,12 +3,20 @@ gap: 16px; min-height: 64px; padding: 12px 0 !important; + flex-wrap: wrap; } .drama-filters { display: flex; align-items: center; gap: 10px; - flex: 0 0 auto; + /* 允许收缩并换行:窄窗口下筛选控件排到第二行,而不是被挤出可视区 */ + flex: 1 1 auto; + min-width: 0; + flex-wrap: wrap; +} +.drama-filters > .el-date-editor { + width: 360px; + max-width: 100%; } .drama-filters > .el-input { width: 300px !important; @@ -29,11 +37,21 @@ align-items: center; gap: 8px; white-space: nowrap; + flex-wrap: wrap; } .table-actions .el-button { + /* 统一尺寸:高度固定、水平内边距,靠行高垂直居中,避免文字被压扁 */ + height: 28px !important; + min-height: 28px !important; + padding: 0 10px !important; font-size: 13px !important; - font-weight: 400 !important; - padding: 5px 4px !important; + font-weight: 500 !important; + border-radius: 4px; +} + +/* Element Plus 默认给相邻按钮加 12px 外边距,会和这里的 gap 叠加导致换行 */ +.table-actions .el-button + .el-button { + margin-left: 0 !important; } .table-actions .danger-action { color: #a66a6a !important; @@ -58,6 +76,95 @@ color: #7e8791; background: #f0f2f4 !important; } + +/* 名称列只放封面 + 标题,长标题在格子里换行,不撑破单元格 */ +.drama-cell > div { + min-width: 0; +} +.drama-cell b { + overflow-wrap: anywhere; +} + +/* 空值占位,弱化显示 */ +.table-empty-text { + color: #a8aeb8; + font-size: 13px; +} + +/* 编辑弹窗里的封面预览:固定竖版尺寸,和上传控件并排,避免弹窗过高 */ +.cover-field { + display: flex; + gap: 12px; + align-items: flex-start; + width: 100%; +} +.cover-field-actions { + flex: 1 1 auto; + min-width: 0; +} +.form-cover-preview { + display: block; + width: 84px; + height: 112px; + flex: 0 0 84px; + object-fit: cover; + border: 1px solid var(--el-border-color-lighter); + border-radius: 6px; + background: #f0f2f4 !important; +} +.dark .form-cover-preview { + background: #353b45 !important; +} + +/* 编辑弹窗里的视频预览 */ +.video-field { + display: flex; + gap: 12px; + align-items: flex-start; + width: 100%; +} +.video-field-actions { + flex: 1 1 auto; + min-width: 0; +} +.form-video-preview { + display: block; + width: 200px; + height: 112px; + flex: 0 0 200px; + object-fit: contain; + border: 1px solid var(--el-border-color-lighter); + border-radius: 6px; + background: #000 !important; +} + +/* 剧集预览弹窗里的播放器 */ +.episode-video-player { + display: block; + width: 100%; + max-height: 60vh; + object-fit: contain; + border-radius: 8px; + background: #000 !important; +} + +/* 剧集列表里的视频缩略播放器:默认显示首帧,鼠标移入即播放 */ +.episode-video-thumb { + display: block; + width: 120px; + height: 68px; + object-fit: contain; + cursor: pointer; + border: 1px solid var(--el-border-color-lighter); + border-radius: 4px; + background: #000 !important; +} + +/* 列表里点视频即放大播放,给个悬停反馈 */ +.episode-video-thumb:hover { + border-color: var(--el-color-primary); + box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.25); +} .dark .table-result-count { color: #a6aeb9; } @@ -123,11 +230,6 @@ .dark .dashboard-el-table td.el-table__cell { color: #c0c6cf; } -.table-actions .el-button { - font-size: 13px !important; - font-weight: 600 !important; - padding: 7px 11px !important; -} .table-actions .el-button--primary { --el-button-bg-color: #409eff; --el-button-border-color: #409eff; @@ -188,9 +290,6 @@ .table-result-count { font-size: 14px; } -.table-actions .el-button { - font-size: 14px !important; -} .library-toolbar .el-input__inner, .library-toolbar .el-select__placeholder { font-size: 14px; @@ -293,9 +392,8 @@ .admin-user-toolbar > .el-select { width: 190px; } -.admin-users-table { - min-width: 1250px; -} +/* 管理员表格不设最小宽度:列宽超出容器时由 el-table 自行横向滚动, + 否则会把固定列顶到可视区之外 */ .menu-editor-layout { display: grid; grid-template-columns: minmax(270px, 0.7fr) minmax(520px, 1.5fr); diff --git a/src/styles/business/layout.css b/src/styles/business/layout.css new file mode 100644 index 0000000..559f51d --- /dev/null +++ b/src/styles/business/layout.css @@ -0,0 +1,36 @@ +/** + * 全局布局约束:弹窗与表格在窄窗口下不能溢出可视区。 + */ + +/* 弹窗宽度不超过视口 */ +.el-dialog { + max-width: 92vw; +} + +/* 内容过高时在弹窗内部滚动,避免标题和底部按钮被顶出屏幕。 + 上限同时受视口高度约束,保证标题栏和底部按钮始终可见 */ +.el-dialog__body { + max-height: min(78vh, calc(100vh - 140px)); + overflow-y: auto; +} + +/* 抽屉内容同样允许滚动 */ +.el-drawer__body { + overflow-y: auto; +} + +/** + * 弹窗表单的两列布局:成对字段并排,避免表单过高把后面的字段挤到可视区之外。 + * 注意不要复用 .app-dialog —— 那套样式带 10px 字号,会让弹窗文字过小。 + */ +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +@media (max-width: 760px) { + .form-row { + grid-template-columns: 1fr; + } +} diff --git a/src/styles/business/operation-log.css b/src/styles/business/operation-log.css new file mode 100644 index 0000000..e3bd0c8 --- /dev/null +++ b/src/styles/business/operation-log.css @@ -0,0 +1,32 @@ +.log-detail { + min-height: 120px; +} + +.log-block { + margin-top: 16px; +} + +.log-block h4 { + margin: 0 0 8px; + font-size: 13px; + font-weight: 600; +} + +.log-json { + margin: 0; + padding: 12px; + max-height: 240px; + overflow: auto; + font-family: Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-all; + background: #f6f7f9 !important; + border: 1px solid var(--el-border-color-lighter); + border-radius: 6px; +} + +.dark .log-json { + background: #2a2e36 !important; +} diff --git a/src/utils/format.js b/src/utils/format.js new file mode 100644 index 0000000..5e57f52 --- /dev/null +++ b/src/utils/format.js @@ -0,0 +1,23 @@ +/** 播放量等大数值的展示格式:12345 → 1.2万,3100000 → 310.0万 */ +export function formatCount(value) { + const num = Number(value || 0) + if (!Number.isFinite(num)) return '0' + if (num >= 100000000) return `${(num / 100000000).toFixed(2)}亿` + if (num >= 10000) return `${(num / 10000).toFixed(1)}万` + return String(num) +} + +/** + * 时间展示:直接返回实体的接口给的是 ISO 时间(2026-10-15T13:22:03), + * 和后台其它页面「yyyy-MM-dd HH:mm:ss」的观感不一致,这里统一成后者。 + */ +export function formatDateTime(value) { + if (!value) return '-' + const text = String(value).replace('T', ' ') + return text.length > 19 ? text.slice(0, 19) : text +} + +/** el-table-column 的 formatter 适配(签名是 row, column, cellValue) */ +export function dateCell(_row, _column, cellValue) { + return formatDateTime(cellValue) +} diff --git a/src/utils/status.js b/src/utils/status.js index 8e08491..5fd7275 100644 --- a/src/utils/status.js +++ b/src/utils/status.js @@ -1,7 +1,13 @@ const statusClasses = { + published: 'is-online', + reviewing: 'is-reviewing', + draft: 'is-draft', + approved: 'is-pending', + offline: 'is-offline', 已上线: 'is-online', 审核中: 'is-reviewing', 草稿: 'is-draft', + 待审核: 'is-reviewing', 待发布: 'is-pending', 已下线: 'is-offline' } diff --git a/src/utils/uploader.ts b/src/utils/uploader.ts new file mode 100644 index 0000000..78bcb4a --- /dev/null +++ b/src/utils/uploader.ts @@ -0,0 +1,64 @@ +import { completeUploadApi, initUploadApi, uploadChunkApi } from '@/api/admin/media' +import type { MediaAsset } from '@/api/admin/types' + +export interface UploadFileOptions { + /** DRAMA_COVER / EPISODE / AVATAR */ + bizType?: string + /** 并发分片数,默认 3 */ + concurrency?: number + /** 视频时长(秒),由调用方探测后传入 */ + durationSec?: number + onProgress?: (percent: number) => void +} + +/** + * 分片上传:init 建会话 → 并发传分片 → complete 合并。 + * 服务端返回合并后的媒资(含可访问 URL)。 + */ +export async function uploadFile(file: File, options: UploadFileOptions = {}): Promise { + const { bizType = 'EPISODE', concurrency = 3, onProgress } = options + const initRes = await initUploadApi({ + fileName: file.name, + fileSize: file.size, + bizType + }) + const info = initRes.data + const total = Math.max(info.totalChunks, 1) + const pending: number[] = Array.from({ length: total }, (_, index) => index) + let finished = 0 + + const worker = async () => { + for (;;) { + const index = pending.shift() + if (index === undefined) return + const start = index * info.chunkSize + const chunk = file.slice(start, Math.min(start + info.chunkSize, file.size)) + await uploadChunkApi(info.uploadId, index, chunk, file.name) + finished += 1 + onProgress?.(Math.min(95, Math.round((finished / total) * 95))) + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, total) }, worker)) + const doneRes = await completeUploadApi(info.uploadId, options.durationSec) + onProgress?.(100) + return doneRes.data +} + +/** 读取本地视频时长,取不到时返回 0。 */ +export function probeVideoDuration(file: File): Promise { + return new Promise((resolve) => { + const url = URL.createObjectURL(file) + const video = document.createElement('video') + video.preload = 'metadata' + const cleanup = (value: number) => { + URL.revokeObjectURL(url) + resolve(value) + } + video.onloadedmetadata = () => { + cleanup(Number.isFinite(video.duration) ? Math.round(video.duration) : 0) + } + video.onerror = () => cleanup(0) + video.src = url + }) +} diff --git a/src/views/Login/Login.vue b/src/views/Login/Login.vue index f69de4e..7f0e62d 100644 --- a/src/views/Login/Login.vue +++ b/src/views/Login/Login.vue @@ -31,7 +31,7 @@ const themeChange = () => { {{ underlineToHump(appStore.getTitle) }}
-

ReelShort Studio

+

FruitShorts Studio

{{ t('login.welcome') }}

{{ t('login.message') }}

    diff --git a/src/views/Login/components/LoginForm.vue b/src/views/Login/components/LoginForm.vue index 947d361..eb48adb 100644 --- a/src/views/Login/components/LoginForm.vue +++ b/src/views/Login/components/LoginForm.vue @@ -188,6 +188,9 @@ const signIn = async () => { } userStore.setRememberMe(unref(remember)) userStore.setUserInfo(res.data) + if (res.data?.token) { + userStore.setToken(res.data.token) + } // 是否使用动态路由 if (appStore.getDynamicRouter) { getRole() diff --git a/src/views/common/DashboardView.vue b/src/views/common/DashboardView.vue index 9975dfe..405c962 100644 --- a/src/views/common/DashboardView.vue +++ b/src/views/common/DashboardView.vue @@ -1,53 +1,85 @@ diff --git a/src/views/contentManagement/BannerManagementView.vue b/src/views/contentManagement/BannerManagementView.vue new file mode 100644 index 0000000..25b7887 --- /dev/null +++ b/src/views/contentManagement/BannerManagementView.vue @@ -0,0 +1,407 @@ + + + diff --git a/src/views/contentManagement/CategoryManagementView.vue b/src/views/contentManagement/CategoryManagementView.vue new file mode 100644 index 0000000..d805b6e --- /dev/null +++ b/src/views/contentManagement/CategoryManagementView.vue @@ -0,0 +1,166 @@ + + + diff --git a/src/views/contentManagement/DramaManagementView.vue b/src/views/contentManagement/DramaManagementView.vue index 8e3adc1..f0116c7 100644 --- a/src/views/contentManagement/DramaManagementView.vue +++ b/src/views/contentManagement/DramaManagementView.vue @@ -1,83 +1,237 @@