Merge remote-tracking branch 'origin/master'
# Conflicts: # .env.production
This commit is contained in:
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
VITE_APP_NAME=后台管理系统
|
VITE_APP_NAME=后台管理系统
|
||||||
VITE_API_URL=http://127.0.0.1:10051/api
|
VITE_API_URL=http://127.0.0.1:38927/api
|
||||||
#VITE_API_URL=https://tuanwei-api.gxwebsoft.com/api
|
#VITE_API_URL=https://tuanwei-api.gxwebsoft.com/api
|
||||||
|
|||||||
+3
-1
@@ -1,2 +1,4 @@
|
|||||||
VITE_APP_NAME=后台管理系统
|
VITE_APP_NAME=后台管理系统
|
||||||
VITE_API_URL=http://10.129.0.125:10051/api
|
# 生产 API 必须与站点同协议:后台以 https 部署时,http 请求会被浏览器按
|
||||||
|
# 混合内容(mixed-content)整条拦掉,登录直接失败。
|
||||||
|
VITE_API_URL=https://twai-api.gxmu.edu.cn/api
|
||||||
|
|||||||
Generated
+6688
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
|
'@vue-office/docx': true
|
||||||
|
'@vue-office/excel': true
|
||||||
|
core-js: true
|
||||||
|
es5-ext: true
|
||||||
|
esbuild: true
|
||||||
|
vue-demi: true
|
||||||
|
vue-echarts: true
|
||||||
@@ -2,14 +2,29 @@ export interface ActivityOutlineGenParams {
|
|||||||
event_theme: string;
|
event_theme: string;
|
||||||
people: string;
|
people: string;
|
||||||
event_time: string;
|
event_time: string;
|
||||||
|
/**
|
||||||
|
* 活动资金。这里保持 string 是**有意**的:表单输入框是自由文本
|
||||||
|
* (示例值形如「预算 5000 元,来源为校团委志愿服务专项经费」),
|
||||||
|
* 由后端 `normalizeEventFunding` 抽出数值再发给 Dify(上游该变量是 number)。
|
||||||
|
* 抽不出数值时后端降级为空串——上游接受空串,但不接受不可解析的文本。
|
||||||
|
*/
|
||||||
event_funding: string;
|
event_funding: string;
|
||||||
event_type?: string;
|
event_type?: string;
|
||||||
event_location?: string;
|
event_location?: string;
|
||||||
event_meme?: string;
|
event_meme?: string;
|
||||||
|
/** 以下 3 项为 Dify 必填变量,当前表单未采集;后端会补空串占位(见 issue 09)。 */
|
||||||
|
event_member?: string;
|
||||||
|
people_manage?: string;
|
||||||
|
tel_people?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityOutlineGenResponse {
|
export interface ActivityOutlineGenResponse {
|
||||||
answer?: string;
|
answer?: string;
|
||||||
|
/**
|
||||||
|
* 上游工作流状态。失败时为 `'failed'` —— 前端据此把失败文案与正文区分开
|
||||||
|
* (见 `@/utils/ai-result` 的 `isFailedPayload` 与 issue 10)。
|
||||||
|
*/
|
||||||
|
status?: string;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
raw?: Record<string, any>;
|
raw?: Record<string, any>;
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export interface AiChatList {
|
|||||||
updateTime?: string;
|
updateTime?: string;
|
||||||
//
|
//
|
||||||
title?: string;
|
title?: string;
|
||||||
|
// 记录类型:chat / writing / image / creative / activity_outline
|
||||||
|
chatType?: string;
|
||||||
|
// 生成记录入参快照(JSON 字符串,可能为 null)
|
||||||
|
params?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,5 +47,6 @@ export interface AiChatListParam extends PageParam {
|
|||||||
status?: number;
|
status?: number;
|
||||||
deleted?: number;
|
deleted?: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
chatType?: string;
|
||||||
keywords?: string;
|
keywords?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,18 @@ import type {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* AI文件上传
|
* AI文件上传
|
||||||
|
*
|
||||||
|
* @param app 应用代号(后端按代号选择 Dify 应用凭证)。
|
||||||
|
* Dify 的 /files/upload 是**按应用鉴权**的:文件只能被上传时所用应用的工作流引用,
|
||||||
|
* 换个应用引用同一个 upload_file_id 会报 `Invalid upload file`。
|
||||||
|
* 所以喂给创意助手 file-list 变量的附件,必须用 app='creativeAssistant' 上传。
|
||||||
*/
|
*/
|
||||||
export async function uploadAiFile(file: File) {
|
export async function uploadAiFile(file: File, app?: string) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
if (app) {
|
||||||
|
formData.append('app', app);
|
||||||
|
}
|
||||||
const res = await request.post<ApiResult<FileRecord>>(
|
const res = await request.post<ApiResult<FileRecord>>(
|
||||||
MODULES_API_URL + '/ai/file/upload',
|
MODULES_API_URL + '/ai/file/upload',
|
||||||
formData
|
formData
|
||||||
|
|||||||
@@ -3,21 +3,48 @@ import type { FileRecord } from '@/api/system/file/model';
|
|||||||
export interface CreativeAssistantUploadFile {
|
export interface CreativeAssistantUploadFile {
|
||||||
transfer_method: 'local_file';
|
transfer_method: 'local_file';
|
||||||
type: 'image' | 'document' | 'audio' | 'video';
|
type: 'image' | 'document' | 'audio' | 'video';
|
||||||
upload_file_id?: number;
|
/**
|
||||||
|
* Dify /files/upload 返回的文件 id。
|
||||||
|
*
|
||||||
|
* 注意它是 **uuid 字符串**(不是本地 FileRecord 的自增 id);原先声明为 number,
|
||||||
|
* 与实际不符 —— 而且 Dify 按应用鉴权该 id,必须用同一应用的凭证上传才有效。
|
||||||
|
*/
|
||||||
|
upload_file_id?: string | number;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreativeAssistantRequest {
|
export interface CreativeAssistantRequest {
|
||||||
user_content?: string;
|
user_content?: string;
|
||||||
user_contentString?: string;
|
user_contentString?: string;
|
||||||
|
/**
|
||||||
|
* 端上的模式。`image` 时后端会把 user_content 包装成一张「生成图片」的意图声明,
|
||||||
|
* 让上游「问题分类器」不把画面描述判成文案/脚本分支(见 issue 19)。
|
||||||
|
*/
|
||||||
|
mode?: 'chat' | 'writing' | 'image';
|
||||||
|
/**
|
||||||
|
* 参考素材。2026-10 起端上已不再发送:上游出图工具只做文生图,没有任何节点消费它
|
||||||
|
* (见 issue 16)。字段暂时保留,便于回退。
|
||||||
|
*/
|
||||||
user_files?: CreativeAssistantUploadFile[];
|
user_files?: CreativeAssistantUploadFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流产出的一项文件。
|
||||||
|
*
|
||||||
|
* 正常是 { url, name, type } 这样的对象(后端已归一),但上游有时直接把
|
||||||
|
* 地址字符串塞进数组;端上按字符串兜一层,否则整条视频链路取不到链接
|
||||||
|
* (见禅道 bug #1:有视频不播放、复制链接为空)。
|
||||||
|
*/
|
||||||
|
export type CreativeFileItem = Partial<FileRecord> | string;
|
||||||
|
|
||||||
|
/** 文件列表:正常情况下是数组,退化时可能是单个对象或裸地址。 */
|
||||||
|
export type CreativeFileList = CreativeFileItem[] | CreativeFileItem;
|
||||||
|
|
||||||
export interface CreativeAssistantOutputs {
|
export interface CreativeAssistantOutputs {
|
||||||
video_result?: Partial<FileRecord>[];
|
video_result?: CreativeFileList;
|
||||||
video_message?: string;
|
video_message?: string;
|
||||||
Script_result?: string;
|
Script_result?: string;
|
||||||
photo_files?: Partial<FileRecord>[];
|
photo_files?: CreativeFileList;
|
||||||
photo_message?: string;
|
photo_message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { FileRecord } from '@/api/system/file/model';
|
import type { FileRecord } from '@/api/system/file/model';
|
||||||
|
|
||||||
export interface ManuscriptGenParams {
|
export interface ManuscriptGenParams {
|
||||||
|
/** 文稿模板;留空表示自由创作,后端会转「AI 创意助手」 */
|
||||||
docType?: string;
|
docType?: string;
|
||||||
|
/** 自由创作时用户想写的内容,转创意助手后作为 user_content 下发 */
|
||||||
|
userContent?: string;
|
||||||
eventType?: string;
|
eventType?: string;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
time?: string;
|
time?: string;
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ export interface Manuscript {
|
|||||||
id?: number;
|
id?: number;
|
||||||
//
|
//
|
||||||
title?: string;
|
title?: string;
|
||||||
//
|
// 正文(HTML,历史上可能是 markdown / 纯文本)
|
||||||
content?: string;
|
content?: string;
|
||||||
|
// 正文格式:html / markdown / text
|
||||||
|
contentFormat?: string;
|
||||||
//
|
//
|
||||||
cover?: string;
|
cover?: string;
|
||||||
// 是否删除, 0否, 1是
|
// 是否删除, 0否, 1是
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
import type { ApiResult } from '@/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤回申报:待审核 -> 草稿
|
||||||
|
*/
|
||||||
|
export async function withdrawDeclareRecord(module: string, id: number) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>(
|
||||||
|
'/gxmu/declare-record/withdraw',
|
||||||
|
{ module, id }
|
||||||
|
);
|
||||||
|
if (res.data.code === 0) {
|
||||||
|
return res.data.message;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作废申报(等同强制放行,作废后申报人可重新申报)
|
||||||
|
*/
|
||||||
|
export async function voidDeclareRecord(
|
||||||
|
module: string,
|
||||||
|
id: number,
|
||||||
|
reason?: string
|
||||||
|
) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>(
|
||||||
|
'/gxmu/declare-record/void',
|
||||||
|
{ module, id, reason }
|
||||||
|
);
|
||||||
|
if (res.data.code === 0) {
|
||||||
|
return res.data.message;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
@@ -36,6 +36,18 @@ export interface Declare {
|
|||||||
createTime?: string;
|
createTime?: string;
|
||||||
// 更新时间
|
// 更新时间
|
||||||
updateTime?: string;
|
updateTime?: string;
|
||||||
|
// 是否允许重复提交:0否 1是
|
||||||
|
allowRepeat?: number;
|
||||||
|
// 最多同时进行中的申报条数
|
||||||
|
maxPending?: number;
|
||||||
|
// 当前用户在该项目下的最新申报状态:0草稿 1待审核 2通过 3驳回 4作废
|
||||||
|
myStatus?: number;
|
||||||
|
// 当前用户在该项目下的最新申报记录id
|
||||||
|
myRecordId?: number;
|
||||||
|
// 当前用户在该项目下的最新申报记录提交时间
|
||||||
|
mySubmitTime?: string;
|
||||||
|
// 当前用户在该项目下的最新申报记录状态备注
|
||||||
|
myStatusRemark?: string;
|
||||||
// 删除标记:0未删 1已删
|
// 删除标记:0未删 1已删
|
||||||
deleted?: number;
|
deleted?: number;
|
||||||
// 是否可申报
|
// 是否可申报
|
||||||
@@ -55,4 +67,4 @@ export interface DeclareParam extends PageParam {
|
|||||||
formProjectGroup?: string;
|
formProjectGroup?: string;
|
||||||
publicProjectType?: string;
|
publicProjectType?: string;
|
||||||
publicProjectGroup?: string;
|
publicProjectGroup?: string;
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ export interface ReviewList {
|
|||||||
businessName?: string;
|
businessName?: string;
|
||||||
// 主键
|
// 主键
|
||||||
pk?: number;
|
pk?: number;
|
||||||
// 状态(0不通过 1通过)
|
// 状态(0驳回 1通过)
|
||||||
status?: number;
|
status?: number;
|
||||||
// 审核意见
|
// 审核意见
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
import type { ApiResult, PageResult } from '@/api';
|
||||||
|
import type { StudentRoster, StudentRosterParam } from './model';
|
||||||
|
|
||||||
|
/** 学生名册分页查询 */
|
||||||
|
export async function pageStudentRoster(params: StudentRosterParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<StudentRoster>>>(
|
||||||
|
'/gxmu/student/page',
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
if (res.data.code === 0 && res.data.data) {
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学生名册详情 */
|
||||||
|
export async function getStudentRoster(id: number) {
|
||||||
|
const res = await request.get<ApiResult<StudentRoster>>(
|
||||||
|
'/gxmu/student/' + id
|
||||||
|
);
|
||||||
|
if (res.data.code === 0 && res.data.data) {
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出名册(返回已脱敏列表,由前端生成文件) */
|
||||||
|
export async function exportStudentRoster(params: StudentRosterParam) {
|
||||||
|
const res = await request.post<ApiResult<StudentRoster[]>>(
|
||||||
|
'/gxmu/student/export',
|
||||||
|
null,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
if (res.data.code === 0 && res.data.data) {
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自助认领(学号 + 姓名) */
|
||||||
|
export async function claimStudent(data: { xh: string; xm: string }) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>(
|
||||||
|
'/gxmu/student/claim',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
if (res.data.code === 0) {
|
||||||
|
return res.data.message;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动认领/改绑 */
|
||||||
|
export async function bindStudent(
|
||||||
|
id: number,
|
||||||
|
data?: { userId?: number; phone?: string }
|
||||||
|
) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>(
|
||||||
|
`/gxmu/student/${id}/claim`,
|
||||||
|
data || {}
|
||||||
|
);
|
||||||
|
if (res.data.code === 0) {
|
||||||
|
return res.data.message;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解除认领 */
|
||||||
|
export async function unbindStudent(id: number) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>(
|
||||||
|
`/gxmu/student/${id}/unclaim`
|
||||||
|
);
|
||||||
|
if (res.data.code === 0) {
|
||||||
|
return res.data.message;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* 学生名册(开放平台同步)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 学生名册记录 */
|
||||||
|
export interface StudentRoster {
|
||||||
|
id: number;
|
||||||
|
/** 学号 */
|
||||||
|
xh: string;
|
||||||
|
/** 姓名 */
|
||||||
|
xm?: string;
|
||||||
|
/** 性别码 */
|
||||||
|
xbm?: string;
|
||||||
|
/** 性别 */
|
||||||
|
sex?: string;
|
||||||
|
/** 民族码 */
|
||||||
|
mzm?: string;
|
||||||
|
/** 民族 */
|
||||||
|
nation?: string;
|
||||||
|
/** 政治面貌码 */
|
||||||
|
zzmmm?: string;
|
||||||
|
/** 政治面貌 */
|
||||||
|
politics?: string;
|
||||||
|
/** 身份证件号(后端已脱敏) */
|
||||||
|
sfzjh?: string;
|
||||||
|
/** 手机号(后端已脱敏) */
|
||||||
|
sjh?: string;
|
||||||
|
/** 平台班级码 */
|
||||||
|
bjm?: string;
|
||||||
|
/** 平台班号 */
|
||||||
|
bh?: string;
|
||||||
|
classId?: number;
|
||||||
|
className?: string;
|
||||||
|
collegeId?: number;
|
||||||
|
collegeName?: string;
|
||||||
|
dwh?: string;
|
||||||
|
zyh?: string;
|
||||||
|
zymc?: string;
|
||||||
|
/** 培养层次 */
|
||||||
|
pyccmc?: string;
|
||||||
|
/** 状态:1在校 0毕业 */
|
||||||
|
status?: number;
|
||||||
|
/** 已认领的账号ID */
|
||||||
|
userId?: number;
|
||||||
|
claimTime?: string;
|
||||||
|
syncTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 名册查询参数 */
|
||||||
|
export interface StudentRosterParam {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
collegeId?: number;
|
||||||
|
classId?: number;
|
||||||
|
xh?: string;
|
||||||
|
xm?: string;
|
||||||
|
pyccmc?: string;
|
||||||
|
status?: number;
|
||||||
|
claimed?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
import type { ApiResult, PageResult } from '@/api';
|
||||||
|
import type {
|
||||||
|
SyncBatchStatus,
|
||||||
|
SyncRecord,
|
||||||
|
SyncRecordParam
|
||||||
|
} from './model';
|
||||||
|
|
||||||
|
/** 提交类接口统一返回批次号 */
|
||||||
|
async function submit(url: string) {
|
||||||
|
const res = await request.post<ApiResult<string>>(url);
|
||||||
|
if (res.data.code === 0 && res.data.data) {
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交全部同步,返回批次号 */
|
||||||
|
export function syncAll() {
|
||||||
|
return submit('/gxmu/sync/all');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交部门同步,返回批次号 */
|
||||||
|
export function syncDept() {
|
||||||
|
return submit('/gxmu/sync/dept');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交教师同步,返回批次号 */
|
||||||
|
export function syncTeacher() {
|
||||||
|
return submit('/gxmu/sync/teacher');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交班级同步,返回批次号 */
|
||||||
|
export function syncClass() {
|
||||||
|
return submit('/gxmu/sync/class');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交学生同步,返回批次号 */
|
||||||
|
export function syncStudent() {
|
||||||
|
return submit('/gxmu/sync/student');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前(或最近一次)批次进度。
|
||||||
|
*
|
||||||
|
* 同步是异步的:学生全量约 9.8 万条、耗时 75~90 秒,提交后需轮询本接口看进度。
|
||||||
|
*/
|
||||||
|
export async function syncStatus() {
|
||||||
|
const res = await request.get<ApiResult<SyncBatchStatus>>('/gxmu/sync/status');
|
||||||
|
if (res.data.code === 0 && res.data.data) {
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步记录分页查询 */
|
||||||
|
export async function pageSyncRecord(params: SyncRecordParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<SyncRecord>>>(
|
||||||
|
'/gxmu/sync/record/page',
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
if (res.data.code === 0 && res.data.data) {
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* 开放平台数据同步
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 单段同步结果(同步返回,异步改动后仅用于记录展示) */
|
||||||
|
export interface SyncResult {
|
||||||
|
/** 同步类型:dept/teacher/class/student */
|
||||||
|
syncType: string;
|
||||||
|
/** 成功条数 */
|
||||||
|
successCount: number;
|
||||||
|
/** 失败条数 */
|
||||||
|
failCount: number;
|
||||||
|
/** 跳过/停用条数 */
|
||||||
|
skipCount: number;
|
||||||
|
/** 是否成功 */
|
||||||
|
success: boolean;
|
||||||
|
/** 错误信息 */
|
||||||
|
errorMsg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某一段的进度 */
|
||||||
|
export interface SyncSectionStatus {
|
||||||
|
/** 同步类型:dept/teacher/class/student */
|
||||||
|
syncType: string;
|
||||||
|
/** pending 待执行 / running 进行中 / success 成功 / fail 失败 */
|
||||||
|
state: 'pending' | 'running' | 'success' | 'fail';
|
||||||
|
successCount?: number;
|
||||||
|
failCount?: number;
|
||||||
|
skipCount?: number;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
errorMsg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批次进度 */
|
||||||
|
export interface SyncBatchStatus {
|
||||||
|
/** 是否有同步任务在执行 */
|
||||||
|
running: boolean;
|
||||||
|
batchId?: string;
|
||||||
|
triggerType?: string;
|
||||||
|
triggerUser?: string;
|
||||||
|
startTime?: string;
|
||||||
|
/** 正在执行的段 */
|
||||||
|
currentSection?: string;
|
||||||
|
/** 本次批次的各段状态 */
|
||||||
|
sections?: SyncSectionStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步记录 */
|
||||||
|
export interface SyncRecord {
|
||||||
|
id: number;
|
||||||
|
/** 批次号 */
|
||||||
|
batchId?: string;
|
||||||
|
/** 同步类型 */
|
||||||
|
syncType: string;
|
||||||
|
/** 触发方式:schedule/manual */
|
||||||
|
triggerType: string;
|
||||||
|
/** 触发人 */
|
||||||
|
triggerUser?: string;
|
||||||
|
/** 开始时间 */
|
||||||
|
startTime?: string;
|
||||||
|
/** 结束时间 */
|
||||||
|
endTime?: string;
|
||||||
|
successCount: number;
|
||||||
|
failCount: number;
|
||||||
|
skipCount: number;
|
||||||
|
/** 状态:0成功 1失败 2进行中 */
|
||||||
|
status: number;
|
||||||
|
/** 错误摘要 */
|
||||||
|
errorMsg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步记录查询参数 */
|
||||||
|
export interface SyncRecordParam {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
syncType?: string;
|
||||||
|
batchId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
import type { ApiResult, PageResult } from '@/api';
|
||||||
|
import type { TeacherRoster, TeacherRosterParam } from './model';
|
||||||
|
|
||||||
|
/** 教师名册分页查询 */
|
||||||
|
export async function pageTeacherRoster(params: TeacherRosterParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<TeacherRoster>>>(
|
||||||
|
'/gxmu/teacher/page',
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
if (res.data.code === 0 && res.data.data) {
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出教师名册(返回列表,由前端生成文件;响应中不含手机号) */
|
||||||
|
export async function exportTeacherRoster(params: TeacherRosterParam) {
|
||||||
|
const res = await request.post<ApiResult<TeacherRoster[]>>(
|
||||||
|
'/gxmu/teacher/export',
|
||||||
|
null,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
if (res.data.code === 0 && res.data.data) {
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 教师名册(开放平台同步)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 教师名册记录 */
|
||||||
|
export interface TeacherRoster {
|
||||||
|
id: number;
|
||||||
|
/** 教工号(开放平台外部键) */
|
||||||
|
jgh: string;
|
||||||
|
/** 姓名 */
|
||||||
|
xm?: string;
|
||||||
|
/** 性别码 */
|
||||||
|
xbm?: string;
|
||||||
|
/** 性别 */
|
||||||
|
sex?: string;
|
||||||
|
/** 单位号(平台原始值) */
|
||||||
|
dwh?: string;
|
||||||
|
/** 解析出的学院ID */
|
||||||
|
collegeId?: number;
|
||||||
|
/** 学院名称 */
|
||||||
|
collegeName?: string;
|
||||||
|
/** 科室教研室编号 */
|
||||||
|
ksjybh?: string;
|
||||||
|
/** 状态:1启用 0停用 */
|
||||||
|
status?: number;
|
||||||
|
/** 最近同步时间 */
|
||||||
|
syncTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师名册查询参数
|
||||||
|
*
|
||||||
|
* 说明:教师手机号由后端落盘但不出任何列表接口(spec §5.2),故此处没有该字段,
|
||||||
|
* 页面也不展示。
|
||||||
|
*/
|
||||||
|
export interface TeacherRosterParam {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
collegeId?: number;
|
||||||
|
jgh?: string;
|
||||||
|
xm?: string;
|
||||||
|
status?: number;
|
||||||
|
}
|
||||||
@@ -20,6 +20,24 @@ export async function login(data: LoginParam) {
|
|||||||
return Promise.reject(new Error(res.data.message));
|
return Promise.reject(new Error(res.data.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校统一身份认证登录(学号 + 统一身份认证密码)
|
||||||
|
*
|
||||||
|
* 与小程序端共用同一个后端接口,返回结构完全一致,
|
||||||
|
* 因此拿到 access_token 后前端流程与账号登录没有区别。
|
||||||
|
*/
|
||||||
|
export async function loginByGxmuSso(data: LoginParam) {
|
||||||
|
const res = await request.post<ApiResult<LoginResult>>('/sso/login', {
|
||||||
|
username: data.username,
|
||||||
|
password: data.password
|
||||||
|
});
|
||||||
|
if (res.data.code === 0) {
|
||||||
|
setToken(res.data.data?.access_token, data.remember);
|
||||||
|
return res.data.message;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取验证码
|
* 获取验证码
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface FileRecordParam extends PageParam {
|
|||||||
name?: string;
|
name?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
|
// 媒体大类: image/video/audio/doc,空表示不限
|
||||||
|
mediaCategory?: string;
|
||||||
createNickname?: string;
|
createNickname?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import {
|
import type {
|
||||||
ColumnItem,
|
ColumnItem,
|
||||||
DatasourceFunction
|
DatasourceFunction
|
||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import {
|
import type {
|
||||||
ColumnItem,
|
ColumnItem,
|
||||||
DatasourceFunction
|
DatasourceFunction
|
||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import {
|
import type {
|
||||||
ColumnItem,
|
ColumnItem,
|
||||||
DatasourceFunction
|
DatasourceFunction
|
||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
import { toTreeData, eachTreeData } from 'ele-admin-pro/es';
|
import { toTreeData, eachTreeData } from 'ele-admin-pro/es';
|
||||||
import type { EleProTable } from 'ele-admin-pro/es';
|
import type { EleProTable } from 'ele-admin-pro/es';
|
||||||
import { listOrganizations } from '@/api/system/organization';
|
import { listOrganizations } from '@/api/system/organization';
|
||||||
import {
|
import type {
|
||||||
Organization,
|
Organization,
|
||||||
OrganizationParam
|
OrganizationParam
|
||||||
} from '@/api/system/organization/model';
|
} from '@/api/system/organization/model';
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import {
|
import type {
|
||||||
ColumnItem,
|
ColumnItem,
|
||||||
DatasourceFunction
|
DatasourceFunction
|
||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import {
|
import type {
|
||||||
ColumnItem,
|
ColumnItem,
|
||||||
DatasourceFunction
|
DatasourceFunction
|
||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import {
|
import type {
|
||||||
ColumnItem,
|
ColumnItem,
|
||||||
DatasourceFunction
|
DatasourceFunction
|
||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import {
|
import type {
|
||||||
ColumnItem,
|
ColumnItem,
|
||||||
DatasourceFunction
|
DatasourceFunction
|
||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import {
|
import type {
|
||||||
ColumnItem,
|
ColumnItem,
|
||||||
DatasourceFunction
|
DatasourceFunction
|
||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import {
|
import type {
|
||||||
ColumnItem,
|
ColumnItem,
|
||||||
DatasourceFunction
|
DatasourceFunction
|
||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import {
|
import type {
|
||||||
ColumnItem,
|
ColumnItem,
|
||||||
DatasourceFunction
|
DatasourceFunction
|
||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* AI 历史记录的类型(对应后端 `ai_chat_list.chat_type`)。
|
||||||
|
*
|
||||||
|
* 契约见 `docs/ai-history-contract.md`:类型由后端在生成成功时判定,前端只负责展示与筛选。
|
||||||
|
* 旧数据没有 `chat_type`,按聊天处理。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AiChatType =
|
||||||
|
| 'chat'
|
||||||
|
| 'writing'
|
||||||
|
| 'image'
|
||||||
|
| 'creative'
|
||||||
|
| 'activity_outline';
|
||||||
|
|
||||||
|
/** 类型的中文名(徽标与后台表格共用) */
|
||||||
|
export const AI_CHAT_TYPE_LABELS: Record<AiChatType, string> = {
|
||||||
|
chat: '聊天',
|
||||||
|
writing: '写作',
|
||||||
|
image: '图片',
|
||||||
|
creative: '创意',
|
||||||
|
activity_outline: '活动策划'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 筛选下拉的可选项(不含「全部」,由使用方按需补充) */
|
||||||
|
export const AI_CHAT_TYPE_OPTIONS: { label: string; value: AiChatType }[] = [
|
||||||
|
{ label: '聊天', value: 'chat' },
|
||||||
|
{ label: '写作', value: 'writing' },
|
||||||
|
{ label: '图片', value: 'image' },
|
||||||
|
{ label: '创意', value: 'creative' },
|
||||||
|
{ label: '活动策划', value: 'activity_outline' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 把后端返回值收敛到已知类型,缺失或未知一律按聊天处理 */
|
||||||
|
export function resolveAiChatType(value?: string | null): AiChatType {
|
||||||
|
const type = String(value || '').trim() as AiChatType;
|
||||||
|
return type in AI_CHAT_TYPE_LABELS ? type : 'chat';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 直接取中文名,用于模板与表格列 */
|
||||||
|
export function getAiChatTypeLabel(value?: string | null): string {
|
||||||
|
return AI_CHAT_TYPE_LABELS[resolveAiChatType(value)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* AI 结果的展示兜底。
|
||||||
|
*
|
||||||
|
* 与小程序端 `tuanwei-uniapp/utils/ai-result.js` 保持同一套语义 —— 同一批 AI 链路
|
||||||
|
* 在两端各有入口,兜底规则必须一致,否则同一个缺陷会在两端以不同面目出现
|
||||||
|
* (见 `.scratch/ai-assistant/issues/01`、`03`、`10`)。
|
||||||
|
*
|
||||||
|
* 背景:各 AI 链路在「拿不到已知字段」时会退回 `JSON.stringify(整包)`,
|
||||||
|
* 把后端原始报文(含 `conversationId` / `messageId` / `taskId` / `workflowRunId`
|
||||||
|
* 等内部标识符与上游错误栈)直接渲染给用户。同一个缺陷曾在多端、多页面里各写一份,
|
||||||
|
* 因此这里收敛为一处公共实现。
|
||||||
|
*
|
||||||
|
* 约定:
|
||||||
|
* - 解析不出内容时,一律给**可读文案**,绝不 stringify 整包;
|
||||||
|
* - 原始报文只应进日志(`console.error`),不进 UI;
|
||||||
|
* - 「内部标识符形状」的值(UUID、无空格长 token)不算可展示内容。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析不出内容时的通用兜底文案。
|
||||||
|
*/
|
||||||
|
export const FALLBACK_TEXT = '接口已返回,但未解析到可展示的内容。';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流失败时的统一提示。
|
||||||
|
*/
|
||||||
|
export const FAILED_TEXT = '生成失败,请稍后重试';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取载荷里的工作流执行状态。
|
||||||
|
*
|
||||||
|
* 后端把 `status` 放在 data 的**顶层**(见 `buildManuscriptResult` /
|
||||||
|
* `buildActivityOutlineResult`);兼容 `data.status` 的嵌套写法
|
||||||
|
* (后端 `workflowStatus` 两处都看,前端不能只看一处)。
|
||||||
|
* 注释口径与 `tuanwei-uniapp/utils/ai-result.js` 保持一致。
|
||||||
|
*
|
||||||
|
* @param payload 接口返回
|
||||||
|
* @returns 小写、去空白的状态;取不到时为空串
|
||||||
|
*/
|
||||||
|
export function getPayloadStatus(payload: unknown): string {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const record = payload as Record<string, any>;
|
||||||
|
const status =
|
||||||
|
record.status !== undefined && record.status !== null
|
||||||
|
? record.status
|
||||||
|
: record.data && record.data.status;
|
||||||
|
return String(status || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流是否以失败收场。
|
||||||
|
*
|
||||||
|
* 只在拿到明确 `failed` 时为真;状态缺失视为未失败,避免误伤正常响应。
|
||||||
|
*
|
||||||
|
* @param payload 接口返回
|
||||||
|
*/
|
||||||
|
export function isFailedPayload(payload: unknown): boolean {
|
||||||
|
return getPayloadStatus(payload) === 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断一个字符串是否适合直接展示给用户。
|
||||||
|
*
|
||||||
|
* 失败报文里 `answer` 常被填成 `workflow_run_id`(UUID)这类内部标识符;
|
||||||
|
* 原样展示对用户毫无意义且构成泄露。
|
||||||
|
*
|
||||||
|
* @param value 待判断的值
|
||||||
|
*/
|
||||||
|
export function isDisplayableAnswer(value: unknown): boolean {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 纯 UUID
|
||||||
|
if (
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 无空格的长 token(task_id / run_id 等)
|
||||||
|
if (!/\s/.test(text) && /^[0-9a-zA-Z_-]{32,}$/.test(text)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在候选值里挑第一个可展示的字符串。
|
||||||
|
*
|
||||||
|
* @param values 候选值,按优先级排列
|
||||||
|
* @returns 挑中的值(已 trim);没有则空串
|
||||||
|
*/
|
||||||
|
export function pickDisplayable(...values: unknown[]): string {
|
||||||
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
|
const value = values[index];
|
||||||
|
if (isDisplayableAnswer(value)) {
|
||||||
|
return (value as string).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把原始报文记进日志(不进 UI)。
|
||||||
|
*
|
||||||
|
* @param scope 调用方标识,便于定位
|
||||||
|
* @param payload 原始载荷
|
||||||
|
*/
|
||||||
|
export function logRawPayload(scope: string, payload: unknown): void {
|
||||||
|
try {
|
||||||
|
console.error(
|
||||||
|
`[ai-result] ${scope} 未解析出可展示内容,原始载荷:`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// 日志失败不影响渲染
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveDisplayTextOptions {
|
||||||
|
/** 原始载荷(用于失败判定与日志) */
|
||||||
|
payload?: unknown;
|
||||||
|
/** 日志标识 */
|
||||||
|
scope?: string;
|
||||||
|
/** 候选文案,按优先级排列(已剔除失败文案后的正文候选) */
|
||||||
|
candidates?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展示兜底:优先可展示的候选值,其次失败文案 / 通用兜底文案。
|
||||||
|
*
|
||||||
|
* @returns 可读文案
|
||||||
|
*/
|
||||||
|
export function resolveDisplayText(
|
||||||
|
options: ResolveDisplayTextOptions = {}
|
||||||
|
): string {
|
||||||
|
const { payload, scope, candidates } = options;
|
||||||
|
if (isFailedPayload(payload)) {
|
||||||
|
return FAILED_TEXT;
|
||||||
|
}
|
||||||
|
const picked = pickDisplayable(...(candidates || []));
|
||||||
|
if (picked) {
|
||||||
|
return picked;
|
||||||
|
}
|
||||||
|
logRawPayload(scope || 'unknown', payload);
|
||||||
|
return FALLBACK_TEXT;
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { API_BASE_URL, TOKEN_HEADER_NAME } from '@/config/setting';
|
||||||
|
import { getSign, getToken } from '@/utils/token-util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台的 HTTP 流式通道(SSE):WebSocket 连不上时的退路。
|
||||||
|
*
|
||||||
|
* 生产网关在 `/api` 前面就把 WebSocket 的 `Upgrade` 头丢了,握手永远到不了后端,
|
||||||
|
* 页面就卡在「正在建立 WebSocket 连接...」。后端因此给每条 AI 链路开了一份
|
||||||
|
* `/stream` 端点:请求体与原接口一模一样,响应是 `text/event-stream`,
|
||||||
|
* 每个 `data:` 行就是原来 WebSocket 上的一帧(同一段 JSON)。
|
||||||
|
*
|
||||||
|
* 所以页面只要把帧喂给原来的消息处理函数,落哪条通道都无感。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TENANT_ID = 10049;
|
||||||
|
|
||||||
|
export interface StreamOptions {
|
||||||
|
/** 相对 `/api` 的路径,如 `/ai/activityOutlineGen/stream` */
|
||||||
|
path: string;
|
||||||
|
data?: Record<string, unknown> | null;
|
||||||
|
/** 每帧回调,内容与 WebSocket 帧一致 */
|
||||||
|
onMessage: (raw: string) => void;
|
||||||
|
/** 中止生成(页面上的「停止」按钮) */
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发一条流式请求,把每个 SSE 帧原样交给 onMessage。 */
|
||||||
|
export async function streamAiRequest(options: StreamOptions): Promise<void> {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
tenantId: TENANT_ID,
|
||||||
|
...(options.data || {})
|
||||||
|
};
|
||||||
|
// 与 axios 拦截器保持一致:后端校验 sign
|
||||||
|
payload.sign = getSign(payload);
|
||||||
|
|
||||||
|
const token = getToken();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers[TOKEN_HEADER_NAME] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}${options.path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: options.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`流式请求失败(HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
if (!response.body || typeof response.body.getReader !== 'function') {
|
||||||
|
throw new Error('当前浏览器不支持流式读取');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
const drain = () => {
|
||||||
|
let boundary = buffer.indexOf('\n\n');
|
||||||
|
while (boundary !== -1) {
|
||||||
|
const frame = buffer.slice(0, boundary);
|
||||||
|
buffer = buffer.slice(boundary + 2);
|
||||||
|
boundary = buffer.indexOf('\n\n');
|
||||||
|
if (frame.startsWith(':')) {
|
||||||
|
// SSE 注释行(后端用它把响应头先顶出去)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const data = frame
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.startsWith('data:'))
|
||||||
|
.map((line) => line.slice(5).replace(/^ /, ''))
|
||||||
|
.join('');
|
||||||
|
if (data) {
|
||||||
|
options.onMessage(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
drain();
|
||||||
|
}
|
||||||
|
buffer += decoder.decode();
|
||||||
|
drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对话(HTTP 流式) */
|
||||||
|
export function streamChatMessage(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
onMessage: (raw: string) => void,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<void> {
|
||||||
|
return streamAiRequest({
|
||||||
|
path: '/ai/chat/message/stream',
|
||||||
|
data,
|
||||||
|
onMessage,
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 稿件生成(HTTP 流式) */
|
||||||
|
export function streamManuscriptGen(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
onMessage: (raw: string) => void,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<void> {
|
||||||
|
return streamAiRequest({
|
||||||
|
path: '/ai/manuscriptGen/stream',
|
||||||
|
data,
|
||||||
|
onMessage,
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 活动策划大纲(HTTP 流式) */
|
||||||
|
export function streamActivityOutlineGen(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
onMessage: (raw: string) => void,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<void> {
|
||||||
|
return streamAiRequest({
|
||||||
|
path: '/ai/activityOutlineGen/stream',
|
||||||
|
data,
|
||||||
|
onMessage,
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创意助手(HTTP 流式) */
|
||||||
|
export function streamCreativeAssistant(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
onMessage: (raw: string) => void,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<void> {
|
||||||
|
return streamAiRequest({
|
||||||
|
path: '/ai/creativeAssistant/stream',
|
||||||
|
data,
|
||||||
|
onMessage,
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 末帧 `result` 是普通接口的完整响应包(`{code, message, data}`)。
|
||||||
|
* 生成成功后后端会在包里带上 `chatId`(见 `docs/ai-history-contract.md`),
|
||||||
|
* 既可能在包上也可能在 `data` 里;这里统一把它合并进返回的 data,
|
||||||
|
* 页面只读 `result.chatId` 即可。
|
||||||
|
*/
|
||||||
|
function unwrapFinalData(finalResult: any) {
|
||||||
|
const data = finalResult?.data ?? null;
|
||||||
|
const chatId = Number(finalResult?.chatId);
|
||||||
|
if (
|
||||||
|
data &&
|
||||||
|
typeof data === 'object' &&
|
||||||
|
!Array.isArray(data) &&
|
||||||
|
Number.isFinite(chatId) &&
|
||||||
|
chatId > 0 &&
|
||||||
|
data.chatId == null
|
||||||
|
) {
|
||||||
|
data.chatId = chatId;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创意助手(出图/出视频):跑完才有结果,中途一个字节都不推。
|
||||||
|
*
|
||||||
|
* 直接用普通 POST 的话,出图那一两分钟链路上完全空闲,会在 60s 左右被网关
|
||||||
|
* 掐成 504 Gateway Time-out(实测 58s 断开)。走流式后后端每 15s 发一帧心跳
|
||||||
|
* 续命,真正的结果放在末帧 `{type:'final', result}` 里,这里拆包后原样返回
|
||||||
|
* 普通接口的 data 字段,页面的渲染逻辑不用改。
|
||||||
|
*/
|
||||||
|
export async function runCreativeAssistantStream(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<Record<string, any> | null> {
|
||||||
|
let finalResult: any = null;
|
||||||
|
await streamCreativeAssistant(
|
||||||
|
data,
|
||||||
|
(raw) => {
|
||||||
|
try {
|
||||||
|
const frame = JSON.parse(raw);
|
||||||
|
if (frame && frame.type === 'final') {
|
||||||
|
finalResult = frame.result;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 业务帧解析失败就忽略:真正的失败原因由末帧的 message 带出来
|
||||||
|
}
|
||||||
|
},
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
if (!finalResult) {
|
||||||
|
throw new Error('创意助手没有返回结果,请稍后重试');
|
||||||
|
}
|
||||||
|
if (Number(finalResult.code) !== 0) {
|
||||||
|
throw new Error(finalResult.message || '创意助手处理失败');
|
||||||
|
}
|
||||||
|
return unwrapFinalData(finalResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 稿件生成:正文逐段推帧,末帧 `{type:'final', result}` 带结构化结果。
|
||||||
|
*
|
||||||
|
* 自由创作(不选文物体裁)在后端会转「AI 创意助手」,整段**不推正文**,
|
||||||
|
* 只有末帧有内容;模板模式则是正文边推、末帧给标题等结构。
|
||||||
|
* 两种都按同一条路走:帧回调照常转发,末帧拆出来当普通接口的返回值。
|
||||||
|
*
|
||||||
|
* @param onMessage 业务帧回调(正文片段),可不传
|
||||||
|
* @returns 结构化结果(普通接口 data 字段);失败返回 null
|
||||||
|
*/
|
||||||
|
export async function runManuscriptGenStream(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
onMessage?: (raw: string) => void
|
||||||
|
): Promise<Record<string, any> | null> {
|
||||||
|
let finalResult: any = null;
|
||||||
|
await streamManuscriptGen(
|
||||||
|
data,
|
||||||
|
(raw) => {
|
||||||
|
try {
|
||||||
|
const frame = JSON.parse(raw);
|
||||||
|
if (frame && frame.type === 'final') {
|
||||||
|
finalResult = frame.result;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 非 JSON 帧按业务帧处理
|
||||||
|
}
|
||||||
|
if (onMessage) {
|
||||||
|
onMessage(raw);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
if (!finalResult) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Number(finalResult.code) !== 0) {
|
||||||
|
throw new Error(finalResult.message || '稿件生成失败');
|
||||||
|
}
|
||||||
|
return unwrapFinalData(finalResult);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { API_BASE_URL } from '@/config/setting';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台与大屏端(Vue)的聊天 WebSocket 细节,集中在这里。
|
||||||
|
*
|
||||||
|
* 四个页面(创意助手、活动策划大纲、稿件生成、公文校验)此前各自抄了一份
|
||||||
|
* 「拼 URL + 连 + 断」,路径改一次要改四处;现在统一走这里。
|
||||||
|
*
|
||||||
|
* 路径是 **`/api/chat/{userId}`**,不是 `/chat/{userId}`:生产网关只放行 `/api` 前缀,
|
||||||
|
* 老路径的握手请求到不了后端(实测 404)。后端同时保留了根路径端点给老版本小程序兜底,
|
||||||
|
* 见 `tuanwei-java` 的 `WebSocketConfig`。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 心跳间隔。nginx / 网关的空闲超时普遍在 60s 上下,25s 留够余量。 */
|
||||||
|
const HEARTBEAT_INTERVAL = 25000;
|
||||||
|
|
||||||
|
export function buildWebSocketUrl(userId: number | string): string {
|
||||||
|
if (/^https?:\/\//.test(API_BASE_URL)) {
|
||||||
|
const url = new URL(API_BASE_URL);
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
url.pathname = url.pathname.replace(/\/api\/?$/, '');
|
||||||
|
return `${url.toString().replace(/\/$/, '')}/api/chat/${userId}`;
|
||||||
|
}
|
||||||
|
// 相对路径(同源反代):后台站点自己的域名下同样挂 /api/chat
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${protocol}//${window.location.host}/api/chat/${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每 25s 发一个 `ping`,顶住反向代理与网关的空闲断开。
|
||||||
|
*
|
||||||
|
* 只发不收:页面把收到的每条消息当流式片段解析,服务端若回 pong 会被拼进正文。
|
||||||
|
* 服务端的 `@OnMessage` 收到文本直接丢弃。
|
||||||
|
*
|
||||||
|
* 连接关闭后定时器会自行结束(下次触发时发现 readyState 已是 CLOSED),
|
||||||
|
* 调用方不需要单独清理。
|
||||||
|
*/
|
||||||
|
export function attachHeartbeat(socket: WebSocket): () => void {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
socket.send('ping');
|
||||||
|
} catch {
|
||||||
|
// 发送失败说明连接正在断,交给下一次 tick 或 onclose 处理
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (socket.readyState === WebSocket.CLOSED) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_INTERVAL);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* 稿件正文的格式判定与转换(后台)。
|
||||||
|
*
|
||||||
|
* `cms_manuscript.content` 历史上混着两种来源:
|
||||||
|
* 1) wangEditor 保存的 HTML;
|
||||||
|
* 2) 从 AI 结果直接粘进后台的 markdown(`## 标题`、`**加粗**`、`- 列表`)。
|
||||||
|
* 后台编辑器只认 HTML:把 markdown 原样塞进 wangEditor,它会当成普通文字,
|
||||||
|
* 保存时再包一层 `<p>`,于是 `## 标题` 变成正文里的一行字——打开编辑即损坏。
|
||||||
|
*
|
||||||
|
* 所以打开编辑弹窗时先把非 HTML 的正文转成 HTML,保存时统一写 `contentFormat: 'html'`。
|
||||||
|
*/
|
||||||
|
import { getProcessor } from 'bytemd';
|
||||||
|
import type { BytemdPlugin } from 'bytemd';
|
||||||
|
|
||||||
|
const HTML_TAG_RE = /<\/?([a-z][a-z0-9]*)\b[^>]*>/i;
|
||||||
|
const MARKDOWN_LINE_RE =
|
||||||
|
/(^|\n)[ \t]{0,3}(#{1,6}[ \t]|[-*+][ \t]|\d+\.[ \t]|>[ \t])/;
|
||||||
|
const MARKDOWN_EMPHASIS_RE =
|
||||||
|
/(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(`[^`\n]+`)|(!\[[^\]]*\]\()/;
|
||||||
|
|
||||||
|
export type ContentFormat = 'html' | 'markdown' | 'text';
|
||||||
|
|
||||||
|
/** 后端 `contentFormat` 的规范值;认不出来返回空串,交给内容判断兜底。 */
|
||||||
|
export function normalizeFormat(format?: string | null): ContentFormat | '' {
|
||||||
|
const value = String(format ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (value === 'html' || value === 'text') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value === 'markdown' || value === 'md') {
|
||||||
|
return 'markdown';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function looksLikeHtml(content?: string | null): boolean {
|
||||||
|
return HTML_TAG_RE.test(String(content ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function looksLikeMarkdown(content?: string | null): boolean {
|
||||||
|
const text = String(content ?? '');
|
||||||
|
if (!text.trim() || looksLikeHtml(text)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return MARKDOWN_LINE_RE.test(text) || MARKDOWN_EMPHASIS_RE.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 没有格式标记时的兜底判断(老接口、或写库前漏填的行)。 */
|
||||||
|
export function detectFormat(content?: string | null): ContentFormat {
|
||||||
|
const text = String(content ?? '');
|
||||||
|
if (!text.trim()) {
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
if (looksLikeHtml(text)) {
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
return looksLikeMarkdown(text) ? 'markdown' : 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* markdown → HTML,用的是后台已有的 bytemd(和 ByteMdViewer 同一套解析)。
|
||||||
|
*
|
||||||
|
* `plugins` 决定润色到什么程度:默认不挂 GFM,表格会原样漏成一堆 `|` 管道符、
|
||||||
|
* 删除线也会丢。要让「表格/删除线」和预览一致,调用方传 gfm 插件进来
|
||||||
|
* (generate 页的一键排版就是这么做的)。转换口径只留这一处,别再各写一套。
|
||||||
|
*/
|
||||||
|
export function markdownToHtml(
|
||||||
|
markdown: string,
|
||||||
|
plugins: BytemdPlugin[] = []
|
||||||
|
): string {
|
||||||
|
try {
|
||||||
|
// processSync 返回的是 vfile(`toString()` 即 HTML),不是裸字符串
|
||||||
|
const result = getProcessor({ plugins }).processSync(String(markdown ?? ''));
|
||||||
|
return String(result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('markdown 转换失败', e);
|
||||||
|
return plainTextToHtml(markdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 纯文本 → HTML:空行分段,段内换行转 `<br>`,否则编辑器里会挤成一段。 */
|
||||||
|
export function plainTextToHtml(text: string): string {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replace(/\r\n?/g, '\n')
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((paragraph) => paragraph.trim())
|
||||||
|
.filter((paragraph) => paragraph !== '')
|
||||||
|
.map(
|
||||||
|
(paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, '<br>')}</p>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import type { Component } from 'vue';
|
||||||
|
import {
|
||||||
|
CustomerServiceOutlined,
|
||||||
|
FileExcelOutlined,
|
||||||
|
FileMarkdownOutlined,
|
||||||
|
FilePdfOutlined,
|
||||||
|
FilePptOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
FileUnknownOutlined,
|
||||||
|
FileWordOutlined,
|
||||||
|
VideoCameraOutlined
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import type { FileRecord } from '@/api/system/file/model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 媒体大类。
|
||||||
|
*
|
||||||
|
* 注意它和 `contentType`(MIME 字符串)不是一回事:MIME 里「文档」没有统一前缀
|
||||||
|
* (pdf/docx/zip 都是 application/…),所以大类由 MIME 前缀 + 后缀一起判定。
|
||||||
|
*/
|
||||||
|
export type MediaKind = 'image' | 'video' | 'audio' | 'doc';
|
||||||
|
|
||||||
|
/** 与后端 UploadRules 的白名单保持一致,改这里要同步改后端 */
|
||||||
|
export const MEDIA_ACCEPT = [
|
||||||
|
'image/*',
|
||||||
|
'.pdf',
|
||||||
|
'.doc',
|
||||||
|
'.docx',
|
||||||
|
'.xls',
|
||||||
|
'.xlsx',
|
||||||
|
'.ppt',
|
||||||
|
'.pptx',
|
||||||
|
'.txt',
|
||||||
|
'.md',
|
||||||
|
'.csv',
|
||||||
|
'.mp3',
|
||||||
|
'.wav',
|
||||||
|
'.m4a',
|
||||||
|
'.aac',
|
||||||
|
'.mp4',
|
||||||
|
'.mov',
|
||||||
|
'.webm'
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
const DOC_SUFFIXES = [
|
||||||
|
'pdf',
|
||||||
|
'doc',
|
||||||
|
'docx',
|
||||||
|
'xls',
|
||||||
|
'xlsx',
|
||||||
|
'ppt',
|
||||||
|
'pptx',
|
||||||
|
'txt',
|
||||||
|
'md',
|
||||||
|
'csv'
|
||||||
|
];
|
||||||
|
const VIDEO_SUFFIXES = ['mp4', 'mov', 'webm'];
|
||||||
|
const AUDIO_SUFFIXES = ['mp3', 'wav', 'm4a', 'aac'];
|
||||||
|
|
||||||
|
/** 图片大小上限(MB),其余类型上限(MB),受后端 multipart 100MB 硬顶约束 */
|
||||||
|
export const MEDIA_MAX_MB_IMAGE = 10;
|
||||||
|
export const MEDIA_MAX_MB_OTHER = 100;
|
||||||
|
|
||||||
|
/** 类型筛选项,value 为空表示不限 */
|
||||||
|
export const MEDIA_CATEGORY_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: '', label: '全部类型' },
|
||||||
|
{ value: 'image', label: '图片' },
|
||||||
|
{ value: 'doc', label: '文档' },
|
||||||
|
{ value: 'audio', label: '音频' },
|
||||||
|
{ value: 'video', label: '视频' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MEDIA_KIND_LABEL: Record<MediaKind, string> = {
|
||||||
|
image: '图片',
|
||||||
|
video: '视频',
|
||||||
|
audio: '音频',
|
||||||
|
doc: '文档'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 取小写后缀,不带点 */
|
||||||
|
export function mediaSuffix(name?: string | null): string {
|
||||||
|
if (!name || name.lastIndexOf('.') < 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return name.substring(name.lastIndexOf('.') + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由 MIME + 文件名判定大类 */
|
||||||
|
export function mediaKindOf(
|
||||||
|
contentType?: string | null,
|
||||||
|
name?: string | null
|
||||||
|
): MediaKind {
|
||||||
|
const mime = (contentType || '').toLowerCase();
|
||||||
|
if (mime.startsWith('image/')) {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
if (mime.startsWith('video/')) {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
if (mime.startsWith('audio/')) {
|
||||||
|
return 'audio';
|
||||||
|
}
|
||||||
|
const suffix = mediaSuffix(name);
|
||||||
|
if (VIDEO_SUFFIXES.includes(suffix)) {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
if (AUDIO_SUFFIXES.includes(suffix)) {
|
||||||
|
return 'audio';
|
||||||
|
}
|
||||||
|
return 'doc';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由文件记录判定大类 */
|
||||||
|
export function mediaKindOfRecord(record?: FileRecord | null): MediaKind {
|
||||||
|
return mediaKindOf(record?.contentType, record?.path || record?.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传前校验,通过返回 null,不通过返回给用户看的错误文案。
|
||||||
|
* 与后端同规则:图片按 MIME 认,其余按后缀白名单认。
|
||||||
|
*/
|
||||||
|
export function validateMediaFile(file: File): string | null {
|
||||||
|
const mime = (file.type || '').toLowerCase();
|
||||||
|
const suffix = mediaSuffix(file.name);
|
||||||
|
const isImage = mime.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
const allowed =
|
||||||
|
DOC_SUFFIXES.includes(suffix) ||
|
||||||
|
VIDEO_SUFFIXES.includes(suffix) ||
|
||||||
|
AUDIO_SUFFIXES.includes(suffix);
|
||||||
|
if (!allowed) {
|
||||||
|
return '不支持的文件格式,仅支持图片、文档、音频、视频';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const maxMb = isImage ? MEDIA_MAX_MB_IMAGE : MEDIA_MAX_MB_OTHER;
|
||||||
|
if (file.size / 1024 / 1024 > maxMb) {
|
||||||
|
return `文件大小不能超过 ${maxMb}MB`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件大小格式化 */
|
||||||
|
export function formatFileSize(length?: number | null): string {
|
||||||
|
const size = Number(length ?? 0);
|
||||||
|
if (!size || size < 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
if (size < 1024) {
|
||||||
|
return size + 'B';
|
||||||
|
}
|
||||||
|
if (size < 1024 * 1024) {
|
||||||
|
return (size / 1024).toFixed(1) + 'KB';
|
||||||
|
}
|
||||||
|
if (size < 1024 * 1024 * 1024) {
|
||||||
|
return (size / 1024 / 1024).toFixed(1) + 'M';
|
||||||
|
}
|
||||||
|
return (size / 1024 / 1024 / 1024).toFixed(1) + 'G';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片/表格里显示的类型图标 */
|
||||||
|
export function mediaIcon(kind: MediaKind, name?: string | null): Component {
|
||||||
|
if (kind === 'audio') {
|
||||||
|
return CustomerServiceOutlined;
|
||||||
|
}
|
||||||
|
if (kind === 'video') {
|
||||||
|
return VideoCameraOutlined;
|
||||||
|
}
|
||||||
|
switch (mediaSuffix(name)) {
|
||||||
|
case 'pdf':
|
||||||
|
return FilePdfOutlined;
|
||||||
|
case 'doc':
|
||||||
|
case 'docx':
|
||||||
|
return FileWordOutlined;
|
||||||
|
case 'xls':
|
||||||
|
case 'xlsx':
|
||||||
|
case 'csv':
|
||||||
|
return FileExcelOutlined;
|
||||||
|
case 'ppt':
|
||||||
|
case 'pptx':
|
||||||
|
return FilePptOutlined;
|
||||||
|
case 'md':
|
||||||
|
return FileMarkdownOutlined;
|
||||||
|
case 'txt':
|
||||||
|
return FileTextOutlined;
|
||||||
|
default:
|
||||||
|
return FileUnknownOutlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图标色,按类型区分但都压在中低饱和,避免卡片花花绿绿 */
|
||||||
|
export function mediaColor(kind: MediaKind, name?: string | null): string {
|
||||||
|
if (kind === 'audio') {
|
||||||
|
return '#805ad5';
|
||||||
|
}
|
||||||
|
if (kind === 'video') {
|
||||||
|
return '#2b6cb0';
|
||||||
|
}
|
||||||
|
switch (mediaSuffix(name)) {
|
||||||
|
case 'pdf':
|
||||||
|
return '#e5484d';
|
||||||
|
case 'doc':
|
||||||
|
case 'docx':
|
||||||
|
return '#2b6cb0';
|
||||||
|
case 'xls':
|
||||||
|
case 'xlsx':
|
||||||
|
case 'csv':
|
||||||
|
return '#2f855a';
|
||||||
|
case 'ppt':
|
||||||
|
case 'pptx':
|
||||||
|
return '#c05621';
|
||||||
|
default:
|
||||||
|
return '#718096';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 非图片卡片右下角的扩展名徽标文本 */
|
||||||
|
export function mediaExtLabel(record?: FileRecord | null): string {
|
||||||
|
const suffix = mediaSuffix(record?.path || record?.name);
|
||||||
|
return suffix ? suffix.toUpperCase() : 'FILE';
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
<!-- 搜索表单 -->
|
<!-- 搜索表单 -->
|
||||||
<template>
|
<template>
|
||||||
<a-space :size="10" style="flex-wrap: wrap">
|
<a-space :size="10" style="flex-wrap: wrap">
|
||||||
|
<a-select
|
||||||
|
v-model:value="where.chatType"
|
||||||
|
allow-clear
|
||||||
|
placeholder="请选择类型"
|
||||||
|
style="width: 180px"
|
||||||
|
:options="chatTypeOptions"
|
||||||
|
@change="search"
|
||||||
|
/>
|
||||||
<a-button type="primary" class="ele-btn-icon" @click="add">
|
<a-button type="primary" class="ele-btn-icon" @click="add">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<PlusOutlined />
|
<PlusOutlined />
|
||||||
@@ -12,8 +20,9 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||||
import type { GradeParam } from '@/api/user/grade/model';
|
import type { AiChatListParam } from '@/api/ai/aiChatList/model';
|
||||||
import { watch } from 'vue';
|
import { AI_CHAT_TYPE_OPTIONS } from '@/utils/ai-chat-type';
|
||||||
|
import { reactive, watch } from 'vue';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -24,12 +33,26 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'search', where?: GradeParam): void;
|
(e: 'search', where?: AiChatListParam): void;
|
||||||
(e: 'add'): void;
|
(e: 'add'): void;
|
||||||
(e: 'remove'): void;
|
(e: 'remove'): void;
|
||||||
(e: 'batchMove'): void;
|
(e: 'batchMove'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const chatTypeOptions = AI_CHAT_TYPE_OPTIONS;
|
||||||
|
|
||||||
|
const where = reactive<AiChatListParam>({
|
||||||
|
chatType: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// 类型筛选:清空即回到全部
|
||||||
|
const search = () => {
|
||||||
|
emit('search', {
|
||||||
|
...where,
|
||||||
|
chatType: where.chatType || undefined
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 新增
|
// 新增
|
||||||
const add = () => {
|
const add = () => {
|
||||||
emit('add');
|
emit('add');
|
||||||
|
|||||||
@@ -24,6 +24,11 @@
|
|||||||
<template v-if="column.key === 'image'">
|
<template v-if="column.key === 'image'">
|
||||||
<a-image :src="record.image" :width="50" />
|
<a-image :src="record.image" :width="50" />
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="column.key === 'chatType'">
|
||||||
|
<a-tag :color="chatTypeColor(record.chatType)">
|
||||||
|
{{ getAiChatTypeLabel(record.chatType) }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
<template v-if="column.key === 'status'">
|
<template v-if="column.key === 'status'">
|
||||||
<a-tag v-if="record.status === 1" color="green">显示</a-tag>
|
<a-tag v-if="record.status === 1" color="green">显示</a-tag>
|
||||||
<a-tag v-if="record.status === 0" color="red">隐藏</a-tag>
|
<a-tag v-if="record.status === 0" color="red">隐藏</a-tag>
|
||||||
@@ -64,6 +69,7 @@
|
|||||||
import AiChatListEdit from './components/aiChatListEdit.vue';
|
import AiChatListEdit from './components/aiChatListEdit.vue';
|
||||||
import { pageAiChatList, removeAiChatList, removeBatchAiChatList } from '@/api/ai/aiChatList';
|
import { pageAiChatList, removeAiChatList, removeBatchAiChatList } from '@/api/ai/aiChatList';
|
||||||
import type { AiChatList, AiChatListParam } from '@/api/ai/aiChatList/model';
|
import type { AiChatList, AiChatListParam } from '@/api/ai/aiChatList/model';
|
||||||
|
import { getAiChatTypeLabel, resolveAiChatType } from '@/utils/ai-chat-type';
|
||||||
|
|
||||||
// 表格实例
|
// 表格实例
|
||||||
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||||
@@ -79,6 +85,22 @@
|
|||||||
// 加载状态
|
// 加载状态
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
||||||
|
// 记录类型徽标的颜色,与助手页历史面板一致
|
||||||
|
const chatTypeColor = (value?: string) => {
|
||||||
|
switch (resolveAiChatType(value)) {
|
||||||
|
case 'writing':
|
||||||
|
return 'orange';
|
||||||
|
case 'image':
|
||||||
|
return 'purple';
|
||||||
|
case 'creative':
|
||||||
|
return 'magenta';
|
||||||
|
case 'activity_outline':
|
||||||
|
return 'green';
|
||||||
|
default:
|
||||||
|
return 'blue';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 表格数据源
|
// 表格数据源
|
||||||
const datasource: DatasourceFunction = ({
|
const datasource: DatasourceFunction = ({
|
||||||
page,
|
page,
|
||||||
@@ -107,6 +129,13 @@
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
width: 90,
|
width: 90,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'chatType',
|
||||||
|
key: 'chatType',
|
||||||
|
align: 'center',
|
||||||
|
width: 110
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
dataIndex: 'conversationId',
|
dataIndex: 'conversationId',
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ import { useUserStore } from "@/store/modules/user";
|
|||||||
import type { Article } from "@/api/cms/article/model";
|
import type { Article } from "@/api/cms/article/model";
|
||||||
import { addArticle, updateArticle } from "@/api/cms/article";
|
import { addArticle, updateArticle } from "@/api/cms/article";
|
||||||
import { FILE_SERVER, FILE_THUMBNAIL } from "@/config/setting";
|
import { FILE_SERVER, FILE_THUMBNAIL } from "@/config/setting";
|
||||||
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
|
import type { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
|
||||||
import { uploadFile } from "@/api/system/file";
|
import { uploadFile } from "@/api/system/file";
|
||||||
import TinymceEditor from "@/components/TinymceEditor/index.vue";
|
import TinymceEditor from "@/components/TinymceEditor/index.vue";
|
||||||
import { FormInstance, Rule, RuleObject } from "ant-design-vue/es/form";
|
import { FormInstance, Rule, RuleObject } from "ant-design-vue/es/form";
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<a-form-item label="上级分类" name="parentId">
|
<a-form-item label="上级分类" name="parentId">
|
||||||
<a-tree-select
|
<a-tree-select
|
||||||
allow-clear
|
allow-clear
|
||||||
:tree-data="categoryList"
|
:tree-data="parentOptions"
|
||||||
tree-default-expand-all
|
tree-default-expand-all
|
||||||
placeholder="请选择上级分类"
|
placeholder="请选择上级分类"
|
||||||
:value="form.parentId || undefined"
|
:value="form.parentId || undefined"
|
||||||
@@ -84,13 +84,12 @@
|
|||||||
v-model:value="form.comments"
|
v-model:value="form.comments"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
</a-form>
|
</a-form>
|
||||||
</ele-modal>
|
</ele-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, watch } from 'vue';
|
import { ref, reactive, computed, watch } from 'vue';
|
||||||
import { message } from 'ant-design-vue/es';
|
import { message } from 'ant-design-vue/es';
|
||||||
import type { FormInstance, Rule } from 'ant-design-vue/es/form';
|
import type { FormInstance, Rule } from 'ant-design-vue/es/form';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
@@ -141,6 +140,26 @@
|
|||||||
sortNumber: 100
|
sortNumber: 100
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 上级分类候选:修改时要把自己及自己的整棵子树剔除,
|
||||||
|
* 否则可以把上级选成自己或自己的下级,形成环导致分类树无法渲染。
|
||||||
|
*/
|
||||||
|
const parentOptions = computed(() => {
|
||||||
|
const currentId = form.categoryId;
|
||||||
|
if (currentId == null) {
|
||||||
|
return props.categoryList ?? [];
|
||||||
|
}
|
||||||
|
const filterSelf = (list: ArticleCategory[]): ArticleCategory[] =>
|
||||||
|
(list ?? [])
|
||||||
|
.filter((d) => d.categoryId !== currentId)
|
||||||
|
.map((d) =>
|
||||||
|
d.children?.length
|
||||||
|
? { ...d, children: filterSelf(d.children) }
|
||||||
|
: { ...d }
|
||||||
|
);
|
||||||
|
return filterSelf(props.categoryList);
|
||||||
|
});
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
const rules = reactive<Record<string, Rule[]>>({
|
const rules = reactive<Record<string, Rule[]>>({
|
||||||
title: [
|
title: [
|
||||||
|
|||||||
@@ -195,8 +195,23 @@
|
|||||||
|
|
||||||
/* 数据转为树形结构 */
|
/* 数据转为树形结构 */
|
||||||
const parseData = (data: ArticleCategory[]) => {
|
const parseData = (data: ArticleCategory[]) => {
|
||||||
|
/*
|
||||||
|
* 防御性过滤:分类是自关联结构,如果库里存在「自己指向自己」的脏数据,
|
||||||
|
* toTreeData 会直接 throw,导致整个页面无法渲染(报 data error: {categoryId: x, parentId: x})。
|
||||||
|
* 这里先剔除这类记录,只影响异常的那一行,不拖垮整个列表。
|
||||||
|
*/
|
||||||
|
const validData = (data ?? []).filter((d) => d.categoryId !== d.parentId);
|
||||||
|
if (validData.length !== (data ?? []).length) {
|
||||||
|
const badIds = (data ?? [])
|
||||||
|
.filter((d) => d.categoryId === d.parentId)
|
||||||
|
.map((d) => d.categoryId)
|
||||||
|
.join('、');
|
||||||
|
message.error(
|
||||||
|
`分类 ${badIds} 的上级分类是它自己,已跳过显示,请在数据库中修正`
|
||||||
|
);
|
||||||
|
}
|
||||||
return toTreeData({
|
return toTreeData({
|
||||||
data: data.map((d) => {
|
data: validData.map((d) => {
|
||||||
return { ...d, key: d.categoryId, value: d.categoryId };
|
return { ...d, key: d.categoryId, value: d.categoryId };
|
||||||
}),
|
}),
|
||||||
idField: 'categoryId',
|
idField: 'categoryId',
|
||||||
|
|||||||
@@ -79,6 +79,12 @@
|
|||||||
import { shallowRef, ref, reactive, watch, onBeforeUnmount } from 'vue';
|
import { shallowRef, ref, reactive, watch, onBeforeUnmount } from 'vue';
|
||||||
import { Form, message } from 'ant-design-vue';
|
import { Form, message } from 'ant-design-vue';
|
||||||
import { assignObject } from 'ele-admin-pro';
|
import { assignObject } from 'ele-admin-pro';
|
||||||
|
import {
|
||||||
|
detectFormat,
|
||||||
|
markdownToHtml,
|
||||||
|
normalizeFormat,
|
||||||
|
plainTextToHtml
|
||||||
|
} from '@/utils/manuscript-content';
|
||||||
import { addManuscript, updateManuscript } from '@/api/cms/manuscript';
|
import { addManuscript, updateManuscript } from '@/api/cms/manuscript';
|
||||||
import { Manuscript } from '@/api/cms/manuscript/model';
|
import { Manuscript } from '@/api/cms/manuscript/model';
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
@@ -147,6 +153,7 @@
|
|||||||
id: undefined,
|
id: undefined,
|
||||||
title: undefined,
|
title: undefined,
|
||||||
content: undefined,
|
content: undefined,
|
||||||
|
contentFormat: 'html',
|
||||||
cover: undefined,
|
cover: undefined,
|
||||||
deleted: undefined,
|
deleted: undefined,
|
||||||
tenantId: undefined,
|
tenantId: undefined,
|
||||||
@@ -161,6 +168,27 @@
|
|||||||
emit('update:visible', value);
|
emit('update:visible', value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 入库格式 → 编辑器 HTML。
|
||||||
|
* 库里可能是 markdown 或纯文本(早期从 AI 结果粘进来的),wangEditor 只认 HTML,
|
||||||
|
* 原样塞进去会在保存时被当成普通文字包进 <p>,等于把正文改坏。
|
||||||
|
*/
|
||||||
|
const toEditorHtml = (data: Manuscript): string => {
|
||||||
|
const content = data.content || '';
|
||||||
|
const format = normalizeFormat(data.contentFormat) || detectFormat(content);
|
||||||
|
if (format === 'markdown') {
|
||||||
|
message.info('这篇稿件存的是 Markdown,已转换为富文本,请确认排版后保存');
|
||||||
|
return markdownToHtml(content);
|
||||||
|
}
|
||||||
|
if (format === 'text') {
|
||||||
|
message.info(
|
||||||
|
'这篇稿件存的是纯文本,已按段落转换为富文本,请确认排版后保存'
|
||||||
|
);
|
||||||
|
return plainTextToHtml(content);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
const hasEditorContent = (html?: string) => {
|
const hasEditorContent = (html?: string) => {
|
||||||
const text = String(html || '')
|
const text = String(html || '')
|
||||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
@@ -257,7 +285,8 @@
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
const formData = {
|
const formData = {
|
||||||
...form,
|
...form,
|
||||||
content: contentHtml.value
|
content: contentHtml.value,
|
||||||
|
contentFormat: 'html'
|
||||||
};
|
};
|
||||||
const saveOrUpdate = isUpdate.value ? updateManuscript : addManuscript;
|
const saveOrUpdate = isUpdate.value ? updateManuscript : addManuscript;
|
||||||
const msg = await saveOrUpdate(formData);
|
const msg = await saveOrUpdate(formData);
|
||||||
@@ -294,7 +323,7 @@
|
|||||||
if (visible) {
|
if (visible) {
|
||||||
if (props.data) {
|
if (props.data) {
|
||||||
assignObject(form, props.data);
|
assignObject(form, props.data);
|
||||||
contentHtml.value = props.data.content || '';
|
contentHtml.value = toEditorHtml(props.data);
|
||||||
coverList.value = props.data.cover
|
coverList.value = props.data.cover
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
:color="item.status === 1 ? 'success' : item.status === 0 ? 'error' : 'default'"
|
:color="item.status === 1 ? 'success' : item.status === 0 ? 'error' : 'default'"
|
||||||
style="margin-left: 8px"
|
style="margin-left: 8px"
|
||||||
>
|
>
|
||||||
{{ item.status === 1 ? '通过' : item.status === 0 ? '不通过' : '-' }}
|
{{ item.status === 1 ? '通过' : item.status === 0 ? '驳回' : '-' }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.user?.realName" style="margin-bottom: 4px">
|
<div v-if="item.user?.realName" style="margin-bottom: 4px">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<a-tag color="green">通过</a-tag>
|
<a-tag color="green">通过</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="getLastReview(record)?.status === 2">
|
<template v-else-if="getLastReview(record)?.status === 2">
|
||||||
<a-tag color="red">不通过</a-tag>
|
<a-tag color="red">驳回</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a-tag>未审核</a-tag>
|
<a-tag>未审核</a-tag>
|
||||||
@@ -58,7 +58,11 @@
|
|||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<!-- 编辑弹窗 -->
|
<!-- 编辑弹窗 -->
|
||||||
<ManuscriptEdit v-model:visible="showEdit" :data="current" @done="reload" />
|
<ManuscriptEdit
|
||||||
|
v-model:visible="showEdit"
|
||||||
|
:data="current"
|
||||||
|
@done="reload"
|
||||||
|
/>
|
||||||
<!-- 新增审核记录弹窗 -->
|
<!-- 新增审核记录弹窗 -->
|
||||||
<ReviewRecordModal
|
<ReviewRecordModal
|
||||||
v-model:visible="showReviewModal"
|
v-model:visible="showReviewModal"
|
||||||
@@ -71,7 +75,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { createVNode, ref } from 'vue';
|
import { createVNode, ref } from 'vue';
|
||||||
import { message, Modal } from 'ant-design-vue';
|
import { message, Modal, Tag } from 'ant-design-vue';
|
||||||
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
|
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
|
||||||
import type { EleProTable } from 'ele-admin-pro';
|
import type { EleProTable } from 'ele-admin-pro';
|
||||||
import { toDateString } from 'ele-admin-pro';
|
import { toDateString } from 'ele-admin-pro';
|
||||||
@@ -82,9 +86,14 @@
|
|||||||
import Search from './components/search.vue';
|
import Search from './components/search.vue';
|
||||||
import ManuscriptEdit from './components/manuscriptEdit.vue';
|
import ManuscriptEdit from './components/manuscriptEdit.vue';
|
||||||
import ReviewRecordModal from './components/reviewRecordModal.vue';
|
import ReviewRecordModal from './components/reviewRecordModal.vue';
|
||||||
import { pageManuscript, removeManuscript, removeBatchManuscript } from '@/api/cms/manuscript';
|
import {
|
||||||
|
pageManuscript,
|
||||||
|
removeManuscript,
|
||||||
|
removeBatchManuscript
|
||||||
|
} from '@/api/cms/manuscript';
|
||||||
import type { Manuscript, ManuscriptParam } from '@/api/cms/manuscript/model';
|
import type { Manuscript, ManuscriptParam } from '@/api/cms/manuscript/model';
|
||||||
import type { ReviewList } from '@/api/gxmu/reviewList/model';
|
import type { ReviewList } from '@/api/gxmu/reviewList/model';
|
||||||
|
import { detectFormat, normalizeFormat } from '@/utils/manuscript-content';
|
||||||
|
|
||||||
// 表格实例
|
// 表格实例
|
||||||
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||||
@@ -130,19 +139,36 @@
|
|||||||
dataIndex: 'id',
|
dataIndex: 'id',
|
||||||
key: 'id',
|
key: 'id',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 90,
|
width: 90
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '标题',
|
title: '标题',
|
||||||
dataIndex: 'title',
|
dataIndex: 'title',
|
||||||
key: 'title',
|
key: 'title',
|
||||||
align: 'center',
|
align: 'center'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '封面',
|
title: '封面',
|
||||||
dataIndex: 'cover',
|
dataIndex: 'cover',
|
||||||
key: 'cover',
|
key: 'cover',
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '正文格式',
|
||||||
|
dataIndex: 'contentFormat',
|
||||||
|
key: 'contentFormat',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
width: 110,
|
||||||
|
customRender: ({ record }: { record: Manuscript }) => {
|
||||||
|
const format =
|
||||||
|
normalizeFormat(record.contentFormat) || detectFormat(record.content);
|
||||||
|
if (format === 'html') {
|
||||||
|
return createVNode(Tag, { color: 'default' }, () => 'HTML');
|
||||||
|
}
|
||||||
|
return createVNode(Tag, { color: 'orange' }, () =>
|
||||||
|
format === 'markdown' ? 'Markdown' : '纯文本'
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '创建时间',
|
title: '创建时间',
|
||||||
@@ -157,7 +183,7 @@
|
|||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
align: 'center',
|
align: 'center'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '当前审核状态',
|
title: '当前审核状态',
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<!-- 音频 / 视频预览弹窗:素材库里非图片项的统一播放器 -->
|
||||||
|
<template>
|
||||||
|
<ele-modal
|
||||||
|
:width="kind === 'video' ? 880 : 480"
|
||||||
|
:footer="null"
|
||||||
|
:visible="visible"
|
||||||
|
:title="record?.name"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
@update:visible="updateVisible"
|
||||||
|
>
|
||||||
|
<div v-if="visible && record" class="media-preview">
|
||||||
|
<ele-xg-player
|
||||||
|
v-if="kind === 'video'"
|
||||||
|
:key="'v-' + record.id"
|
||||||
|
:config="playerConfig"
|
||||||
|
/>
|
||||||
|
<div v-else class="media-preview-audio">
|
||||||
|
<customer-service-outlined class="media-preview-audio-icon" />
|
||||||
|
<div class="media-preview-audio-name ele-text-secondary">
|
||||||
|
{{ record.name }}
|
||||||
|
</div>
|
||||||
|
<audio
|
||||||
|
:key="'a-' + record.id"
|
||||||
|
:src="record.url"
|
||||||
|
controls
|
||||||
|
autoplay
|
||||||
|
class="media-preview-audio-player"
|
||||||
|
></audio>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ele-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { CustomerServiceOutlined } from '@ant-design/icons-vue';
|
||||||
|
import type { FileRecord } from '@/api/system/file/model';
|
||||||
|
import { mediaKindOfRecord } from '@/utils/media';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
// 弹窗是否打开
|
||||||
|
visible: boolean;
|
||||||
|
// 当前预览的文件
|
||||||
|
record?: FileRecord | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:visible', visible: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const kind = computed(() => mediaKindOfRecord(props.record));
|
||||||
|
|
||||||
|
// xgplayer 配置,key 变化时重建实例,避免沿用上一条的播放状态
|
||||||
|
const playerConfig = computed(() => ({
|
||||||
|
id: 'cmsPhotoPreviewPlayer',
|
||||||
|
lang: 'zh-cn',
|
||||||
|
fluid: true,
|
||||||
|
url: props.record?.url,
|
||||||
|
playbackRate: [0.5, 1, 1.5, 2],
|
||||||
|
pip: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
/* 更新 visible */
|
||||||
|
const updateVisible = (value: boolean) => {
|
||||||
|
emit('update:visible', value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.media-preview-audio {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-preview-audio-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #805ad5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-preview-audio-name {
|
||||||
|
margin: 12px 0 20px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-preview-audio-player {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<!-- 角色编辑弹窗 -->
|
<!-- 素材编辑弹窗:文件在列表页上传时已入库,这里只改元数据和分享代码 -->
|
||||||
<template>
|
<template>
|
||||||
<ele-modal
|
<ele-modal
|
||||||
:width="600"
|
:width="600"
|
||||||
:visible="visible"
|
:visible="visible"
|
||||||
:confirm-loading="loading"
|
:confirm-loading="loading"
|
||||||
:title="isUpdate ? '编辑' : '上传文件'"
|
title="编辑素材"
|
||||||
:body-style="{ paddingBottom: '8px' }"
|
:body-style="{ paddingBottom: '8px' }"
|
||||||
okText="保存"
|
okText="保存"
|
||||||
@update:visible="updateVisible"
|
@update:visible="updateVisible"
|
||||||
@@ -19,27 +19,22 @@
|
|||||||
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
|
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<a-form-item label="上传文件" name="fileName" v-if="!isUpdate">
|
<a-form-item label="文件">
|
||||||
<span
|
<div class="media-file">
|
||||||
class="ele-text-success"
|
<component
|
||||||
v-if="fileName"
|
:is="fileIcon"
|
||||||
style="margin-right: 10px"
|
class="media-file-icon"
|
||||||
>
|
:style="{ color: fileColor }"
|
||||||
{{ fileName }}
|
/>
|
||||||
</span>
|
<div class="media-file-info">
|
||||||
<a-upload
|
<div class="media-file-name" :title="record?.name">
|
||||||
:show-upload-list="false"
|
{{ record?.name }}
|
||||||
:accept="'video/*'"
|
</div>
|
||||||
v-if="!fileName"
|
<div class="ele-text-secondary media-file-sub">
|
||||||
:customRequest="onUpload"
|
{{ kindLabel }} · {{ fileSize }}
|
||||||
>
|
</div>
|
||||||
<a-button type="primary" class="ele-btn-icon">
|
</div>
|
||||||
<template #icon>
|
</div>
|
||||||
<UploadOutlined />
|
|
||||||
</template>
|
|
||||||
<span>上传文件</span>
|
|
||||||
</a-button>
|
|
||||||
</a-upload>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="文件名称" name="name">
|
<a-form-item label="文件名称" name="name">
|
||||||
<a-input
|
<a-input
|
||||||
@@ -53,17 +48,17 @@
|
|||||||
<a-textarea
|
<a-textarea
|
||||||
:rows="4"
|
:rows="4"
|
||||||
:maxlength="200"
|
:maxlength="200"
|
||||||
placeholder="图片描述"
|
placeholder="素材描述"
|
||||||
v-model:value="form.comments"
|
v-model:value="form.comments"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<div style="margin-bottom: 22px">
|
<div style="margin-bottom: 22px">
|
||||||
<a-divider orientation="left">图片分享</a-divider>
|
<a-divider orientation="left">{{ shareTitle }}</a-divider>
|
||||||
</div>
|
</div>
|
||||||
<a-form-item label="图片URL链接">
|
<a-form-item label="素材链接">
|
||||||
<a-input v-model:value="share.url" @click="copyText(share.url)" />
|
<a-input v-model:value="share.url" @click="copyText(share.url)" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="网页代码(HTML)">
|
<a-form-item label="网页代码(HTML)" v-if="share.html">
|
||||||
<a-input v-model:value="share.html" @click="copyText(share.html)" />
|
<a-input v-model:value="share.html" @click="copyText(share.html)" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Markdown">
|
<a-form-item label="Markdown">
|
||||||
@@ -72,38 +67,36 @@
|
|||||||
@click="copyText(share.Markdown)"
|
@click="copyText(share.Markdown)"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="论坛BBCode">
|
<a-form-item label="论坛BBCode" v-if="share.bbCode">
|
||||||
<a-input v-model:value="share.bbCode" @click="copyText(share.bbCode)" />
|
<a-input v-model:value="share.bbCode" @click="copyText(share.bbCode)" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="下载地址">
|
||||||
<!-- <a-form-item label="封面图" name="images">-->
|
<a-input
|
||||||
<!-- <ele-image-upload-->
|
v-model:value="share.download"
|
||||||
<!-- v-model:value="images"-->
|
@click="copyText(share.download)"
|
||||||
<!-- :limit="1"-->
|
/>
|
||||||
<!-- :drag="true"-->
|
</a-form-item>
|
||||||
<!-- :item-style="{ width: '60px', height: '60px' }"-->
|
|
||||||
<!-- :accept="'image/*'"-->
|
|
||||||
<!-- :upload-handler="uploadHandler"-->
|
|
||||||
<!-- @upload="onUpload"-->
|
|
||||||
<!-- />-->
|
|
||||||
<!-- </a-form-item>-->
|
|
||||||
</a-form>
|
</a-form>
|
||||||
</ele-modal>
|
</ele-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, watch } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { message } from 'ant-design-vue/es';
|
import { message } from 'ant-design-vue/es';
|
||||||
import type { FormInstance, Rule } from 'ant-design-vue/es/form';
|
import type { FormInstance, Rule } from 'ant-design-vue/es/form';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import useFormData from '@/utils/use-form-data';
|
import useFormData from '@/utils/use-form-data';
|
||||||
import type { FileRecord } from '@/api/system/file/model';
|
import type { FileRecord } from '@/api/system/file/model';
|
||||||
import { messageLoading } from 'ele-admin-pro';
|
import { updateFiles } from '@/api/system/file';
|
||||||
import { addFiles, updateFiles, uploadFile } from '@/api/system/file';
|
|
||||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
|
||||||
import { RuleObject } from 'ant-design-vue/es/form';
|
|
||||||
import { copyText } from '@/utils/common';
|
import { copyText } from '@/utils/common';
|
||||||
|
import {
|
||||||
|
formatFileSize,
|
||||||
|
mediaColor,
|
||||||
|
mediaIcon,
|
||||||
|
mediaKindOfRecord,
|
||||||
|
MEDIA_KIND_LABEL
|
||||||
|
} from '@/utils/media';
|
||||||
|
|
||||||
// 是否开启响应式布局
|
// 是否开启响应式布局
|
||||||
const themeStore = useThemeStore();
|
const themeStore = useThemeStore();
|
||||||
@@ -121,20 +114,11 @@
|
|||||||
data?: FileRecord | null;
|
data?: FileRecord | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
//
|
|
||||||
const formRef = ref<FormInstance | null>(null);
|
const formRef = ref<FormInstance | null>(null);
|
||||||
const fileName = ref('');
|
// 当前编辑的原始记录,分享代码依赖它的 url,不随表单变化
|
||||||
// 是否是修改
|
const record = ref<FileRecord | null>(null);
|
||||||
const isUpdate = ref(false);
|
|
||||||
// 提交状态
|
// 提交状态
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
// 图片资源分享
|
|
||||||
const share = ref({
|
|
||||||
url: '',
|
|
||||||
Markdown: '',
|
|
||||||
html: '',
|
|
||||||
bbCode: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const { form, resetFields, assignFields } = useFormData<FileRecord>({
|
const { form, resetFields, assignFields } = useFormData<FileRecord>({
|
||||||
@@ -143,22 +127,49 @@
|
|||||||
comments: ''
|
comments: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const kind = computed(() => mediaKindOfRecord(record.value));
|
||||||
|
const kindLabel = computed(() => MEDIA_KIND_LABEL[kind.value]);
|
||||||
|
const fileSize = computed(() => formatFileSize(record.value?.length));
|
||||||
|
const fileIcon = computed(() =>
|
||||||
|
mediaIcon(kind.value, record.value?.path || record.value?.name)
|
||||||
|
);
|
||||||
|
const fileColor = computed(() =>
|
||||||
|
mediaColor(kind.value, record.value?.path || record.value?.name)
|
||||||
|
);
|
||||||
|
// 文档给的是链接与下载地址,图片/音视频给的是嵌入代码
|
||||||
|
const shareTitle = computed(() =>
|
||||||
|
kind.value === 'doc' ? '素材链接' : '素材分享'
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分享代码按类型生成:图片沿用 img 系列,音视频给 video/audio 标签,
|
||||||
|
* 文档只给直链和下载地址(不提供 HTML 嵌入)。
|
||||||
|
*/
|
||||||
|
const share = computed(() => {
|
||||||
|
const data = record.value;
|
||||||
|
const url = data?.url || '';
|
||||||
|
const name = data?.name || '';
|
||||||
|
const result = {
|
||||||
|
url,
|
||||||
|
html: '',
|
||||||
|
Markdown: `[${name}](${url})`,
|
||||||
|
bbCode: `[url=${url}]${name}[/url]`,
|
||||||
|
download: data?.downloadUrl || ''
|
||||||
|
};
|
||||||
|
if (kind.value === 'image') {
|
||||||
|
result.html = `<a href="${url}"><img src="${url}" alt="${name}" border="0" /></a>`;
|
||||||
|
result.Markdown = `[](${url})`;
|
||||||
|
result.bbCode = `[url=${url}][img]${url}[/img][/url]`;
|
||||||
|
} else if (kind.value === 'video') {
|
||||||
|
result.html = `<video src="${url}" controls></video>`;
|
||||||
|
} else if (kind.value === 'audio') {
|
||||||
|
result.html = `<audio src="${url}" controls></audio>`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
const rules = reactive<Record<string, Rule[]>>({
|
const rules = reactive<Record<string, Rule[]>>({
|
||||||
fileName: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: '请上传文件',
|
|
||||||
type: 'string',
|
|
||||||
trigger: 'blur',
|
|
||||||
validator: async (_rule: RuleObject) => {
|
|
||||||
if (!isUpdate.value && fileName.value.length == 0) {
|
|
||||||
return Promise.reject('请上传文件');
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
name: [
|
name: [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
@@ -169,39 +180,6 @@
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
// 上传文件
|
|
||||||
const onUpload = (item) => {
|
|
||||||
const { file } = item;
|
|
||||||
if (!file.type.startsWith('video')) {
|
|
||||||
message.error('文件格式不正确!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.size / 1024 / 1024 > 100) {
|
|
||||||
message.error('大小不能超过 100MB');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hide = messageLoading({
|
|
||||||
content: '上传中..',
|
|
||||||
duration: 0,
|
|
||||||
mask: true
|
|
||||||
});
|
|
||||||
uploadFile(file)
|
|
||||||
.then((data) => {
|
|
||||||
hide();
|
|
||||||
fileName.value = String(data.name);
|
|
||||||
// images.value.push({
|
|
||||||
// uid: data.id,
|
|
||||||
// url: FILE_THUMBNAIL + data.path,
|
|
||||||
// status: 'done'
|
|
||||||
// });
|
|
||||||
message.success('上传成功');
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
message.error(e.message);
|
|
||||||
hide();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/* 保存编辑 */
|
/* 保存编辑 */
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (!formRef.value) {
|
if (!formRef.value) {
|
||||||
@@ -211,8 +189,7 @@
|
|||||||
.validate()
|
.validate()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const saveOrUpdate = isUpdate.value ? updateFiles : addFiles;
|
updateFiles(form)
|
||||||
saveOrUpdate(form)
|
|
||||||
.then((msg) => {
|
.then((msg) => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
message.success(msg);
|
message.success(msg);
|
||||||
@@ -236,21 +213,44 @@
|
|||||||
() => props.visible,
|
() => props.visible,
|
||||||
(visible) => {
|
(visible) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
|
record.value = props.data ?? null;
|
||||||
if (props.data) {
|
if (props.data) {
|
||||||
assignFields(props.data);
|
assignFields(props.data);
|
||||||
// 图片分享代码
|
|
||||||
share.value.url = `${props.data.url}`;
|
|
||||||
share.value.html = `<a href="${props.data.url}"><img src="${props.data.url}" alt="${props.data.name}" border="0" /></a>`;
|
|
||||||
share.value.Markdown = `[](${props.data.url})`;
|
|
||||||
share.value.bbCode = `[url=${props.data.url}][img]${props.data.url}[/img][/url]`;
|
|
||||||
isUpdate.value = true;
|
|
||||||
} else {
|
|
||||||
isUpdate.value = false;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
record.value = null;
|
||||||
resetFields();
|
resetFields();
|
||||||
formRef.value?.clearValidate();
|
formRef.value?.clearValidate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.media-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-file-icon {
|
||||||
|
flex: none;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-file-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-file-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-file-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+131
-28
@@ -1,9 +1,10 @@
|
|||||||
|
<!-- 素材库 · 网格视图 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<a-row :gutter="20">
|
<a-row :gutter="20">
|
||||||
<a-col
|
<a-col
|
||||||
v-for="(item, index) in data"
|
v-for="item in data"
|
||||||
:key="index"
|
:key="item.id"
|
||||||
v-bind="
|
v-bind="
|
||||||
styleResponsive
|
styleResponsive
|
||||||
? { xl: 4, lg: 8, md: 12, sm: 12, xs: 24 }
|
? { xl: 4, lg: 8, md: 12, sm: 12, xs: 24 }
|
||||||
@@ -12,10 +13,33 @@
|
|||||||
>
|
>
|
||||||
<a-card :bordered="false" hoverable style="margin-top: 16px">
|
<a-card :bordered="false" hoverable style="margin-top: 16px">
|
||||||
<template #cover>
|
<template #cover>
|
||||||
<a-image :src="item.url" alt="" />
|
<!-- 统一 4:3 封面盒子:图片裁剪填充,其余类型放类型图标 -->
|
||||||
|
<div class="media-cover" @click="openPreview(item)">
|
||||||
|
<a-image
|
||||||
|
v-if="kindOf(item) === 'image'"
|
||||||
|
class="media-cover-image"
|
||||||
|
:src="item.thumbnail || item.url"
|
||||||
|
:preview="{ src: item.url }"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<component
|
||||||
|
:is="iconOf(item)"
|
||||||
|
class="media-cover-icon"
|
||||||
|
:style="{ color: colorOf(item) }"
|
||||||
|
/>
|
||||||
|
<span class="media-cover-ext">{{ extOf(item) }}</span>
|
||||||
|
<play-circle-filled
|
||||||
|
v-if="kindOf(item) === 'video'"
|
||||||
|
class="media-cover-play"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<a-card-meta :title="item.name" @click="openEdit(item)">
|
<a-card-meta :title="item.name" @click="openEdit(item)">
|
||||||
<template #description>
|
<template #description>
|
||||||
|
<div class="media-meta">
|
||||||
|
{{ labelOf(item) }} · {{ sizeOf(item) }}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="project-list-desc"
|
class="project-list-desc"
|
||||||
v-if="item.comments"
|
v-if="item.comments"
|
||||||
@@ -41,58 +65,85 @@
|
|||||||
</a-row>
|
</a-row>
|
||||||
<!-- 编辑弹窗 -->
|
<!-- 编辑弹窗 -->
|
||||||
<PhotoEdit v-model:visible="showEdit" :data="current" @done="query" />
|
<PhotoEdit v-model:visible="showEdit" :data="current" @done="query" />
|
||||||
|
<!-- 音视频预览 -->
|
||||||
|
<MediaPreview v-model:visible="showPreview" :record="current" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, watch } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { PlayCircleFilled } from '@ant-design/icons-vue';
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import { FileRecord, FileRecordParam } from '@/api/system/file/model';
|
import type { FileRecord, FileRecordParam } from '@/api/system/file/model';
|
||||||
import PhotoEdit from './components/photo-edit.vue';
|
import PhotoEdit from './components/photo-edit.vue';
|
||||||
|
import MediaPreview from './components/media-preview.vue';
|
||||||
import { timeAgo } from 'ele-admin-pro';
|
import { timeAgo } from 'ele-admin-pro';
|
||||||
|
import {
|
||||||
|
formatFileSize,
|
||||||
|
mediaColor,
|
||||||
|
mediaExtLabel,
|
||||||
|
mediaIcon,
|
||||||
|
mediaKindOfRecord,
|
||||||
|
MEDIA_KIND_LABEL,
|
||||||
|
type MediaKind
|
||||||
|
} from '@/utils/media';
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
// 搜索关键字
|
// 列表数据
|
||||||
searchText: string;
|
data?: FileRecord[];
|
||||||
// 搜索条件
|
// 搜索条件
|
||||||
where?: FileRecordParam | null;
|
where?: FileRecordParam | null;
|
||||||
// 修改回显的数据
|
|
||||||
data?: FileRecord | null;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// 当前编辑数据
|
|
||||||
const current = ref<FileRecord | null>(null);
|
|
||||||
// 是否显示编辑弹窗
|
|
||||||
const showEdit = ref(false);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'done'): void;
|
(e: 'done'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// 当前操作的数据(编辑 / 预览共用)
|
||||||
|
const current = ref<FileRecord | null>(null);
|
||||||
|
// 是否显示编辑弹窗
|
||||||
|
const showEdit = ref(false);
|
||||||
|
// 是否显示音视频预览
|
||||||
|
const showPreview = ref(false);
|
||||||
|
|
||||||
|
// 是否开启响应式布局
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
const { styleResponsive } = storeToRefs(themeStore);
|
||||||
|
|
||||||
|
const kindOf = (item: FileRecord): MediaKind => mediaKindOfRecord(item);
|
||||||
|
const iconOf = (item: FileRecord) =>
|
||||||
|
mediaIcon(kindOf(item), item.path || item.name);
|
||||||
|
const colorOf = (item: FileRecord) =>
|
||||||
|
mediaColor(kindOf(item), item.path || item.name);
|
||||||
|
const extOf = (item: FileRecord) => mediaExtLabel(item);
|
||||||
|
const labelOf = (item: FileRecord) => MEDIA_KIND_LABEL[kindOf(item)];
|
||||||
|
const sizeOf = (item: FileRecord) => formatFileSize(item.length);
|
||||||
|
|
||||||
/* 打开编辑弹窗 */
|
/* 打开编辑弹窗 */
|
||||||
const openEdit = (row?: FileRecord) => {
|
const openEdit = (row?: FileRecord) => {
|
||||||
current.value = row ?? null;
|
current.value = row ?? null;
|
||||||
showEdit.value = true;
|
showEdit.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 是否开启响应式布局
|
/* 点击封面:图片由 a-image 自己预览,音视频开播放器,文档新窗口打开 */
|
||||||
const themeStore = useThemeStore();
|
const openPreview = (row: FileRecord) => {
|
||||||
const { styleResponsive } = storeToRefs(themeStore);
|
const kind = kindOf(row);
|
||||||
|
if (kind === 'image') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === 'doc') {
|
||||||
|
window.open(row.url, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current.value = row;
|
||||||
|
showPreview.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 刷新列表 */
|
||||||
const query = () => {
|
const query = () => {
|
||||||
emit('done');
|
emit('done');
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.data,
|
|
||||||
(visible) => {
|
|
||||||
if (visible) {
|
|
||||||
console.log(props.data);
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -102,6 +153,58 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
|
.media-cover {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
// 所有类型共用 4:3,混排时卡片高度才对得齐
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f6f8;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
:deep(.ant-image) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-image-img) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-cover-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-cover-ext {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: #718096;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-cover-play {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 44px;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-meta {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
.project-list-desc {
|
.project-list-desc {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<a-page-header :ghost="false" title="图片库">
|
<a-page-header :ghost="false" title="素材库">
|
||||||
<div class="ele-text-secondary"> 上传、分享有价值的图片素材 </div>
|
<div class="ele-text-secondary"> 上传、分享图片、文档与音视频素材 </div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<a-tabs v-model:activeKey="activeKey">
|
<a-tabs v-model:activeKey="activeKey">
|
||||||
<a-tab-pane key="1" tab="网格模式" />
|
<a-tab-pane key="1" tab="网格模式" />
|
||||||
@@ -10,16 +10,29 @@
|
|||||||
<div style="margin-bottom: 10px">
|
<div style="margin-bottom: 10px">
|
||||||
<a-upload
|
<a-upload
|
||||||
:show-upload-list="false"
|
:show-upload-list="false"
|
||||||
:accept="'image/*'"
|
:accept="MEDIA_ACCEPT"
|
||||||
:customRequest="onUpload"
|
:customRequest="onUpload"
|
||||||
>
|
>
|
||||||
<a-button key="1" type="primary" class="ele-btn-icon">
|
<a-button key="1" type="primary" class="ele-btn-icon">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<UploadOutlined />
|
<UploadOutlined />
|
||||||
</template>
|
</template>
|
||||||
<span>上传图片</span>
|
<span>上传素材</span>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-upload>
|
</a-upload>
|
||||||
|
<a-select
|
||||||
|
v-model:value="category"
|
||||||
|
style="width: 120px; margin-left: 10px"
|
||||||
|
@change="onCategoryChange"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="item in MEDIA_CATEGORY_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
<a-input-search
|
<a-input-search
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 360px; margin-left: 10px"
|
style="width: 360px; margin-left: 10px"
|
||||||
@@ -68,15 +81,22 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { reactive, ref } from 'vue';
|
import { reactive, ref } from 'vue';
|
||||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||||
import { FileRecord, FileRecordParam } from '@/api/system/file/model';
|
import type { FileRecord, FileRecordParam } from '@/api/system/file/model';
|
||||||
import Image from './image.vue';
|
import Image from './image.vue';
|
||||||
import List from './list.vue';
|
import List from './list.vue';
|
||||||
import { messageLoading } from 'ele-admin-pro';
|
import { messageLoading } from 'ele-admin-pro';
|
||||||
import { pageFiles, uploadFile } from '@/api/system/file';
|
import { pageFiles, uploadFile } from '@/api/system/file';
|
||||||
import { message } from 'ant-design-vue/es';
|
import { message } from 'ant-design-vue/es';
|
||||||
|
import {
|
||||||
|
MEDIA_ACCEPT,
|
||||||
|
MEDIA_CATEGORY_OPTIONS,
|
||||||
|
validateMediaFile
|
||||||
|
} from '@/utils/media';
|
||||||
|
|
||||||
const type = ref('name');
|
const type = ref('name');
|
||||||
const searchText = ref('');
|
const searchText = ref('');
|
||||||
|
// 媒体大类筛选,空串表示全部
|
||||||
|
const category = ref('');
|
||||||
const data = ref<FileRecord[]>([]);
|
const data = ref<FileRecord[]>([]);
|
||||||
// 当前选项卡
|
// 当前选项卡
|
||||||
const activeKey = ref('1');
|
const activeKey = ref('1');
|
||||||
@@ -94,7 +114,6 @@
|
|||||||
|
|
||||||
/* 查询数据 */
|
/* 查询数据 */
|
||||||
const query = () => {
|
const query = () => {
|
||||||
console.log('query()');
|
|
||||||
if (type.value == 'name') {
|
if (type.value == 'name') {
|
||||||
where.name = searchText.value;
|
where.name = searchText.value;
|
||||||
}
|
}
|
||||||
@@ -103,8 +122,8 @@
|
|||||||
}
|
}
|
||||||
where.page = page.value;
|
where.page = page.value;
|
||||||
where.limit = limit.value;
|
where.limit = limit.value;
|
||||||
where.contentType = 'image';
|
// 空串会被当成一个真实的筛选值,必须置空
|
||||||
console.log(where);
|
where.mediaCategory = category.value || undefined;
|
||||||
const hide = messageLoading('请求中..', 0);
|
const hide = messageLoading('请求中..', 0);
|
||||||
pageFiles(where).then((res) => {
|
pageFiles(where).then((res) => {
|
||||||
count.value = Number(res?.count);
|
count.value = Number(res?.count);
|
||||||
@@ -113,17 +132,20 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* 切换类型回到第一页 */
|
||||||
|
const onCategoryChange = () => {
|
||||||
|
page.value = 1;
|
||||||
|
query();
|
||||||
|
};
|
||||||
|
|
||||||
query();
|
query();
|
||||||
|
|
||||||
// 上传文件
|
// 上传文件
|
||||||
const onUpload = (item) => {
|
const onUpload = (item) => {
|
||||||
const { file } = item;
|
const { file } = item;
|
||||||
if (!file.type.startsWith('image')) {
|
const error = validateMediaFile(file);
|
||||||
message.error('只能选择图片');
|
if (error) {
|
||||||
return;
|
message.error(error);
|
||||||
}
|
|
||||||
if (file.size / 1024 / 1024 > 10) {
|
|
||||||
message.error('大小不能超过 10MB');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hide = messageLoading({
|
const hide = messageLoading({
|
||||||
@@ -132,7 +154,7 @@
|
|||||||
mask: true
|
mask: true
|
||||||
});
|
});
|
||||||
uploadFile(file)
|
uploadFile(file)
|
||||||
.then((data) => {
|
.then(() => {
|
||||||
hide();
|
hide();
|
||||||
message.success('上传成功');
|
message.success('上传成功');
|
||||||
query();
|
query();
|
||||||
@@ -149,15 +171,3 @@
|
|||||||
name: 'PhotoIndex'
|
name: 'PhotoIndex'
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.project-list-desc {
|
|
||||||
height: 44px;
|
|
||||||
line-height: 22px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
+133
-31
@@ -1,3 +1,4 @@
|
|||||||
|
<!-- 素材库 · 列表视图 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<a-card :bordered="false">
|
<a-card :bordered="false">
|
||||||
@@ -8,21 +9,21 @@
|
|||||||
:columns="columns"
|
:columns="columns"
|
||||||
:datasource="datasource"
|
:datasource="datasource"
|
||||||
v-model:selection="selection"
|
v-model:selection="selection"
|
||||||
:scroll="{ x: 800 }"
|
:scroll="{ x: 860 }"
|
||||||
cache-key="proCmsVideoTable"
|
cache-key="proCmsPhotoTable"
|
||||||
>
|
>
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-upload
|
<a-upload
|
||||||
:show-upload-list="false"
|
:show-upload-list="false"
|
||||||
:accept="'image/*'"
|
:accept="MEDIA_ACCEPT"
|
||||||
:customRequest="onUpload"
|
:customRequest="onUpload"
|
||||||
>
|
>
|
||||||
<a-button type="primary" class="ele-btn-icon">
|
<a-button type="primary" class="ele-btn-icon">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<UploadOutlined />
|
<UploadOutlined />
|
||||||
</template>
|
</template>
|
||||||
<span>上传图片</span>
|
<span>上传素材</span>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-upload>
|
</a-upload>
|
||||||
<a-button
|
<a-button
|
||||||
@@ -37,6 +38,19 @@
|
|||||||
</template>
|
</template>
|
||||||
<span>删除</span>
|
<span>删除</span>
|
||||||
</a-button>
|
</a-button>
|
||||||
|
<a-select
|
||||||
|
v-model:value="category"
|
||||||
|
style="width: 120px"
|
||||||
|
@change="reload()"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="item in MEDIA_CATEGORY_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
<a-input-search
|
<a-input-search
|
||||||
allow-clear
|
allow-clear
|
||||||
v-model:value="searchText"
|
v-model:value="searchText"
|
||||||
@@ -60,7 +74,23 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'path'">
|
<template v-if="column.key === 'path'">
|
||||||
<a-image :src="record.url" :width="120" />
|
<!-- 与网格视图同一套 4:3 缩略图规则 -->
|
||||||
|
<div class="media-thumb" @click="openPreview(record)">
|
||||||
|
<a-image
|
||||||
|
v-if="kindOf(record) === 'image'"
|
||||||
|
:src="record.thumbnail || record.url"
|
||||||
|
:preview="{ src: record.url }"
|
||||||
|
:width="120"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<component
|
||||||
|
:is="iconOf(record)"
|
||||||
|
class="media-thumb-icon"
|
||||||
|
:style="{ color: colorOf(record) }"
|
||||||
|
/>
|
||||||
|
<span class="media-thumb-ext">{{ extOf(record) }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.dataIndex === 'name'">
|
<template v-if="column.dataIndex === 'name'">
|
||||||
<span>{{ record.name }}</span>
|
<span>{{ record.name }}</span>
|
||||||
@@ -78,7 +108,7 @@
|
|||||||
<a-divider type="vertical" />
|
<a-divider type="vertical" />
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
placement="topRight"
|
placement="topRight"
|
||||||
title="确定要删除此评价吗?"
|
title="确定要删除此素材吗?"
|
||||||
@confirm="remove(record)"
|
@confirm="remove(record)"
|
||||||
>
|
>
|
||||||
<a class="ele-text-danger">删除</a>
|
<a class="ele-text-danger">删除</a>
|
||||||
@@ -90,6 +120,8 @@
|
|||||||
</a-card>
|
</a-card>
|
||||||
<!-- 编辑弹窗 -->
|
<!-- 编辑弹窗 -->
|
||||||
<PhotoEdit v-model:visible="showEdit" :data="current" @done="reload" />
|
<PhotoEdit v-model:visible="showEdit" :data="current" @done="reload" />
|
||||||
|
<!-- 音视频预览 -->
|
||||||
|
<MediaPreview v-model:visible="showPreview" :record="current" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -109,6 +141,7 @@
|
|||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
import { messageLoading, toDateString } from 'ele-admin-pro/es';
|
import { messageLoading, toDateString } from 'ele-admin-pro/es';
|
||||||
import PhotoEdit from './components/photo-edit.vue';
|
import PhotoEdit from './components/photo-edit.vue';
|
||||||
|
import MediaPreview from './components/media-preview.vue';
|
||||||
import {
|
import {
|
||||||
pageFiles,
|
pageFiles,
|
||||||
removeFile,
|
removeFile,
|
||||||
@@ -119,17 +152,33 @@
|
|||||||
FileRecord,
|
FileRecord,
|
||||||
FileRecordParam
|
FileRecordParam
|
||||||
} from '@/api/system/file/model/index';
|
} from '@/api/system/file/model/index';
|
||||||
|
import {
|
||||||
|
formatFileSize,
|
||||||
|
mediaColor,
|
||||||
|
mediaExtLabel,
|
||||||
|
mediaIcon,
|
||||||
|
mediaKindOfRecord,
|
||||||
|
MEDIA_ACCEPT,
|
||||||
|
MEDIA_CATEGORY_OPTIONS,
|
||||||
|
MEDIA_KIND_LABEL,
|
||||||
|
validateMediaFile,
|
||||||
|
type MediaKind
|
||||||
|
} from '@/utils/media';
|
||||||
|
|
||||||
// 表格实例
|
// 表格实例
|
||||||
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||||
// 表格选中数据
|
// 表格选中数据
|
||||||
const selection = ref<FileRecord[]>([]);
|
const selection = ref<FileRecord[]>([]);
|
||||||
// 当前编辑数据
|
// 当前操作的数据(编辑 / 预览共用)
|
||||||
const current = ref<FileRecord | null>(null);
|
const current = ref<FileRecord | null>(null);
|
||||||
// 是否显示编辑弹窗
|
// 是否显示编辑弹窗
|
||||||
const showEdit = ref(false);
|
const showEdit = ref(false);
|
||||||
|
// 是否显示音视频预览
|
||||||
|
const showPreview = ref(false);
|
||||||
const type = ref('name');
|
const type = ref('name');
|
||||||
const searchText = ref('');
|
const searchText = ref('');
|
||||||
|
// 媒体大类筛选,空串表示全部
|
||||||
|
const category = ref('');
|
||||||
// 表格列配置
|
// 表格列配置
|
||||||
const columns = ref<ColumnItem[]>([
|
const columns = ref<ColumnItem[]>([
|
||||||
{
|
{
|
||||||
@@ -141,11 +190,17 @@
|
|||||||
customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
|
customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '图片',
|
title: '缩略图',
|
||||||
dataIndex: 'path',
|
dataIndex: 'path',
|
||||||
width: 180,
|
width: 140,
|
||||||
key: 'path'
|
key: 'path'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'contentType',
|
||||||
|
width: 100,
|
||||||
|
customRender: ({ record }) => MEDIA_KIND_LABEL[kindOf(record)]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '文件名称',
|
title: '文件名称',
|
||||||
dataIndex: 'name'
|
dataIndex: 'name'
|
||||||
@@ -161,26 +216,16 @@
|
|||||||
sorter: true,
|
sorter: true,
|
||||||
showSorterTooltip: false,
|
showSorterTooltip: false,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
customRender: ({ text }) => {
|
customRender: ({ text }) => formatFileSize(text),
|
||||||
if (text < 1024) {
|
|
||||||
return text + 'B';
|
|
||||||
} else if (text < 1024 * 1024) {
|
|
||||||
return (text / 1024).toFixed(1) + 'KB';
|
|
||||||
} else if (text < 1024 * 1024 * 1024) {
|
|
||||||
return (text / 1024 / 1024).toFixed(1) + 'M';
|
|
||||||
} else {
|
|
||||||
return (text / 1024 / 1024 / 1024).toFixed(1) + 'G';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
width: 120
|
width: 120
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '发布者',
|
title: '上传人',
|
||||||
width: 120,
|
width: 120,
|
||||||
dataIndex: 'createNickname'
|
dataIndex: 'createNickname'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '发布时间',
|
title: '上传时间',
|
||||||
dataIndex: 'createTime',
|
dataIndex: 'createTime',
|
||||||
sorter: true,
|
sorter: true,
|
||||||
width: 180,
|
width: 180,
|
||||||
@@ -196,6 +241,13 @@
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const kindOf = (record: FileRecord): MediaKind => mediaKindOfRecord(record);
|
||||||
|
const iconOf = (record: FileRecord) =>
|
||||||
|
mediaIcon(kindOf(record), record.path || record.name);
|
||||||
|
const colorOf = (record: FileRecord) =>
|
||||||
|
mediaColor(kindOf(record), record.path || record.name);
|
||||||
|
const extOf = (record: FileRecord) => mediaExtLabel(record);
|
||||||
|
|
||||||
// 表格数据源
|
// 表格数据源
|
||||||
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
|
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
|
||||||
where = {};
|
where = {};
|
||||||
@@ -205,7 +257,8 @@
|
|||||||
if (type.value == 'createNickname') {
|
if (type.value == 'createNickname') {
|
||||||
where.createNickname = searchText.value;
|
where.createNickname = searchText.value;
|
||||||
}
|
}
|
||||||
where.contentType = 'image';
|
// 空串会被当成一个真实的筛选值,必须置空
|
||||||
|
where.mediaCategory = category.value || undefined;
|
||||||
return pageFiles({ ...where, ...orders, page, limit });
|
return pageFiles({ ...where, ...orders, page, limit });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,6 +274,20 @@
|
|||||||
showEdit.value = true;
|
showEdit.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* 点击缩略图:音视频开播放器,文档新窗口打开 */
|
||||||
|
const openPreview = (row: FileRecord) => {
|
||||||
|
const kind = kindOf(row);
|
||||||
|
if (kind === 'image') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === 'doc') {
|
||||||
|
window.open(row.url, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current.value = row;
|
||||||
|
showPreview.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
/* 删除单个 */
|
/* 删除单个 */
|
||||||
const remove = (row: FileRecord) => {
|
const remove = (row: FileRecord) => {
|
||||||
const hide = messageLoading('请求中..', 0);
|
const hide = messageLoading('请求中..', 0);
|
||||||
@@ -266,12 +333,9 @@
|
|||||||
// 上传文件
|
// 上传文件
|
||||||
const onUpload = (item) => {
|
const onUpload = (item) => {
|
||||||
const { file } = item;
|
const { file } = item;
|
||||||
if (!file.type.startsWith('image')) {
|
const error = validateMediaFile(file);
|
||||||
message.error('只能选择图片');
|
if (error) {
|
||||||
return;
|
message.error(error);
|
||||||
}
|
|
||||||
if (file.size / 1024 / 1024 > 10) {
|
|
||||||
message.error('大小不能超过 10MB');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hide = messageLoading({
|
const hide = messageLoading({
|
||||||
@@ -280,7 +344,7 @@
|
|||||||
mask: true
|
mask: true
|
||||||
});
|
});
|
||||||
uploadFile(file)
|
uploadFile(file)
|
||||||
.then((data) => {
|
.then(() => {
|
||||||
hide();
|
hide();
|
||||||
message.success('上传成功');
|
message.success('上传成功');
|
||||||
reload();
|
reload();
|
||||||
@@ -294,6 +358,44 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export default {
|
export default {
|
||||||
name: 'VideoIndex'
|
name: 'PhotoList'
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.media-thumb {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 120px;
|
||||||
|
height: 90px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f6f8;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
:deep(.ant-image),
|
||||||
|
:deep(.ant-image-img) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-thumb-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-thumb-ext {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #718096;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="青马工程"
|
module-name="青马工程"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
@@ -363,4 +364,4 @@
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
@import '../toolbar.less';
|
@import '../toolbar.less';
|
||||||
</style>
|
</style>
|
||||||
@@ -89,6 +89,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="十佳青年岗位能手"
|
module-name="十佳青年岗位能手"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
@@ -386,4 +387,4 @@
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
@import '../toolbar.less';
|
@import '../toolbar.less';
|
||||||
</style>
|
</style>
|
||||||
@@ -89,6 +89,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="十佳团支部书记"
|
module-name="十佳团支部书记"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
@@ -376,4 +377,4 @@
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
@import '../toolbar.less';
|
@import '../toolbar.less';
|
||||||
</style>
|
</style>
|
||||||
@@ -114,6 +114,7 @@
|
|||||||
module="gxmu_tygl_form"
|
module="gxmu_tygl_form"
|
||||||
module-name="团员管理"
|
module-name="团员管理"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
@@ -510,4 +511,4 @@
|
|||||||
gap: 8px 12px;
|
gap: 8px 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -84,6 +84,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="挑战杯"
|
module-name="挑战杯"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
@@ -289,4 +290,4 @@
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
@import '../toolbar.less';
|
@import '../toolbar.less';
|
||||||
</style>
|
</style>
|
||||||
@@ -89,6 +89,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="五四红旗团委"
|
module-name="五四红旗团委"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
@@ -444,4 +445,4 @@
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
@import '../toolbar.less';
|
@import '../toolbar.less';
|
||||||
</style>
|
</style>
|
||||||
@@ -89,6 +89,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="五四红旗团支部"
|
module-name="五四红旗团支部"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
@@ -478,4 +479,4 @@
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
@import '../toolbar.less';
|
@import '../toolbar.less';
|
||||||
</style>
|
</style>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
|
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
|
||||||
import { reviewWxxzxProjectBookQuality } from '@/api/gxmu/wxxzx-form';
|
import { reviewWxxzxProjectBookQuality } from '@/api/gxmu/wxxzx-form';
|
||||||
import { stopMessage } from '@/api/oa/chatgpt';
|
import { stopMessage } from '@/api/oa/chatgpt';
|
||||||
import { API_BASE_URL } from '@/config/setting';
|
import { attachHeartbeat, buildWebSocketUrl } from '@/utils/chat-socket';
|
||||||
import { useUserStore } from '@/store/modules/user';
|
import { useUserStore } from '@/store/modules/user';
|
||||||
import { copyText } from '@/utils/common';
|
import { copyText } from '@/utils/common';
|
||||||
import type { WxxzxProjectQualityReviewResult } from '@/api/gxmu/wxxzx-form/model';
|
import type { WxxzxProjectQualityReviewResult } from '@/api/gxmu/wxxzx-form/model';
|
||||||
@@ -249,17 +249,6 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildWebSocketUrl = (userId: number) => {
|
|
||||||
if (/^https?:\/\//.test(API_BASE_URL)) {
|
|
||||||
const url = new URL(API_BASE_URL);
|
|
||||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
url.pathname = url.pathname.replace(/\/api\/?$/, '');
|
|
||||||
return `${url.toString().replace(/\/$/, '')}/chat/${userId}`;
|
|
||||||
}
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
return `${protocol}//${window.location.host}/chat/${userId}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearStreamResolver = () => {
|
const clearStreamResolver = () => {
|
||||||
streamResolverRef.value = null;
|
streamResolverRef.value = null;
|
||||||
};
|
};
|
||||||
@@ -402,6 +391,10 @@
|
|||||||
const connectPromise = new Promise<void>((resolve, reject) => {
|
const connectPromise = new Promise<void>((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const socket = new WebSocket(buildWebSocketUrl(userId));
|
const socket = new WebSocket(buildWebSocketUrl(userId));
|
||||||
|
socket.onopen = () => {
|
||||||
|
// 握手成功后再挂心跳:连接没建起来时发出去只会失败
|
||||||
|
attachHeartbeat(socket);
|
||||||
|
};
|
||||||
cleanupPending = () => {
|
cleanupPending = () => {
|
||||||
if (wsConnectPromiseRef.value === connectPromise) {
|
if (wsConnectPromiseRef.value === connectPromise) {
|
||||||
wsConnectPromiseRef.value = null;
|
wsConnectPromiseRef.value = null;
|
||||||
|
|||||||
@@ -105,6 +105,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="未来学术之星"
|
module-name="未来学术之星"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
@@ -375,4 +376,4 @@
|
|||||||
gap: 8px 12px;
|
gap: 8px 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -89,6 +89,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="优秀共青团干部"
|
module-name="优秀共青团干部"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
@@ -409,4 +410,4 @@
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
@import '../toolbar.less';
|
@import '../toolbar.less';
|
||||||
</style>
|
</style>
|
||||||
@@ -333,6 +333,7 @@
|
|||||||
:module="REVIEW_MODULE"
|
:module="REVIEW_MODULE"
|
||||||
module-name="优秀共青团员"
|
module-name="优秀共青团员"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewHistoryModal
|
<ReviewHistoryModal
|
||||||
v-model:visible="showReviewHistory"
|
v-model:visible="showReviewHistory"
|
||||||
@@ -867,4 +868,4 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -4,11 +4,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="hero-kicker">AI EVENT PLANNING OUTLINE</div>
|
<div class="hero-kicker">AI EVENT PLANNING OUTLINE</div>
|
||||||
<h1>活动策划大纲生成</h1>
|
<h1>活动策划大纲生成</h1>
|
||||||
<p>
|
<p>填写活动主题、受众、时间和预算,AI 给出一份结构完整的策划大纲,可直接改、复制或导出。</p>
|
||||||
输入活动主题、受众、时间和资金等信息,系统将调用
|
</div>
|
||||||
`/ai/activityOutlineGen`
|
<div class="hero-actions">
|
||||||
生成结构化活动策划大纲,适合方案初稿、汇报材料和执行拆解场景。
|
<a-button ghost @click="goHistory">历史记录</a-button>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -91,13 +90,6 @@
|
|||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hint-block">
|
|
||||||
<div class="hint-title">提交说明</div>
|
|
||||||
<div class="hint-text">
|
|
||||||
必填字段会直接作为 `inputs` 透传给
|
|
||||||
AI,后端固定触发“活动策划大纲生成”指令,并返回整理后的正文结果。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
@@ -140,31 +132,19 @@
|
|||||||
|
|
||||||
<a-empty
|
<a-empty
|
||||||
v-if="!loading && !hasOutlineContent"
|
v-if="!loading && !hasOutlineContent"
|
||||||
description="填写左侧信息后开始生成活动策划大纲"
|
description="填写活动信息后开始生成"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-else class="result-shell">
|
<div v-else class="result-shell">
|
||||||
<div v-if="loading" class="loading-panel">
|
<div v-if="loading" class="loading-panel">
|
||||||
<div class="loading-kicker">OUTLINE GENERATING</div>
|
<div class="loading-title">正在生成活动策划大纲…</div>
|
||||||
<div class="loading-title">AI 正在整理活动策划结构</div>
|
|
||||||
<div class="loading-desc">
|
|
||||||
正在综合活动主题、受众、时间与经费信息,生成可直接二次编辑的策划大纲。
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showThinkingIndicator" class="outline-thinking">
|
<div v-if="showThinkingIndicator" class="outline-thinking">
|
||||||
<span class="thinking-dot" aria-hidden="true"></span>
|
<span class="thinking-dot" aria-hidden="true"></span>
|
||||||
<span>AI 正在生成正文内容,请稍候...</span>
|
<span>正在生成正文…</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-alert
|
|
||||||
v-if="resultSummary"
|
|
||||||
class="result-alert"
|
|
||||||
type="info"
|
|
||||||
show-icon
|
|
||||||
:message="resultSummary"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ByteMdEditor
|
<ByteMdEditor
|
||||||
v-if="hasOutlineContent"
|
v-if="hasOutlineContent"
|
||||||
v-model:value="editableOutlineMarkdown"
|
v-model:value="editableOutlineMarkdown"
|
||||||
@@ -192,24 +172,32 @@
|
|||||||
import zh_Hans from 'bytemd/locales/zh_Hans.json';
|
import zh_Hans from 'bytemd/locales/zh_Hans.json';
|
||||||
import { getProcessor } from 'bytemd';
|
import { getProcessor } from 'bytemd';
|
||||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import { messageLoading } from 'ele-admin-pro';
|
import { messageLoading } from 'ele-admin-pro';
|
||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
import { asBlob } from 'html-docx-js-typescript';
|
import { asBlob } from 'html-docx-js-typescript';
|
||||||
import ByteMdEditor from '@/components/ByteMdEditor/index.vue';
|
import ByteMdEditor from '@/components/ByteMdEditor/index.vue';
|
||||||
import { activityOutlineGen } from '@/api/ai/activityOutlineGen';
|
import { streamActivityOutlineGen } from '@/utils/ai-stream';
|
||||||
import type {
|
import type {
|
||||||
ActivityOutlineGenParams,
|
ActivityOutlineGenParams,
|
||||||
ActivityOutlineGenResponse
|
ActivityOutlineGenResponse
|
||||||
} from '@/api/ai/activityOutlineGen/model';
|
} from '@/api/ai/activityOutlineGen/model';
|
||||||
import { API_BASE_URL } from '@/config/setting';
|
|
||||||
import { useUserStore } from '@/store/modules/user';
|
|
||||||
import { copyText } from '@/utils/common';
|
import { copyText } from '@/utils/common';
|
||||||
|
import { FAILED_TEXT } from '@/utils/ai-result';
|
||||||
|
|
||||||
type ExampleType = 'volunteer' | 'salon' | 'sports';
|
type ExampleType = 'volunteer' | 'salon' | 'sports';
|
||||||
|
|
||||||
|
const { push } = useRouter();
|
||||||
|
|
||||||
|
/** 活动策划记录统一在助手页查看,这里只带类型跳过去 */
|
||||||
|
const goHistory = () => {
|
||||||
|
push({ path: '/ai', query: { type: 'activity_outline' } });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 等「流结束」的挂起点:只由 __END__ 帧或请求收尾来 resolve */
|
||||||
type StreamResolver = {
|
type StreamResolver = {
|
||||||
resolve: () => void;
|
resolve: () => void;
|
||||||
reject: (error: Error) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
@@ -222,7 +210,6 @@
|
|||||||
event_meme: string;
|
event_meme: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userStore = useUserStore();
|
|
||||||
const plugins = ref([
|
const plugins = ref([
|
||||||
gfm({
|
gfm({
|
||||||
locale: zh_HansGfm
|
locale: zh_HansGfm
|
||||||
@@ -234,17 +221,10 @@
|
|||||||
const result = ref<ActivityOutlineGenResponse | null>(null);
|
const result = ref<ActivityOutlineGenResponse | null>(null);
|
||||||
const outlineMarkdown = ref('');
|
const outlineMarkdown = ref('');
|
||||||
const editableOutlineMarkdown = ref('');
|
const editableOutlineMarkdown = ref('');
|
||||||
const resultSummary = ref('');
|
|
||||||
const activeConversationId = ref<string>();
|
|
||||||
const activeMessageId = ref<string>();
|
|
||||||
const webSocketRef = ref<WebSocket | null>(null);
|
|
||||||
const wsConnectPromiseRef = ref<Promise<void> | null>(null);
|
|
||||||
const streamStarted = ref(false);
|
const streamStarted = ref(false);
|
||||||
const streamResolverRef = ref<StreamResolver | null>(null);
|
const streamResolverRef = ref<StreamResolver | null>(null);
|
||||||
const loginUser = computed(() => userStore.info ?? {});
|
|
||||||
|
|
||||||
const STREAM_END_TEXT = '__END__';
|
const STREAM_END_TEXT = '__END__';
|
||||||
const SOCKET_CONNECTED_TEXT = '连接成功';
|
|
||||||
const THINK_OPEN_TAG = '<think>';
|
const THINK_OPEN_TAG = '<think>';
|
||||||
const THINK_CLOSE_TAG = '</think>';
|
const THINK_CLOSE_TAG = '</think>';
|
||||||
|
|
||||||
@@ -364,15 +344,6 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickNonEmptyString = (...values: unknown[]) => {
|
|
||||||
for (const value of values) {
|
|
||||||
if (typeof value === 'string' && value.length > 0) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickRawString = (...values: unknown[]) => {
|
const pickRawString = (...values: unknown[]) => {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
@@ -382,54 +353,10 @@
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const stringifyUnknown = (value: unknown) => {
|
// 原本这里还有 pickNonEmptyString 与 stringifyUnknown 两个局部兜底,已移除:
|
||||||
try {
|
// stringifyUnknown 会把整包原始报文(含 conversationId / messageId)渲染进编辑器;
|
||||||
return JSON.stringify(value, null, 2);
|
// pickNonEmptyString 则只判「非空字符串」,会在失败报文里把 UUID 形状的 answer
|
||||||
} catch (e) {
|
// 当正文。现在统一走 @/utils/ai-result(见 issue 10)。
|
||||||
return String(value ?? '');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseOutline = (payload?: ActivityOutlineGenResponse | null) => {
|
|
||||||
if (!payload) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
const answer = pickNonEmptyString(
|
|
||||||
payload.answer,
|
|
||||||
payload.raw?.answer,
|
|
||||||
payload.raw?.data?.answer,
|
|
||||||
payload.raw?.output,
|
|
||||||
payload.raw?.result,
|
|
||||||
payload.raw?.content,
|
|
||||||
payload.raw?.message
|
|
||||||
);
|
|
||||||
if (answer) {
|
|
||||||
return answer;
|
|
||||||
}
|
|
||||||
return stringifyUnknown(payload.raw || payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildSummary = () => {
|
|
||||||
const parts = [] as string[];
|
|
||||||
if (activeConversationId.value) {
|
|
||||||
parts.push(`conversationId: ${activeConversationId.value}`);
|
|
||||||
}
|
|
||||||
if (activeMessageId.value) {
|
|
||||||
parts.push(`messageId: ${activeMessageId.value}`);
|
|
||||||
}
|
|
||||||
return parts.join(' | ');
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildWebSocketUrl = (userId: number) => {
|
|
||||||
if (/^https?:\/\//.test(API_BASE_URL)) {
|
|
||||||
const url = new URL(API_BASE_URL);
|
|
||||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
url.pathname = url.pathname.replace(/\/api\/?$/, '');
|
|
||||||
return `${url.toString().replace(/\/$/, '')}/chat/${userId}`;
|
|
||||||
}
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
return `${protocol}//${window.location.host}/chat/${userId}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearStreamResolver = () => {
|
const clearStreamResolver = () => {
|
||||||
streamResolverRef.value = null;
|
streamResolverRef.value = null;
|
||||||
@@ -440,65 +367,35 @@
|
|||||||
clearStreamResolver();
|
clearStreamResolver();
|
||||||
};
|
};
|
||||||
|
|
||||||
const rejectStream = (error: Error) => {
|
|
||||||
streamResolverRef.value?.reject(error);
|
|
||||||
clearStreamResolver();
|
|
||||||
};
|
|
||||||
|
|
||||||
const createStreamWaiter = () => {
|
const createStreamWaiter = () => {
|
||||||
streamStarted.value = false;
|
streamStarted.value = false;
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve) => {
|
||||||
streamResolverRef.value = { resolve, reject };
|
streamResolverRef.value = { resolve };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const disconnectWebSocket = () => {
|
const appendOutlineChunk = (chunk: string, reset?: boolean) => {
|
||||||
wsConnectPromiseRef.value = null;
|
|
||||||
const socket = webSocketRef.value;
|
|
||||||
webSocketRef.value = null;
|
|
||||||
if (
|
|
||||||
socket &&
|
|
||||||
(socket.readyState === WebSocket.OPEN ||
|
|
||||||
socket.readyState === WebSocket.CONNECTING)
|
|
||||||
) {
|
|
||||||
socket.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const appendOutlineChunk = (
|
|
||||||
chunk: string,
|
|
||||||
conversationId?: string,
|
|
||||||
messageId?: string
|
|
||||||
) => {
|
|
||||||
if (!chunk) {
|
if (!chunk) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (conversationId && conversationId !== '0') {
|
|
||||||
activeConversationId.value = conversationId;
|
|
||||||
}
|
|
||||||
if (messageId && messageId !== '0') {
|
|
||||||
activeMessageId.value = messageId;
|
|
||||||
}
|
|
||||||
if (!streamStarted.value) {
|
if (!streamStarted.value) {
|
||||||
streamStarted.value = true;
|
streamStarted.value = true;
|
||||||
outlineMarkdown.value = '';
|
outlineMarkdown.value = '';
|
||||||
}
|
}
|
||||||
outlineMarkdown.value += chunk;
|
// reset=true 表示「丢弃已累积的片段,只用本条内容」。
|
||||||
|
// 后端在失败收场时带这个标志覆盖半截内容;不处理就会变成
|
||||||
|
// 「半截正文 + 生成失败,请稍后重试」(见 issue 03 / 10)。
|
||||||
|
if (reset) {
|
||||||
|
outlineMarkdown.value = chunk;
|
||||||
|
} else {
|
||||||
|
outlineMarkdown.value += chunk;
|
||||||
|
}
|
||||||
syncEditableOutline();
|
syncEditableOutline();
|
||||||
resultSummary.value = buildSummary() || '正在通过 WebSocket 接收流式内容';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const finishStream = (conversationId?: string, messageId?: string) => {
|
const finishStream = () => {
|
||||||
if (conversationId && conversationId !== '0') {
|
|
||||||
activeConversationId.value = conversationId;
|
|
||||||
}
|
|
||||||
if (messageId && messageId !== '0') {
|
|
||||||
activeMessageId.value = messageId;
|
|
||||||
}
|
|
||||||
resultSummary.value = buildSummary() || '已通过 WebSocket 接收生成结果';
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
resolveStream();
|
resolveStream();
|
||||||
disconnectWebSocket();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSocketMessage = (raw: string) => {
|
const handleSocketMessage = (raw: string) => {
|
||||||
@@ -508,100 +405,25 @@
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(raw);
|
const data = JSON.parse(raw);
|
||||||
if (data?.answer === STREAM_END_TEXT) {
|
if (data?.answer === STREAM_END_TEXT) {
|
||||||
finishStream(data?.conversationId, data?.messageId);
|
finishStream();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data?.loading === 'true' || data?.loading === true) {
|
if (data?.loading === 'true' || data?.loading === true) {
|
||||||
resultSummary.value = 'AI 正在组织活动策划大纲结构...';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const chunk = pickRawString(data?.notice, data?.answer);
|
const chunk = pickRawString(data?.notice, data?.answer);
|
||||||
if (typeof chunk === 'string') {
|
if (typeof chunk === 'string') {
|
||||||
appendOutlineChunk(chunk, data?.conversationId, data?.messageId);
|
appendOutlineChunk(chunk, data?.reset === 'true' || data?.reset === true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appendOutlineChunk(raw);
|
appendOutlineChunk(raw);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectWebSocket = async () => {
|
|
||||||
const userId = loginUser.value.userId;
|
|
||||||
if (!userId) {
|
|
||||||
return Promise.reject(new Error('未获取到当前登录用户'));
|
|
||||||
}
|
|
||||||
const currentSocket = webSocketRef.value;
|
|
||||||
if (currentSocket?.readyState === WebSocket.OPEN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(currentSocket?.readyState === WebSocket.OPEN ||
|
|
||||||
currentSocket?.readyState === WebSocket.CONNECTING) &&
|
|
||||||
wsConnectPromiseRef.value
|
|
||||||
) {
|
|
||||||
return wsConnectPromiseRef.value;
|
|
||||||
}
|
|
||||||
let cleanupPending = () => undefined;
|
|
||||||
const connectPromise = new Promise<void>((resolve, reject) => {
|
|
||||||
let settled = false;
|
|
||||||
const socket = new WebSocket(buildWebSocketUrl(userId));
|
|
||||||
cleanupPending = () => {
|
|
||||||
if (wsConnectPromiseRef.value === connectPromise) {
|
|
||||||
wsConnectPromiseRef.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.onmessage = (event) => {
|
|
||||||
const raw = String(event.data || '');
|
|
||||||
if (raw === SOCKET_CONNECTED_TEXT) {
|
|
||||||
if (!settled) {
|
|
||||||
settled = true;
|
|
||||||
cleanupPending();
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleSocketMessage(raw);
|
|
||||||
};
|
|
||||||
socket.onclose = () => {
|
|
||||||
if (webSocketRef.value === socket) {
|
|
||||||
webSocketRef.value = null;
|
|
||||||
}
|
|
||||||
if (!settled) {
|
|
||||||
settled = true;
|
|
||||||
cleanupPending();
|
|
||||||
reject(new Error('活动策划大纲连接已关闭'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (loading.value) {
|
|
||||||
rejectStream(new Error('活动策划大纲连接已关闭'));
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
cleanupPending();
|
|
||||||
};
|
|
||||||
socket.onerror = () => {
|
|
||||||
if (!settled) {
|
|
||||||
settled = true;
|
|
||||||
cleanupPending();
|
|
||||||
reject(new Error('活动策划大纲连接失败'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (loading.value) {
|
|
||||||
rejectStream(new Error('活动策划大纲连接失败'));
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
webSocketRef.value = socket;
|
|
||||||
});
|
|
||||||
wsConnectPromiseRef.value = connectPromise;
|
|
||||||
return connectPromise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearResult = () => {
|
const clearResult = () => {
|
||||||
result.value = null;
|
result.value = null;
|
||||||
outlineMarkdown.value = '';
|
outlineMarkdown.value = '';
|
||||||
editableOutlineMarkdown.value = '';
|
editableOutlineMarkdown.value = '';
|
||||||
resultSummary.value = '';
|
|
||||||
activeConversationId.value = undefined;
|
|
||||||
activeMessageId.value = undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
@@ -622,33 +444,34 @@
|
|||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
clearResult();
|
clearResult();
|
||||||
resultSummary.value = '正在建立 WebSocket 连接...';
|
|
||||||
try {
|
try {
|
||||||
await connectWebSocket();
|
// 一律走 HTTP 流式(/stream),不再先连 WebSocket:
|
||||||
resultSummary.value = '连接成功,已提交生成任务,等待流式返回...';
|
// 触发生成的那个 POST 在整段生成期间是零字节的,会被链路上的 60s 空闲
|
||||||
|
// 超时掐成 504 Gateway Time-out。/stream 每 15s 有后端心跳续命。
|
||||||
|
const payload = buildPayload();
|
||||||
const waitStream = createStreamWaiter();
|
const waitStream = createStreamWaiter();
|
||||||
const data = await activityOutlineGen(buildPayload());
|
await streamActivityOutlineGen(
|
||||||
result.value = data || null;
|
payload as unknown as Record<string, unknown>,
|
||||||
if (!streamStarted.value && data && parseOutline(data)) {
|
(raw) => handleSocketMessage(raw)
|
||||||
outlineMarkdown.value = parseOutline(data);
|
);
|
||||||
syncEditableOutline();
|
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
|
||||||
resultSummary.value = buildSummary() || '接口直接返回生成结果';
|
resolveStream();
|
||||||
loading.value = false;
|
await waitStream;
|
||||||
resolveStream();
|
const text = visibleOutlineMarkdown.value.trim();
|
||||||
disconnectWebSocket();
|
if (!text) {
|
||||||
|
message.warning('这次没有生成出内容,请重试');
|
||||||
|
} else if (text === FAILED_TEXT) {
|
||||||
|
message.error('生成失败,请稍后重试');
|
||||||
} else {
|
} else {
|
||||||
await waitStream;
|
message.success('活动策划大纲生成完成');
|
||||||
}
|
}
|
||||||
if (!visibleOutlineMarkdown.value) {
|
|
||||||
message.warning('接口已返回,但未解析到正文内容');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
message.success('活动策划大纲生成完成');
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
disconnectWebSocket();
|
message.error(e.message || '生成失败');
|
||||||
|
} finally {
|
||||||
|
// 收尾以「请求结束」为准,不能只靠 __END__ 帧:网关返回空流、
|
||||||
|
// 上游中途报错时都不会有 __END__,只等它会把生成按钮永久卡在 loading。
|
||||||
clearStreamResolver();
|
clearStreamResolver();
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
message.error(e.message || '生成失败');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -798,7 +621,6 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
disconnectWebSocket();
|
|
||||||
clearStreamResolver();
|
clearStreamResolver();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -855,6 +677,18 @@
|
|||||||
color: rgba(255, 238, 238, 0.9);
|
color: rgba(255, 238, 238, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions :deep(.ant-btn) {
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-actions-panel {
|
.quick-actions-panel {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -888,27 +722,6 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint-block {
|
|
||||||
margin-top: 18px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: #f6f9fe;
|
|
||||||
border: 1px solid #d9e6fb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint-title {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #274f84;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint-text {
|
|
||||||
color: #5f6f84;
|
|
||||||
line-height: 1.8;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-card :deep(.ant-card-head) {
|
.result-card :deep(.ant-card-head) {
|
||||||
border-bottom: 1px solid #edf2f8;
|
border-bottom: 1px solid #edf2f8;
|
||||||
}
|
}
|
||||||
@@ -937,29 +750,12 @@
|
|||||||
border: 1px solid #d9e8fb;
|
border: 1px solid #d9e8fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-kicker {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
letter-spacing: 0.18em;
|
|
||||||
color: #4875b0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-title {
|
.loading-title {
|
||||||
font-size: 22px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #183b68;
|
color: #183b68;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-desc {
|
|
||||||
margin-top: 10px;
|
|
||||||
line-height: 1.8;
|
|
||||||
color: #607086;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-alert {
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outline-thinking {
|
.outline-thinking {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1033,5 +829,9 @@
|
|||||||
.hero-panel h1 {
|
.hero-panel h1 {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div class="attachment-list">
|
||||||
|
<a-empty
|
||||||
|
v-if="!files.length"
|
||||||
|
:description="emptyText"
|
||||||
|
class="attachment-empty"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-for="(file, index) in files"
|
||||||
|
:key="`${file.url}-${index}`"
|
||||||
|
class="attachment-item"
|
||||||
|
>
|
||||||
|
<a-image
|
||||||
|
v-if="isImageFile(file.url)"
|
||||||
|
:src="file.url"
|
||||||
|
:width="96"
|
||||||
|
:height="96"
|
||||||
|
class="attachment-image"
|
||||||
|
/>
|
||||||
|
<div class="attachment-body">
|
||||||
|
<a
|
||||||
|
:href="file.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="attachment-name"
|
||||||
|
:title="file.name"
|
||||||
|
>
|
||||||
|
{{ file.name }}
|
||||||
|
</a>
|
||||||
|
<span class="attachment-tip">点击可预览</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
isImageFile,
|
||||||
|
parseAttachments,
|
||||||
|
type AttachmentFile
|
||||||
|
} from '@/views/gxmu/utils/attachment';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
// 原始附件字段值
|
||||||
|
value?: unknown;
|
||||||
|
// 已解析好的文件列表(优先使用)
|
||||||
|
files?: AttachmentFile[];
|
||||||
|
emptyText?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
value: undefined,
|
||||||
|
files: undefined,
|
||||||
|
emptyText: '暂无材料'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = computed<AttachmentFile[]>(() => {
|
||||||
|
if (props.files) {
|
||||||
|
return props.files;
|
||||||
|
}
|
||||||
|
return parseAttachments(props.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
name: 'GxmuAttachmentList'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.attachment-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #eef1f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-image {
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-name {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
color: #1677ff;
|
||||||
|
font-weight: 500;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-tip {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-empty {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -26,6 +26,21 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="ant-upload-hint">将 Excel 文件拖到此处,或点击上传</p>
|
<p class="ant-upload-hint">将 Excel 文件拖到此处,或点击上传</p>
|
||||||
</a-upload-dragger>
|
</a-upload-dragger>
|
||||||
|
<a-alert
|
||||||
|
v-if="failures.length"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
:message="`${successCount} 条导入成功,${failures.length} 条被跳过`"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<div class="import-failures">
|
||||||
|
<div v-for="failure in failures" :key="failure.row">
|
||||||
|
第 {{ failure.row }} 行:{{ failure.reason }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-button size="small" @click="exportFailures">下载失败清单</a-button>
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
</ele-modal>
|
</ele-modal>
|
||||||
@@ -63,6 +78,8 @@
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const failures = ref<{ row: number; reason: string }[]>([]);
|
||||||
|
const successCount = ref(0);
|
||||||
|
|
||||||
const description = computed(
|
const description = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -141,13 +158,17 @@
|
|||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
throw new Error('导入文件内容为空');
|
throw new Error('导入文件内容为空');
|
||||||
}
|
}
|
||||||
const data = rows
|
const entries = rows
|
||||||
.map((row, index) => props.createPayload(row, index + 2))
|
.map((row, index) => ({
|
||||||
.filter((item) =>
|
rowNo: index + 2,
|
||||||
|
item: props.createPayload(row, index + 2)
|
||||||
|
}))
|
||||||
|
.filter(({ item }) =>
|
||||||
props.templateFields.some((field) =>
|
props.templateFields.some((field) =>
|
||||||
!!String(item[field.key] ?? '').trim()
|
!!String(item[field.key] ?? '').trim()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const data = entries.map((entry) => entry.item);
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
throw new Error('未识别到有效数据');
|
throw new Error('未识别到有效数据');
|
||||||
}
|
}
|
||||||
@@ -155,10 +176,28 @@
|
|||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
throw new Error(errors.slice(0, 5).join(';'));
|
throw new Error(errors.slice(0, 5).join(';'));
|
||||||
}
|
}
|
||||||
for (const item of data) {
|
failures.value = [];
|
||||||
await props.saveItem(item);
|
successCount.value = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
try {
|
||||||
|
await props.saveItem(entry.item);
|
||||||
|
successCount.value += 1;
|
||||||
|
} catch (e: any) {
|
||||||
|
failures.value.push({
|
||||||
|
row: entry.rowNo,
|
||||||
|
reason: e?.message || '保存失败'
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
message.success(`成功导入 ${data.length} 条${props.moduleName}数据`);
|
if (failures.value.length) {
|
||||||
|
// 被跳过的是「同一申报项目已有进行中/已通过/被驳回」的记录,保留在清单里供人工处理
|
||||||
|
message.warning(
|
||||||
|
`成功 ${successCount.value} 条,跳过 ${failures.value.length} 条`
|
||||||
|
);
|
||||||
|
emit('done');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
message.success(`成功导入 ${successCount.value} 条${props.moduleName}数据`);
|
||||||
updateVisible(false);
|
updateVisible(false);
|
||||||
emit('done');
|
emit('done');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -169,7 +208,32 @@
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const exportFailures = () => {
|
||||||
|
if (!failures.value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sheet = utils.aoa_to_sheet([
|
||||||
|
['行号', '跳过原因'],
|
||||||
|
...failures.value.map((item) => [item.row, item.reason])
|
||||||
|
]);
|
||||||
|
sheet['!cols'] = [{ wch: 10 }, { wch: 64 }];
|
||||||
|
writeFile(
|
||||||
|
{
|
||||||
|
SheetNames: ['失败清单'],
|
||||||
|
Sheets: { 失败清单: sheet }
|
||||||
|
},
|
||||||
|
`${props.moduleName}导入失败清单.xlsx`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const updateVisible = (value: boolean) => {
|
const updateVisible = (value: boolean) => {
|
||||||
emit('update:visible', value);
|
emit('update:visible', value);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.import-failures {
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
:color="item.status === 1 ? 'success' : item.status === 2 ? 'error' : 'default'"
|
:color="item.status === 1 ? 'success' : item.status === 2 ? 'error' : 'default'"
|
||||||
style="margin-left: 8px"
|
style="margin-left: 8px"
|
||||||
>
|
>
|
||||||
{{ item.status === 1 ? '通过' : item.status === 2 ? '不通过' : '待审核' }}
|
{{ item.status === 1 ? '通过' : item.status === 2 ? '驳回' : '待审核' }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.user?.realName" style="margin-bottom: 4px">
|
<div v-if="item.user?.realName" style="margin-bottom: 4px">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<a-tag color="green">通过</a-tag>
|
<a-tag color="green">通过</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="latest?.status === 2">
|
<template v-else-if="latest?.status === 2">
|
||||||
<a-tag color="red">不通过</a-tag>
|
<a-tag color="red">驳回</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a-tag>待审核</a-tag>
|
<a-tag>待审核</a-tag>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
支持文本指令与多类型附件联合驱动工作流,自动返回视频、脚本 Markdown 或图像/排版结果。
|
支持文本指令与多类型附件联合驱动工作流,自动返回视频、脚本 Markdown 或图像/排版结果。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a-button ghost @click="goHistory">历史记录</a-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-row :gutter="[24, 24]">
|
<a-row :gutter="[24, 24]">
|
||||||
@@ -30,40 +33,6 @@
|
|||||||
placeholder="请输入用户的文本指令或需求描述,例如:根据活动照片和通知文档,生成一条公众号推文封面及 60 秒短视频脚本。"
|
placeholder="请输入用户的文本指令或需求描述,例如:根据活动照片和通知文档,生成一条公众号推文封面及 60 秒短视频脚本。"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="上传附件">
|
|
||||||
<a-upload
|
|
||||||
:multiple="true"
|
|
||||||
:show-upload-list="false"
|
|
||||||
:accept="acceptTypes"
|
|
||||||
:customRequest="handleUpload"
|
|
||||||
>
|
|
||||||
<a-button>
|
|
||||||
<template #icon>
|
|
||||||
<UploadOutlined />
|
|
||||||
</template>
|
|
||||||
添加附件
|
|
||||||
</a-button>
|
|
||||||
</a-upload>
|
|
||||||
<div class="upload-tip">
|
|
||||||
支持图片、视频、文档、音频。
|
|
||||||
</div>
|
|
||||||
<div v-if="uploadedFiles.length" class="file-list">
|
|
||||||
<div
|
|
||||||
v-for="(item, index) in uploadedFiles"
|
|
||||||
:key="`${item.url || item.path || item.name}-${index}`"
|
|
||||||
class="file-item"
|
|
||||||
>
|
|
||||||
<div class="file-main">
|
|
||||||
<div class="file-badge">{{ getFileTypeLabel(item) }}</div>
|
|
||||||
<div class="file-text">
|
|
||||||
<div class="file-name">{{ item.name || getFileName(item) }}</div>
|
|
||||||
<div class="file-url">{{ resolveFileUrl(item) || '-' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a class="file-remove" @click="removeUploadedFile(index)">移除</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
</a-form>
|
||||||
|
|
||||||
<div class="submit-bar">
|
<div class="submit-bar">
|
||||||
@@ -92,9 +61,16 @@
|
|||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
必须同时判 `!workflowResponse`:若只判 `!hasAnyResult`,
|
||||||
|
则「已执行但失败 / 未识别到输出」时 hasAnyResult 为假,
|
||||||
|
会落回这个空状态,把下方 result-body 里的 status-alert
|
||||||
|
(失败文案 / 兜底文案)整个盖住 —— 用户看到的仍是
|
||||||
|
「填写文本指令后执行工作流」,等于什么都没发生(见 issue 10)。
|
||||||
|
-->
|
||||||
<a-empty
|
<a-empty
|
||||||
v-if="!loading && !hasAnyResult"
|
v-if="!loading && !hasAnyResult && !workflowResponse"
|
||||||
description="填写文本指令并上传附件后执行工作流"
|
description="填写文本指令后执行工作流"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-else class="result-body">
|
<div v-else class="result-body">
|
||||||
@@ -199,11 +175,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div v-if="rawOutputsText" class="result-section">-->
|
|
||||||
<!-- <div class="section-title">原始输出</div>-->
|
|
||||||
<!-- <pre class="raw-output">{{ rawOutputsText }}</pre>-->
|
|
||||||
<!-- </div>-->
|
|
||||||
</div>
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -219,22 +190,28 @@
|
|||||||
import highlight from '@bytemd/plugin-highlight-ssr';
|
import highlight from '@bytemd/plugin-highlight-ssr';
|
||||||
import 'highlight.js/styles/default.css';
|
import 'highlight.js/styles/default.css';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
|
||||||
import { messageLoading } from 'ele-admin-pro';
|
|
||||||
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
|
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
|
||||||
import { creativeAssistant, uploadAiFile } from '@/api/ai/creativeAssistant';
|
import { runCreativeAssistantStream } from '@/utils/ai-stream';
|
||||||
import type { FileRecord } from '@/api/system/file/model';
|
import type { FileRecord } from '@/api/system/file/model';
|
||||||
import type {
|
import type {
|
||||||
CreativeAssistantOutputs,
|
CreativeAssistantOutputs,
|
||||||
CreativeAssistantResponse
|
CreativeAssistantResponse,
|
||||||
|
CreativeFileList
|
||||||
} from '@/api/ai/creativeAssistant/model';
|
} from '@/api/ai/creativeAssistant/model';
|
||||||
import { copyText } from '@/utils/common';
|
import { copyText } from '@/utils/common';
|
||||||
|
import { resolveDisplayText, isFailedPayload, FAILED_TEXT } from '@/utils/ai-result';
|
||||||
|
|
||||||
type ExampleType = 'video' | 'script' | 'image';
|
type ExampleType = 'video' | 'script' | 'image';
|
||||||
|
|
||||||
const acceptTypes =
|
const { push } = useRouter();
|
||||||
'image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md';
|
|
||||||
|
/** 创意助手记录统一在助手页查看,这里只带类型跳过去 */
|
||||||
|
const goHistory = () => {
|
||||||
|
push({ path: '/ai', query: { type: 'creative' } });
|
||||||
|
};
|
||||||
|
|
||||||
const plugins = ref([
|
const plugins = ref([
|
||||||
gfm({
|
gfm({
|
||||||
locale: zh_HansGfm
|
locale: zh_HansGfm
|
||||||
@@ -244,23 +221,56 @@
|
|||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const content = ref('');
|
const content = ref('');
|
||||||
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
|
|
||||||
const workflowResponse = ref<CreativeAssistantResponse | null>(null);
|
const workflowResponse = ref<CreativeAssistantResponse | null>(null);
|
||||||
const workflowOutputs = ref<CreativeAssistantOutputs | null>(null);
|
const workflowOutputs = ref<CreativeAssistantOutputs | null>(null);
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
return !!content.value.trim() || uploadedFiles.value.length > 0;
|
return !!content.value.trim();
|
||||||
});
|
});
|
||||||
|
|
||||||
const videoFiles = computed(() => workflowOutputs.value?.video_result || []);
|
/**
|
||||||
|
* 文件列表兜底:上游可能给成单个对象或裸地址字符串,不是数组时当一项处理,
|
||||||
|
* 否则视频区直接空掉(见禅道 bug #1)。
|
||||||
|
*/
|
||||||
|
const asFileList = (
|
||||||
|
value?: CreativeFileList | null
|
||||||
|
): Partial<FileRecord>[] => {
|
||||||
|
const items = !value ? [] : Array.isArray(value) ? value : [value];
|
||||||
|
// 裸地址统一包成 { url },下游的 resolveFileUrl / getFileName 不必各自判类型。
|
||||||
|
return items.map((item) =>
|
||||||
|
typeof item === 'string' ? { url: item } : item
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const videoFiles = computed(() =>
|
||||||
|
asFileList(workflowOutputs.value?.video_result)
|
||||||
|
);
|
||||||
const scriptMarkdown = computed(() => workflowOutputs.value?.Script_result || '');
|
const scriptMarkdown = computed(() => workflowOutputs.value?.Script_result || '');
|
||||||
const photoFiles = computed(() => workflowOutputs.value?.photo_files || []);
|
const photoFiles = computed(() =>
|
||||||
|
asFileList(workflowOutputs.value?.photo_files)
|
||||||
|
);
|
||||||
const statusMessage = computed(() => {
|
const statusMessage = computed(() => {
|
||||||
return (
|
// 失败态优先:后端失败时 outputs 为空,若不先判失败,用户既看不到正文
|
||||||
|
// 也看不到原因,只有一个 toast 一闪而过(见 issue 10)。
|
||||||
|
if (isFailedPayload(workflowResponse.value)) {
|
||||||
|
return FAILED_TEXT;
|
||||||
|
}
|
||||||
|
const text =
|
||||||
workflowOutputs.value?.video_message ||
|
workflowOutputs.value?.video_message ||
|
||||||
workflowOutputs.value?.photo_message ||
|
workflowOutputs.value?.photo_message ||
|
||||||
''
|
'';
|
||||||
);
|
if (text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
// 接口已返回但认不出约定的输出字段:给可读文案,不要静默什么都不显示。
|
||||||
|
if (workflowResponse.value && !hasAnyResult.value) {
|
||||||
|
return resolveDisplayText({
|
||||||
|
payload: workflowResponse.value,
|
||||||
|
scope: 'gxmu/creativeAssistant.statusMessage',
|
||||||
|
candidates: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return '';
|
||||||
});
|
});
|
||||||
const hasAnyResult = computed(() => {
|
const hasAnyResult = computed(() => {
|
||||||
return !!(
|
return !!(
|
||||||
@@ -269,16 +279,9 @@
|
|||||||
photoFiles.value.length
|
photoFiles.value.length
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const rawOutputsText = computed(() => {
|
// 原本这里有一个 rawOutputsText(JSON.stringify 整包),已移除:
|
||||||
if (!workflowOutputs.value) {
|
// 它会把 taskId / workflowRunId 等内部标识符渲染给用户,且模板里的引用
|
||||||
return '';
|
// 早已被注释掉、留下一个「看似有原始输出视图」的死代码(见 issue 10)。
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(workflowOutputs.value, null, 2);
|
|
||||||
} catch (e) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const resultTagText = computed(() => {
|
const resultTagText = computed(() => {
|
||||||
if (loading.value) {
|
if (loading.value) {
|
||||||
return '执行中';
|
return '执行中';
|
||||||
@@ -304,12 +307,6 @@
|
|||||||
return 'default';
|
return 'default';
|
||||||
});
|
});
|
||||||
const loadingTitle = computed(() => {
|
const loadingTitle = computed(() => {
|
||||||
if (uploadedFiles.value.length && content.value.trim()) {
|
|
||||||
return '正在分析指令并处理附件';
|
|
||||||
}
|
|
||||||
if (uploadedFiles.value.length) {
|
|
||||||
return '正在解析附件并生成结果';
|
|
||||||
}
|
|
||||||
return '正在根据文本指令生成内容';
|
return '正在根据文本指令生成内容';
|
||||||
});
|
});
|
||||||
const loadingDescription = computed(() => {
|
const loadingDescription = computed(() => {
|
||||||
@@ -363,25 +360,6 @@
|
|||||||
return name.split('.').pop() || '';
|
return name.split('.').pop() || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const DOCUMENT_FILE_EXTS = new Set([
|
|
||||||
'TXT',
|
|
||||||
'MD',
|
|
||||||
'MARKDOWN',
|
|
||||||
'PDF',
|
|
||||||
'HTML',
|
|
||||||
'XLSX',
|
|
||||||
'XLS',
|
|
||||||
'DOCX',
|
|
||||||
'CSV',
|
|
||||||
'EML',
|
|
||||||
'MSG',
|
|
||||||
'PPTX',
|
|
||||||
'PPT',
|
|
||||||
'XML',
|
|
||||||
'EPUB'
|
|
||||||
]);
|
|
||||||
const AUDIO_FILE_EXTS = new Set(['MP3', 'M4A', 'WAV', 'WEBM', 'AMR']);
|
|
||||||
const VIDEO_FILE_EXTS = new Set(['MP4', 'MOV', 'MPEG', 'MPGA']);
|
|
||||||
|
|
||||||
const isImageFile = (file?: Partial<FileRecord> | null) => {
|
const isImageFile = (file?: Partial<FileRecord> | null) => {
|
||||||
const contentType = String(file?.contentType || '').toLowerCase();
|
const contentType = String(file?.contentType || '').toLowerCase();
|
||||||
@@ -407,26 +385,6 @@
|
|||||||
return ['mp3', 'wav', 'aac', 'm4a', 'ogg'].includes(getFileExt(file));
|
return ['mp3', 'wav', 'aac', 'm4a', 'ogg'].includes(getFileExt(file));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUploadFileType = (
|
|
||||||
file?: Partial<FileRecord> | null
|
|
||||||
): 'image' | 'document' | 'audio' | 'video' => {
|
|
||||||
const contentType = String(file?.contentType || '').toLowerCase();
|
|
||||||
const ext = getFileExt(file).toUpperCase();
|
|
||||||
if (contentType.startsWith('image/')) {
|
|
||||||
return 'image';
|
|
||||||
}
|
|
||||||
if (AUDIO_FILE_EXTS.has(ext)) {
|
|
||||||
return 'audio';
|
|
||||||
}
|
|
||||||
if (VIDEO_FILE_EXTS.has(ext)) {
|
|
||||||
return 'video';
|
|
||||||
}
|
|
||||||
if (DOCUMENT_FILE_EXTS.has(ext)) {
|
|
||||||
return 'document';
|
|
||||||
}
|
|
||||||
return 'document';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFileTypeLabel = (file?: Partial<FileRecord> | null) => {
|
const getFileTypeLabel = (file?: Partial<FileRecord> | null) => {
|
||||||
if (isImageFile(file)) {
|
if (isImageFile(file)) {
|
||||||
return '图片';
|
return '图片';
|
||||||
@@ -447,73 +405,37 @@
|
|||||||
|
|
||||||
const resetAll = () => {
|
const resetAll = () => {
|
||||||
content.value = '';
|
content.value = '';
|
||||||
uploadedFiles.value = [];
|
|
||||||
clearResult();
|
clearResult();
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeUploadedFile = (index: number) => {
|
|
||||||
uploadedFiles.value = uploadedFiles.value.filter((_, current) => current !== index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpload = async (options: any) => {
|
|
||||||
const rawFile = options.file as File;
|
|
||||||
if (rawFile.size / 1024 / 1024 > 100) {
|
|
||||||
message.error('单个附件不能超过 100MB');
|
|
||||||
options.onError?.(new Error('文件过大'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hide = messageLoading({
|
|
||||||
content: '上传中..',
|
|
||||||
duration: 0,
|
|
||||||
mask: true
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const result = await uploadAiFile(rawFile);
|
|
||||||
uploadedFiles.value = [
|
|
||||||
...uploadedFiles.value,
|
|
||||||
{
|
|
||||||
id: result.id,
|
|
||||||
name: result.name || rawFile.name,
|
|
||||||
path: result.path,
|
|
||||||
url: result.url,
|
|
||||||
downloadUrl: result.downloadUrl,
|
|
||||||
contentType: result.contentType
|
|
||||||
}
|
|
||||||
];
|
|
||||||
options.onSuccess?.(result);
|
|
||||||
message.success('附件上传成功');
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || '上传失败');
|
|
||||||
options.onError?.(e);
|
|
||||||
} finally {
|
|
||||||
hide();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求体。
|
||||||
|
*
|
||||||
|
* <p>附件已下线:上游出图工具只做文生图,开始节点的 file-list 变量没有任何节点消费
|
||||||
|
* (见 issue 16),继续传只是让用户白等上传。
|
||||||
|
*
|
||||||
|
* <p>不传 {@code mode}:这个页面是通用的创意助手入口,用户可能写视频、脚本或图片需求,
|
||||||
|
* 由用户的文字自己说明意图;只有聊天页「图片」模式这类**用户显式选了功能**的地方
|
||||||
|
* 才传 {@code mode=image} 让后端包装提示词(见 issue 19)。
|
||||||
|
*/
|
||||||
const buildRequestPayload = () => {
|
const buildRequestPayload = () => {
|
||||||
const payload: any = {
|
const value = content.value.trim();
|
||||||
user_content: content.value.trim(),
|
return {
|
||||||
user_contentString: content.value.trim()
|
user_content: value,
|
||||||
|
user_contentString: value
|
||||||
};
|
};
|
||||||
if (uploadedFiles.value.length) {
|
|
||||||
payload.user_files = uploadedFiles.value.map((item) => ({
|
|
||||||
transfer_method: 'local_file',
|
|
||||||
type: getUploadFileType(item),
|
|
||||||
upload_file_id: item.id,
|
|
||||||
url: ''
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const runWorkflow = async () => {
|
const runWorkflow = async () => {
|
||||||
if (!canSubmit.value) {
|
if (!canSubmit.value) {
|
||||||
message.warning('请先输入文本指令或上传附件');
|
message.warning('请先输入文本指令');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await creativeAssistant(buildRequestPayload());
|
// 出图中途零字节,普通 POST 会被网关按 60s 空闲超时掐成 504,改走流式
|
||||||
|
const result = await runCreativeAssistantStream(buildRequestPayload());
|
||||||
workflowResponse.value = result || null;
|
workflowResponse.value = result || null;
|
||||||
workflowOutputs.value = normalizeOutputPayload(result || null);
|
workflowOutputs.value = normalizeOutputPayload(result || null);
|
||||||
if (
|
if (
|
||||||
@@ -543,7 +465,7 @@
|
|||||||
const fillExample = (type: ExampleType) => {
|
const fillExample = (type: ExampleType) => {
|
||||||
if (type === 'video') {
|
if (type === 'video') {
|
||||||
content.value =
|
content.value =
|
||||||
'请根据我上传的活动照片和通知文档,生成一条 45 秒校园活动宣传短视频,画面节奏明快,配一段适合配音的解说文案,并输出视频结果。';
|
'以「青年志愿者进社区」为主题,生成一条 45 秒校园活动宣传短视频,画面节奏明快,配一段适合配音的解说文案,并输出视频结果。';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === 'script') {
|
if (type === 'script') {
|
||||||
@@ -552,7 +474,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
content.value =
|
content.value =
|
||||||
'请根据上传的活动照片、LOGO 和文案素材,生成一套公众号头图与活动海报排版,风格青春、明亮,输出图片文件。';
|
'以「毕业季」为主题,生成一套公众号头图与活动海报排版,风格青春、明亮,输出图片文件。';
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -605,6 +527,18 @@
|
|||||||
color: rgba(255, 239, 241, 0.88);
|
color: rgba(255, 239, 241, 0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions :deep(.ant-btn) {
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-card {
|
.panel-card {
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
box-shadow: 0 14px 34px rgba(87, 54, 30, 0.08);
|
box-shadow: 0 14px 34px rgba(87, 54, 30, 0.08);
|
||||||
@@ -631,38 +565,6 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-tip {
|
|
||||||
margin-top: 10px;
|
|
||||||
color: #88715f;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list {
|
|
||||||
margin-top: 14px;
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: #faf5ef;
|
|
||||||
border: 1px solid #c7e8ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-main {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-badge {
|
.file-badge {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-width: 48px;
|
min-width: 48px;
|
||||||
@@ -675,35 +577,12 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-text {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name,
|
|
||||||
.file-url,
|
|
||||||
.result-name {
|
.result-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-name {
|
|
||||||
color: #3b2c20;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-url {
|
|
||||||
margin-top: 4px;
|
|
||||||
color: #8c7462;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-remove {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-bar {
|
.submit-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -987,6 +866,10 @@
|
|||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.video-grid,
|
.video-grid,
|
||||||
.photo-grid {
|
.photo-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="ele-body">
|
||||||
|
<a-card :bordered="false" :body-style="{ padding: '16px' }">
|
||||||
|
<a-alert
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
message="同步为异步执行:提交后立即返回,下方可实时查看进度。学生全量约 9.8 万条,预计 1~2 分钟。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-space style="flex-wrap: wrap; margin-bottom: 16px">
|
||||||
|
<a-button
|
||||||
|
v-if="canOperate"
|
||||||
|
type="primary"
|
||||||
|
:disabled="running"
|
||||||
|
@click="doSync('all')"
|
||||||
|
>
|
||||||
|
全部同步
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="canOperate"
|
||||||
|
:disabled="running"
|
||||||
|
@click="doSync('dept')"
|
||||||
|
>
|
||||||
|
同步部门
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="canOperate"
|
||||||
|
:disabled="running"
|
||||||
|
@click="doSync('teacher')"
|
||||||
|
>
|
||||||
|
同步教师
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="canOperate"
|
||||||
|
:disabled="running"
|
||||||
|
@click="doSync('class')"
|
||||||
|
>
|
||||||
|
同步班级
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="canOperate"
|
||||||
|
:disabled="running"
|
||||||
|
@click="doSync('student')"
|
||||||
|
>
|
||||||
|
同步学生
|
||||||
|
</a-button>
|
||||||
|
<a-button :disabled="running" @click="reload">刷新记录</a-button>
|
||||||
|
</a-space>
|
||||||
|
|
||||||
|
<div v-if="sections.length" style="margin-bottom: 16px">
|
||||||
|
<a-space style="margin-bottom: 8px">
|
||||||
|
<a-spin v-if="running" size="small" />
|
||||||
|
<span>
|
||||||
|
{{ running ? '正在同步' : '最近批次' }}
|
||||||
|
<a-typography-text v-if="batchId" type="secondary">
|
||||||
|
(批次 {{ batchId }})
|
||||||
|
</a-typography-text>
|
||||||
|
</span>
|
||||||
|
</a-space>
|
||||||
|
<a-table
|
||||||
|
row-key="syncType"
|
||||||
|
size="small"
|
||||||
|
:columns="progressColumns"
|
||||||
|
:data-source="sections"
|
||||||
|
:pagination="false"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'syncType'">
|
||||||
|
{{ sectionLabel(record.syncType) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'state'">
|
||||||
|
<a-tag :color="stateColor(record.state)">
|
||||||
|
{{ stateText(record.state) }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'counts'">
|
||||||
|
<span v-if="record.state === 'pending'">-</span>
|
||||||
|
<span v-else>
|
||||||
|
成功 {{ record.successCount ?? 0 }} / 失败
|
||||||
|
{{ record.failCount ?? 0 }} / 停用
|
||||||
|
{{ record.skipCount ?? 0 }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'errorMsg'">
|
||||||
|
<a-typography-text type="danger">
|
||||||
|
{{ record.errorMsg || '-' }}
|
||||||
|
</a-typography-text>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ele-pro-table
|
||||||
|
ref="tableRef"
|
||||||
|
row-key="id"
|
||||||
|
:columns="columns"
|
||||||
|
:datasource="datasource"
|
||||||
|
:scroll="{ x: 1200 }"
|
||||||
|
tool-class="ele-toolbar-form"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<a-space>
|
||||||
|
<a-select
|
||||||
|
v-model:value="syncType"
|
||||||
|
allow-clear
|
||||||
|
style="width: 160px"
|
||||||
|
placeholder="同步类型"
|
||||||
|
:options="typeOptions"
|
||||||
|
/>
|
||||||
|
<a-button type="primary" @click="reload">查询</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'status'">
|
||||||
|
<a-tag :color="recordStatusColor(record.status)">
|
||||||
|
{{ recordStatusText(record.status) }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'triggerType'">
|
||||||
|
{{ record.triggerType === 'schedule' ? '定时' : '手动' }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</ele-pro-table>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import type { EleProTable } from 'ele-admin-pro';
|
||||||
|
import { toDateString } from 'ele-admin-pro';
|
||||||
|
import type {
|
||||||
|
DatasourceFunction,
|
||||||
|
ColumnItem
|
||||||
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
import { hasPermission } from '@/utils/permission';
|
||||||
|
import {
|
||||||
|
pageSyncRecord,
|
||||||
|
syncAll,
|
||||||
|
syncClass,
|
||||||
|
syncDept,
|
||||||
|
syncStatus,
|
||||||
|
syncStudent,
|
||||||
|
syncTeacher
|
||||||
|
} from '@/api/gxmu/sync';
|
||||||
|
import type {
|
||||||
|
SyncRecord,
|
||||||
|
SyncSectionStatus
|
||||||
|
} from '@/api/gxmu/sync/model';
|
||||||
|
|
||||||
|
type SyncKind = 'all' | 'dept' | 'teacher' | 'class' | 'student';
|
||||||
|
|
||||||
|
const POLL_INTERVAL = 2000;
|
||||||
|
|
||||||
|
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||||
|
const running = ref(false);
|
||||||
|
const batchId = ref<string>('');
|
||||||
|
const sections = ref<SyncSectionStatus[]>([]);
|
||||||
|
const syncType = ref<string | undefined>(undefined);
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
const canOperate = computed(() => hasPermission('gxmu:sync:manual'));
|
||||||
|
|
||||||
|
const typeOptions = [
|
||||||
|
{ label: '部门', value: 'dept' },
|
||||||
|
{ label: '教师', value: 'teacher' },
|
||||||
|
{ label: '班级', value: 'class' },
|
||||||
|
{ label: '学生', value: 'student' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const progressColumns: ColumnItem[] = [
|
||||||
|
{ title: '同步段', dataIndex: 'syncType', key: 'syncType', width: 120 },
|
||||||
|
{ title: '状态', dataIndex: 'state', key: 'state', width: 110 },
|
||||||
|
{ title: '数量', key: 'counts', width: 260 },
|
||||||
|
{ title: '错误', dataIndex: 'errorMsg', key: 'errorMsg' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns = ref<ColumnItem[]>([
|
||||||
|
{ title: '批次', dataIndex: 'batchId', key: 'batchId', width: 200 },
|
||||||
|
{ title: '类型', dataIndex: 'syncType', key: 'syncType', width: 100 },
|
||||||
|
{
|
||||||
|
title: '触发方式',
|
||||||
|
dataIndex: 'triggerType',
|
||||||
|
key: 'triggerType',
|
||||||
|
width: 110
|
||||||
|
},
|
||||||
|
{ title: '触发人', dataIndex: 'triggerUser', key: 'triggerUser', width: 140 },
|
||||||
|
{
|
||||||
|
title: '开始时间',
|
||||||
|
dataIndex: 'startTime',
|
||||||
|
key: 'startTime',
|
||||||
|
width: 180,
|
||||||
|
customRender: ({ text }) => (text ? toDateString(text) : '-')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结束时间',
|
||||||
|
dataIndex: 'endTime',
|
||||||
|
key: 'endTime',
|
||||||
|
width: 180,
|
||||||
|
customRender: ({ text }) => (text ? toDateString(text) : '-')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '成功',
|
||||||
|
dataIndex: 'successCount',
|
||||||
|
key: 'successCount',
|
||||||
|
align: 'center',
|
||||||
|
width: 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '失败',
|
||||||
|
dataIndex: 'failCount',
|
||||||
|
key: 'failCount',
|
||||||
|
align: 'center',
|
||||||
|
width: 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '停用/跳过',
|
||||||
|
dataIndex: 'skipCount',
|
||||||
|
key: 'skipCount',
|
||||||
|
align: 'center',
|
||||||
|
width: 110
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
align: 'center',
|
||||||
|
width: 90
|
||||||
|
},
|
||||||
|
{ title: '错误摘要', dataIndex: 'errorMsg', key: 'errorMsg', width: 260 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sectionLabel = (type: string) =>
|
||||||
|
typeOptions.find((item) => item.value === type)?.label || type;
|
||||||
|
|
||||||
|
const stateText = (state: string) =>
|
||||||
|
({ pending: '待执行', running: '进行中', success: '成功', fail: '失败' }[
|
||||||
|
state
|
||||||
|
] || state);
|
||||||
|
|
||||||
|
const stateColor = (state: string) =>
|
||||||
|
({ pending: 'default', running: 'processing', success: 'green', fail: 'red' }[
|
||||||
|
state
|
||||||
|
] || 'default');
|
||||||
|
|
||||||
|
const recordStatusText = (status: number) =>
|
||||||
|
({ 0: '成功', 1: '失败', 2: '进行中' }[status] ?? '未知');
|
||||||
|
|
||||||
|
const recordStatusColor = (status: number) =>
|
||||||
|
({ 0: 'green', 1: 'red', 2: 'processing' }[status] ?? 'default');
|
||||||
|
|
||||||
|
const datasource: DatasourceFunction = ({ page, limit }) => {
|
||||||
|
return pageSyncRecord({
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
syncType: syncType.value
|
||||||
|
}).catch((e) => {
|
||||||
|
message.error(e.message);
|
||||||
|
return { list: [] as SyncRecord[], count: 0 };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
tableRef.value?.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyStatus = (status: {
|
||||||
|
running: boolean;
|
||||||
|
batchId?: string;
|
||||||
|
sections?: SyncSectionStatus[];
|
||||||
|
}) => {
|
||||||
|
running.value = status.running;
|
||||||
|
batchId.value = status.batchId || '';
|
||||||
|
sections.value = status.sections || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const status = await syncStatus();
|
||||||
|
const wasRunning = running.value;
|
||||||
|
applyStatus(status);
|
||||||
|
if (!status.running) {
|
||||||
|
stopPolling();
|
||||||
|
if (wasRunning) {
|
||||||
|
const failed = (status.sections || []).filter(
|
||||||
|
(item) => item.state === 'fail'
|
||||||
|
);
|
||||||
|
if (failed.length) {
|
||||||
|
message.warning(
|
||||||
|
`同步结束,${failed.length} 个段失败:${failed
|
||||||
|
.map((item) => `${sectionLabel(item.syncType)}(${item.errorMsg || '未知错误'})`)
|
||||||
|
.join(';')}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const total = (status.sections || []).reduce(
|
||||||
|
(sum, item) => sum + (item.successCount || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
message.success(`同步完成,共写入 ${total} 条`);
|
||||||
|
}
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
stopPolling();
|
||||||
|
running.value = false;
|
||||||
|
message.error(e?.message || '获取同步进度失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPolling = () => {
|
||||||
|
stopPolling();
|
||||||
|
timer = setInterval(poll, POLL_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doSync = (kind: SyncKind) => {
|
||||||
|
const task =
|
||||||
|
kind === 'all'
|
||||||
|
? syncAll()
|
||||||
|
: kind === 'dept'
|
||||||
|
? syncDept()
|
||||||
|
: kind === 'teacher'
|
||||||
|
? syncTeacher()
|
||||||
|
: kind === 'class'
|
||||||
|
? syncClass()
|
||||||
|
: syncStudent();
|
||||||
|
task
|
||||||
|
.then((id) => {
|
||||||
|
// 提交成功即认为在跑,先占位再等第一次轮询回填
|
||||||
|
running.value = true;
|
||||||
|
batchId.value = id;
|
||||||
|
sections.value = [];
|
||||||
|
message.success('已提交同步任务');
|
||||||
|
startPolling();
|
||||||
|
poll();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
message.error(e.message || '提交同步任务失败');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 页面打开时可能已有定时任务或别处触发的同步在跑,接管进度显示
|
||||||
|
poll().then(() => {
|
||||||
|
if (running.value) {
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(stopPolling);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
name: 'DataSync'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -149,6 +149,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
<a-form-item
|
||||||
|
label="允许重复提交"
|
||||||
|
name="allowRepeat"
|
||||||
|
extra="关闭时,同一用户在同一申报项目下同时只能有一条待审核申报;开启后可配置最多同时几条"
|
||||||
|
>
|
||||||
|
<a-switch
|
||||||
|
:checked="form.allowRepeat === 1"
|
||||||
|
@change="(checked: boolean) => (form.allowRepeat = checked ? 1 : 0)"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="form.allowRepeat === 1" label="最多同时几条" name="maxPending">
|
||||||
|
<a-input-number :min="1" :max="20" class="ele-fluid" v-model:value="form.maxPending" />
|
||||||
|
</a-form-item>
|
||||||
<a-form-item label="开始时间" name="startTime">
|
<a-form-item label="开始时间" name="startTime">
|
||||||
<a-date-picker
|
<a-date-picker
|
||||||
allow-clear
|
allow-clear
|
||||||
@@ -182,6 +195,10 @@
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { FormInstance } from 'ant-design-vue/es/form';
|
import { FormInstance } from 'ant-design-vue/es/form';
|
||||||
import { DECLARE_MODULE_OPTIONS } from '../moduleOptions';
|
import { DECLARE_MODULE_OPTIONS } from '../moduleOptions';
|
||||||
|
import {
|
||||||
|
TZBCY_PROJECT_GROUP_OPTIONS,
|
||||||
|
TZBCY_PROJECT_TYPE_OPTIONS
|
||||||
|
} from '../../TZBCYForm/options';
|
||||||
import {
|
import {
|
||||||
normalizeDeclareRecord,
|
normalizeDeclareRecord,
|
||||||
stringifyDeclareMultiValueField
|
stringifyDeclareMultiValueField
|
||||||
@@ -244,6 +261,8 @@
|
|||||||
publicProjectGroup: [''],
|
publicProjectGroup: [''],
|
||||||
startTime: undefined,
|
startTime: undefined,
|
||||||
endTime: undefined,
|
endTime: undefined,
|
||||||
|
allowRepeat: 0,
|
||||||
|
maxPending: 1,
|
||||||
userId: undefined,
|
userId: undefined,
|
||||||
tenantId: undefined,
|
tenantId: undefined,
|
||||||
createTime: undefined,
|
createTime: undefined,
|
||||||
@@ -273,29 +292,68 @@
|
|||||||
const { resetFields } = useForm(form, rules);
|
const { resetFields } = useForm(form, rules);
|
||||||
const isTzbcyModule = computed(() => form.module === 'gxmu_tzbcy_form');
|
const isTzbcyModule = computed(() => form.module === 'gxmu_tzbcy_form');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挑战杯模块的默认配置
|
||||||
|
* 与挑战杯申报表共用 TZBCYForm/options 中的同一份常量,避免两处维护产生漂移
|
||||||
|
*/
|
||||||
|
type ChallengeCupConfigKey =
|
||||||
|
| 'formProjectType'
|
||||||
|
| 'formProjectGroup'
|
||||||
|
| 'publicProjectType'
|
||||||
|
| 'publicProjectGroup';
|
||||||
|
|
||||||
|
const CHALLENGE_CUP_CONFIG_KEYS: ChallengeCupConfigKey[] = [
|
||||||
|
'formProjectType',
|
||||||
|
'formProjectGroup',
|
||||||
|
'publicProjectType',
|
||||||
|
'publicProjectGroup'
|
||||||
|
];
|
||||||
|
|
||||||
|
const toOptionValues = (options: readonly { value: string }[]) =>
|
||||||
|
options.map((item) => item.value);
|
||||||
|
|
||||||
|
const getChallengeCupDefaultConfig = (
|
||||||
|
key: ChallengeCupConfigKey
|
||||||
|
): string[] => {
|
||||||
|
switch (key) {
|
||||||
|
case 'formProjectType':
|
||||||
|
case 'publicProjectType':
|
||||||
|
return toOptionValues(TZBCY_PROJECT_TYPE_OPTIONS);
|
||||||
|
default:
|
||||||
|
return toOptionValues(TZBCY_PROJECT_GROUP_OPTIONS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 列表是否为空(没有任意一个有效值,空行不算)
|
||||||
|
const isBlankConfigList = (list?: string[]) =>
|
||||||
|
!(list || []).some((item) => !!item?.trim());
|
||||||
|
|
||||||
|
// 仅填充空配置,已有的配置(含用户手动编辑过的内容)保持不变
|
||||||
|
const fillBlankConfigWithDefault = () => {
|
||||||
|
CHALLENGE_CUP_CONFIG_KEYS.forEach((key) => {
|
||||||
|
if (isBlankConfigList(form[key])) {
|
||||||
|
form[key] = getChallengeCupDefaultConfig(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选中挑战杯模块时套用默认配置
|
||||||
|
const applyChallengeCupPreset = () => {
|
||||||
|
if (isTzbcyModule.value) {
|
||||||
|
fillBlankConfigWithDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeEditableList = (list?: string[]) => {
|
const normalizeEditableList = (list?: string[]) => {
|
||||||
const values = (list || []).map((item) => item?.trim() || '').filter(Boolean);
|
const values = (list || []).map((item) => item?.trim() || '').filter(Boolean);
|
||||||
return values.length ? values : [''];
|
return values.length ? values : [''];
|
||||||
};
|
};
|
||||||
|
|
||||||
const addConfigRow = (
|
const addConfigRow = (key: ChallengeCupConfigKey) => {
|
||||||
key:
|
|
||||||
| 'formProjectType'
|
|
||||||
| 'formProjectGroup'
|
|
||||||
| 'publicProjectType'
|
|
||||||
| 'publicProjectGroup'
|
|
||||||
) => {
|
|
||||||
form[key].push('');
|
form[key].push('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeConfigRow = (
|
const removeConfigRow = (key: ChallengeCupConfigKey, index: number) => {
|
||||||
key:
|
|
||||||
| 'formProjectType'
|
|
||||||
| 'formProjectGroup'
|
|
||||||
| 'publicProjectType'
|
|
||||||
| 'publicProjectGroup',
|
|
||||||
index: number
|
|
||||||
) => {
|
|
||||||
form[key].splice(index, 1);
|
form[key].splice(index, 1);
|
||||||
if (!form[key].length) {
|
if (!form[key].length) {
|
||||||
form[key].push('');
|
form[key].push('');
|
||||||
@@ -418,6 +476,8 @@
|
|||||||
);
|
);
|
||||||
yearValue.value = props.data.year ? String(props.data.year) : undefined;
|
yearValue.value = props.data.year ? String(props.data.year) : undefined;
|
||||||
isUpdate.value = true;
|
isUpdate.value = true;
|
||||||
|
// 历史记录若未配置过挑战杯配置,补上默认值;已配置的记录保持原样
|
||||||
|
applyChallengeCupPreset();
|
||||||
} else {
|
} else {
|
||||||
Object.assign(form, createDefaultForm());
|
Object.assign(form, createDefaultForm());
|
||||||
isUpdate.value = false;
|
isUpdate.value = false;
|
||||||
@@ -435,14 +495,15 @@
|
|||||||
watch(
|
watch(
|
||||||
() => form.module,
|
() => form.module,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (value !== 'gxmu_tzbcy_form') {
|
if (value === 'gxmu_tzbcy_form') {
|
||||||
form.projectType = [];
|
// 选中挑战杯模块时预填项目申报表 / 公开展示信息表的默认配置
|
||||||
form.projectGroup = [];
|
applyChallengeCupPreset();
|
||||||
form.formProjectType = [''];
|
return;
|
||||||
form.formProjectGroup = [''];
|
|
||||||
form.publicProjectType = [''];
|
|
||||||
form.publicProjectGroup = [''];
|
|
||||||
}
|
}
|
||||||
|
// 切到其他模块时不清空配置,避免用户手滑切换后已编辑的内容丢失
|
||||||
|
// (非挑战杯模块不会展示也不会提交这些字段)
|
||||||
|
form.projectType = [];
|
||||||
|
form.projectGroup = [];
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@@ -471,4 +532,4 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'status'">
|
<template v-else-if="column.key === 'status'">
|
||||||
<a-tag v-if="record.status === 1" color="green">通过</a-tag>
|
<a-tag v-if="record.status === 1" color="green">通过</a-tag>
|
||||||
<a-tag v-else-if="record.status === 2" color="red">不通过</a-tag>
|
<a-tag v-else-if="record.status === 2" color="red">驳回</a-tag>
|
||||||
<a-tag v-else>待审核</a-tag>
|
<a-tag v-else>待审核</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'submitTime'">
|
<template v-else-if="column.key === 'submitTime'">
|
||||||
@@ -92,6 +92,7 @@
|
|||||||
:module="detailCurrent?.module"
|
:module="detailCurrent?.module"
|
||||||
:module-name="getDeclareModuleLabel(detailCurrent?.module)"
|
:module-name="getDeclareModuleLabel(detailCurrent?.module)"
|
||||||
:pk="detailCurrent?.id"
|
:pk="detailCurrent?.id"
|
||||||
|
@done="loadHistoryData"
|
||||||
/>
|
/>
|
||||||
<ReviewFlowModal
|
<ReviewFlowModal
|
||||||
v-model:visible="showReviewFlow"
|
v-model:visible="showReviewFlow"
|
||||||
@@ -499,4 +500,4 @@ export default {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,904 @@
|
|||||||
|
/**
|
||||||
|
* 审核列表「查看数据」详情的申报表配置。
|
||||||
|
*
|
||||||
|
* sections 决定申报表的字段分组与展示顺序,materialFields 声明需要在上传材料
|
||||||
|
* 中展示的附件字段,未列出的字段会兜底追加到「其他信息」分组,避免数据丢失。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ModuleSection {
|
||||||
|
// 分组标题
|
||||||
|
title: string;
|
||||||
|
// 分组内字段(按展示顺序)
|
||||||
|
fields: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleMaterialTable {
|
||||||
|
// 明细数组字段
|
||||||
|
field: string;
|
||||||
|
// 明细中保存附件地址的字段
|
||||||
|
fileField: string;
|
||||||
|
// 明细标题字段
|
||||||
|
titleField?: string;
|
||||||
|
// 材料分组标题
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleDetailConfig {
|
||||||
|
// 模块名称
|
||||||
|
title: string;
|
||||||
|
// 申报表名称,用于拼接表单标题
|
||||||
|
sheetName: string;
|
||||||
|
// 字段中文名
|
||||||
|
labels: Record<string, string>;
|
||||||
|
// 申报表分组
|
||||||
|
sections: ModuleSection[];
|
||||||
|
// 附件字段(上传材料)
|
||||||
|
materialFields: string[];
|
||||||
|
// 明细数组中的附件
|
||||||
|
materialTables?: ModuleMaterialTable[];
|
||||||
|
// 额外的材料数据来源
|
||||||
|
materialSource?: 'tzbcy';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用字段中文名
|
||||||
|
*/
|
||||||
|
export const COMMON_LABELS: Record<string, string> = {
|
||||||
|
year: '年份',
|
||||||
|
id: 'ID',
|
||||||
|
title: '标题',
|
||||||
|
content: '内容',
|
||||||
|
contentFormat: '正文格式',
|
||||||
|
cover: '封面',
|
||||||
|
createTime: '提交时间',
|
||||||
|
updateTime: '更新时间',
|
||||||
|
status: '状态',
|
||||||
|
sortNumber: '排序',
|
||||||
|
provinceCity: '省市',
|
||||||
|
schoolName: '学校名称',
|
||||||
|
projectName: '项目名称',
|
||||||
|
projectType: '项目类型',
|
||||||
|
projectGroup: '项目分组',
|
||||||
|
publicProjectType: '公开展示项目类型',
|
||||||
|
publicProjectGroup: '公开展示项目分组',
|
||||||
|
leader: '负责人',
|
||||||
|
phone: '联系电话',
|
||||||
|
teamMembers: '团队成员',
|
||||||
|
advisors: '指导老师',
|
||||||
|
projectBrief: '项目简介',
|
||||||
|
socialValue: '社会价值',
|
||||||
|
practiceProcess: '实践过程',
|
||||||
|
innovationMeaning: '创新意义',
|
||||||
|
developmentProspect: '发展前景',
|
||||||
|
teamCooperation: '团队协作情况',
|
||||||
|
projectMaterials: '项目介绍材料',
|
||||||
|
otherProofs: '其他相关证明材料',
|
||||||
|
projectSummary: '项目总结',
|
||||||
|
teamIntro: '团队介绍',
|
||||||
|
teamSlogan: '团队口号',
|
||||||
|
practiceLog: '实践日志',
|
||||||
|
name: '姓名',
|
||||||
|
gender: '性别',
|
||||||
|
nation: '民族',
|
||||||
|
photo: '电子版照片',
|
||||||
|
birthMonth: '出生年月',
|
||||||
|
politics: '政治面貌',
|
||||||
|
nativePlace: '籍贯',
|
||||||
|
wechat: '微信号',
|
||||||
|
email: '邮箱',
|
||||||
|
qq: 'QQ',
|
||||||
|
idCardNo: '身份证号',
|
||||||
|
hobby: '兴趣爱好',
|
||||||
|
willingToWorkInGuangxi: '是否愿意留桂工作',
|
||||||
|
schoolInfo: '学校信息',
|
||||||
|
leaguePosition: '团内职务',
|
||||||
|
resume: '个人简历',
|
||||||
|
awards: '获得奖励',
|
||||||
|
academicPerformance: '学习成绩',
|
||||||
|
secondaryLeagueOpinion: '二级团组织意见',
|
||||||
|
secondaryLeagueOpinionDate: '二级团组织意见日期',
|
||||||
|
secondaryPartyOpinion: '二级党组织意见',
|
||||||
|
secondaryPartyOpinionDate: '二级党组织意见日期',
|
||||||
|
schoolLeagueOpinion: '学校团组织意见',
|
||||||
|
schoolLeagueOpinionDate: '学校团组织意见日期',
|
||||||
|
applyType: '申报类别',
|
||||||
|
education: '学历',
|
||||||
|
position: '职务',
|
||||||
|
unit: '工作单位',
|
||||||
|
experience: '主要经历',
|
||||||
|
mainStory: '主要事迹',
|
||||||
|
leagueOpinion: '团组织意见',
|
||||||
|
partyOpinion: '党组织意见',
|
||||||
|
schoolOpinion: '学校意见',
|
||||||
|
collegeClass: '学院班级',
|
||||||
|
branch: '团支部名称',
|
||||||
|
eduEval: '教育评议情况',
|
||||||
|
serialNo: '序号',
|
||||||
|
archiveInCurrentOrg: '团籍是否在本组织',
|
||||||
|
joinMonth: '入团时间',
|
||||||
|
memberRecords: '团员记录',
|
||||||
|
growthArchives: '成长档案',
|
||||||
|
orgName: '组织名称',
|
||||||
|
memberTotal: '团员总数',
|
||||||
|
memberDeveloped2024: '上一年度发展团员数',
|
||||||
|
smartSystemLogin: '智慧团建系统录入情况',
|
||||||
|
committeeCount: '团委委员数',
|
||||||
|
fulltimeCadreCount: '专职团干部数',
|
||||||
|
parttimeCadreCount: '兼职团干部数',
|
||||||
|
lastElectionTime: '最近一次换届时间',
|
||||||
|
feeReceivable2024: '上一年度应收团费',
|
||||||
|
feeReceived2024: '上一年度实收团费',
|
||||||
|
feePayable2024: '上一年度应缴团费',
|
||||||
|
feePaid2024: '上一年度实缴团费',
|
||||||
|
branchCount: '团支部数量',
|
||||||
|
standardizedWork2024: '上一年度标准化建设开展情况',
|
||||||
|
recommendActivist2024: '上一年度推优入党人数',
|
||||||
|
activistConfirmed: '入党积极分子确定情况',
|
||||||
|
recommendDevTarget2024: '上一年度推荐发展对象人数',
|
||||||
|
devTargetConfirmed: '发展对象确定情况',
|
||||||
|
honorsFiveYears: '近五年获奖情况',
|
||||||
|
workSummaryThreeYears: '近三年工作总结',
|
||||||
|
branchName: '团支部名称',
|
||||||
|
secondOrg: '所属二级党组织',
|
||||||
|
secretary: '团支部书记',
|
||||||
|
contact: '联系方式',
|
||||||
|
establishTime: '成立时间',
|
||||||
|
branchCommitteeMeetingCount: '支委会次数',
|
||||||
|
branchMemberMeetingCount: '支部大会次数',
|
||||||
|
eduEvalDone: '教育评议是否完成',
|
||||||
|
annualRegDone: '年度团籍注册是否完成',
|
||||||
|
classCount: '班级数量',
|
||||||
|
smartSystem100: '智慧团建系统覆盖率',
|
||||||
|
topicName: '课题名称',
|
||||||
|
collegeGradeClass: '学院年级班级',
|
||||||
|
advisor: '指导老师',
|
||||||
|
topicType: '课题类别',
|
||||||
|
guidanceTeachers: '指导教师组',
|
||||||
|
applicants: '申请人信息',
|
||||||
|
budgets: '经费预算',
|
||||||
|
rationale: '立项依据',
|
||||||
|
teacherOpinion: '指导教师意见',
|
||||||
|
teacherSign: '指导教师签名',
|
||||||
|
reviewOpinion: '评审意见',
|
||||||
|
reviewScore: '评审成绩',
|
||||||
|
reviewLeaderSign: '评审负责人签名',
|
||||||
|
collegeOpinion: '学院意见',
|
||||||
|
promiseSigner: '承诺签字人',
|
||||||
|
promiseSignature: '承诺书签名',
|
||||||
|
reviewCollege: '评审学院',
|
||||||
|
reviewReporter: '汇报人',
|
||||||
|
reviewDate: '评审日期',
|
||||||
|
reviewMembers: '评审成员',
|
||||||
|
fundingBudgetFile: '经费预算表',
|
||||||
|
fundingAllocationFile: '经费划拨明细表',
|
||||||
|
midtermRemark: '中期检查说明',
|
||||||
|
midtermMaterials: '中期检查材料',
|
||||||
|
finalRemark: '结题说明',
|
||||||
|
finalMaterials: '结题材料',
|
||||||
|
archiveAwards: '获奖情况',
|
||||||
|
archiveProofs: '佐证材料',
|
||||||
|
archiveRecords: '成果归档',
|
||||||
|
grade: '年级',
|
||||||
|
className: '班级',
|
||||||
|
major: '专业',
|
||||||
|
role: '角色',
|
||||||
|
item: '项目',
|
||||||
|
amount: '金额',
|
||||||
|
reason: '原因',
|
||||||
|
dept: '部门',
|
||||||
|
sign: '签名',
|
||||||
|
department: '部门',
|
||||||
|
remark: '备注',
|
||||||
|
type: '类型',
|
||||||
|
recordTime: '时间',
|
||||||
|
archiveTime: '时间',
|
||||||
|
identity: '身份',
|
||||||
|
organization: '所在组织',
|
||||||
|
memberNo: '团员编号',
|
||||||
|
currentDutyTime: '现任职务时间',
|
||||||
|
cadreYears: '从事团学工作年限',
|
||||||
|
assessment2024: '上一年度考核情况',
|
||||||
|
volunteerRegTime: '志愿汇注册时间',
|
||||||
|
cadreExperience: '团学工作经历',
|
||||||
|
joinTime: '入团时间',
|
||||||
|
collegeMajorClass: '学院专业班级',
|
||||||
|
eduEval2024: '上一年度教育评议情况',
|
||||||
|
smartSystem: '智慧团建系统录入情况',
|
||||||
|
totalVolunteerHours: '累计志愿服务时长',
|
||||||
|
volunteerHours2024: '上一年度志愿服务时长',
|
||||||
|
duty: '职务',
|
||||||
|
college: '学院',
|
||||||
|
gradeMajor: '年级专业',
|
||||||
|
birthDate: '出生日期',
|
||||||
|
branchOrganizationName: '所属团支部',
|
||||||
|
awardName: '获奖名称',
|
||||||
|
awardTime: '获奖时间',
|
||||||
|
proofFile: '证明材料',
|
||||||
|
archiveOrder: '序号'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 五四类申报表的意见栏分组
|
||||||
|
*/
|
||||||
|
const OPINION_SECTION: ModuleSection = {
|
||||||
|
title: '意见栏',
|
||||||
|
fields: ['leagueOpinion', 'partyOpinion', 'schoolOpinion']
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MODULE_DETAIL_CONFIGS: Record<string, ModuleDetailConfig> = {
|
||||||
|
gxmu_sjqn: {
|
||||||
|
title: '十佳青年岗位能手',
|
||||||
|
sheetName: '十佳青年岗位能手',
|
||||||
|
labels: {
|
||||||
|
applyType: '申报类别',
|
||||||
|
education: '学历',
|
||||||
|
position: '职务',
|
||||||
|
title: '职称',
|
||||||
|
unit: '所在单位',
|
||||||
|
awards: '近三年曾获校级及以上奖励',
|
||||||
|
experience: '工作(学习)经历',
|
||||||
|
mainStory: '主要事迹',
|
||||||
|
leagueOpinion: '所在二级团组织意见',
|
||||||
|
partyOpinion: '所在单位党组织意见',
|
||||||
|
schoolOpinion: '校团委意见'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '基本信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'applyType',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'birthMonth',
|
||||||
|
'nation',
|
||||||
|
'politics',
|
||||||
|
'education',
|
||||||
|
'position',
|
||||||
|
'title',
|
||||||
|
'unit'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '主要事迹与经历',
|
||||||
|
fields: ['awards', 'experience', 'mainStory']
|
||||||
|
},
|
||||||
|
OPINION_SECTION
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
gxmu_sjtbzbsj: {
|
||||||
|
title: '十佳团支部书记',
|
||||||
|
sheetName: '十佳团支部书记',
|
||||||
|
labels: {
|
||||||
|
collegeClass: '学院班级/单位科室',
|
||||||
|
branch: '所在团支部',
|
||||||
|
eduEval: '上一年度团员教育评议等次',
|
||||||
|
awards: '近三年曾获校级及以上奖励',
|
||||||
|
experience: '工作(学习)经历',
|
||||||
|
mainStory: '主要事迹',
|
||||||
|
leagueOpinion: '所在二级团组织意见',
|
||||||
|
partyOpinion: '所在单位党组织意见',
|
||||||
|
schoolOpinion: '校团委意见'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '基本信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'birthMonth',
|
||||||
|
'nation',
|
||||||
|
'politics',
|
||||||
|
'collegeClass',
|
||||||
|
'branch',
|
||||||
|
'eduEval'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '主要事迹与经历',
|
||||||
|
fields: ['awards', 'experience', 'mainStory']
|
||||||
|
},
|
||||||
|
OPINION_SECTION
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
gxmu_wshqtw: {
|
||||||
|
title: '五四红旗团委',
|
||||||
|
sheetName: '五四红旗团委',
|
||||||
|
labels: {
|
||||||
|
orgName: '二级团组织全称',
|
||||||
|
leader: '负责人',
|
||||||
|
phone: '联系电话',
|
||||||
|
memberTotal: '现有团员总数',
|
||||||
|
memberDeveloped2024: '上一年度发展团员人数',
|
||||||
|
smartSystemLogin: '是否已登录智慧团建系统',
|
||||||
|
committeeCount: '团委委员人数',
|
||||||
|
fulltimeCadreCount: '专职团干部数',
|
||||||
|
parttimeCadreCount: '兼职团干部数',
|
||||||
|
lastElectionTime: '团委最近一次换届时间',
|
||||||
|
feeReceivable2024: '上一年度应收团费',
|
||||||
|
feeReceived2024: '上一年度实收团费',
|
||||||
|
feePayable2024: '上一年度应上缴团费',
|
||||||
|
feePaid2024: '上一年度实际上缴团费',
|
||||||
|
branchCount: '团支部数',
|
||||||
|
standardizedWork2024: '是否开展规范化建设工作',
|
||||||
|
recommendActivist2024: '推荐入党积极分子人数',
|
||||||
|
activistConfirmed: '确定为入党积极分子数',
|
||||||
|
recommendDevTarget2024: '推荐党的发展对象人数',
|
||||||
|
devTargetConfirmed: '确定为党的发展对象数',
|
||||||
|
honorsFiveYears: '近五年获得校级及以上荣誉情况',
|
||||||
|
workSummaryThreeYears: '近三年开展的主要工作及取得的效果',
|
||||||
|
partyOpinion: '所在单位党组织意见',
|
||||||
|
schoolOpinion: '校团委意见'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{ title: '组织信息', fields: ['year', 'orgName', 'leader', 'phone'] },
|
||||||
|
{
|
||||||
|
title: '团员与团干部情况',
|
||||||
|
fields: [
|
||||||
|
'memberTotal',
|
||||||
|
'memberDeveloped2024',
|
||||||
|
'smartSystemLogin',
|
||||||
|
'committeeCount',
|
||||||
|
'fulltimeCadreCount',
|
||||||
|
'parttimeCadreCount',
|
||||||
|
'lastElectionTime'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '团费收缴情况',
|
||||||
|
fields: [
|
||||||
|
'feeReceivable2024',
|
||||||
|
'feeReceived2024',
|
||||||
|
'feePayable2024',
|
||||||
|
'feePaid2024'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '团支部与推优情况',
|
||||||
|
fields: [
|
||||||
|
'branchCount',
|
||||||
|
'recommendActivist2024',
|
||||||
|
'activistConfirmed',
|
||||||
|
'recommendDevTarget2024',
|
||||||
|
'devTargetConfirmed'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '工作与荣誉',
|
||||||
|
fields: [
|
||||||
|
'standardizedWork2024',
|
||||||
|
'honorsFiveYears',
|
||||||
|
'workSummaryThreeYears'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ title: '意见栏', fields: ['partyOpinion', 'schoolOpinion'] }
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
gxmu_wshqtzb: {
|
||||||
|
title: '五四红旗团支部',
|
||||||
|
sheetName: '五四红旗团支部',
|
||||||
|
labels: {
|
||||||
|
branchName: '团支部全称',
|
||||||
|
secondOrg: '所属二级团组织',
|
||||||
|
secretary: '团支部书记',
|
||||||
|
politics: '政治面貌',
|
||||||
|
contact: '联系方式',
|
||||||
|
establishTime: '成立时间',
|
||||||
|
lastElectionTime: '最近一次换届时间',
|
||||||
|
smartSystemLogin: '是否已登录智慧团建系统',
|
||||||
|
memberTotal: '现有团员总数',
|
||||||
|
memberDeveloped2024: '上一年度发展团员数',
|
||||||
|
feeReceivable2024: '上一年度应收团费',
|
||||||
|
feeReceived2024: '上一年度实收团费',
|
||||||
|
feePayable2024: '上一年度应上缴团费',
|
||||||
|
feePaid2024: '上一年度实际上缴团费',
|
||||||
|
recommendActivist2024: '推荐入党积极分子人数',
|
||||||
|
activistConfirmed: '确定为入党积极分子数',
|
||||||
|
recommendDevTarget2024: '推荐党的发展对象人数',
|
||||||
|
devTargetConfirmed: '确定为党的发展对象数',
|
||||||
|
branchCommitteeMeetingCount: '团支部委员会会议召开次数',
|
||||||
|
branchMemberMeetingCount: '团支部团员大会召开次数',
|
||||||
|
eduEvalDone: '是否开展团员教育评议',
|
||||||
|
annualRegDone: '是否开展团员年度团籍注册',
|
||||||
|
classCount: '开展团课次数',
|
||||||
|
smartSystem100: '是否100%录入智慧团建',
|
||||||
|
honorsFiveYears: '近五年获得院级及以上荣誉情况',
|
||||||
|
workSummaryThreeYears: '近三年开展的主要工作及取得的效果',
|
||||||
|
leagueOpinion: '所在二级团组织意见',
|
||||||
|
partyOpinion: '所在单位党组织意见',
|
||||||
|
schoolOpinion: '校团委意见'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '支部信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'branchName',
|
||||||
|
'secondOrg',
|
||||||
|
'secretary',
|
||||||
|
'politics',
|
||||||
|
'contact',
|
||||||
|
'establishTime',
|
||||||
|
'lastElectionTime'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '团员与团费情况',
|
||||||
|
fields: [
|
||||||
|
'smartSystemLogin',
|
||||||
|
'memberTotal',
|
||||||
|
'memberDeveloped2024',
|
||||||
|
'feeReceivable2024',
|
||||||
|
'feeReceived2024',
|
||||||
|
'feePayable2024',
|
||||||
|
'feePaid2024'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '推优情况',
|
||||||
|
fields: [
|
||||||
|
'recommendActivist2024',
|
||||||
|
'activistConfirmed',
|
||||||
|
'recommendDevTarget2024',
|
||||||
|
'devTargetConfirmed'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '组织生活',
|
||||||
|
fields: [
|
||||||
|
'branchCommitteeMeetingCount',
|
||||||
|
'branchMemberMeetingCount',
|
||||||
|
'eduEvalDone',
|
||||||
|
'annualRegDone',
|
||||||
|
'classCount',
|
||||||
|
'smartSystem100'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '工作与荣誉',
|
||||||
|
fields: ['honorsFiveYears', 'workSummaryThreeYears']
|
||||||
|
},
|
||||||
|
OPINION_SECTION
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
gxmu_yxgqtdgb: {
|
||||||
|
title: '优秀共青团干部',
|
||||||
|
sheetName: '优秀共青团干部',
|
||||||
|
labels: {
|
||||||
|
identity: '身份',
|
||||||
|
organization: '所在团组织',
|
||||||
|
contact: '联系方式',
|
||||||
|
memberNo: '发展团员编号',
|
||||||
|
currentDutyTime: '任现团内职务时间',
|
||||||
|
cadreYears: '担任团干部年限',
|
||||||
|
assessment2024: '上一年度工作考核结果',
|
||||||
|
volunteerRegTime: '成为注册志愿者时间',
|
||||||
|
cadreExperience: '从事团干部经历',
|
||||||
|
honorsFiveYears: '近五年获得校级及以上荣誉情况',
|
||||||
|
mainStory: '主要事迹',
|
||||||
|
leagueOpinion: '所在单位团组织意见',
|
||||||
|
partyOpinion: '所在单位党组织意见'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '基本信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'nation',
|
||||||
|
'birthMonth',
|
||||||
|
'politics',
|
||||||
|
'position',
|
||||||
|
'identity',
|
||||||
|
'organization',
|
||||||
|
'contact',
|
||||||
|
'memberNo'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '团干部履历',
|
||||||
|
fields: [
|
||||||
|
'currentDutyTime',
|
||||||
|
'cadreYears',
|
||||||
|
'assessment2024',
|
||||||
|
'volunteerRegTime',
|
||||||
|
'cadreExperience'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '主要事迹与荣誉',
|
||||||
|
fields: ['honorsFiveYears', 'mainStory']
|
||||||
|
},
|
||||||
|
{ title: '意见栏', fields: ['leagueOpinion', 'partyOpinion'] }
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
gxmu_yxgqty: {
|
||||||
|
title: '优秀共青团员',
|
||||||
|
sheetName: '优秀共青团员',
|
||||||
|
labels: {
|
||||||
|
joinTime: '入团时间',
|
||||||
|
collegeMajorClass: '所在学院、专业、班级',
|
||||||
|
position: '职务',
|
||||||
|
volunteerRegTime: '成为注册志愿者时间',
|
||||||
|
eduEval2024: '上一年度团员教育评议等次',
|
||||||
|
smartSystem: '是否已登录智慧团建系统',
|
||||||
|
contact: '联系电话',
|
||||||
|
totalVolunteerHours: '累计志愿服务时长',
|
||||||
|
volunteerHours2024: '上一年度志愿服务时长',
|
||||||
|
memberNo: '发展团员编号',
|
||||||
|
honorsFiveYears: '近五年获得荣誉情况',
|
||||||
|
mainStory: '主要事迹',
|
||||||
|
leagueOpinion: '学生社团指导教师意见/所在单位团委',
|
||||||
|
partyOpinion: '业务指导单位意见/所在单位党组织',
|
||||||
|
schoolOpinion: '校团委意见'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '基本信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'nation',
|
||||||
|
'politics',
|
||||||
|
'birthMonth',
|
||||||
|
'joinTime',
|
||||||
|
'collegeMajorClass',
|
||||||
|
'position',
|
||||||
|
'contact',
|
||||||
|
'memberNo'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '志愿服务与教育评议',
|
||||||
|
fields: [
|
||||||
|
'volunteerRegTime',
|
||||||
|
'eduEval2024',
|
||||||
|
'smartSystem',
|
||||||
|
'totalVolunteerHours',
|
||||||
|
'volunteerHours2024'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '主要事迹与荣誉',
|
||||||
|
fields: ['honorsFiveYears', 'mainStory']
|
||||||
|
},
|
||||||
|
OPINION_SECTION
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
gxmu_tzbcy_form: {
|
||||||
|
title: '挑战杯',
|
||||||
|
sheetName: '挑战杯',
|
||||||
|
labels: {
|
||||||
|
projectMaterials: '项目介绍材料',
|
||||||
|
otherProofs: '其他相关证明材料',
|
||||||
|
teamMembers: '团队成员',
|
||||||
|
advisors: '指导老师'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '项目信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'projectName',
|
||||||
|
'provinceCity',
|
||||||
|
'schoolName',
|
||||||
|
'projectType',
|
||||||
|
'projectGroup'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ title: '团队成员', fields: ['teamMembers'] },
|
||||||
|
{ title: '指导老师', fields: ['advisors'] },
|
||||||
|
{
|
||||||
|
title: '项目说明',
|
||||||
|
fields: [
|
||||||
|
'projectBrief',
|
||||||
|
'socialValue',
|
||||||
|
'practiceProcess',
|
||||||
|
'innovationMeaning',
|
||||||
|
'developmentProspect',
|
||||||
|
'teamCooperation',
|
||||||
|
'projectMaterials',
|
||||||
|
'otherProofs'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '公开展示信息',
|
||||||
|
fields: [
|
||||||
|
'publicProjectType',
|
||||||
|
'publicProjectGroup',
|
||||||
|
'projectSummary',
|
||||||
|
'teamIntro',
|
||||||
|
'teamSlogan',
|
||||||
|
'practiceLog'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
materialFields: [],
|
||||||
|
materialSource: 'tzbcy'
|
||||||
|
},
|
||||||
|
gxmu_qmgc_form: {
|
||||||
|
title: '青马工程',
|
||||||
|
sheetName: '青马工程',
|
||||||
|
labels: {
|
||||||
|
hobby: '兴趣爱好',
|
||||||
|
willingToWorkInGuangxi: '是否愿意留桂工作',
|
||||||
|
schoolInfo: '学校信息',
|
||||||
|
leaguePosition: '团内职务',
|
||||||
|
resume: '个人简历',
|
||||||
|
awards: '获得奖励',
|
||||||
|
academicPerformance: '学习成绩',
|
||||||
|
secondaryLeagueOpinion: '二级团组织意见',
|
||||||
|
secondaryLeagueOpinionDate: '二级团组织意见日期',
|
||||||
|
secondaryPartyOpinion: '二级党组织意见',
|
||||||
|
secondaryPartyOpinionDate: '二级党组织意见日期',
|
||||||
|
schoolLeagueOpinion: '学校团组织意见',
|
||||||
|
schoolLeagueOpinionDate: '学校团组织意见日期'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '个人信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'nation',
|
||||||
|
'photo',
|
||||||
|
'birthMonth',
|
||||||
|
'politics',
|
||||||
|
'nativePlace',
|
||||||
|
'phone',
|
||||||
|
'wechat',
|
||||||
|
'email',
|
||||||
|
'qq',
|
||||||
|
'idCardNo',
|
||||||
|
'hobby',
|
||||||
|
'willingToWorkInGuangxi',
|
||||||
|
'schoolInfo',
|
||||||
|
'leaguePosition'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '学习与经历',
|
||||||
|
fields: ['resume', 'awards', 'academicPerformance']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '意见栏',
|
||||||
|
fields: [
|
||||||
|
'secondaryLeagueOpinion',
|
||||||
|
'secondaryLeagueOpinionDate',
|
||||||
|
'secondaryPartyOpinion',
|
||||||
|
'secondaryPartyOpinionDate',
|
||||||
|
'schoolLeagueOpinion',
|
||||||
|
'schoolLeagueOpinionDate'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
gxmu_wxxzx_project: {
|
||||||
|
title: '未来学术之星',
|
||||||
|
sheetName: '未来学术之星',
|
||||||
|
labels: {
|
||||||
|
topicName: '课题名称',
|
||||||
|
leader: '课题负责人',
|
||||||
|
collegeGradeClass: '学院年级班级',
|
||||||
|
phone: '联系电话',
|
||||||
|
advisor: '指导老师',
|
||||||
|
topicType: '课题类别',
|
||||||
|
guidanceTeachers: '指导教师组',
|
||||||
|
applicants: '申请人信息',
|
||||||
|
budgets: '经费预算',
|
||||||
|
rationale: '立项依据',
|
||||||
|
teacherOpinion: '指导教师意见',
|
||||||
|
teacherSign: '指导教师签名',
|
||||||
|
reviewCollege: '评审学院',
|
||||||
|
reviewReporter: '汇报人',
|
||||||
|
reviewDate: '评审日期',
|
||||||
|
reviewOpinion: '评审意见',
|
||||||
|
reviewScore: '评审成绩',
|
||||||
|
reviewLeaderSign: '评审负责人签名',
|
||||||
|
collegeOpinion: '学院意见',
|
||||||
|
schoolOpinion: '学校意见',
|
||||||
|
reviewMembers: '评审成员',
|
||||||
|
midtermRemark: '中期检查说明',
|
||||||
|
midtermMaterials: '中期检查材料',
|
||||||
|
finalRemark: '结题说明',
|
||||||
|
finalMaterials: '结题材料',
|
||||||
|
archiveRecords: '成果归档',
|
||||||
|
archiveAwards: '获奖情况',
|
||||||
|
archiveProofs: '佐证材料',
|
||||||
|
promiseSigner: '承诺签字人',
|
||||||
|
promiseSignature: '承诺书签名',
|
||||||
|
fundingBudgetFile: '经费预算表',
|
||||||
|
fundingAllocationFile: '经费划拨明细表',
|
||||||
|
proofFile: '证明材料'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '课题信息',
|
||||||
|
fields: [
|
||||||
|
'year',
|
||||||
|
'topicName',
|
||||||
|
'leader',
|
||||||
|
'collegeGradeClass',
|
||||||
|
'phone',
|
||||||
|
'advisor',
|
||||||
|
'topicType'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ title: '指导教师组', fields: ['guidanceTeachers'] },
|
||||||
|
{ title: '申请人信息', fields: ['applicants'] },
|
||||||
|
{
|
||||||
|
title: '经费预算',
|
||||||
|
fields: ['budgets', 'fundingBudgetFile', 'fundingAllocationFile']
|
||||||
|
},
|
||||||
|
{ title: '立项依据', fields: ['rationale'] },
|
||||||
|
{
|
||||||
|
title: '审核评审',
|
||||||
|
fields: [
|
||||||
|
'teacherOpinion',
|
||||||
|
'teacherSign',
|
||||||
|
'reviewCollege',
|
||||||
|
'reviewReporter',
|
||||||
|
'reviewDate',
|
||||||
|
'reviewOpinion',
|
||||||
|
'reviewScore',
|
||||||
|
'reviewLeaderSign',
|
||||||
|
'collegeOpinion',
|
||||||
|
'schoolOpinion'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ title: '评审成员', fields: ['reviewMembers'] },
|
||||||
|
{
|
||||||
|
title: '中期检查与结题',
|
||||||
|
fields: [
|
||||||
|
'midtermRemark',
|
||||||
|
'midtermMaterials',
|
||||||
|
'finalRemark',
|
||||||
|
'finalMaterials'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '成果归档',
|
||||||
|
fields: ['archiveRecords', 'archiveAwards', 'archiveProofs']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '承诺书',
|
||||||
|
fields: ['promiseSigner', 'promiseSignature']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
materialFields: [
|
||||||
|
'fundingBudgetFile',
|
||||||
|
'fundingAllocationFile',
|
||||||
|
'midtermMaterials',
|
||||||
|
'finalMaterials',
|
||||||
|
'archiveProofs'
|
||||||
|
],
|
||||||
|
materialTables: [
|
||||||
|
{
|
||||||
|
field: 'archiveRecords',
|
||||||
|
fileField: 'proofFile',
|
||||||
|
titleField: 'awardName',
|
||||||
|
label: '成果归档证明材料'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
gxmu_tygl_form: {
|
||||||
|
title: '团员管理',
|
||||||
|
sheetName: '团员信息登记表',
|
||||||
|
labels: {
|
||||||
|
serialNo: '序号',
|
||||||
|
college: '学院',
|
||||||
|
className: '班级',
|
||||||
|
phone: '手机号码',
|
||||||
|
leaguePosition: '团内职务',
|
||||||
|
branchOrganizationName: '所属团支部',
|
||||||
|
idCardNo: '身份证号',
|
||||||
|
birthDate: '出生日期',
|
||||||
|
archiveInCurrentOrg: '团籍是否在本组织',
|
||||||
|
joinMonth: '入团年月',
|
||||||
|
memberRecords: '团员记录',
|
||||||
|
growthArchives: '成长档案'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '基本信息',
|
||||||
|
fields: [
|
||||||
|
'serialNo',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'nation',
|
||||||
|
'politics',
|
||||||
|
'college',
|
||||||
|
'className',
|
||||||
|
'phone',
|
||||||
|
'leaguePosition',
|
||||||
|
'branchOrganizationName',
|
||||||
|
'idCardNo',
|
||||||
|
'birthDate',
|
||||||
|
'archiveInCurrentOrg',
|
||||||
|
'joinMonth'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ title: '团员记录', fields: ['memberRecords'] },
|
||||||
|
{ title: '成长档案', fields: ['growthArchives'] }
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
},
|
||||||
|
cms_manuscript: {
|
||||||
|
title: '稿件',
|
||||||
|
sheetName: '稿件',
|
||||||
|
labels: {
|
||||||
|
title: '标题',
|
||||||
|
content: '正文',
|
||||||
|
contentFormat: '正文格式',
|
||||||
|
cover: '封面',
|
||||||
|
status: '状态',
|
||||||
|
sortNumber: '排序'
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: '基本信息',
|
||||||
|
fields: ['title', 'status', 'sortNumber']
|
||||||
|
},
|
||||||
|
{ title: '封面', fields: ['cover'] },
|
||||||
|
{ title: '正文', fields: ['content'] }
|
||||||
|
],
|
||||||
|
materialFields: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 团员管理模块历史上存在两个模块标识
|
||||||
|
MODULE_DETAIL_CONFIGS.gxmu_tygl = MODULE_DETAIL_CONFIGS.gxmu_tygl_form;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 明细数组表头字段中文名
|
||||||
|
*/
|
||||||
|
export const TABLE_FIELD_LABELS: Record<string, string> = {
|
||||||
|
name: '姓名',
|
||||||
|
gender: '性别',
|
||||||
|
nation: '民族',
|
||||||
|
politics: '政治面貌',
|
||||||
|
birthMonth: '出生年月',
|
||||||
|
title: '职称',
|
||||||
|
duty: '职务',
|
||||||
|
department: '部门',
|
||||||
|
dept: '部门',
|
||||||
|
remark: '备注',
|
||||||
|
college: '学院',
|
||||||
|
gradeMajor: '年级专业',
|
||||||
|
grade: '年级',
|
||||||
|
className: '班级',
|
||||||
|
major: '专业',
|
||||||
|
role: '角色',
|
||||||
|
phone: '联系电话',
|
||||||
|
sign: '签名',
|
||||||
|
item: '项目',
|
||||||
|
amount: '金额',
|
||||||
|
reason: '原因',
|
||||||
|
type: '类型',
|
||||||
|
content: '内容',
|
||||||
|
recordTime: '时间',
|
||||||
|
archiveTime: '时间',
|
||||||
|
awardName: '获奖名称',
|
||||||
|
awardTime: '获奖时间',
|
||||||
|
archiveOrder: '序号',
|
||||||
|
proofFile: '证明材料',
|
||||||
|
year: '年份'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getModuleDetailConfig = (module?: string) => {
|
||||||
|
if (!module) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return MODULE_DETAIL_CONFIGS[module];
|
||||||
|
};
|
||||||
@@ -15,7 +15,9 @@
|
|||||||
<a-tag>{{ moduleName || module || '-' }}</a-tag>
|
<a-tag>{{ moduleName || module || '-' }}</a-tag>
|
||||||
<span>业务ID:</span>
|
<span>业务ID:</span>
|
||||||
<a-tag>{{ pk ?? '-' }}</a-tag>
|
<a-tag>{{ pk ?? '-' }}</a-tag>
|
||||||
<a-button size="small" :loading="loading" @click="loadData">刷新</a-button>
|
<a-button size="small" :loading="loading" @click="loadData"
|
||||||
|
>刷新</a-button
|
||||||
|
>
|
||||||
</a-space>
|
</a-space>
|
||||||
|
|
||||||
<div v-if="flowList.length" class="flow-wrap">
|
<div v-if="flowList.length" class="flow-wrap">
|
||||||
@@ -23,7 +25,9 @@
|
|||||||
<a-step v-for="item in flowList" :key="item.id">
|
<a-step v-for="item in flowList" :key="item.id">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flow-title">
|
<div class="flow-title">
|
||||||
<span>{{ item.user?.realName || item.user?.username || '未分配审核人' }}</span>
|
<span>{{
|
||||||
|
item.user?.realName || item.user?.username || '未分配审核人'
|
||||||
|
}}</span>
|
||||||
<a-tag :color="getStatusColor(item.status)">
|
<a-tag :color="getStatusColor(item.status)">
|
||||||
{{ getStatusText(item.status) }}
|
{{ getStatusText(item.status) }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
@@ -31,14 +35,28 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #description>
|
<template #description>
|
||||||
<div class="flow-desc">
|
<div class="flow-desc">
|
||||||
<div>创建时间:{{ toDateString(item.createTime, 'yyyy-MM-dd HH:mm:ss') }}</div>
|
<div
|
||||||
|
>创建时间:{{
|
||||||
|
toDateString(item.createTime, 'yyyy-MM-dd HH:mm:ss')
|
||||||
|
}}</div
|
||||||
|
>
|
||||||
<div v-if="item.content">审核意见:{{ item.content }}</div>
|
<div v-if="item.content">审核意见:{{ item.content }}</div>
|
||||||
<a-space v-if="canReview(item)" style="margin-top: 12px">
|
<a-space v-if="canReview(item)" style="margin-top: 12px">
|
||||||
<a-button type="primary" size="small" :loading="submitting" @click="openActionModal('approve', item)">
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="openActionModal('approve', item)"
|
||||||
|
>
|
||||||
通过
|
通过
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button danger size="small" :loading="submitting" @click="openActionModal('reject', item)">
|
<a-button
|
||||||
拒绝
|
danger
|
||||||
|
size="small"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="openActionModal('reject', item)"
|
||||||
|
>
|
||||||
|
驳回
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,19 +68,26 @@
|
|||||||
|
|
||||||
<a-modal
|
<a-modal
|
||||||
v-model:visible="actionVisible"
|
v-model:visible="actionVisible"
|
||||||
:title="actionType === 'approve' ? '审核通过' : '审核拒绝'"
|
:title="actionType === 'approve' ? '审核通过' : '审核驳回'"
|
||||||
ok-text="确认"
|
ok-text="确认"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
:confirm-loading="submitting"
|
:confirm-loading="submitting"
|
||||||
@ok="submitReview"
|
@ok="submitReview"
|
||||||
>
|
>
|
||||||
<a-form layout="vertical">
|
<a-form
|
||||||
<a-form-item label="审核意见(非必填)">
|
ref="actionFormRef"
|
||||||
|
layout="vertical"
|
||||||
|
:model="actionForm"
|
||||||
|
:rules="actionRules"
|
||||||
|
>
|
||||||
|
<a-form-item label="审核意见" name="content">
|
||||||
<a-textarea
|
<a-textarea
|
||||||
v-model:value="actionContent"
|
v-model:value="actionForm.content"
|
||||||
:rows="4"
|
:rows="4"
|
||||||
|
:maxlength="500"
|
||||||
|
show-count
|
||||||
allow-clear
|
allow-clear
|
||||||
placeholder="请输入审核意见"
|
placeholder="请输入审核意见(必填)"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
@@ -71,163 +96,187 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { Empty, message } from 'ant-design-vue';
|
import { Empty, message } from 'ant-design-vue';
|
||||||
import { toDateString } from 'ele-admin-pro';
|
import type { FormInstance } from 'ant-design-vue/es/form';
|
||||||
import { listReviewList, updateReviewList } from '@/api/gxmu/reviewList';
|
import { toDateString } from 'ele-admin-pro';
|
||||||
import type { ReviewList } from '@/api/gxmu/reviewList/model';
|
import { listReviewList, updateReviewList } from '@/api/gxmu/reviewList';
|
||||||
import { useUserStore } from '@/store/modules/user';
|
import type { ReviewList } from '@/api/gxmu/reviewList/model';
|
||||||
|
import { useUserStore } from '@/store/modules/user';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
module?: string;
|
module?: string;
|
||||||
moduleName?: string;
|
moduleName?: string;
|
||||||
pk?: number;
|
pk?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:visible', visible: boolean): void;
|
(e: 'update:visible', visible: boolean): void;
|
||||||
(e: 'done'): void;
|
(e: 'done'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const loginUserId = computed(() => Number(userStore.info?.userId || 0));
|
const loginUserId = computed(() => Number(userStore.info?.userId || 0));
|
||||||
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
|
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const flowList = ref<ReviewList[]>([]);
|
const flowList = ref<ReviewList[]>([]);
|
||||||
const actionVisible = ref(false);
|
const actionVisible = ref(false);
|
||||||
const actionType = ref<'approve' | 'reject'>('approve');
|
const actionType = ref<'approve' | 'reject'>('approve');
|
||||||
const actionContent = ref('');
|
const actionFormRef = ref<FormInstance | null>(null);
|
||||||
const actionTarget = ref<ReviewList | null>(null);
|
const actionForm = reactive<{ content: string }>({ content: '' });
|
||||||
|
const actionTarget = ref<ReviewList | null>(null);
|
||||||
|
|
||||||
const currentStepIndex = computed(() => {
|
// 审核意见必填(仅空白字符视为未填写)
|
||||||
const pendingIndex = flowList.value.findIndex((item) => item.status === 0);
|
const actionRules = {
|
||||||
if (pendingIndex >= 0) {
|
content: [
|
||||||
return pendingIndex;
|
{
|
||||||
}
|
required: true,
|
||||||
return Math.max(flowList.value.length - 1, 0);
|
validator: (_rule: unknown, value: string) => {
|
||||||
});
|
const text = (value || '').trim();
|
||||||
|
return text
|
||||||
|
? Promise.resolve()
|
||||||
|
: Promise.reject(new Error('请输入审核意见'));
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
const updateVisible = (value: boolean) => {
|
const currentStepIndex = computed(() => {
|
||||||
emit('update:visible', value);
|
const pendingIndex = flowList.value.findIndex((item) => item.status === 0);
|
||||||
};
|
if (pendingIndex >= 0) {
|
||||||
|
return pendingIndex;
|
||||||
const getStatusText = (status?: number) => {
|
|
||||||
if (status === 1) return '通过';
|
|
||||||
if (status === 2) return '拒绝';
|
|
||||||
return '待审核';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status?: number) => {
|
|
||||||
if (status === 1) return 'success';
|
|
||||||
if (status === 2) return 'error';
|
|
||||||
return 'default';
|
|
||||||
};
|
|
||||||
|
|
||||||
const canReview = (item: ReviewList) => {
|
|
||||||
return item.status === 0 && Number(item.userId || 0) === loginUserId.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadData = () => {
|
|
||||||
if (!props.module || !props.pk) {
|
|
||||||
flowList.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading.value = true;
|
|
||||||
listReviewList({
|
|
||||||
module: props.module,
|
|
||||||
pk: props.pk
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
flowList.value = [...(data || [])].sort(
|
|
||||||
(a, b) => Number(b.id || 0) - Number(a.id || 0)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
flowList.value = [];
|
|
||||||
message.error(e.message);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
loading.value = false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const openActionModal = (type: 'approve' | 'reject', item: ReviewList) => {
|
|
||||||
if (!canReview(item)) {
|
|
||||||
message.error('当前记录不是由你审核');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
actionType.value = type;
|
|
||||||
actionTarget.value = item;
|
|
||||||
actionContent.value = '';
|
|
||||||
actionVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitReview = () => {
|
|
||||||
if (!actionTarget.value?.id) {
|
|
||||||
actionVisible.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!canReview(actionTarget.value)) {
|
|
||||||
actionVisible.value = false;
|
|
||||||
message.error('当前记录不是由你审核');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
submitting.value = true;
|
|
||||||
updateReviewList({
|
|
||||||
id: actionTarget.value.id,
|
|
||||||
status: actionType.value === 'approve' ? 1 : 2,
|
|
||||||
module: actionTarget.value.module,
|
|
||||||
pk: actionTarget.value.pk,
|
|
||||||
sortNumber: actionTarget.value.sortNumber,
|
|
||||||
content: actionContent.value || undefined
|
|
||||||
})
|
|
||||||
.then((msg) => {
|
|
||||||
message.success(msg);
|
|
||||||
actionVisible.value = false;
|
|
||||||
emit('done');
|
|
||||||
loadData();
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
message.error(e.message);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
submitting.value = false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.visible,
|
|
||||||
(visible) => {
|
|
||||||
if (visible) {
|
|
||||||
loadData();
|
|
||||||
} else {
|
|
||||||
flowList.value = [];
|
|
||||||
actionVisible.value = false;
|
|
||||||
actionTarget.value = null;
|
|
||||||
actionContent.value = '';
|
|
||||||
}
|
}
|
||||||
},
|
return Math.max(flowList.value.length - 1, 0);
|
||||||
{ immediate: true }
|
});
|
||||||
);
|
|
||||||
|
const updateVisible = (value: boolean) => {
|
||||||
|
emit('update:visible', value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status?: number) => {
|
||||||
|
if (status === 1) return '通过';
|
||||||
|
if (status === 2) return '驳回';
|
||||||
|
return '待审核';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status?: number) => {
|
||||||
|
if (status === 1) return 'success';
|
||||||
|
if (status === 2) return 'error';
|
||||||
|
return 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const canReview = (item: ReviewList) => {
|
||||||
|
return item.status === 0 && Number(item.userId || 0) === loginUserId.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadData = () => {
|
||||||
|
if (!props.module || !props.pk) {
|
||||||
|
flowList.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
listReviewList({
|
||||||
|
module: props.module,
|
||||||
|
pk: props.pk
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
flowList.value = [...(data || [])].sort(
|
||||||
|
(a, b) => Number(b.id || 0) - Number(a.id || 0)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
flowList.value = [];
|
||||||
|
message.error(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openActionModal = (type: 'approve' | 'reject', item: ReviewList) => {
|
||||||
|
if (!canReview(item)) {
|
||||||
|
message.error('当前记录不是由你审核');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
actionType.value = type;
|
||||||
|
actionTarget.value = item;
|
||||||
|
actionForm.content = '';
|
||||||
|
actionVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitReview = () => {
|
||||||
|
const target = actionTarget.value;
|
||||||
|
if (!target?.id) {
|
||||||
|
actionVisible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!canReview(target)) {
|
||||||
|
actionVisible.value = false;
|
||||||
|
message.error('当前记录不是由你审核');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
actionFormRef.value
|
||||||
|
?.validate()
|
||||||
|
.then(() => {
|
||||||
|
submitting.value = true;
|
||||||
|
return updateReviewList({
|
||||||
|
id: target.id,
|
||||||
|
status: actionType.value === 'approve' ? 1 : 2,
|
||||||
|
module: target.module,
|
||||||
|
pk: target.pk,
|
||||||
|
sortNumber: target.sortNumber,
|
||||||
|
content: actionForm.content.trim()
|
||||||
|
})
|
||||||
|
.then((msg) => {
|
||||||
|
message.success(msg);
|
||||||
|
actionVisible.value = false;
|
||||||
|
emit('done');
|
||||||
|
loadData();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
message.error(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submitting.value = false;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
flowList.value = [];
|
||||||
|
actionVisible.value = false;
|
||||||
|
actionTarget.value = null;
|
||||||
|
actionForm.content = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.flow-wrap {
|
.flow-wrap {
|
||||||
max-height: 560px;
|
max-height: 560px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-title {
|
.flow-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-desc {
|
.flow-desc {
|
||||||
color: rgba(0, 0, 0, 0.65);
|
color: rgba(0, 0, 0, 0.65);
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
v-model:value="form.pk"
|
v-model:value="form.pk"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="状态(0不通过 1通过)" name="status">
|
<a-form-item label="状态(0驳回 1通过)" name="status">
|
||||||
<a-radio-group v-model:value="form.status">
|
<a-radio-group v-model:value="form.status">
|
||||||
<a-radio :value="1">显示</a-radio>
|
<a-radio :value="1">显示</a-radio>
|
||||||
<a-radio :value="0">隐藏</a-radio>
|
<a-radio :value="0">隐藏</a-radio>
|
||||||
|
|||||||
+170
-150
@@ -23,7 +23,7 @@
|
|||||||
<template v-if="column.key === 'status'">
|
<template v-if="column.key === 'status'">
|
||||||
<a-tag v-if="record.status === 0">待审核</a-tag>
|
<a-tag v-if="record.status === 0">待审核</a-tag>
|
||||||
<a-tag v-else-if="record.status === 1" color="green">通过</a-tag>
|
<a-tag v-else-if="record.status === 1" color="green">通过</a-tag>
|
||||||
<a-tag v-else-if="record.status === 2" color="red">拒绝</a-tag>
|
<a-tag v-else-if="record.status === 2" color="red">驳回</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.key === 'action'">
|
<template v-if="column.key === 'action'">
|
||||||
<a-space>
|
<a-space>
|
||||||
@@ -36,12 +36,17 @@
|
|||||||
</ele-pro-table>
|
</ele-pro-table>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<ReviewListEdit v-model:visible="showEdit" :data="current" @done="reload" />
|
<ReviewListEdit
|
||||||
|
v-model:visible="showEdit"
|
||||||
|
:data="current"
|
||||||
|
@done="reload"
|
||||||
|
/>
|
||||||
<ModuleDataModal
|
<ModuleDataModal
|
||||||
v-model:visible="showDataVisible"
|
v-model:visible="showDataVisible"
|
||||||
:module="dataCurrent?.module"
|
:module="dataCurrent?.module"
|
||||||
:module-name="dataCurrent?.moduleName"
|
:module-name="dataCurrent?.moduleName"
|
||||||
:pk="dataCurrent?.pk"
|
:pk="dataCurrent?.pk"
|
||||||
|
@done="reload"
|
||||||
/>
|
/>
|
||||||
<ReviewFlowModal
|
<ReviewFlowModal
|
||||||
v-model:visible="showFlowVisible"
|
v-model:visible="showFlowVisible"
|
||||||
@@ -55,162 +60,177 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { createVNode, ref } from 'vue';
|
import { createVNode, ref } from 'vue';
|
||||||
import { message, Modal } from 'ant-design-vue';
|
import { message, Modal } from 'ant-design-vue';
|
||||||
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
|
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
|
||||||
import type { EleProTable } from 'ele-admin-pro';
|
import type { EleProTable } from 'ele-admin-pro';
|
||||||
import { toDateString } from 'ele-admin-pro';
|
import { toDateString } from 'ele-admin-pro';
|
||||||
import type { DatasourceFunction, ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
|
import type {
|
||||||
import Search from './components/search.vue';
|
DatasourceFunction,
|
||||||
import ReviewListEdit from './components/reviewListEdit.vue';
|
ColumnItem
|
||||||
import ModuleDataModal from './components/moduleDataModal.vue';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
import ReviewFlowModal from './components/reviewFlowModal.vue';
|
import Search from './components/search.vue';
|
||||||
import { pageGroupReviewList, removeBatchReviewList } from '@/api/gxmu/reviewList';
|
import ReviewListEdit from './components/reviewListEdit.vue';
|
||||||
import type { ReviewList, ReviewListParam } from '@/api/gxmu/reviewList/model';
|
import ModuleDataModal from './components/moduleDataModal.vue';
|
||||||
|
import ReviewFlowModal from './components/reviewFlowModal.vue';
|
||||||
|
import {
|
||||||
|
pageGroupReviewList,
|
||||||
|
removeBatchReviewList
|
||||||
|
} from '@/api/gxmu/reviewList';
|
||||||
|
import type {
|
||||||
|
ReviewList,
|
||||||
|
ReviewListParam
|
||||||
|
} from '@/api/gxmu/reviewList/model';
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||||
const selection = ref<ReviewList[]>([]);
|
const selection = ref<ReviewList[]>([]);
|
||||||
const current = ref<ReviewList | null>(null);
|
const current = ref<ReviewList | null>(null);
|
||||||
const dataCurrent = ref<ReviewList | null>(null);
|
const dataCurrent = ref<ReviewList | null>(null);
|
||||||
const flowCurrent = ref<ReviewList | null>(null);
|
const flowCurrent = ref<ReviewList | null>(null);
|
||||||
const showEdit = ref(false);
|
const showEdit = ref(false);
|
||||||
const showDataVisible = ref(false);
|
const showDataVisible = ref(false);
|
||||||
const showFlowVisible = ref(false);
|
const showFlowVisible = ref(false);
|
||||||
const showMove = ref(false);
|
const showMove = ref(false);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
||||||
const datasource: DatasourceFunction = ({ page, limit, where, orders, filters }) => {
|
const datasource: DatasourceFunction = ({
|
||||||
if (filters) {
|
|
||||||
where.status = filters.status;
|
|
||||||
}
|
|
||||||
return pageGroupReviewList({
|
|
||||||
...where,
|
|
||||||
...orders,
|
|
||||||
page,
|
page,
|
||||||
limit
|
limit,
|
||||||
});
|
where,
|
||||||
};
|
orders,
|
||||||
|
filters
|
||||||
const columns = ref<ColumnItem[]>([
|
}) => {
|
||||||
{
|
if (filters) {
|
||||||
title: 'ID',
|
where.status = filters.status;
|
||||||
dataIndex: 'id',
|
|
||||||
key: 'id',
|
|
||||||
align: 'center',
|
|
||||||
width: 90
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '模块',
|
|
||||||
dataIndex: 'moduleName',
|
|
||||||
key: 'module',
|
|
||||||
align: 'center'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '名称',
|
|
||||||
dataIndex: 'businessName',
|
|
||||||
key: 'businessName',
|
|
||||||
align: 'center',
|
|
||||||
ellipsis: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
key: 'status',
|
|
||||||
align: 'center'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '最新审核意见',
|
|
||||||
dataIndex: 'content',
|
|
||||||
key: 'content',
|
|
||||||
align: 'center',
|
|
||||||
ellipsis: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '提交时间',
|
|
||||||
dataIndex: 'createTime',
|
|
||||||
key: 'createTime',
|
|
||||||
align: 'center',
|
|
||||||
sorter: true,
|
|
||||||
ellipsis: true,
|
|
||||||
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '当前审核人',
|
|
||||||
dataIndex: ['user', 'realName'],
|
|
||||||
key: 'userId',
|
|
||||||
align: 'center'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'action',
|
|
||||||
width: 220,
|
|
||||||
fixed: 'right',
|
|
||||||
align: 'center',
|
|
||||||
hideInSetting: true
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
const reload = (where?: ReviewListParam) => {
|
|
||||||
selection.value = [];
|
|
||||||
tableRef?.value?.reload({ where });
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (row?: ReviewList) => {
|
|
||||||
current.value = row ?? null;
|
|
||||||
showEdit.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openMove = () => {
|
|
||||||
showMove.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openDataModal = (row: ReviewList) => {
|
|
||||||
dataCurrent.value = row;
|
|
||||||
showDataVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openFlowModal = (row: ReviewList) => {
|
|
||||||
flowCurrent.value = row;
|
|
||||||
showFlowVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeBatch = () => {
|
|
||||||
if (!selection.value.length) {
|
|
||||||
message.error('请至少选择一条数据');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Modal.confirm({
|
|
||||||
title: '提示',
|
|
||||||
content: '确定要删除选中的记录吗?',
|
|
||||||
icon: createVNode(ExclamationCircleOutlined),
|
|
||||||
maskClosable: true,
|
|
||||||
onOk: () => {
|
|
||||||
const hide = message.loading('请求中..', 0);
|
|
||||||
removeBatchReviewList(selection.value.map((d) => d.id))
|
|
||||||
.then((msg) => {
|
|
||||||
hide();
|
|
||||||
message.success(msg);
|
|
||||||
reload();
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
hide();
|
|
||||||
message.error(e.message);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
return pageGroupReviewList({
|
||||||
};
|
...where,
|
||||||
|
...orders,
|
||||||
|
page,
|
||||||
|
limit
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const query = () => {
|
const columns = ref<ColumnItem[]>([
|
||||||
loading.value = true;
|
{
|
||||||
};
|
title: 'ID',
|
||||||
|
dataIndex: 'id',
|
||||||
|
key: 'id',
|
||||||
|
align: 'center',
|
||||||
|
width: 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '模块',
|
||||||
|
dataIndex: 'moduleName',
|
||||||
|
key: 'module',
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'businessName',
|
||||||
|
key: 'businessName',
|
||||||
|
align: 'center',
|
||||||
|
ellipsis: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最新审核意见',
|
||||||
|
dataIndex: 'content',
|
||||||
|
key: 'content',
|
||||||
|
align: 'center',
|
||||||
|
ellipsis: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '提交时间',
|
||||||
|
dataIndex: 'createTime',
|
||||||
|
key: 'createTime',
|
||||||
|
align: 'center',
|
||||||
|
sorter: true,
|
||||||
|
ellipsis: true,
|
||||||
|
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '当前审核人',
|
||||||
|
dataIndex: ['user', 'realName'],
|
||||||
|
key: 'userId',
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 220,
|
||||||
|
fixed: 'right',
|
||||||
|
align: 'center',
|
||||||
|
hideInSetting: true
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
query();
|
const reload = (where?: ReviewListParam) => {
|
||||||
|
selection.value = [];
|
||||||
|
tableRef?.value?.reload({ where });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (row?: ReviewList) => {
|
||||||
|
current.value = row ?? null;
|
||||||
|
showEdit.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMove = () => {
|
||||||
|
showMove.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDataModal = (row: ReviewList) => {
|
||||||
|
dataCurrent.value = row;
|
||||||
|
showDataVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFlowModal = (row: ReviewList) => {
|
||||||
|
flowCurrent.value = row;
|
||||||
|
showFlowVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeBatch = () => {
|
||||||
|
if (!selection.value.length) {
|
||||||
|
message.error('请至少选择一条数据');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Modal.confirm({
|
||||||
|
title: '提示',
|
||||||
|
content: '确定要删除选中的记录吗?',
|
||||||
|
icon: createVNode(ExclamationCircleOutlined),
|
||||||
|
maskClosable: true,
|
||||||
|
onOk: () => {
|
||||||
|
const hide = message.loading('请求中..', 0);
|
||||||
|
removeBatchReviewList(selection.value.map((d) => d.id))
|
||||||
|
.then((msg) => {
|
||||||
|
hide();
|
||||||
|
message.success(msg);
|
||||||
|
reload();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
hide();
|
||||||
|
message.error(e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = () => {
|
||||||
|
loading.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
query();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export default {
|
export default {
|
||||||
name: 'ReviewList'
|
name: 'ReviewList'
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped></style>
|
<style lang="less" scoped></style>
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="ele-body">
|
||||||
|
<a-card :bordered="false" :body-style="{ padding: '16px' }">
|
||||||
|
<ele-pro-table
|
||||||
|
ref="tableRef"
|
||||||
|
row-key="id"
|
||||||
|
:columns="columns"
|
||||||
|
:datasource="datasource"
|
||||||
|
:scroll="{ x: 1600 }"
|
||||||
|
tool-class="ele-toolbar-form"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<a-space style="flex-wrap: wrap">
|
||||||
|
<a-select
|
||||||
|
v-model:value="collegeId"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
option-filter-prop="label"
|
||||||
|
style="width: 220px"
|
||||||
|
placeholder="学院"
|
||||||
|
:options="collegeOptions"
|
||||||
|
@change="onCollegeChange"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="classId"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
option-filter-prop="label"
|
||||||
|
style="width: 220px"
|
||||||
|
placeholder="班级"
|
||||||
|
:options="classOptions"
|
||||||
|
/>
|
||||||
|
<a-input
|
||||||
|
v-model:value="xh"
|
||||||
|
allow-clear
|
||||||
|
style="width: 160px"
|
||||||
|
placeholder="学号"
|
||||||
|
@pressEnter="reload"
|
||||||
|
/>
|
||||||
|
<a-input
|
||||||
|
v-model:value="xm"
|
||||||
|
allow-clear
|
||||||
|
style="width: 140px"
|
||||||
|
placeholder="姓名"
|
||||||
|
@pressEnter="reload"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="status"
|
||||||
|
style="width: 130px"
|
||||||
|
placeholder="状态"
|
||||||
|
:options="statusOptions"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="claimed"
|
||||||
|
allow-clear
|
||||||
|
style="width: 130px"
|
||||||
|
placeholder="认领状态"
|
||||||
|
:options="claimedOptions"
|
||||||
|
/>
|
||||||
|
<a-button type="primary" @click="reload">查询</a-button>
|
||||||
|
<a-button @click="resetSearch">重置</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="hasPermission('gxmu:student:export')"
|
||||||
|
:loading="exporting"
|
||||||
|
@click="doExport"
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'status'">
|
||||||
|
<a-tag :color="record.status === 1 ? 'green' : 'default'">
|
||||||
|
{{ record.status === 1 ? '在校' : '毕业' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'claimed'">
|
||||||
|
<a-tag :color="record.userId ? 'blue' : 'default'">
|
||||||
|
{{ record.userId ? '已认领' : '未认领' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</ele-pro-table>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import type { EleProTable } from 'ele-admin-pro';
|
||||||
|
import { toDateString } from 'ele-admin-pro';
|
||||||
|
import type {
|
||||||
|
DatasourceFunction,
|
||||||
|
ColumnItem
|
||||||
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
import { hasPermission } from '@/utils/permission';
|
||||||
|
import { listOrganizations } from '@/api/system/organization';
|
||||||
|
import { listClassInfo } from '@/api/gxmu/class';
|
||||||
|
import { exportStudentRoster, pageStudentRoster } from '@/api/gxmu/student';
|
||||||
|
import type {
|
||||||
|
StudentRoster,
|
||||||
|
StudentRosterParam
|
||||||
|
} from '@/api/gxmu/student/model';
|
||||||
|
|
||||||
|
/** 学院挂载的父节点:广西医科大学 */
|
||||||
|
const COLLEGE_PARENT_ID = 24;
|
||||||
|
|
||||||
|
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||||
|
const collegeId = ref<number | undefined>(undefined);
|
||||||
|
const classId = ref<number | undefined>(undefined);
|
||||||
|
const xh = ref<string>('');
|
||||||
|
const xm = ref<string>('');
|
||||||
|
const status = ref<number>(1);
|
||||||
|
const claimed = ref<boolean | undefined>(undefined);
|
||||||
|
const exporting = ref(false);
|
||||||
|
const collegeOptions = ref<{ label: string; value: number }[]>([]);
|
||||||
|
const classOptions = ref<{ label: string; value: number }[]>([]);
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '在校', value: 1 },
|
||||||
|
{ label: '毕业', value: 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const claimedOptions = [
|
||||||
|
{ label: '已认领', value: true },
|
||||||
|
{ label: '未认领', value: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns = ref<ColumnItem[]>([
|
||||||
|
{ title: '学号', dataIndex: 'xh', key: 'xh', width: 140 },
|
||||||
|
{ title: '姓名', dataIndex: 'xm', key: 'xm', width: 110 },
|
||||||
|
{ title: '性别', dataIndex: 'sex', key: 'sex', width: 80 },
|
||||||
|
{ title: '学院', dataIndex: 'collegeName', key: 'collegeName', width: 200 },
|
||||||
|
{
|
||||||
|
title: '班级',
|
||||||
|
dataIndex: 'className',
|
||||||
|
key: 'className',
|
||||||
|
width: 170,
|
||||||
|
customRender: ({ text }) => text || '-'
|
||||||
|
},
|
||||||
|
{ title: '专业', dataIndex: 'zymc', key: 'zymc', width: 170 },
|
||||||
|
{
|
||||||
|
title: '培养层次',
|
||||||
|
dataIndex: 'pyccmc',
|
||||||
|
key: 'pyccmc',
|
||||||
|
width: 110
|
||||||
|
},
|
||||||
|
{ title: '手机号', dataIndex: 'sjh', key: 'sjh', width: 140 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
align: 'center',
|
||||||
|
width: 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '认领',
|
||||||
|
dataIndex: 'userId',
|
||||||
|
key: 'claimed',
|
||||||
|
align: 'center',
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '同步时间',
|
||||||
|
dataIndex: 'syncTime',
|
||||||
|
key: 'syncTime',
|
||||||
|
width: 170,
|
||||||
|
customRender: ({ text }) => (text ? toDateString(text) : '-')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentParam = (): StudentRosterParam => ({
|
||||||
|
collegeId: collegeId.value,
|
||||||
|
classId: classId.value,
|
||||||
|
xh: xh.value.trim() || undefined,
|
||||||
|
xm: xm.value.trim() || undefined,
|
||||||
|
status: status.value,
|
||||||
|
claimed: claimed.value
|
||||||
|
});
|
||||||
|
|
||||||
|
const datasource: DatasourceFunction = ({ page, limit }) => {
|
||||||
|
return pageStudentRoster({ ...currentParam(), page, limit }).catch((e) => {
|
||||||
|
message.error(e.message);
|
||||||
|
return { list: [] as StudentRoster[], count: 0 };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
tableRef.value?.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadColleges = () => {
|
||||||
|
// 学院取组织树中「广西医科大学」的直接子节点,不再走已废弃的 gxmu_college
|
||||||
|
listOrganizations({ parentId: COLLEGE_PARENT_ID })
|
||||||
|
.then((list) => {
|
||||||
|
collegeOptions.value = list.map((item) => ({
|
||||||
|
label: item.organizationFullName || item.organizationName || '',
|
||||||
|
value: item.organizationId || 0
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
message.error(e.message);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadClasses = (id?: number) => {
|
||||||
|
if (!id) {
|
||||||
|
classOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listClassInfo({ collegeId: id })
|
||||||
|
.then((list) => {
|
||||||
|
classOptions.value = list.map((item) => ({
|
||||||
|
label: item.className || '',
|
||||||
|
value: item.id || 0
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
classOptions.value = [];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCollegeChange = (value?: number) => {
|
||||||
|
classId.value = undefined;
|
||||||
|
loadClasses(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSearch = () => {
|
||||||
|
collegeId.value = undefined;
|
||||||
|
classId.value = undefined;
|
||||||
|
xh.value = '';
|
||||||
|
xm.value = '';
|
||||||
|
status.value = 1;
|
||||||
|
claimed.value = undefined;
|
||||||
|
classOptions.value = [];
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const doExport = () => {
|
||||||
|
exporting.value = true;
|
||||||
|
exportStudentRoster(currentParam())
|
||||||
|
.then((list) => {
|
||||||
|
if (!list.length) {
|
||||||
|
message.warning('没有可导出的数据');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const header = [
|
||||||
|
'学号',
|
||||||
|
'姓名',
|
||||||
|
'性别',
|
||||||
|
'学院',
|
||||||
|
'班级',
|
||||||
|
'专业',
|
||||||
|
'培养层次',
|
||||||
|
'手机号',
|
||||||
|
'状态'
|
||||||
|
];
|
||||||
|
const lines = list.map((item) =>
|
||||||
|
[
|
||||||
|
item.xh,
|
||||||
|
item.xm,
|
||||||
|
item.sex,
|
||||||
|
item.collegeName,
|
||||||
|
item.className || '',
|
||||||
|
item.zymc,
|
||||||
|
item.pyccmc,
|
||||||
|
item.sjh,
|
||||||
|
item.status === 1 ? '在校' : '毕业'
|
||||||
|
]
|
||||||
|
.map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`)
|
||||||
|
.join(',')
|
||||||
|
);
|
||||||
|
const csv = '\uFEFF' + [header.join(','), ...lines].join('\r\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `学生名册_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
message.success(`已导出 ${list.length} 条`);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
message.error(e.message || '导出失败');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
exporting.value = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadColleges();
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
name: 'StudentRoster'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="ele-body">
|
||||||
|
<a-card :bordered="false" :body-style="{ padding: '16px' }">
|
||||||
|
<ele-pro-table
|
||||||
|
ref="tableRef"
|
||||||
|
row-key="id"
|
||||||
|
:columns="columns"
|
||||||
|
:datasource="datasource"
|
||||||
|
:scroll="{ x: 1200 }"
|
||||||
|
tool-class="ele-toolbar-form"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<a-space style="flex-wrap: wrap">
|
||||||
|
<a-select
|
||||||
|
v-model:value="collegeId"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
option-filter-prop="label"
|
||||||
|
style="width: 220px"
|
||||||
|
placeholder="学院"
|
||||||
|
:options="collegeOptions"
|
||||||
|
/>
|
||||||
|
<a-input
|
||||||
|
v-model:value="jgh"
|
||||||
|
allow-clear
|
||||||
|
style="width: 160px"
|
||||||
|
placeholder="教工号"
|
||||||
|
@pressEnter="reload"
|
||||||
|
/>
|
||||||
|
<a-input
|
||||||
|
v-model:value="xm"
|
||||||
|
allow-clear
|
||||||
|
style="width: 140px"
|
||||||
|
placeholder="姓名"
|
||||||
|
@pressEnter="reload"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="status"
|
||||||
|
style="width: 130px"
|
||||||
|
placeholder="状态"
|
||||||
|
:options="statusOptions"
|
||||||
|
/>
|
||||||
|
<a-button type="primary" @click="reload">查询</a-button>
|
||||||
|
<a-button @click="resetSearch">重置</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="hasPermission('gxmu:teacher:export')"
|
||||||
|
:loading="exporting"
|
||||||
|
@click="doExport"
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'status'">
|
||||||
|
<a-tag :color="record.status === 1 ? 'green' : 'default'">
|
||||||
|
{{ record.status === 1 ? '启用' : '停用' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</ele-pro-table>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import type { EleProTable } from 'ele-admin-pro';
|
||||||
|
import { toDateString } from 'ele-admin-pro';
|
||||||
|
import type {
|
||||||
|
DatasourceFunction,
|
||||||
|
ColumnItem
|
||||||
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
import { hasPermission } from '@/utils/permission';
|
||||||
|
import { listOrganizations } from '@/api/system/organization';
|
||||||
|
import { exportTeacherRoster, pageTeacherRoster } from '@/api/gxmu/teacher';
|
||||||
|
import type {
|
||||||
|
TeacherRoster,
|
||||||
|
TeacherRosterParam
|
||||||
|
} from '@/api/gxmu/teacher/model';
|
||||||
|
|
||||||
|
/** 学院挂载的父节点:广西医科大学 */
|
||||||
|
const COLLEGE_PARENT_ID = 24;
|
||||||
|
|
||||||
|
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||||
|
const collegeId = ref<number | undefined>(undefined);
|
||||||
|
const jgh = ref<string>('');
|
||||||
|
const xm = ref<string>('');
|
||||||
|
const status = ref<number>(1);
|
||||||
|
const exporting = ref(false);
|
||||||
|
const collegeOptions = ref<{ label: string; value: number }[]>([]);
|
||||||
|
|
||||||
|
/** 停用=上游已消失,默认不显示(ADR-0004) */
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '启用', value: 1 },
|
||||||
|
{ label: '停用', value: 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns = ref<ColumnItem[]>([
|
||||||
|
{ title: '教工号', dataIndex: 'jgh', key: 'jgh', width: 140 },
|
||||||
|
{ title: '姓名', dataIndex: 'xm', key: 'xm', width: 110 },
|
||||||
|
{ title: '性别', dataIndex: 'sex', key: 'sex', width: 80 },
|
||||||
|
{
|
||||||
|
title: '学院',
|
||||||
|
dataIndex: 'collegeName',
|
||||||
|
key: 'collegeName',
|
||||||
|
width: 220,
|
||||||
|
customRender: ({ text }) => text || '-'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单位号',
|
||||||
|
dataIndex: 'dwh',
|
||||||
|
key: 'dwh',
|
||||||
|
width: 120,
|
||||||
|
customRender: ({ text }) => text || '-'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '科室教研室编号',
|
||||||
|
dataIndex: 'ksjybh',
|
||||||
|
key: 'ksjybh',
|
||||||
|
width: 160,
|
||||||
|
customRender: ({ text }) => text || '-'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
align: 'center',
|
||||||
|
width: 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '同步时间',
|
||||||
|
dataIndex: 'syncTime',
|
||||||
|
key: 'syncTime',
|
||||||
|
width: 170,
|
||||||
|
customRender: ({ text }) => (text ? toDateString(text) : '-')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentParam = (): TeacherRosterParam => ({
|
||||||
|
collegeId: collegeId.value,
|
||||||
|
jgh: jgh.value.trim() || undefined,
|
||||||
|
xm: xm.value.trim() || undefined,
|
||||||
|
status: status.value
|
||||||
|
});
|
||||||
|
|
||||||
|
const datasource: DatasourceFunction = ({ page, limit }) => {
|
||||||
|
return pageTeacherRoster({ ...currentParam(), page, limit }).catch((e) => {
|
||||||
|
message.error(e.message);
|
||||||
|
return { list: [] as TeacherRoster[], count: 0 };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
tableRef.value?.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadColleges = () => {
|
||||||
|
// 学院取组织树中「广西医科大学」的直接子节点,与学生名册一致
|
||||||
|
listOrganizations({ parentId: COLLEGE_PARENT_ID })
|
||||||
|
.then((list) => {
|
||||||
|
collegeOptions.value = list.map((item) => ({
|
||||||
|
label: item.organizationFullName || item.organizationName || '',
|
||||||
|
value: item.organizationId || 0
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
message.error(e.message);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSearch = () => {
|
||||||
|
collegeId.value = undefined;
|
||||||
|
jgh.value = '';
|
||||||
|
xm.value = '';
|
||||||
|
status.value = 1;
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const doExport = () => {
|
||||||
|
exporting.value = true;
|
||||||
|
exportTeacherRoster(currentParam())
|
||||||
|
.then((list) => {
|
||||||
|
if (!list.length) {
|
||||||
|
message.warning('没有可导出的数据');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 手机号不在导出范围内:后端不返回该字段(spec §5.2)
|
||||||
|
const header = [
|
||||||
|
'教工号',
|
||||||
|
'姓名',
|
||||||
|
'性别',
|
||||||
|
'学院',
|
||||||
|
'单位号',
|
||||||
|
'科室教研室编号',
|
||||||
|
'状态'
|
||||||
|
];
|
||||||
|
const lines = list.map((item) =>
|
||||||
|
[
|
||||||
|
item.jgh,
|
||||||
|
item.xm,
|
||||||
|
item.sex,
|
||||||
|
item.collegeName,
|
||||||
|
item.dwh || '',
|
||||||
|
item.ksjybh || '',
|
||||||
|
item.status === 1 ? '启用' : '停用'
|
||||||
|
]
|
||||||
|
.map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`)
|
||||||
|
.join(',')
|
||||||
|
);
|
||||||
|
const csv = '\uFEFF' + [header.join(','), ...lines].join('\r\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `教师名册_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
message.success(`已导出 ${list.length} 条`);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
message.error(e.message || '导出失败');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
exporting.value = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadColleges();
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
name: 'TeacherRoster'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
export interface AttachmentFile {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_EXT_PATTERN =
|
||||||
|
/\.(pdf|docx?|xlsx?|pptx?|csv|txt|zip|rar|7z|jpe?g|png|gif|webp|bmp|svg|mp4|mp3|wav)$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件地址中取出展示用的文件名
|
||||||
|
*/
|
||||||
|
export function getAttachmentName(url?: string) {
|
||||||
|
if (!url) {
|
||||||
|
return '未命名文件';
|
||||||
|
}
|
||||||
|
const cleanUrl = url.split('?')[0];
|
||||||
|
const name = cleanUrl.split('/').pop() || cleanUrl;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(name);
|
||||||
|
} catch (e) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断字符串是否像文件地址(http(s)、协议相对地址、带扩展名的相对路径)
|
||||||
|
*/
|
||||||
|
export function isFileUrl(value?: string) {
|
||||||
|
const text = value?.trim();
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^(https?:)?\/\//i.test(text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return FILE_EXT_PATTERN.test(text) && !/\s/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageFile(url?: string) {
|
||||||
|
const text = (url || '').split('?')[0];
|
||||||
|
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeEntry = (item: unknown): AttachmentFile | null => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return isFileUrl(item)
|
||||||
|
? { name: getAttachmentName(item), url: item.trim() }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
const rawUrl = [
|
||||||
|
record.url,
|
||||||
|
record.fileUrl,
|
||||||
|
record.downloadUrl,
|
||||||
|
record.path
|
||||||
|
].find((value) => typeof value === 'string' && isFileUrl(value as string));
|
||||||
|
if (typeof rawUrl !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rawName = [record.name, record.fileName, record.originalName].find(
|
||||||
|
(value) => typeof value === 'string' && (value as string).trim()
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name: (rawName as string | undefined) || getAttachmentName(rawUrl),
|
||||||
|
url: rawUrl.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把后端存储的附件字段解析成文件列表。
|
||||||
|
* 兼容:单个地址、JSON 数组字符串、对象数组(name/url、fileName/fileUrl 等)
|
||||||
|
*/
|
||||||
|
export function parseAttachments(value: unknown): AttachmentFile[] {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (text.startsWith('[') || text.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
return parseAttachments(JSON.parse(text));
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败时按普通地址处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const single = normalizeEntry(text);
|
||||||
|
return single ? [single] : [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => normalizeEntry(item))
|
||||||
|
.filter((item): item is AttachmentFile => !!item);
|
||||||
|
}
|
||||||
|
const single = normalizeEntry(value);
|
||||||
|
return single ? [single] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把附件字段解析结果转换成图片地址列表
|
||||||
|
*/
|
||||||
|
export function parseAttachmentImages(value: unknown) {
|
||||||
|
const urls: string[] = [];
|
||||||
|
parseAttachments(value).forEach((item) => {
|
||||||
|
if (isImageFile(item.url)) {
|
||||||
|
urls.push(item.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!urls.length && typeof value === 'string' && isImageFile(value)) {
|
||||||
|
urls.push(value);
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
<div class="dialog-card">
|
<div class="dialog-card">
|
||||||
<div class="dialog-card__title">开始对话</div>
|
<div class="dialog-card__title">开始对话</div>
|
||||||
<div class="dialog-card__sub">AI+党建的双向赋能</div>
|
<div class="dialog-card__sub">AI+团建的双向赋能</div>
|
||||||
<div class="dialog-card__main">体验全新的AI赋能团委工作</div>
|
<div class="dialog-card__main">体验全新的AI赋能团委工作</div>
|
||||||
<button class="start-btn" @click="goLogin">立即开始</button>
|
<button class="start-btn" @click="goLogin">立即开始</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,7 +63,7 @@ const router = useRouter();
|
|||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
title: "AI+党建的双向赋能",
|
title: "AI+团建的双向赋能",
|
||||||
desc: "赋能广西医科大学团委的各项工作。项目遵循“先基础,后智能”的原则,信息化和智能化相结合,确保项目稳步推进,价值逐步释放。"
|
desc: "赋能广西医科大学团委的各项工作。项目遵循“先基础,后智能”的原则,信息化和智能化相结合,确保项目稳步推进,价值逐步释放。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -75,8 +75,8 @@ const features = [
|
|||||||
desc: "成为团委老师和学生干部的得力助手,从重复性文案工作中解放生产力。移动端仅提供提供最基础的编辑、审核功能"
|
desc: "成为团委老师和学生干部的得力助手,从重复性文案工作中解放生产力。移动端仅提供提供最基础的编辑、审核功能"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "创新竞赛历史项目数据库及创新科研课题申报系统",
|
title: "科创竞赛历史项目数据库及创新科研课题申报系统",
|
||||||
desc: "打破信息壁垒,为学生参与创新竞赛提供数据参考;固化校内团委课题申报管理流程,提升管理效率。移动端仅用于查阅信息及简单审核"
|
desc: "打破信息壁垒,为学生参与科创竞赛提供数据参考;固化校内团委课题申报管理流程,提升管理效率。移动端仅用于查阅信息及简单审核"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "多模态智能宣传矩阵",
|
title: "多模态智能宣传矩阵",
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
import { FormInstance } from 'ant-design-vue/es/form';
|
import { FormInstance } from 'ant-design-vue/es/form';
|
||||||
import { FILE_SERVER, FILE_THUMBNAIL } from "@/config/setting";
|
import { FILE_SERVER, FILE_THUMBNAIL } from "@/config/setting";
|
||||||
import { uploadFile } from '@/api/system/file';
|
import { uploadFile } from '@/api/system/file';
|
||||||
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
|
import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
|
||||||
import { App } from '@/api/app/model';
|
import { App } from '@/api/app/model';
|
||||||
import { addApp } from '@/api/app';
|
import { addApp } from '@/api/app';
|
||||||
import { assignObject } from 'ele-admin-pro';
|
import { assignObject } from 'ele-admin-pro';
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
import type { Merchant } from '@/api/merchant/model';
|
import type { Merchant } from '@/api/merchant/model';
|
||||||
import { addMerchant, updateMerchant } from '@/api/merchant';
|
import { addMerchant, updateMerchant } from '@/api/merchant';
|
||||||
import { FormInstance, Rule, RuleObject } from 'ant-design-vue/es/form';
|
import { FormInstance, Rule, RuleObject } from 'ant-design-vue/es/form';
|
||||||
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
|
import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
|
||||||
import { uploadFile } from '@/api/system/file';
|
import { uploadFile } from '@/api/system/file';
|
||||||
import OwnerChoose from '@/components/UserChoose/index.vue';
|
import OwnerChoose from '@/components/UserChoose/index.vue';
|
||||||
import { FILE_SERVER, FILE_THUMBNAIL } from "@/config/setting";
|
import { FILE_SERVER, FILE_THUMBNAIL } from "@/config/setting";
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
MerchantClerk,
|
MerchantClerk,
|
||||||
MerchantClerkParam
|
MerchantClerkParam
|
||||||
} from '@/api/merchant/clerk/model';
|
} from '@/api/merchant/clerk/model';
|
||||||
import {
|
import type {
|
||||||
ColumnItem,
|
ColumnItem,
|
||||||
DatasourceFunction
|
DatasourceFunction
|
||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
|||||||
@@ -162,7 +162,7 @@
|
|||||||
import type { Merchant } from '@/api/merchant/model';
|
import type { Merchant } from '@/api/merchant/model';
|
||||||
import { addMerchant, updateMerchant } from '@/api/merchant';
|
import { addMerchant, updateMerchant } from '@/api/merchant';
|
||||||
import { FormInstance, Rule, RuleObject } from 'ant-design-vue/es/form';
|
import { FormInstance, Rule, RuleObject } from 'ant-design-vue/es/form';
|
||||||
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
|
import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
|
||||||
import { uploadFile } from '@/api/system/file';
|
import { uploadFile } from '@/api/system/file';
|
||||||
// import OwnerChoose from '@/components/UserChoose/index.vue';
|
// import OwnerChoose from '@/components/UserChoose/index.vue';
|
||||||
import { FILE_SERVER, FILE_THUMBNAIL } from '@/config/setting';
|
import { FILE_SERVER, FILE_THUMBNAIL } from '@/config/setting';
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
updateMerchantWithdraw
|
updateMerchantWithdraw
|
||||||
} from '@/api/merchant/withdraw';
|
} from '@/api/merchant/withdraw';
|
||||||
import { FormInstance, Rule, RuleObject } from 'ant-design-vue/es/form';
|
import { FormInstance, Rule, RuleObject } from 'ant-design-vue/es/form';
|
||||||
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
|
import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
|
||||||
import { uploadFile } from '@/api/system/file';
|
import { uploadFile } from '@/api/system/file';
|
||||||
import { FILE_SERVER, FILE_THUMBNAIL } from '@/config/setting';
|
import { FILE_SERVER, FILE_THUMBNAIL } from '@/config/setting';
|
||||||
import useFormData from '@/utils/use-form-data';
|
import useFormData from '@/utils/use-form-data';
|
||||||
|
|||||||
@@ -517,7 +517,7 @@
|
|||||||
import { UploadOutlined } from '@/layout/menu-icons';
|
import { UploadOutlined } from '@/layout/menu-icons';
|
||||||
import { FormInstance } from 'ant-design-vue/es/form';
|
import { FormInstance } from 'ant-design-vue/es/form';
|
||||||
import { Company } from '@/api/system/company/model';
|
import { Company } from '@/api/system/company/model';
|
||||||
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
|
import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
|
||||||
import DictSelect from '@/views/search/components/dict-select.vue';
|
import DictSelect from '@/views/search/components/dict-select.vue';
|
||||||
import { User } from '@/api/system/user/model';
|
import { User } from '@/api/system/user/model';
|
||||||
import { uploadFile } from "@/api/system/file";
|
import { uploadFile } from "@/api/system/file";
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import {
|
import type {
|
||||||
ColumnItem,
|
ColumnItem,
|
||||||
DatasourceFunction
|
DatasourceFunction
|
||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import {
|
import type {
|
||||||
ColumnItem,
|
ColumnItem,
|
||||||
DatasourceFunction
|
DatasourceFunction
|
||||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
<!-- 用户编辑弹窗 -->
|
|
||||||
<template>
|
|
||||||
<div class="page">
|
|
||||||
<div class="ele-body" style="padding-bottom: 60px;">
|
|
||||||
<!-- 表格 -->
|
|
||||||
<a-spin :spinning="loading">
|
|
||||||
<table class="ele-table ele-table-stripe ele-table-medium">
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(item,index) in notice" :key="index">
|
|
||||||
<td style="text-align: right; width: 360px; vertical-align: top;">
|
|
||||||
<div class="user-avatar">
|
|
||||||
<a-avatar v-if="item.channel === 0" :src="item.avatar" />
|
|
||||||
<a-avatar v-else class="openai" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="content"><byte-md-viewer :value="item.content" ref="md-content" :plugins="plugins" /></div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a-button v-if="item.channel === 1" @click="copyText(item.content)">复制</a-button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</a-spin>
|
|
||||||
<a-space class="action">
|
|
||||||
<a-popconfirm
|
|
||||||
title="确定要删除当前会话吗?"
|
|
||||||
@confirm="remove(item)"
|
|
||||||
>
|
|
||||||
<a-button class="ele-text-danger">清空当前会话</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
|
||||||
<a-card class="foot">
|
|
||||||
<div class="send-box">
|
|
||||||
<a-space>
|
|
||||||
<a-input
|
|
||||||
v-model:value="content"
|
|
||||||
placeholder="我是AI机器人,你想和我聊点什么?"
|
|
||||||
size="large"
|
|
||||||
:disabled="loading"
|
|
||||||
style="width: 770px"
|
|
||||||
@pressEnter="onSend"
|
|
||||||
>
|
|
||||||
<template #suffix>
|
|
||||||
<send-outlined @click="onSend" />
|
|
||||||
</template>
|
|
||||||
</a-input>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
<a-back-top />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import "bytemd/dist/index.min.css";
|
|
||||||
import { ref } from "vue";
|
|
||||||
import "github-markdown-css/github-markdown-light.css";
|
|
||||||
// // 链接、删除线、复选框、表格等的插件
|
|
||||||
import gfm from "@bytemd/plugin-gfm";
|
|
||||||
// // 插件的中文语言文件
|
|
||||||
import zh_HansGfm from "@bytemd/plugin-gfm/locales/zh_Hans.json";
|
|
||||||
// 中文语言文件
|
|
||||||
import "bytemd/dist/index.min.css";
|
|
||||||
import highlight from "@bytemd/plugin-highlight-ssr";
|
|
||||||
import "highlight.js/styles/default.css";
|
|
||||||
import { message} from "ant-design-vue";
|
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
import { useThemeStore } from "@/store/modules/theme";
|
|
||||||
import { uploadFile } from "@/api/system/file";
|
|
||||||
import type { Notice } from "@/api/oa/notice/model";
|
|
||||||
import { FILE_SERVER } from "@/config/setting";
|
|
||||||
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
|
|
||||||
import { listNotice, removeNotice } from "@/api/oa/notice";
|
|
||||||
import { chat, send } from "@/api/oa/chatgpt";
|
|
||||||
import {
|
|
||||||
SendOutlined
|
|
||||||
} from '@ant-design/icons-vue';
|
|
||||||
import { Message } from "@/api/user/message/model";
|
|
||||||
import { EquipmentFault } from "@/api/apps/equipment/fault/model";
|
|
||||||
import { removeEquipmentFault } from "@/api/apps/equipment/fault";
|
|
||||||
import { EleTableSelect } from "ele-admin-pro";
|
|
||||||
import { FormInstance } from "ant-design-vue/es/form";
|
|
||||||
import { copyText, isImage } from "@/utils/common";
|
|
||||||
|
|
||||||
// 插件
|
|
||||||
const plugins = ref([
|
|
||||||
gfm({
|
|
||||||
locale: zh_HansGfm
|
|
||||||
}),
|
|
||||||
highlight()
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 是否开启响应式布局
|
|
||||||
const themeStore = useThemeStore();
|
|
||||||
const { styleResponsive } = storeToRefs(themeStore);
|
|
||||||
// 请求状态
|
|
||||||
const loading = ref(false);
|
|
||||||
const noticeId = ref(0);
|
|
||||||
const content = ref('');
|
|
||||||
const files = ref<ItemType[]>([]);
|
|
||||||
const notice = ref<Notice[]>([]);
|
|
||||||
|
|
||||||
/* 上传事件 */
|
|
||||||
const uploadHandler = (file: File) => {
|
|
||||||
const item: ItemType = {
|
|
||||||
file,
|
|
||||||
uid: (file as any).uid,
|
|
||||||
name: file.name
|
|
||||||
};
|
|
||||||
if (!file.type.startsWith("image")) {
|
|
||||||
message.error("只能选择图片");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.size / 1024 / 1024 > 2) {
|
|
||||||
message.error("大小不能超过 2MB");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onUpload(item);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onUpload = (d: ItemType) => {
|
|
||||||
uploadFile(<File>d.file)
|
|
||||||
.then((result) => {
|
|
||||||
files.value.push({
|
|
||||||
uid: result.id,
|
|
||||||
url: FILE_SERVER + result.path,
|
|
||||||
name: isImage(result.path) ? 'image' : result.name,
|
|
||||||
status: "done"
|
|
||||||
});
|
|
||||||
message.success("上传成功");
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
message.error(e.message);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/* 向chatGPT提问 */
|
|
||||||
const onSend = () => {
|
|
||||||
send({
|
|
||||||
tenantId: Number(localStorage.getItem('tenantId')),
|
|
||||||
content: content.value
|
|
||||||
}).then((noticeId) => {
|
|
||||||
query();
|
|
||||||
onChat(noticeId);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const onChat = (noticeId) => {
|
|
||||||
chat({
|
|
||||||
tenantId: Number(localStorage.getItem('tenantId')),
|
|
||||||
content: content.value,
|
|
||||||
noticeId
|
|
||||||
}).then(() => {
|
|
||||||
query();
|
|
||||||
content.value = '';
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 删除单个 */
|
|
||||||
const remove = (row: Notice) => {
|
|
||||||
console.log(row);
|
|
||||||
const hide = message.loading('请求中..', 0);
|
|
||||||
removeNotice(row.noticeId)
|
|
||||||
.then((msg) => {
|
|
||||||
hide();
|
|
||||||
message.success(msg);
|
|
||||||
query();
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
hide();
|
|
||||||
message.error(e.message);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const query = () => {
|
|
||||||
listNotice({limit: 2})
|
|
||||||
.then((response) => {
|
|
||||||
notice.value = response
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
message.error(e.message);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
query();
|
|
||||||
</script>
|
|
||||||
<script lang="ts">
|
|
||||||
export default {
|
|
||||||
name: "ChatGPT"
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.content {
|
|
||||||
max-width: 1000px;
|
|
||||||
padding: 20px 0;
|
|
||||||
.edit-md {
|
|
||||||
margin-top: 50px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foot{
|
|
||||||
position: fixed;
|
|
||||||
width: 100%;
|
|
||||||
background-color: #f3f9ff;
|
|
||||||
bottom: 0;
|
|
||||||
height: 100px;
|
|
||||||
//display: flex;
|
|
||||||
//justify-content: center;
|
|
||||||
.send-box{
|
|
||||||
margin-left: 360px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.user-avatar{
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
.openai{
|
|
||||||
background-image: url("@/assets/icon/openai-avatar.jpeg");
|
|
||||||
background-size: 100%;
|
|
||||||
}
|
|
||||||
.markdown-body{
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
.action{
|
|
||||||
padding: 20px 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
.copy-btn{display: flex; justify-content: flex-end;}
|
|
||||||
</style>
|
|
||||||
+1071
-173
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* 「AI 写作助手」的可选项定义(2026-10-21 改造后)。
|
||||||
|
*
|
||||||
|
* 本文件与 `tuanwei-uniapp/src/pages/assistant/writing-params.js` 保持同一套常量,
|
||||||
|
* 改动其一请同步另一侧。
|
||||||
|
*
|
||||||
|
* 改造前这里还负责把用户原话解析成「键:值」入参,再据此填 doc_type / Number。
|
||||||
|
* 现在写作模式只有两条路,都不解析自然语言:
|
||||||
|
*
|
||||||
|
* 1. 自由创作(value = `auto`):原话整段作为 `userContent`,后端见 `docType` 为空
|
||||||
|
* 就转「AI 创意助手」(只吃 `user_content`);
|
||||||
|
* 2. 选了文体:原话整段作为 `highlights`(核心亮点与具体要求),连同 doc_type
|
||||||
|
* 交给「内容创作与宣传助手」,字数只认界面上的显式选择。
|
||||||
|
*
|
||||||
|
* 为什么不猜:上游提示词里 doc_type / Number 是**主控指令**,模型会显式服从、
|
||||||
|
* 甚至压掉用户原话里的冲突要求(实测思维链:「I will ignore the conflicting
|
||||||
|
* "300字推文" instruction … because the main task says "文稿类型为:新闻稿"」)。
|
||||||
|
* 从原话里推断还会在短祈使句上产出「帮我写个」这类残片当活动主题
|
||||||
|
* (见 issue 13 的「2026-10-19 契约修正」)。
|
||||||
|
*
|
||||||
|
* 本模块只剩选项常量:纯数据、零依赖。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 「自由创作」的取值,同时也是 `writingDocType` 的默认值 */
|
||||||
|
export const WRITING_DOC_TYPE_FREE = 'auto';
|
||||||
|
|
||||||
|
/** 上游 Dify 提示词里真正定义了 Markdown 结构与文风的 4 种文体 */
|
||||||
|
export const WRITING_DOC_TYPE_OPTIONS = [
|
||||||
|
{ label: '自由创作', value: WRITING_DOC_TYPE_FREE },
|
||||||
|
{ label: '新闻稿', value: '新闻稿' },
|
||||||
|
{ label: '活动总结', value: '活动总结' },
|
||||||
|
{ label: '微信推文', value: '微信推文' },
|
||||||
|
{ label: '通知公告', value: '通知公告' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 字数只在选了文体之后才显示;`auto` 表示不下发 */
|
||||||
|
export const WRITING_WORD_COUNT_OPTIONS = [
|
||||||
|
{ label: '默认', value: 'auto' },
|
||||||
|
{ label: '300字', value: '300' },
|
||||||
|
{ label: '500字', value: '500' },
|
||||||
|
{ label: '800字', value: '800' },
|
||||||
|
{ label: '1000字', value: '1000' },
|
||||||
|
{ label: '1500字', value: '1500' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 活动类型(上游 `event_type`)的候选值。
|
||||||
|
*
|
||||||
|
* 上游开始节点把 event_type 声明成普通 text-input,没有枚举约束,这里给一组常用值
|
||||||
|
* 只是为了让用户不用手打;与两个「写作表单页」的取值保持一致
|
||||||
|
* (tuanwei-uniapp/src/pages/manuscript 与 tuanwei-admin/src/views/gxmu/manuscriptGen)。
|
||||||
|
*/
|
||||||
|
export const WRITING_EVENT_TYPE_OPTIONS = [
|
||||||
|
'校园活动',
|
||||||
|
'志愿服务',
|
||||||
|
'会议论坛',
|
||||||
|
'社会实践',
|
||||||
|
'文体活动',
|
||||||
|
'品牌项目'
|
||||||
|
];
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
class="ele-btn-icon"
|
class="ele-btn-icon"
|
||||||
@click="openUrl('https://p.haoii123.com/')"
|
@click="openUrl('https://p.haoii123.com/')"
|
||||||
>
|
>
|
||||||
一键排版
|
公众号排版
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ import zh_Hans from 'bytemd/locales/zh_Hans.json';
|
|||||||
import gfm from '@bytemd/plugin-gfm';
|
import gfm from '@bytemd/plugin-gfm';
|
||||||
// 插件的中文语言文件
|
// 插件的中文语言文件
|
||||||
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
|
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
|
||||||
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
|
import type { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
|
||||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||||
import { User } from "@/api/system/user/model";
|
import { User } from "@/api/system/user/model";
|
||||||
import { Company } from "@/api/system/company/model";
|
import { Company } from "@/api/system/company/model";
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ import { FILE_SERVER } from "@/config/setting";
|
|||||||
import { getDictionaryOptions, isImage, openUrl } from "@/utils/common";
|
import { getDictionaryOptions, isImage, openUrl } from "@/utils/common";
|
||||||
import { TaskRecord } from "@/api/oa/task-record/model";
|
import { TaskRecord } from "@/api/oa/task-record/model";
|
||||||
import { App } from "@/api/app/model"
|
import { App } from "@/api/app/model"
|
||||||
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
|
import type { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
|
||||||
import { addTaskRecord, listTaskRecord, removeTaskRecord } from "@/api/oa/task-record";
|
import { addTaskRecord, listTaskRecord, removeTaskRecord } from "@/api/oa/task-record";
|
||||||
import SelectStaff from '@/components/SelectStaff/index.vue';
|
import SelectStaff from '@/components/SelectStaff/index.vue';
|
||||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ import zh_Hans from 'bytemd/locales/zh_Hans.json';
|
|||||||
import gfm from '@bytemd/plugin-gfm';
|
import gfm from '@bytemd/plugin-gfm';
|
||||||
// 插件的中文语言文件
|
// 插件的中文语言文件
|
||||||
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
|
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
|
||||||
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
|
import type { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
|
||||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||||
import { User } from "@/api/system/user/model";
|
import { User } from "@/api/system/user/model";
|
||||||
import { Company } from "@/api/system/company/model";
|
import { Company } from "@/api/system/company/model";
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ import { FILE_SERVER } from "@/config/setting";
|
|||||||
import { getDictionaryOptions, isImage, openUrl } from "@/utils/common";
|
import { getDictionaryOptions, isImage, openUrl } from "@/utils/common";
|
||||||
import { TaskRecord } from "@/api/oa/task-record/model";
|
import { TaskRecord } from "@/api/oa/task-record/model";
|
||||||
import { App } from "@/api/app/model"
|
import { App } from "@/api/app/model"
|
||||||
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
|
import type { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
|
||||||
import { addTaskRecord, listTaskRecord, removeTaskRecord } from "@/api/oa/task-record";
|
import { addTaskRecord, listTaskRecord, removeTaskRecord } from "@/api/oa/task-record";
|
||||||
import SelectStaff from '@/components/SelectStaff/index.vue';
|
import SelectStaff from '@/components/SelectStaff/index.vue';
|
||||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||||
|
|||||||
@@ -26,6 +26,13 @@
|
|||||||
>
|
>
|
||||||
账号登录
|
账号登录
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['login-tab', { active: loginType === 'sso' }]"
|
||||||
|
@click="onLoginType('sso')"
|
||||||
|
>
|
||||||
|
统一身份认证
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="loginType === 'sms'">
|
<template v-if="loginType === 'sms'">
|
||||||
@@ -60,7 +67,7 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else-if="loginType === 'account'">
|
||||||
<a-form-item v-bind="validateInfos.username">
|
<a-form-item v-bind="validateInfos.username">
|
||||||
<a-input
|
<a-input
|
||||||
v-model:value="form.username"
|
v-model:value="form.username"
|
||||||
@@ -83,7 +90,33 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="login-agree">
|
<template v-else>
|
||||||
|
<a-form-item v-bind="validateInfos.username">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.username"
|
||||||
|
allow-clear
|
||||||
|
size="large"
|
||||||
|
placeholder="请输入学号"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-bind="validateInfos.password">
|
||||||
|
<div class="login-input-group login-input-plain">
|
||||||
|
<a-input-password
|
||||||
|
v-model:value="form.password"
|
||||||
|
allow-clear
|
||||||
|
size="large"
|
||||||
|
placeholder="请输入统一身份认证密码"
|
||||||
|
@pressEnter="submit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="loginType === 'sso'" class="login-agree">
|
||||||
|
使用学校统一身份认证账号(学号)登录,密码由学校认证平台校验,本系统不留存。
|
||||||
|
</div>
|
||||||
|
<div v-else class="login-agree">
|
||||||
注册登录即代表已阅读并同意我们的
|
注册登录即代表已阅读并同意我们的
|
||||||
<span class="link">用户协议</span>
|
<span class="link">用户协议</span>
|
||||||
与
|
与
|
||||||
@@ -102,19 +135,7 @@
|
|||||||
{{ loading ? "登录中" : "登录" }}
|
{{ loading ? "登录中" : "登录" }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<div class="login-divider"></div>
|
|
||||||
</a-form>
|
</a-form>
|
||||||
|
|
||||||
<div class="login-qr-card">
|
|
||||||
<div class="qr-frame">
|
|
||||||
<div class="qr-placeholder"></div>
|
|
||||||
</div>
|
|
||||||
<div class="qr-text">
|
|
||||||
<img class="wechat-icon" src="@/assets/icon/weixin.png" alt="微信" />
|
|
||||||
微信扫码登录
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -132,7 +153,7 @@ import { computed, onBeforeUnmount, reactive, ref, unref } from "vue";
|
|||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { Form, message } from "ant-design-vue";
|
import { Form, message } from "ant-design-vue";
|
||||||
import { cleanPageTabs } from "@/utils/page-tab-util";
|
import { cleanPageTabs } from "@/utils/page-tab-util";
|
||||||
import { login, sendSmsCaptcha } from "@/api/passport/login";
|
import { login, loginByGxmuSso, sendSmsCaptcha } from "@/api/passport/login";
|
||||||
import { User } from "@/api/system/user/model";
|
import { User } from "@/api/system/user/model";
|
||||||
import { THEME_STORE_NAME } from "@/config/setting";
|
import { THEME_STORE_NAME } from "@/config/setting";
|
||||||
|
|
||||||
@@ -144,7 +165,7 @@ const sending = ref(false);
|
|||||||
const countdown = ref(0);
|
const countdown = ref(0);
|
||||||
let countdownTimer: number | null = null;
|
let countdownTimer: number | null = null;
|
||||||
|
|
||||||
const loginType = ref<"sms" | "account">("sms");
|
const loginType = ref<"sms" | "account" | "sso">("sms");
|
||||||
|
|
||||||
const form = reactive<User & { code?: string }>({
|
const form = reactive<User & { code?: string }>({
|
||||||
username: "",
|
username: "",
|
||||||
@@ -160,19 +181,25 @@ const toHome = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rules = computed(() => {
|
const rules = computed(() => {
|
||||||
if (loginType.value === "account") {
|
if (loginType.value === "account" || loginType.value === "sso") {
|
||||||
return {
|
return {
|
||||||
username: [
|
username: [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: "请输入账号",
|
message:
|
||||||
|
loginType.value === "sso"
|
||||||
|
? "请输入学号"
|
||||||
|
: "请输入账号",
|
||||||
type: "string"
|
type: "string"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
password: [
|
password: [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: "请输入密码",
|
message:
|
||||||
|
loginType.value === "sso"
|
||||||
|
? "请输入统一身份认证密码"
|
||||||
|
: "请输入密码",
|
||||||
type: "string"
|
type: "string"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -216,7 +243,7 @@ const startCountdown = () => {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLoginType = (type: "sms" | "account") => {
|
const onLoginType = (type: "sms" | "account" | "sso") => {
|
||||||
loginType.value = type;
|
loginType.value = type;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -262,7 +289,9 @@ const submit = () => {
|
|||||||
if (query.tenantId) {
|
if (query.tenantId) {
|
||||||
form.tenantId = Number(query.tenantId);
|
form.tenantId = Number(query.tenantId);
|
||||||
}
|
}
|
||||||
login(form)
|
const request =
|
||||||
|
loginType.value === "sso" ? loginByGxmuSso(form) : login(form);
|
||||||
|
request
|
||||||
.then((msg) => {
|
.then((msg) => {
|
||||||
message.success(msg);
|
message.success(msg);
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -338,8 +367,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.login-panel {
|
.login-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 320px;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 28px;
|
|
||||||
align-items: start;
|
align-items: start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
@@ -521,60 +549,6 @@ onBeforeUnmount(() => {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-divider {
|
|
||||||
margin-top: 16px;
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(90deg, transparent, #e6e6e6 20%, #e6e6e6 80%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-qr-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 320px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px solid #ececec;
|
|
||||||
padding: 22px 20px 18px;
|
|
||||||
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.08);
|
|
||||||
display: grid;
|
|
||||||
justify-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-frame {
|
|
||||||
width: 210px;
|
|
||||||
height: 210px;
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid #e6e6e6;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.08);
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-placeholder {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 8px;
|
|
||||||
background-image: linear-gradient(90deg, rgba(0, 0, 0, 0.1) 50%, transparent 50%),
|
|
||||||
linear-gradient(rgba(0, 0, 0, 0.1) 50%, transparent 50%);
|
|
||||||
background-size: 14px 14px;
|
|
||||||
background-position: 0 0, 7px 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-text {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: #3e3e3e;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wechat-icon {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-copyright {
|
.login-copyright {
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -583,19 +557,10 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.login-panel {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
justify-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-qr-card {
|
|
||||||
max-width: 360px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
@@ -634,10 +599,5 @@ onBeforeUnmount(() => {
|
|||||||
height: 50px;
|
height: 50px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-frame {
|
|
||||||
width: 180px;
|
|
||||||
height: 180px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ import iconData from 'ele-admin-pro/es/ele-icon-picker/icons';
|
|||||||
import {assignObject, isExternalLink} from 'ele-admin-pro';
|
import {assignObject, isExternalLink} from 'ele-admin-pro';
|
||||||
import {addApp, updateApp} from '@/api/dashboard/appstore';
|
import {addApp, updateApp} from '@/api/dashboard/appstore';
|
||||||
import type {App} from '@/api/dashboard/appstore/model';
|
import type {App} from '@/api/dashboard/appstore/model';
|
||||||
import {ItemType} from "ele-admin-pro/es/ele-image-upload/types";
|
import type {ItemType} from "ele-admin-pro/es/ele-image-upload/types";
|
||||||
import {uploadFile} from "@/api/system/file";
|
import {uploadFile} from "@/api/system/file";
|
||||||
import {getDictionaryOptions} from "@/utils/common";
|
import {getDictionaryOptions} from "@/utils/common";
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ import iconData from 'ele-admin-pro/es/ele-icon-picker/icons';
|
|||||||
import {assignObject, isExternalLink} from 'ele-admin-pro';
|
import {assignObject, isExternalLink} from 'ele-admin-pro';
|
||||||
import {addApp, updateApp} from '@/api/dashboard/appstore';
|
import {addApp, updateApp} from '@/api/dashboard/appstore';
|
||||||
import type {App} from '@/api/dashboard/appstore/model';
|
import type {App} from '@/api/dashboard/appstore/model';
|
||||||
import {ItemType} from "ele-admin-pro/es/ele-image-upload/types";
|
import type {ItemType} from "ele-admin-pro/es/ele-image-upload/types";
|
||||||
import {uploadFile} from "@/api/system/file";
|
import {uploadFile} from "@/api/system/file";
|
||||||
import {getDictionaryOptions} from "@/utils/common";
|
import {getDictionaryOptions} from "@/utils/common";
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user