Merge remote-tracking branch 'origin/master'
# Conflicts: # .env.production
This commit is contained in:
+1
-1
@@ -1,3 +1,3 @@
|
||||
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
|
||||
|
||||
+3
-1
@@ -1,2 +1,4 @@
|
||||
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;
|
||||
people: string;
|
||||
event_time: string;
|
||||
/**
|
||||
* 活动资金。这里保持 string 是**有意**的:表单输入框是自由文本
|
||||
* (示例值形如「预算 5000 元,来源为校团委志愿服务专项经费」),
|
||||
* 由后端 `normalizeEventFunding` 抽出数值再发给 Dify(上游该变量是 number)。
|
||||
* 抽不出数值时后端降级为空串——上游接受空串,但不接受不可解析的文本。
|
||||
*/
|
||||
event_funding: string;
|
||||
event_type?: string;
|
||||
event_location?: string;
|
||||
event_meme?: string;
|
||||
/** 以下 3 项为 Dify 必填变量,当前表单未采集;后端会补空串占位(见 issue 09)。 */
|
||||
event_member?: string;
|
||||
people_manage?: string;
|
||||
tel_people?: string;
|
||||
}
|
||||
|
||||
export interface ActivityOutlineGenResponse {
|
||||
answer?: string;
|
||||
/**
|
||||
* 上游工作流状态。失败时为 `'failed'` —— 前端据此把失败文案与正文区分开
|
||||
* (见 `@/utils/ai-result` 的 `isFailedPayload` 与 issue 10)。
|
||||
*/
|
||||
status?: string;
|
||||
conversationId?: string;
|
||||
messageId?: string;
|
||||
raw?: Record<string, any>;
|
||||
|
||||
@@ -28,6 +28,10 @@ export interface AiChatList {
|
||||
updateTime?: 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;
|
||||
deleted?: number;
|
||||
title?: string;
|
||||
chatType?: string;
|
||||
keywords?: string;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,18 @@ import type {
|
||||
|
||||
/**
|
||||
* 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();
|
||||
formData.append('file', file);
|
||||
if (app) {
|
||||
formData.append('app', app);
|
||||
}
|
||||
const res = await request.post<ApiResult<FileRecord>>(
|
||||
MODULES_API_URL + '/ai/file/upload',
|
||||
formData
|
||||
|
||||
@@ -3,21 +3,48 @@ import type { FileRecord } from '@/api/system/file/model';
|
||||
export interface CreativeAssistantUploadFile {
|
||||
transfer_method: 'local_file';
|
||||
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;
|
||||
}
|
||||
|
||||
export interface CreativeAssistantRequest {
|
||||
user_content?: string;
|
||||
user_contentString?: string;
|
||||
/**
|
||||
* 端上的模式。`image` 时后端会把 user_content 包装成一张「生成图片」的意图声明,
|
||||
* 让上游「问题分类器」不把画面描述判成文案/脚本分支(见 issue 19)。
|
||||
*/
|
||||
mode?: 'chat' | 'writing' | 'image';
|
||||
/**
|
||||
* 参考素材。2026-10 起端上已不再发送:上游出图工具只做文生图,没有任何节点消费它
|
||||
* (见 issue 16)。字段暂时保留,便于回退。
|
||||
*/
|
||||
user_files?: CreativeAssistantUploadFile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流产出的一项文件。
|
||||
*
|
||||
* 正常是 { url, name, type } 这样的对象(后端已归一),但上游有时直接把
|
||||
* 地址字符串塞进数组;端上按字符串兜一层,否则整条视频链路取不到链接
|
||||
* (见禅道 bug #1:有视频不播放、复制链接为空)。
|
||||
*/
|
||||
export type CreativeFileItem = Partial<FileRecord> | string;
|
||||
|
||||
/** 文件列表:正常情况下是数组,退化时可能是单个对象或裸地址。 */
|
||||
export type CreativeFileList = CreativeFileItem[] | CreativeFileItem;
|
||||
|
||||
export interface CreativeAssistantOutputs {
|
||||
video_result?: Partial<FileRecord>[];
|
||||
video_result?: CreativeFileList;
|
||||
video_message?: string;
|
||||
Script_result?: string;
|
||||
photo_files?: Partial<FileRecord>[];
|
||||
photo_files?: CreativeFileList;
|
||||
photo_message?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { FileRecord } from '@/api/system/file/model';
|
||||
|
||||
export interface ManuscriptGenParams {
|
||||
/** 文稿模板;留空表示自由创作,后端会转「AI 创意助手」 */
|
||||
docType?: string;
|
||||
/** 自由创作时用户想写的内容,转创意助手后作为 user_content 下发 */
|
||||
userContent?: string;
|
||||
eventType?: string;
|
||||
theme?: string;
|
||||
time?: string;
|
||||
|
||||
@@ -9,8 +9,10 @@ export interface Manuscript {
|
||||
id?: number;
|
||||
//
|
||||
title?: string;
|
||||
//
|
||||
// 正文(HTML,历史上可能是 markdown / 纯文本)
|
||||
content?: string;
|
||||
// 正文格式:html / markdown / text
|
||||
contentFormat?: string;
|
||||
//
|
||||
cover?: string;
|
||||
// 是否删除, 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;
|
||||
// 更新时间
|
||||
updateTime?: string;
|
||||
// 是否允许重复提交:0否 1是
|
||||
allowRepeat?: number;
|
||||
// 最多同时进行中的申报条数
|
||||
maxPending?: number;
|
||||
// 当前用户在该项目下的最新申报状态:0草稿 1待审核 2通过 3驳回 4作废
|
||||
myStatus?: number;
|
||||
// 当前用户在该项目下的最新申报记录id
|
||||
myRecordId?: number;
|
||||
// 当前用户在该项目下的最新申报记录提交时间
|
||||
mySubmitTime?: string;
|
||||
// 当前用户在该项目下的最新申报记录状态备注
|
||||
myStatusRemark?: string;
|
||||
// 删除标记:0未删 1已删
|
||||
deleted?: number;
|
||||
// 是否可申报
|
||||
@@ -55,4 +67,4 @@ export interface DeclareParam extends PageParam {
|
||||
formProjectGroup?: string;
|
||||
publicProjectType?: string;
|
||||
publicProjectGroup?: string;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export interface ReviewList {
|
||||
businessName?: string;
|
||||
// 主键
|
||||
pk?: number;
|
||||
// 状态(0不通过 1通过)
|
||||
// 状态(0驳回 1通过)
|
||||
status?: number;
|
||||
// 审核意见
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 学校统一身份认证登录(学号 + 统一身份认证密码)
|
||||
*
|
||||
* 与小程序端共用同一个后端接口,返回结构完全一致,
|
||||
* 因此拿到 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;
|
||||
path?: string;
|
||||
contentType?: string;
|
||||
// 媒体大类: image/video/audio/doc,空表示不限
|
||||
mediaCategory?: string;
|
||||
createNickname?: string;
|
||||
count?: number;
|
||||
page?: number;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
import type {
|
||||
ColumnItem,
|
||||
DatasourceFunction
|
||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
import type {
|
||||
ColumnItem,
|
||||
DatasourceFunction
|
||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
import type {
|
||||
ColumnItem,
|
||||
DatasourceFunction
|
||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
import { toTreeData, eachTreeData } from 'ele-admin-pro/es';
|
||||
import type { EleProTable } from 'ele-admin-pro/es';
|
||||
import { listOrganizations } from '@/api/system/organization';
|
||||
import {
|
||||
import type {
|
||||
Organization,
|
||||
OrganizationParam
|
||||
} from '@/api/system/organization/model';
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
import type {
|
||||
ColumnItem,
|
||||
DatasourceFunction
|
||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
import type {
|
||||
ColumnItem,
|
||||
DatasourceFunction
|
||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
import type {
|
||||
ColumnItem,
|
||||
DatasourceFunction
|
||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
import type {
|
||||
ColumnItem,
|
||||
DatasourceFunction
|
||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
import type {
|
||||
ColumnItem,
|
||||
DatasourceFunction
|
||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
import type {
|
||||
ColumnItem,
|
||||
DatasourceFunction
|
||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
import type {
|
||||
ColumnItem,
|
||||
DatasourceFunction
|
||||
} 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>
|
||||
<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">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
@@ -12,8 +20,9 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import type { GradeParam } from '@/api/user/grade/model';
|
||||
import { watch } from 'vue';
|
||||
import type { AiChatListParam } from '@/api/ai/aiChatList/model';
|
||||
import { AI_CHAT_TYPE_OPTIONS } from '@/utils/ai-chat-type';
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -24,12 +33,26 @@
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'search', where?: GradeParam): void;
|
||||
(e: 'search', where?: AiChatListParam): void;
|
||||
(e: 'add'): void;
|
||||
(e: 'remove'): 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 = () => {
|
||||
emit('add');
|
||||
|
||||
@@ -24,6 +24,11 @@
|
||||
<template v-if="column.key === 'image'">
|
||||
<a-image :src="record.image" :width="50" />
|
||||
</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'">
|
||||
<a-tag v-if="record.status === 1" color="green">显示</a-tag>
|
||||
<a-tag v-if="record.status === 0" color="red">隐藏</a-tag>
|
||||
@@ -64,6 +69,7 @@
|
||||
import AiChatListEdit from './components/aiChatListEdit.vue';
|
||||
import { pageAiChatList, removeAiChatList, removeBatchAiChatList } from '@/api/ai/aiChatList';
|
||||
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);
|
||||
@@ -79,6 +85,22 @@
|
||||
// 加载状态
|
||||
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 = ({
|
||||
page,
|
||||
@@ -107,6 +129,13 @@
|
||||
align: 'center',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'chatType',
|
||||
key: 'chatType',
|
||||
align: 'center',
|
||||
width: 110
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'conversationId',
|
||||
|
||||
@@ -85,7 +85,7 @@ import { useUserStore } from "@/store/modules/user";
|
||||
import type { Article } from "@/api/cms/article/model";
|
||||
import { addArticle, updateArticle } from "@/api/cms/article";
|
||||
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 TinymceEditor from "@/components/TinymceEditor/index.vue";
|
||||
import { FormInstance, Rule, RuleObject } from "ant-design-vue/es/form";
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<a-form-item label="上级分类" name="parentId">
|
||||
<a-tree-select
|
||||
allow-clear
|
||||
:tree-data="categoryList"
|
||||
:tree-data="parentOptions"
|
||||
tree-default-expand-all
|
||||
placeholder="请选择上级分类"
|
||||
:value="form.parentId || undefined"
|
||||
@@ -84,13 +84,12 @@
|
||||
v-model:value="form.comments"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
</a-form>
|
||||
</ele-modal>
|
||||
</template>
|
||||
|
||||
<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 type { FormInstance, Rule } from 'ant-design-vue/es/form';
|
||||
import { storeToRefs } from 'pinia';
|
||||
@@ -141,6 +140,26 @@
|
||||
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[]>>({
|
||||
title: [
|
||||
|
||||
@@ -195,8 +195,23 @@
|
||||
|
||||
/* 数据转为树形结构 */
|
||||
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({
|
||||
data: data.map((d) => {
|
||||
data: validData.map((d) => {
|
||||
return { ...d, key: d.categoryId, value: d.categoryId };
|
||||
}),
|
||||
idField: 'categoryId',
|
||||
|
||||
@@ -79,6 +79,12 @@
|
||||
import { shallowRef, ref, reactive, watch, onBeforeUnmount } from 'vue';
|
||||
import { Form, message } from 'ant-design-vue';
|
||||
import { assignObject } from 'ele-admin-pro';
|
||||
import {
|
||||
detectFormat,
|
||||
markdownToHtml,
|
||||
normalizeFormat,
|
||||
plainTextToHtml
|
||||
} from '@/utils/manuscript-content';
|
||||
import { addManuscript, updateManuscript } from '@/api/cms/manuscript';
|
||||
import { Manuscript } from '@/api/cms/manuscript/model';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
@@ -147,6 +153,7 @@
|
||||
id: undefined,
|
||||
title: undefined,
|
||||
content: undefined,
|
||||
contentFormat: 'html',
|
||||
cover: undefined,
|
||||
deleted: undefined,
|
||||
tenantId: undefined,
|
||||
@@ -161,6 +168,27 @@
|
||||
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 text = String(html || '')
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
@@ -257,7 +285,8 @@
|
||||
loading.value = true;
|
||||
const formData = {
|
||||
...form,
|
||||
content: contentHtml.value
|
||||
content: contentHtml.value,
|
||||
contentFormat: 'html'
|
||||
};
|
||||
const saveOrUpdate = isUpdate.value ? updateManuscript : addManuscript;
|
||||
const msg = await saveOrUpdate(formData);
|
||||
@@ -294,7 +323,7 @@
|
||||
if (visible) {
|
||||
if (props.data) {
|
||||
assignObject(form, props.data);
|
||||
contentHtml.value = props.data.content || '';
|
||||
contentHtml.value = toEditorHtml(props.data);
|
||||
coverList.value = props.data.cover
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
:color="item.status === 1 ? 'success' : item.status === 0 ? 'error' : 'default'"
|
||||
style="margin-left: 8px"
|
||||
>
|
||||
{{ item.status === 1 ? '通过' : item.status === 0 ? '不通过' : '-' }}
|
||||
{{ item.status === 1 ? '通过' : item.status === 0 ? '驳回' : '-' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div v-if="item.user?.realName" style="margin-bottom: 4px">
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<a-tag color="green">通过</a-tag>
|
||||
</template>
|
||||
<template v-else-if="getLastReview(record)?.status === 2">
|
||||
<a-tag color="red">不通过</a-tag>
|
||||
<a-tag color="red">驳回</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag>未审核</a-tag>
|
||||
@@ -58,7 +58,11 @@
|
||||
</a-card>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<ManuscriptEdit v-model:visible="showEdit" :data="current" @done="reload" />
|
||||
<ManuscriptEdit
|
||||
v-model:visible="showEdit"
|
||||
:data="current"
|
||||
@done="reload"
|
||||
/>
|
||||
<!-- 新增审核记录弹窗 -->
|
||||
<ReviewRecordModal
|
||||
v-model:visible="showReviewModal"
|
||||
@@ -71,7 +75,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
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 type { EleProTable } from 'ele-admin-pro';
|
||||
import { toDateString } from 'ele-admin-pro';
|
||||
@@ -82,9 +86,14 @@
|
||||
import Search from './components/search.vue';
|
||||
import ManuscriptEdit from './components/manuscriptEdit.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 { ReviewList } from '@/api/gxmu/reviewList/model';
|
||||
import { detectFormat, normalizeFormat } from '@/utils/manuscript-content';
|
||||
|
||||
// 表格实例
|
||||
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||
@@ -130,19 +139,36 @@
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
width: 90
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
align: 'center',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '封面',
|
||||
dataIndex: 'cover',
|
||||
key: 'cover',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '正文格式',
|
||||
dataIndex: 'contentFormat',
|
||||
key: 'contentFormat',
|
||||
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: '创建时间',
|
||||
@@ -157,7 +183,7 @@
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
align: 'center',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
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>
|
||||
<ele-modal
|
||||
:width="600"
|
||||
:visible="visible"
|
||||
:confirm-loading="loading"
|
||||
:title="isUpdate ? '编辑' : '上传文件'"
|
||||
title="编辑素材"
|
||||
:body-style="{ paddingBottom: '8px' }"
|
||||
okText="保存"
|
||||
@update:visible="updateVisible"
|
||||
@@ -19,27 +19,22 @@
|
||||
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
|
||||
"
|
||||
>
|
||||
<a-form-item label="上传文件" name="fileName" v-if="!isUpdate">
|
||||
<span
|
||||
class="ele-text-success"
|
||||
v-if="fileName"
|
||||
style="margin-right: 10px"
|
||||
>
|
||||
{{ fileName }}
|
||||
</span>
|
||||
<a-upload
|
||||
:show-upload-list="false"
|
||||
:accept="'video/*'"
|
||||
v-if="!fileName"
|
||||
:customRequest="onUpload"
|
||||
>
|
||||
<a-button type="primary" class="ele-btn-icon">
|
||||
<template #icon>
|
||||
<UploadOutlined />
|
||||
</template>
|
||||
<span>上传文件</span>
|
||||
</a-button>
|
||||
</a-upload>
|
||||
<a-form-item label="文件">
|
||||
<div class="media-file">
|
||||
<component
|
||||
:is="fileIcon"
|
||||
class="media-file-icon"
|
||||
:style="{ color: fileColor }"
|
||||
/>
|
||||
<div class="media-file-info">
|
||||
<div class="media-file-name" :title="record?.name">
|
||||
{{ record?.name }}
|
||||
</div>
|
||||
<div class="ele-text-secondary media-file-sub">
|
||||
{{ kindLabel }} · {{ fileSize }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="文件名称" name="name">
|
||||
<a-input
|
||||
@@ -53,17 +48,17 @@
|
||||
<a-textarea
|
||||
:rows="4"
|
||||
:maxlength="200"
|
||||
placeholder="图片描述"
|
||||
placeholder="素材描述"
|
||||
v-model:value="form.comments"
|
||||
/>
|
||||
</a-form-item>
|
||||
<div style="margin-bottom: 22px">
|
||||
<a-divider orientation="left">图片分享</a-divider>
|
||||
<a-divider orientation="left">{{ shareTitle }}</a-divider>
|
||||
</div>
|
||||
<a-form-item label="图片URL链接">
|
||||
<a-form-item label="素材链接">
|
||||
<a-input v-model:value="share.url" @click="copyText(share.url)" />
|
||||
</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-form-item>
|
||||
<a-form-item label="Markdown">
|
||||
@@ -72,38 +67,36 @@
|
||||
@click="copyText(share.Markdown)"
|
||||
/>
|
||||
</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-form-item>
|
||||
|
||||
<!-- <a-form-item label="封面图" name="images">-->
|
||||
<!-- <ele-image-upload-->
|
||||
<!-- v-model:value="images"-->
|
||||
<!-- :limit="1"-->
|
||||
<!-- :drag="true"-->
|
||||
<!-- :item-style="{ width: '60px', height: '60px' }"-->
|
||||
<!-- :accept="'image/*'"-->
|
||||
<!-- :upload-handler="uploadHandler"-->
|
||||
<!-- @upload="onUpload"-->
|
||||
<!-- />-->
|
||||
<!-- </a-form-item>-->
|
||||
<a-form-item label="下载地址">
|
||||
<a-input
|
||||
v-model:value="share.download"
|
||||
@click="copyText(share.download)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</ele-modal>
|
||||
</template>
|
||||
|
||||
<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 type { FormInstance, Rule } from 'ant-design-vue/es/form';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import useFormData from '@/utils/use-form-data';
|
||||
import type { FileRecord } from '@/api/system/file/model';
|
||||
import { messageLoading } from 'ele-admin-pro';
|
||||
import { addFiles, updateFiles, uploadFile } from '@/api/system/file';
|
||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||
import { RuleObject } from 'ant-design-vue/es/form';
|
||||
import { updateFiles } from '@/api/system/file';
|
||||
import { copyText } from '@/utils/common';
|
||||
import {
|
||||
formatFileSize,
|
||||
mediaColor,
|
||||
mediaIcon,
|
||||
mediaKindOfRecord,
|
||||
MEDIA_KIND_LABEL
|
||||
} from '@/utils/media';
|
||||
|
||||
// 是否开启响应式布局
|
||||
const themeStore = useThemeStore();
|
||||
@@ -121,20 +114,11 @@
|
||||
data?: FileRecord | null;
|
||||
}>();
|
||||
|
||||
//
|
||||
const formRef = ref<FormInstance | null>(null);
|
||||
const fileName = ref('');
|
||||
// 是否是修改
|
||||
const isUpdate = ref(false);
|
||||
// 当前编辑的原始记录,分享代码依赖它的 url,不随表单变化
|
||||
const record = ref<FileRecord | null>(null);
|
||||
// 提交状态
|
||||
const loading = ref(false);
|
||||
// 图片资源分享
|
||||
const share = ref({
|
||||
url: '',
|
||||
Markdown: '',
|
||||
html: '',
|
||||
bbCode: ''
|
||||
});
|
||||
|
||||
// 表单数据
|
||||
const { form, resetFields, assignFields } = useFormData<FileRecord>({
|
||||
@@ -143,22 +127,49 @@
|
||||
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[]>>({
|
||||
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: [
|
||||
{
|
||||
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 = () => {
|
||||
if (!formRef.value) {
|
||||
@@ -211,8 +189,7 @@
|
||||
.validate()
|
||||
.then(() => {
|
||||
loading.value = true;
|
||||
const saveOrUpdate = isUpdate.value ? updateFiles : addFiles;
|
||||
saveOrUpdate(form)
|
||||
updateFiles(form)
|
||||
.then((msg) => {
|
||||
loading.value = false;
|
||||
message.success(msg);
|
||||
@@ -236,21 +213,44 @@
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
record.value = props.data ?? null;
|
||||
if (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 {
|
||||
record.value = null;
|
||||
resetFields();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
}
|
||||
);
|
||||
</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>
|
||||
<div class="page">
|
||||
<a-row :gutter="20">
|
||||
<a-col
|
||||
v-for="(item, index) in data"
|
||||
:key="index"
|
||||
v-for="item in data"
|
||||
:key="item.id"
|
||||
v-bind="
|
||||
styleResponsive
|
||||
? { xl: 4, lg: 8, md: 12, sm: 12, xs: 24 }
|
||||
@@ -12,10 +13,33 @@
|
||||
>
|
||||
<a-card :bordered="false" hoverable style="margin-top: 16px">
|
||||
<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>
|
||||
<a-card-meta :title="item.name" @click="openEdit(item)">
|
||||
<template #description>
|
||||
<div class="media-meta">
|
||||
{{ labelOf(item) }} · {{ sizeOf(item) }}
|
||||
</div>
|
||||
<div
|
||||
class="project-list-desc"
|
||||
v-if="item.comments"
|
||||
@@ -41,58 +65,85 @@
|
||||
</a-row>
|
||||
<!-- 编辑弹窗 -->
|
||||
<PhotoEdit v-model:visible="showEdit" :data="current" @done="query" />
|
||||
<!-- 音视频预览 -->
|
||||
<MediaPreview v-model:visible="showPreview" :record="current" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { PlayCircleFilled } from '@ant-design/icons-vue';
|
||||
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 MediaPreview from './components/media-preview.vue';
|
||||
import { timeAgo } from 'ele-admin-pro';
|
||||
import {
|
||||
formatFileSize,
|
||||
mediaColor,
|
||||
mediaExtLabel,
|
||||
mediaIcon,
|
||||
mediaKindOfRecord,
|
||||
MEDIA_KIND_LABEL,
|
||||
type MediaKind
|
||||
} from '@/utils/media';
|
||||
|
||||
const props = defineProps<{
|
||||
// 搜索关键字
|
||||
searchText: string;
|
||||
defineProps<{
|
||||
// 列表数据
|
||||
data?: FileRecord[];
|
||||
// 搜索条件
|
||||
where?: FileRecordParam | null;
|
||||
// 修改回显的数据
|
||||
data?: FileRecord | null;
|
||||
}>();
|
||||
|
||||
// 当前编辑数据
|
||||
const current = ref<FileRecord | null>(null);
|
||||
// 是否显示编辑弹窗
|
||||
const showEdit = ref(false);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(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) => {
|
||||
current.value = row ?? null;
|
||||
showEdit.value = true;
|
||||
};
|
||||
|
||||
// 是否开启响应式布局
|
||||
const themeStore = useThemeStore();
|
||||
const { styleResponsive } = storeToRefs(themeStore);
|
||||
/* 点击封面:图片由 a-image 自己预览,音视频开播放器,文档新窗口打开 */
|
||||
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 query = () => {
|
||||
emit('done');
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
console.log(props.data);
|
||||
} else {
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -102,6 +153,58 @@
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
height: 44px;
|
||||
line-height: 22px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<a-page-header :ghost="false" title="图片库">
|
||||
<div class="ele-text-secondary"> 上传、分享有价值的图片素材 </div>
|
||||
<a-page-header :ghost="false" title="素材库">
|
||||
<div class="ele-text-secondary"> 上传、分享图片、文档与音视频素材 </div>
|
||||
<template #footer>
|
||||
<a-tabs v-model:activeKey="activeKey">
|
||||
<a-tab-pane key="1" tab="网格模式" />
|
||||
@@ -10,16 +10,29 @@
|
||||
<div style="margin-bottom: 10px">
|
||||
<a-upload
|
||||
:show-upload-list="false"
|
||||
:accept="'image/*'"
|
||||
:accept="MEDIA_ACCEPT"
|
||||
:customRequest="onUpload"
|
||||
>
|
||||
<a-button key="1" type="primary" class="ele-btn-icon">
|
||||
<template #icon>
|
||||
<UploadOutlined />
|
||||
</template>
|
||||
<span>上传图片</span>
|
||||
<span>上传素材</span>
|
||||
</a-button>
|
||||
</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
|
||||
allow-clear
|
||||
style="width: 360px; margin-left: 10px"
|
||||
@@ -68,15 +81,22 @@
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from '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 List from './list.vue';
|
||||
import { messageLoading } from 'ele-admin-pro';
|
||||
import { pageFiles, uploadFile } from '@/api/system/file';
|
||||
import { message } from 'ant-design-vue/es';
|
||||
import {
|
||||
MEDIA_ACCEPT,
|
||||
MEDIA_CATEGORY_OPTIONS,
|
||||
validateMediaFile
|
||||
} from '@/utils/media';
|
||||
|
||||
const type = ref('name');
|
||||
const searchText = ref('');
|
||||
// 媒体大类筛选,空串表示全部
|
||||
const category = ref('');
|
||||
const data = ref<FileRecord[]>([]);
|
||||
// 当前选项卡
|
||||
const activeKey = ref('1');
|
||||
@@ -94,7 +114,6 @@
|
||||
|
||||
/* 查询数据 */
|
||||
const query = () => {
|
||||
console.log('query()');
|
||||
if (type.value == 'name') {
|
||||
where.name = searchText.value;
|
||||
}
|
||||
@@ -103,8 +122,8 @@
|
||||
}
|
||||
where.page = page.value;
|
||||
where.limit = limit.value;
|
||||
where.contentType = 'image';
|
||||
console.log(where);
|
||||
// 空串会被当成一个真实的筛选值,必须置空
|
||||
where.mediaCategory = category.value || undefined;
|
||||
const hide = messageLoading('请求中..', 0);
|
||||
pageFiles(where).then((res) => {
|
||||
count.value = Number(res?.count);
|
||||
@@ -113,17 +132,20 @@
|
||||
});
|
||||
};
|
||||
|
||||
/* 切换类型回到第一页 */
|
||||
const onCategoryChange = () => {
|
||||
page.value = 1;
|
||||
query();
|
||||
};
|
||||
|
||||
query();
|
||||
|
||||
// 上传文件
|
||||
const onUpload = (item) => {
|
||||
const { file } = item;
|
||||
if (!file.type.startsWith('image')) {
|
||||
message.error('只能选择图片');
|
||||
return;
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 10) {
|
||||
message.error('大小不能超过 10MB');
|
||||
const error = validateMediaFile(file);
|
||||
if (error) {
|
||||
message.error(error);
|
||||
return;
|
||||
}
|
||||
const hide = messageLoading({
|
||||
@@ -132,7 +154,7 @@
|
||||
mask: true
|
||||
});
|
||||
uploadFile(file)
|
||||
.then((data) => {
|
||||
.then(() => {
|
||||
hide();
|
||||
message.success('上传成功');
|
||||
query();
|
||||
@@ -149,15 +171,3 @@
|
||||
name: 'PhotoIndex'
|
||||
};
|
||||
</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>
|
||||
<div class="page">
|
||||
<a-card :bordered="false">
|
||||
@@ -8,21 +9,21 @@
|
||||
:columns="columns"
|
||||
:datasource="datasource"
|
||||
v-model:selection="selection"
|
||||
:scroll="{ x: 800 }"
|
||||
cache-key="proCmsVideoTable"
|
||||
:scroll="{ x: 860 }"
|
||||
cache-key="proCmsPhotoTable"
|
||||
>
|
||||
<template #toolbar>
|
||||
<a-space>
|
||||
<a-upload
|
||||
:show-upload-list="false"
|
||||
:accept="'image/*'"
|
||||
:accept="MEDIA_ACCEPT"
|
||||
:customRequest="onUpload"
|
||||
>
|
||||
<a-button type="primary" class="ele-btn-icon">
|
||||
<template #icon>
|
||||
<UploadOutlined />
|
||||
</template>
|
||||
<span>上传图片</span>
|
||||
<span>上传素材</span>
|
||||
</a-button>
|
||||
</a-upload>
|
||||
<a-button
|
||||
@@ -37,6 +38,19 @@
|
||||
</template>
|
||||
<span>删除</span>
|
||||
</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
|
||||
allow-clear
|
||||
v-model:value="searchText"
|
||||
@@ -60,7 +74,23 @@
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<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 v-if="column.dataIndex === 'name'">
|
||||
<span>{{ record.name }}</span>
|
||||
@@ -78,7 +108,7 @@
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm
|
||||
placement="topRight"
|
||||
title="确定要删除此评价吗?"
|
||||
title="确定要删除此素材吗?"
|
||||
@confirm="remove(record)"
|
||||
>
|
||||
<a class="ele-text-danger">删除</a>
|
||||
@@ -90,6 +120,8 @@
|
||||
</a-card>
|
||||
<!-- 编辑弹窗 -->
|
||||
<PhotoEdit v-model:visible="showEdit" :data="current" @done="reload" />
|
||||
<!-- 音视频预览 -->
|
||||
<MediaPreview v-model:visible="showPreview" :record="current" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -109,6 +141,7 @@
|
||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||
import { messageLoading, toDateString } from 'ele-admin-pro/es';
|
||||
import PhotoEdit from './components/photo-edit.vue';
|
||||
import MediaPreview from './components/media-preview.vue';
|
||||
import {
|
||||
pageFiles,
|
||||
removeFile,
|
||||
@@ -119,17 +152,33 @@
|
||||
FileRecord,
|
||||
FileRecordParam
|
||||
} 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 selection = ref<FileRecord[]>([]);
|
||||
// 当前编辑数据
|
||||
// 当前操作的数据(编辑 / 预览共用)
|
||||
const current = ref<FileRecord | null>(null);
|
||||
// 是否显示编辑弹窗
|
||||
const showEdit = ref(false);
|
||||
// 是否显示音视频预览
|
||||
const showPreview = ref(false);
|
||||
const type = ref('name');
|
||||
const searchText = ref('');
|
||||
// 媒体大类筛选,空串表示全部
|
||||
const category = ref('');
|
||||
// 表格列配置
|
||||
const columns = ref<ColumnItem[]>([
|
||||
{
|
||||
@@ -141,11 +190,17 @@
|
||||
customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
|
||||
},
|
||||
{
|
||||
title: '图片',
|
||||
title: '缩略图',
|
||||
dataIndex: 'path',
|
||||
width: 180,
|
||||
width: 140,
|
||||
key: 'path'
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'contentType',
|
||||
width: 100,
|
||||
customRender: ({ record }) => MEDIA_KIND_LABEL[kindOf(record)]
|
||||
},
|
||||
{
|
||||
title: '文件名称',
|
||||
dataIndex: 'name'
|
||||
@@ -161,26 +216,16 @@
|
||||
sorter: true,
|
||||
showSorterTooltip: false,
|
||||
ellipsis: true,
|
||||
customRender: ({ 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';
|
||||
}
|
||||
},
|
||||
customRender: ({ text }) => formatFileSize(text),
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '发布者',
|
||||
title: '上传人',
|
||||
width: 120,
|
||||
dataIndex: 'createNickname'
|
||||
},
|
||||
{
|
||||
title: '发布时间',
|
||||
title: '上传时间',
|
||||
dataIndex: 'createTime',
|
||||
sorter: true,
|
||||
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 }) => {
|
||||
where = {};
|
||||
@@ -205,7 +257,8 @@
|
||||
if (type.value == 'createNickname') {
|
||||
where.createNickname = searchText.value;
|
||||
}
|
||||
where.contentType = 'image';
|
||||
// 空串会被当成一个真实的筛选值,必须置空
|
||||
where.mediaCategory = category.value || undefined;
|
||||
return pageFiles({ ...where, ...orders, page, limit });
|
||||
};
|
||||
|
||||
@@ -221,6 +274,20 @@
|
||||
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 hide = messageLoading('请求中..', 0);
|
||||
@@ -266,12 +333,9 @@
|
||||
// 上传文件
|
||||
const onUpload = (item) => {
|
||||
const { file } = item;
|
||||
if (!file.type.startsWith('image')) {
|
||||
message.error('只能选择图片');
|
||||
return;
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 10) {
|
||||
message.error('大小不能超过 10MB');
|
||||
const error = validateMediaFile(file);
|
||||
if (error) {
|
||||
message.error(error);
|
||||
return;
|
||||
}
|
||||
const hide = messageLoading({
|
||||
@@ -280,7 +344,7 @@
|
||||
mask: true
|
||||
});
|
||||
uploadFile(file)
|
||||
.then((data) => {
|
||||
.then(() => {
|
||||
hide();
|
||||
message.success('上传成功');
|
||||
reload();
|
||||
@@ -294,6 +358,44 @@
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'VideoIndex'
|
||||
name: 'PhotoList'
|
||||
};
|
||||
</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-name="青马工程"
|
||||
:pk="detailCurrent?.id"
|
||||
@done="reload"
|
||||
/>
|
||||
<ReviewHistoryModal
|
||||
v-model:visible="showReviewHistory"
|
||||
@@ -363,4 +364,4 @@
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import '../toolbar.less';
|
||||
</style>
|
||||
</style>
|
||||
@@ -89,6 +89,7 @@
|
||||
:module="REVIEW_MODULE"
|
||||
module-name="十佳青年岗位能手"
|
||||
:pk="detailCurrent?.id"
|
||||
@done="reload"
|
||||
/>
|
||||
<ReviewHistoryModal
|
||||
v-model:visible="showReviewHistory"
|
||||
@@ -386,4 +387,4 @@
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import '../toolbar.less';
|
||||
</style>
|
||||
</style>
|
||||
@@ -89,6 +89,7 @@
|
||||
:module="REVIEW_MODULE"
|
||||
module-name="十佳团支部书记"
|
||||
:pk="detailCurrent?.id"
|
||||
@done="reload"
|
||||
/>
|
||||
<ReviewHistoryModal
|
||||
v-model:visible="showReviewHistory"
|
||||
@@ -376,4 +377,4 @@
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import '../toolbar.less';
|
||||
</style>
|
||||
</style>
|
||||
@@ -114,6 +114,7 @@
|
||||
module="gxmu_tygl_form"
|
||||
module-name="团员管理"
|
||||
:pk="detailCurrent?.id"
|
||||
@done="reload"
|
||||
/>
|
||||
<ReviewHistoryModal
|
||||
v-model:visible="showReviewHistory"
|
||||
@@ -510,4 +511,4 @@
|
||||
gap: 8px 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -84,6 +84,7 @@
|
||||
:module="REVIEW_MODULE"
|
||||
module-name="挑战杯"
|
||||
:pk="detailCurrent?.id"
|
||||
@done="reload"
|
||||
/>
|
||||
<ReviewHistoryModal
|
||||
v-model:visible="showReviewHistory"
|
||||
@@ -289,4 +290,4 @@
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import '../toolbar.less';
|
||||
</style>
|
||||
</style>
|
||||
@@ -89,6 +89,7 @@
|
||||
:module="REVIEW_MODULE"
|
||||
module-name="五四红旗团委"
|
||||
:pk="detailCurrent?.id"
|
||||
@done="reload"
|
||||
/>
|
||||
<ReviewHistoryModal
|
||||
v-model:visible="showReviewHistory"
|
||||
@@ -444,4 +445,4 @@
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import '../toolbar.less';
|
||||
</style>
|
||||
</style>
|
||||
@@ -89,6 +89,7 @@
|
||||
:module="REVIEW_MODULE"
|
||||
module-name="五四红旗团支部"
|
||||
:pk="detailCurrent?.id"
|
||||
@done="reload"
|
||||
/>
|
||||
<ReviewHistoryModal
|
||||
v-model:visible="showReviewHistory"
|
||||
@@ -478,4 +479,4 @@
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import '../toolbar.less';
|
||||
</style>
|
||||
</style>
|
||||
@@ -146,7 +146,7 @@
|
||||
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
|
||||
import { reviewWxxzxProjectBookQuality } from '@/api/gxmu/wxxzx-form';
|
||||
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 { copyText } from '@/utils/common';
|
||||
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 = () => {
|
||||
streamResolverRef.value = null;
|
||||
};
|
||||
@@ -402,6 +391,10 @@
|
||||
const connectPromise = new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const socket = new WebSocket(buildWebSocketUrl(userId));
|
||||
socket.onopen = () => {
|
||||
// 握手成功后再挂心跳:连接没建起来时发出去只会失败
|
||||
attachHeartbeat(socket);
|
||||
};
|
||||
cleanupPending = () => {
|
||||
if (wsConnectPromiseRef.value === connectPromise) {
|
||||
wsConnectPromiseRef.value = null;
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
:module="REVIEW_MODULE"
|
||||
module-name="未来学术之星"
|
||||
:pk="detailCurrent?.id"
|
||||
@done="reload"
|
||||
/>
|
||||
<ReviewHistoryModal
|
||||
v-model:visible="showReviewHistory"
|
||||
@@ -375,4 +376,4 @@
|
||||
gap: 8px 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -89,6 +89,7 @@
|
||||
:module="REVIEW_MODULE"
|
||||
module-name="优秀共青团干部"
|
||||
:pk="detailCurrent?.id"
|
||||
@done="reload"
|
||||
/>
|
||||
<ReviewHistoryModal
|
||||
v-model:visible="showReviewHistory"
|
||||
@@ -409,4 +410,4 @@
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import '../toolbar.less';
|
||||
</style>
|
||||
</style>
|
||||
@@ -333,6 +333,7 @@
|
||||
:module="REVIEW_MODULE"
|
||||
module-name="优秀共青团员"
|
||||
:pk="detailCurrent?.id"
|
||||
@done="reload"
|
||||
/>
|
||||
<ReviewHistoryModal
|
||||
v-model:visible="showReviewHistory"
|
||||
@@ -867,4 +868,4 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -4,11 +4,10 @@
|
||||
<div>
|
||||
<div class="hero-kicker">AI EVENT PLANNING OUTLINE</div>
|
||||
<h1>活动策划大纲生成</h1>
|
||||
<p>
|
||||
输入活动主题、受众、时间和资金等信息,系统将调用
|
||||
`/ai/activityOutlineGen`
|
||||
生成结构化活动策划大纲,适合方案初稿、汇报材料和执行拆解场景。
|
||||
</p>
|
||||
<p>填写活动主题、受众、时间和预算,AI 给出一份结构完整的策划大纲,可直接改、复制或导出。</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<a-button ghost @click="goHistory">历史记录</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,13 +90,6 @@
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="hint-block">
|
||||
<div class="hint-title">提交说明</div>
|
||||
<div class="hint-text">
|
||||
必填字段会直接作为 `inputs` 透传给
|
||||
AI,后端固定触发“活动策划大纲生成”指令,并返回整理后的正文结果。
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
@@ -140,31 +132,19 @@
|
||||
|
||||
<a-empty
|
||||
v-if="!loading && !hasOutlineContent"
|
||||
description="填写左侧信息后开始生成活动策划大纲"
|
||||
description="填写活动信息后开始生成"
|
||||
/>
|
||||
|
||||
<div v-else class="result-shell">
|
||||
<div v-if="loading" class="loading-panel">
|
||||
<div class="loading-kicker">OUTLINE GENERATING</div>
|
||||
<div class="loading-title">AI 正在整理活动策划结构</div>
|
||||
<div class="loading-desc">
|
||||
正在综合活动主题、受众、时间与经费信息,生成可直接二次编辑的策划大纲。
|
||||
</div>
|
||||
<div class="loading-title">正在生成活动策划大纲…</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showThinkingIndicator" class="outline-thinking">
|
||||
<span class="thinking-dot" aria-hidden="true"></span>
|
||||
<span>AI 正在生成正文内容,请稍候...</span>
|
||||
<span>正在生成正文…</span>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="resultSummary"
|
||||
class="result-alert"
|
||||
type="info"
|
||||
show-icon
|
||||
:message="resultSummary"
|
||||
/>
|
||||
|
||||
<ByteMdEditor
|
||||
v-if="hasOutlineContent"
|
||||
v-model:value="editableOutlineMarkdown"
|
||||
@@ -192,24 +172,32 @@
|
||||
import zh_Hans from 'bytemd/locales/zh_Hans.json';
|
||||
import { getProcessor } from 'bytemd';
|
||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { messageLoading } from 'ele-admin-pro';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { asBlob } from 'html-docx-js-typescript';
|
||||
import ByteMdEditor from '@/components/ByteMdEditor/index.vue';
|
||||
import { activityOutlineGen } from '@/api/ai/activityOutlineGen';
|
||||
import { streamActivityOutlineGen } from '@/utils/ai-stream';
|
||||
import type {
|
||||
ActivityOutlineGenParams,
|
||||
ActivityOutlineGenResponse
|
||||
} from '@/api/ai/activityOutlineGen/model';
|
||||
import { API_BASE_URL } from '@/config/setting';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { copyText } from '@/utils/common';
|
||||
import { FAILED_TEXT } from '@/utils/ai-result';
|
||||
|
||||
type ExampleType = 'volunteer' | 'salon' | 'sports';
|
||||
|
||||
const { push } = useRouter();
|
||||
|
||||
/** 活动策划记录统一在助手页查看,这里只带类型跳过去 */
|
||||
const goHistory = () => {
|
||||
push({ path: '/ai', query: { type: 'activity_outline' } });
|
||||
};
|
||||
|
||||
/** 等「流结束」的挂起点:只由 __END__ 帧或请求收尾来 resolve */
|
||||
type StreamResolver = {
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
interface FormState {
|
||||
@@ -222,7 +210,6 @@
|
||||
event_meme: string;
|
||||
}
|
||||
|
||||
const userStore = useUserStore();
|
||||
const plugins = ref([
|
||||
gfm({
|
||||
locale: zh_HansGfm
|
||||
@@ -234,17 +221,10 @@
|
||||
const result = ref<ActivityOutlineGenResponse | null>(null);
|
||||
const outlineMarkdown = 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 streamResolverRef = ref<StreamResolver | null>(null);
|
||||
const loginUser = computed(() => userStore.info ?? {});
|
||||
|
||||
const STREAM_END_TEXT = '__END__';
|
||||
const SOCKET_CONNECTED_TEXT = '连接成功';
|
||||
const THINK_OPEN_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[]) => {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string') {
|
||||
@@ -382,54 +353,10 @@
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const stringifyUnknown = (value: unknown) => {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch (e) {
|
||||
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}`;
|
||||
};
|
||||
// 原本这里还有 pickNonEmptyString 与 stringifyUnknown 两个局部兜底,已移除:
|
||||
// stringifyUnknown 会把整包原始报文(含 conversationId / messageId)渲染进编辑器;
|
||||
// pickNonEmptyString 则只判「非空字符串」,会在失败报文里把 UUID 形状的 answer
|
||||
// 当正文。现在统一走 @/utils/ai-result(见 issue 10)。
|
||||
|
||||
const clearStreamResolver = () => {
|
||||
streamResolverRef.value = null;
|
||||
@@ -440,65 +367,35 @@
|
||||
clearStreamResolver();
|
||||
};
|
||||
|
||||
const rejectStream = (error: Error) => {
|
||||
streamResolverRef.value?.reject(error);
|
||||
clearStreamResolver();
|
||||
};
|
||||
|
||||
const createStreamWaiter = () => {
|
||||
streamStarted.value = false;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
streamResolverRef.value = { resolve, reject };
|
||||
return new Promise<void>((resolve) => {
|
||||
streamResolverRef.value = { resolve };
|
||||
});
|
||||
};
|
||||
|
||||
const disconnectWebSocket = () => {
|
||||
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
|
||||
) => {
|
||||
const appendOutlineChunk = (chunk: string, reset?: boolean) => {
|
||||
if (!chunk) {
|
||||
return;
|
||||
}
|
||||
if (conversationId && conversationId !== '0') {
|
||||
activeConversationId.value = conversationId;
|
||||
}
|
||||
if (messageId && messageId !== '0') {
|
||||
activeMessageId.value = messageId;
|
||||
}
|
||||
if (!streamStarted.value) {
|
||||
streamStarted.value = true;
|
||||
outlineMarkdown.value = '';
|
||||
}
|
||||
outlineMarkdown.value += chunk;
|
||||
// reset=true 表示「丢弃已累积的片段,只用本条内容」。
|
||||
// 后端在失败收场时带这个标志覆盖半截内容;不处理就会变成
|
||||
// 「半截正文 + 生成失败,请稍后重试」(见 issue 03 / 10)。
|
||||
if (reset) {
|
||||
outlineMarkdown.value = chunk;
|
||||
} else {
|
||||
outlineMarkdown.value += chunk;
|
||||
}
|
||||
syncEditableOutline();
|
||||
resultSummary.value = buildSummary() || '正在通过 WebSocket 接收流式内容';
|
||||
};
|
||||
|
||||
const finishStream = (conversationId?: string, messageId?: string) => {
|
||||
if (conversationId && conversationId !== '0') {
|
||||
activeConversationId.value = conversationId;
|
||||
}
|
||||
if (messageId && messageId !== '0') {
|
||||
activeMessageId.value = messageId;
|
||||
}
|
||||
resultSummary.value = buildSummary() || '已通过 WebSocket 接收生成结果';
|
||||
const finishStream = () => {
|
||||
loading.value = false;
|
||||
resolveStream();
|
||||
disconnectWebSocket();
|
||||
};
|
||||
|
||||
const handleSocketMessage = (raw: string) => {
|
||||
@@ -508,100 +405,25 @@
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (data?.answer === STREAM_END_TEXT) {
|
||||
finishStream(data?.conversationId, data?.messageId);
|
||||
finishStream();
|
||||
return;
|
||||
}
|
||||
if (data?.loading === 'true' || data?.loading === true) {
|
||||
resultSummary.value = 'AI 正在组织活动策划大纲结构...';
|
||||
return;
|
||||
}
|
||||
const chunk = pickRawString(data?.notice, data?.answer);
|
||||
if (typeof chunk === 'string') {
|
||||
appendOutlineChunk(chunk, data?.conversationId, data?.messageId);
|
||||
appendOutlineChunk(chunk, data?.reset === 'true' || data?.reset === true);
|
||||
}
|
||||
} catch (e) {
|
||||
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 = () => {
|
||||
result.value = null;
|
||||
outlineMarkdown.value = '';
|
||||
editableOutlineMarkdown.value = '';
|
||||
resultSummary.value = '';
|
||||
activeConversationId.value = undefined;
|
||||
activeMessageId.value = undefined;
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
@@ -622,33 +444,34 @@
|
||||
}
|
||||
loading.value = true;
|
||||
clearResult();
|
||||
resultSummary.value = '正在建立 WebSocket 连接...';
|
||||
try {
|
||||
await connectWebSocket();
|
||||
resultSummary.value = '连接成功,已提交生成任务,等待流式返回...';
|
||||
// 一律走 HTTP 流式(/stream),不再先连 WebSocket:
|
||||
// 触发生成的那个 POST 在整段生成期间是零字节的,会被链路上的 60s 空闲
|
||||
// 超时掐成 504 Gateway Time-out。/stream 每 15s 有后端心跳续命。
|
||||
const payload = buildPayload();
|
||||
const waitStream = createStreamWaiter();
|
||||
const data = await activityOutlineGen(buildPayload());
|
||||
result.value = data || null;
|
||||
if (!streamStarted.value && data && parseOutline(data)) {
|
||||
outlineMarkdown.value = parseOutline(data);
|
||||
syncEditableOutline();
|
||||
resultSummary.value = buildSummary() || '接口直接返回生成结果';
|
||||
loading.value = false;
|
||||
resolveStream();
|
||||
disconnectWebSocket();
|
||||
await streamActivityOutlineGen(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
(raw) => handleSocketMessage(raw)
|
||||
);
|
||||
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
|
||||
resolveStream();
|
||||
await waitStream;
|
||||
const text = visibleOutlineMarkdown.value.trim();
|
||||
if (!text) {
|
||||
message.warning('这次没有生成出内容,请重试');
|
||||
} else if (text === FAILED_TEXT) {
|
||||
message.error('生成失败,请稍后重试');
|
||||
} else {
|
||||
await waitStream;
|
||||
message.success('活动策划大纲生成完成');
|
||||
}
|
||||
if (!visibleOutlineMarkdown.value) {
|
||||
message.warning('接口已返回,但未解析到正文内容');
|
||||
return;
|
||||
}
|
||||
message.success('活动策划大纲生成完成');
|
||||
} catch (e: any) {
|
||||
disconnectWebSocket();
|
||||
message.error(e.message || '生成失败');
|
||||
} finally {
|
||||
// 收尾以「请求结束」为准,不能只靠 __END__ 帧:网关返回空流、
|
||||
// 上游中途报错时都不会有 __END__,只等它会把生成按钮永久卡在 loading。
|
||||
clearStreamResolver();
|
||||
loading.value = false;
|
||||
message.error(e.message || '生成失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -798,7 +621,6 @@
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnectWebSocket();
|
||||
clearStreamResolver();
|
||||
});
|
||||
</script>
|
||||
@@ -855,6 +677,18 @@
|
||||
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 {
|
||||
margin-bottom: 14px;
|
||||
padding: 16px;
|
||||
@@ -888,27 +722,6 @@
|
||||
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) {
|
||||
border-bottom: 1px solid #edf2f8;
|
||||
}
|
||||
@@ -937,29 +750,12 @@
|
||||
border: 1px solid #d9e8fb;
|
||||
}
|
||||
|
||||
.loading-kicker {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
color: #4875b0;
|
||||
}
|
||||
|
||||
.loading-title {
|
||||
font-size: 22px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #183b68;
|
||||
}
|
||||
|
||||
.loading-desc {
|
||||
margin-top: 10px;
|
||||
line-height: 1.8;
|
||||
color: #607086;
|
||||
}
|
||||
|
||||
.result-alert {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.outline-thinking {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1033,5 +829,9 @@
|
||||
.hero-panel h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</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 class="ant-upload-hint">将 Excel 文件拖到此处,或点击上传</p>
|
||||
</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-spin>
|
||||
</ele-modal>
|
||||
@@ -63,6 +78,8 @@
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const failures = ref<{ row: number; reason: string }[]>([]);
|
||||
const successCount = ref(0);
|
||||
|
||||
const description = computed(
|
||||
() =>
|
||||
@@ -141,13 +158,17 @@
|
||||
if (!rows.length) {
|
||||
throw new Error('导入文件内容为空');
|
||||
}
|
||||
const data = rows
|
||||
.map((row, index) => props.createPayload(row, index + 2))
|
||||
.filter((item) =>
|
||||
const entries = rows
|
||||
.map((row, index) => ({
|
||||
rowNo: index + 2,
|
||||
item: props.createPayload(row, index + 2)
|
||||
}))
|
||||
.filter(({ item }) =>
|
||||
props.templateFields.some((field) =>
|
||||
!!String(item[field.key] ?? '').trim()
|
||||
)
|
||||
);
|
||||
const data = entries.map((entry) => entry.item);
|
||||
if (!data.length) {
|
||||
throw new Error('未识别到有效数据');
|
||||
}
|
||||
@@ -155,10 +176,28 @@
|
||||
if (errors.length) {
|
||||
throw new Error(errors.slice(0, 5).join(';'));
|
||||
}
|
||||
for (const item of data) {
|
||||
await props.saveItem(item);
|
||||
failures.value = [];
|
||||
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);
|
||||
emit('done');
|
||||
} catch (e: any) {
|
||||
@@ -169,7 +208,32 @@
|
||||
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) => {
|
||||
emit('update:visible', value);
|
||||
};
|
||||
</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'"
|
||||
style="margin-left: 8px"
|
||||
>
|
||||
{{ item.status === 1 ? '通过' : item.status === 2 ? '不通过' : '待审核' }}
|
||||
{{ item.status === 1 ? '通过' : item.status === 2 ? '驳回' : '待审核' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div v-if="item.user?.realName" style="margin-bottom: 4px">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<a-tag color="green">通过</a-tag>
|
||||
</template>
|
||||
<template v-else-if="latest?.status === 2">
|
||||
<a-tag color="red">不通过</a-tag>
|
||||
<a-tag color="red">驳回</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag>待审核</a-tag>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
支持文本指令与多类型附件联合驱动工作流,自动返回视频、脚本 Markdown 或图像/排版结果。
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<a-button ghost @click="goHistory">历史记录</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="[24, 24]">
|
||||
@@ -30,40 +33,6 @@
|
||||
placeholder="请输入用户的文本指令或需求描述,例如:根据活动照片和通知文档,生成一条公众号推文封面及 60 秒短视频脚本。"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<div class="submit-bar">
|
||||
@@ -92,9 +61,16 @@
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
必须同时判 `!workflowResponse`:若只判 `!hasAnyResult`,
|
||||
则「已执行但失败 / 未识别到输出」时 hasAnyResult 为假,
|
||||
会落回这个空状态,把下方 result-body 里的 status-alert
|
||||
(失败文案 / 兜底文案)整个盖住 —— 用户看到的仍是
|
||||
「填写文本指令后执行工作流」,等于什么都没发生(见 issue 10)。
|
||||
-->
|
||||
<a-empty
|
||||
v-if="!loading && !hasAnyResult"
|
||||
description="填写文本指令并上传附件后执行工作流"
|
||||
v-if="!loading && !hasAnyResult && !workflowResponse"
|
||||
description="填写文本指令后执行工作流"
|
||||
/>
|
||||
|
||||
<div v-else class="result-body">
|
||||
@@ -199,11 +175,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div v-if="rawOutputsText" class="result-section">-->
|
||||
<!-- <div class="section-title">原始输出</div>-->
|
||||
<!-- <pre class="raw-output">{{ rawOutputsText }}</pre>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
@@ -219,22 +190,28 @@
|
||||
import highlight from '@bytemd/plugin-highlight-ssr';
|
||||
import 'highlight.js/styles/default.css';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
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 { creativeAssistant, uploadAiFile } from '@/api/ai/creativeAssistant';
|
||||
import { runCreativeAssistantStream } from '@/utils/ai-stream';
|
||||
import type { FileRecord } from '@/api/system/file/model';
|
||||
import type {
|
||||
CreativeAssistantOutputs,
|
||||
CreativeAssistantResponse
|
||||
CreativeAssistantResponse,
|
||||
CreativeFileList
|
||||
} from '@/api/ai/creativeAssistant/model';
|
||||
import { copyText } from '@/utils/common';
|
||||
import { resolveDisplayText, isFailedPayload, FAILED_TEXT } from '@/utils/ai-result';
|
||||
|
||||
type ExampleType = 'video' | 'script' | 'image';
|
||||
|
||||
const acceptTypes =
|
||||
'image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md';
|
||||
const { push } = useRouter();
|
||||
|
||||
/** 创意助手记录统一在助手页查看,这里只带类型跳过去 */
|
||||
const goHistory = () => {
|
||||
push({ path: '/ai', query: { type: 'creative' } });
|
||||
};
|
||||
|
||||
const plugins = ref([
|
||||
gfm({
|
||||
locale: zh_HansGfm
|
||||
@@ -244,23 +221,56 @@
|
||||
|
||||
const loading = ref(false);
|
||||
const content = ref('');
|
||||
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
|
||||
const workflowResponse = ref<CreativeAssistantResponse | null>(null);
|
||||
const workflowOutputs = ref<CreativeAssistantOutputs | null>(null);
|
||||
|
||||
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 photoFiles = computed(() => workflowOutputs.value?.photo_files || []);
|
||||
const photoFiles = computed(() =>
|
||||
asFileList(workflowOutputs.value?.photo_files)
|
||||
);
|
||||
const statusMessage = computed(() => {
|
||||
return (
|
||||
// 失败态优先:后端失败时 outputs 为空,若不先判失败,用户既看不到正文
|
||||
// 也看不到原因,只有一个 toast 一闪而过(见 issue 10)。
|
||||
if (isFailedPayload(workflowResponse.value)) {
|
||||
return FAILED_TEXT;
|
||||
}
|
||||
const text =
|
||||
workflowOutputs.value?.video_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(() => {
|
||||
return !!(
|
||||
@@ -269,16 +279,9 @@
|
||||
photoFiles.value.length
|
||||
);
|
||||
});
|
||||
const rawOutputsText = computed(() => {
|
||||
if (!workflowOutputs.value) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(workflowOutputs.value, null, 2);
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
// 原本这里有一个 rawOutputsText(JSON.stringify 整包),已移除:
|
||||
// 它会把 taskId / workflowRunId 等内部标识符渲染给用户,且模板里的引用
|
||||
// 早已被注释掉、留下一个「看似有原始输出视图」的死代码(见 issue 10)。
|
||||
const resultTagText = computed(() => {
|
||||
if (loading.value) {
|
||||
return '执行中';
|
||||
@@ -304,12 +307,6 @@
|
||||
return 'default';
|
||||
});
|
||||
const loadingTitle = computed(() => {
|
||||
if (uploadedFiles.value.length && content.value.trim()) {
|
||||
return '正在分析指令并处理附件';
|
||||
}
|
||||
if (uploadedFiles.value.length) {
|
||||
return '正在解析附件并生成结果';
|
||||
}
|
||||
return '正在根据文本指令生成内容';
|
||||
});
|
||||
const loadingDescription = computed(() => {
|
||||
@@ -363,25 +360,6 @@
|
||||
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 contentType = String(file?.contentType || '').toLowerCase();
|
||||
@@ -407,26 +385,6 @@
|
||||
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) => {
|
||||
if (isImageFile(file)) {
|
||||
return '图片';
|
||||
@@ -447,73 +405,37 @@
|
||||
|
||||
const resetAll = () => {
|
||||
content.value = '';
|
||||
uploadedFiles.value = [];
|
||||
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 payload: any = {
|
||||
user_content: content.value.trim(),
|
||||
user_contentString: content.value.trim()
|
||||
const value = content.value.trim();
|
||||
return {
|
||||
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 () => {
|
||||
if (!canSubmit.value) {
|
||||
message.warning('请先输入文本指令或上传附件');
|
||||
message.warning('请先输入文本指令');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await creativeAssistant(buildRequestPayload());
|
||||
// 出图中途零字节,普通 POST 会被网关按 60s 空闲超时掐成 504,改走流式
|
||||
const result = await runCreativeAssistantStream(buildRequestPayload());
|
||||
workflowResponse.value = result || null;
|
||||
workflowOutputs.value = normalizeOutputPayload(result || null);
|
||||
if (
|
||||
@@ -543,7 +465,7 @@
|
||||
const fillExample = (type: ExampleType) => {
|
||||
if (type === 'video') {
|
||||
content.value =
|
||||
'请根据我上传的活动照片和通知文档,生成一条 45 秒校园活动宣传短视频,画面节奏明快,配一段适合配音的解说文案,并输出视频结果。';
|
||||
'以「青年志愿者进社区」为主题,生成一条 45 秒校园活动宣传短视频,画面节奏明快,配一段适合配音的解说文案,并输出视频结果。';
|
||||
return;
|
||||
}
|
||||
if (type === 'script') {
|
||||
@@ -552,7 +474,7 @@
|
||||
return;
|
||||
}
|
||||
content.value =
|
||||
'请根据上传的活动照片、LOGO 和文案素材,生成一套公众号头图与活动海报排版,风格青春、明亮,输出图片文件。';
|
||||
'以「毕业季」为主题,生成一套公众号头图与活动海报排版,风格青春、明亮,输出图片文件。';
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -605,6 +527,18 @@
|
||||
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 {
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 14px 34px rgba(87, 54, 30, 0.08);
|
||||
@@ -631,38 +565,6 @@
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
min-width: 48px;
|
||||
@@ -675,35 +577,12 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.file-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name,
|
||||
.file-url,
|
||||
.result-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -987,6 +866,10 @@
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.video-grid,
|
||||
.photo-grid {
|
||||
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>
|
||||
</a-form-item>
|
||||
</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-date-picker
|
||||
allow-clear
|
||||
@@ -182,6 +195,10 @@
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { FormInstance } from 'ant-design-vue/es/form';
|
||||
import { DECLARE_MODULE_OPTIONS } from '../moduleOptions';
|
||||
import {
|
||||
TZBCY_PROJECT_GROUP_OPTIONS,
|
||||
TZBCY_PROJECT_TYPE_OPTIONS
|
||||
} from '../../TZBCYForm/options';
|
||||
import {
|
||||
normalizeDeclareRecord,
|
||||
stringifyDeclareMultiValueField
|
||||
@@ -244,6 +261,8 @@
|
||||
publicProjectGroup: [''],
|
||||
startTime: undefined,
|
||||
endTime: undefined,
|
||||
allowRepeat: 0,
|
||||
maxPending: 1,
|
||||
userId: undefined,
|
||||
tenantId: undefined,
|
||||
createTime: undefined,
|
||||
@@ -273,29 +292,68 @@
|
||||
const { resetFields } = useForm(form, rules);
|
||||
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 values = (list || []).map((item) => item?.trim() || '').filter(Boolean);
|
||||
return values.length ? values : [''];
|
||||
};
|
||||
|
||||
const addConfigRow = (
|
||||
key:
|
||||
| 'formProjectType'
|
||||
| 'formProjectGroup'
|
||||
| 'publicProjectType'
|
||||
| 'publicProjectGroup'
|
||||
) => {
|
||||
const addConfigRow = (key: ChallengeCupConfigKey) => {
|
||||
form[key].push('');
|
||||
};
|
||||
|
||||
const removeConfigRow = (
|
||||
key:
|
||||
| 'formProjectType'
|
||||
| 'formProjectGroup'
|
||||
| 'publicProjectType'
|
||||
| 'publicProjectGroup',
|
||||
index: number
|
||||
) => {
|
||||
const removeConfigRow = (key: ChallengeCupConfigKey, index: number) => {
|
||||
form[key].splice(index, 1);
|
||||
if (!form[key].length) {
|
||||
form[key].push('');
|
||||
@@ -418,6 +476,8 @@
|
||||
);
|
||||
yearValue.value = props.data.year ? String(props.data.year) : undefined;
|
||||
isUpdate.value = true;
|
||||
// 历史记录若未配置过挑战杯配置,补上默认值;已配置的记录保持原样
|
||||
applyChallengeCupPreset();
|
||||
} else {
|
||||
Object.assign(form, createDefaultForm());
|
||||
isUpdate.value = false;
|
||||
@@ -435,14 +495,15 @@
|
||||
watch(
|
||||
() => form.module,
|
||||
(value) => {
|
||||
if (value !== 'gxmu_tzbcy_form') {
|
||||
form.projectType = [];
|
||||
form.projectGroup = [];
|
||||
form.formProjectType = [''];
|
||||
form.formProjectGroup = [''];
|
||||
form.publicProjectType = [''];
|
||||
form.publicProjectGroup = [''];
|
||||
if (value === 'gxmu_tzbcy_form') {
|
||||
// 选中挑战杯模块时预填项目申报表 / 公开展示信息表的默认配置
|
||||
applyChallengeCupPreset();
|
||||
return;
|
||||
}
|
||||
// 切到其他模块时不清空配置,避免用户手滑切换后已编辑的内容丢失
|
||||
// (非挑战杯模块不会展示也不会提交这些字段)
|
||||
form.projectType = [];
|
||||
form.projectGroup = [];
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -471,4 +532,4 @@
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -63,7 +63,7 @@
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<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>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'submitTime'">
|
||||
@@ -92,6 +92,7 @@
|
||||
:module="detailCurrent?.module"
|
||||
:module-name="getDeclareModuleLabel(detailCurrent?.module)"
|
||||
:pk="detailCurrent?.id"
|
||||
@done="loadHistoryData"
|
||||
/>
|
||||
<ReviewFlowModal
|
||||
v-model:visible="showReviewFlow"
|
||||
@@ -499,4 +500,4 @@ export default {
|
||||
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>
|
||||
<span>业务ID:</span>
|
||||
<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>
|
||||
|
||||
<div v-if="flowList.length" class="flow-wrap">
|
||||
@@ -23,7 +25,9 @@
|
||||
<a-step v-for="item in flowList" :key="item.id">
|
||||
<template #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)">
|
||||
{{ getStatusText(item.status) }}
|
||||
</a-tag>
|
||||
@@ -31,14 +35,28 @@
|
||||
</template>
|
||||
<template #description>
|
||||
<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>
|
||||
<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 danger size="small" :loading="submitting" @click="openActionModal('reject', item)">
|
||||
拒绝
|
||||
<a-button
|
||||
danger
|
||||
size="small"
|
||||
:loading="submitting"
|
||||
@click="openActionModal('reject', item)"
|
||||
>
|
||||
驳回
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
@@ -50,19 +68,26 @@
|
||||
|
||||
<a-modal
|
||||
v-model:visible="actionVisible"
|
||||
:title="actionType === 'approve' ? '审核通过' : '审核拒绝'"
|
||||
:title="actionType === 'approve' ? '审核通过' : '审核驳回'"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="submitting"
|
||||
@ok="submitReview"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="审核意见(非必填)">
|
||||
<a-form
|
||||
ref="actionFormRef"
|
||||
layout="vertical"
|
||||
:model="actionForm"
|
||||
:rules="actionRules"
|
||||
>
|
||||
<a-form-item label="审核意见" name="content">
|
||||
<a-textarea
|
||||
v-model:value="actionContent"
|
||||
v-model:value="actionForm.content"
|
||||
:rows="4"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
allow-clear
|
||||
placeholder="请输入审核意见"
|
||||
placeholder="请输入审核意见(必填)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
@@ -71,163 +96,187 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Empty, message } from 'ant-design-vue';
|
||||
import { toDateString } from 'ele-admin-pro';
|
||||
import { listReviewList, updateReviewList } from '@/api/gxmu/reviewList';
|
||||
import type { ReviewList } from '@/api/gxmu/reviewList/model';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { Empty, message } from 'ant-design-vue';
|
||||
import type { FormInstance } from 'ant-design-vue/es/form';
|
||||
import { toDateString } from 'ele-admin-pro';
|
||||
import { listReviewList, updateReviewList } from '@/api/gxmu/reviewList';
|
||||
import type { ReviewList } from '@/api/gxmu/reviewList/model';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
module?: string;
|
||||
moduleName?: string;
|
||||
pk?: number;
|
||||
}>();
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
module?: string;
|
||||
moduleName?: string;
|
||||
pk?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', visible: boolean): void;
|
||||
(e: 'done'): void;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', visible: boolean): void;
|
||||
(e: 'done'): void;
|
||||
}>();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const loginUserId = computed(() => Number(userStore.info?.userId || 0));
|
||||
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const flowList = ref<ReviewList[]>([]);
|
||||
const actionVisible = ref(false);
|
||||
const actionType = ref<'approve' | 'reject'>('approve');
|
||||
const actionContent = ref('');
|
||||
const actionTarget = ref<ReviewList | null>(null);
|
||||
const userStore = useUserStore();
|
||||
const loginUserId = computed(() => Number(userStore.info?.userId || 0));
|
||||
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const flowList = ref<ReviewList[]>([]);
|
||||
const actionVisible = ref(false);
|
||||
const actionType = ref<'approve' | 'reject'>('approve');
|
||||
const actionFormRef = ref<FormInstance | 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);
|
||||
if (pendingIndex >= 0) {
|
||||
return pendingIndex;
|
||||
}
|
||||
return Math.max(flowList.value.length - 1, 0);
|
||||
});
|
||||
// 审核意见必填(仅空白字符视为未填写)
|
||||
const actionRules = {
|
||||
content: [
|
||||
{
|
||||
required: true,
|
||||
validator: (_rule: unknown, value: string) => {
|
||||
const text = (value || '').trim();
|
||||
return text
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请输入审核意见'));
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
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;
|
||||
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 = '';
|
||||
const currentStepIndex = computed(() => {
|
||||
const pendingIndex = flowList.value.findIndex((item) => item.status === 0);
|
||||
if (pendingIndex >= 0) {
|
||||
return pendingIndex;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
return Math.max(flowList.value.length - 1, 0);
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.flow-wrap {
|
||||
max-height: 560px;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.flow-wrap {
|
||||
max-height: 560px;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.flow-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.flow-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.flow-desc {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.8;
|
||||
}
|
||||
.flow-desc {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
v-model:value="form.pk"
|
||||
/>
|
||||
</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 :value="1">显示</a-radio>
|
||||
<a-radio :value="0">隐藏</a-radio>
|
||||
|
||||
+170
-150
@@ -23,7 +23,7 @@
|
||||
<template v-if="column.key === 'status'">
|
||||
<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 === 2" color="red">拒绝</a-tag>
|
||||
<a-tag v-else-if="record.status === 2" color="red">驳回</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
@@ -36,12 +36,17 @@
|
||||
</ele-pro-table>
|
||||
</a-card>
|
||||
|
||||
<ReviewListEdit v-model:visible="showEdit" :data="current" @done="reload" />
|
||||
<ReviewListEdit
|
||||
v-model:visible="showEdit"
|
||||
:data="current"
|
||||
@done="reload"
|
||||
/>
|
||||
<ModuleDataModal
|
||||
v-model:visible="showDataVisible"
|
||||
:module="dataCurrent?.module"
|
||||
:module-name="dataCurrent?.moduleName"
|
||||
:pk="dataCurrent?.pk"
|
||||
@done="reload"
|
||||
/>
|
||||
<ReviewFlowModal
|
||||
v-model:visible="showFlowVisible"
|
||||
@@ -55,162 +60,177 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { createVNode, ref } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons-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 Search from './components/search.vue';
|
||||
import ReviewListEdit from './components/reviewListEdit.vue';
|
||||
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';
|
||||
import { createVNode, ref } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons-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 Search from './components/search.vue';
|
||||
import ReviewListEdit from './components/reviewListEdit.vue';
|
||||
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 selection = ref<ReviewList[]>([]);
|
||||
const current = ref<ReviewList | null>(null);
|
||||
const dataCurrent = ref<ReviewList | null>(null);
|
||||
const flowCurrent = ref<ReviewList | null>(null);
|
||||
const showEdit = ref(false);
|
||||
const showDataVisible = ref(false);
|
||||
const showFlowVisible = ref(false);
|
||||
const showMove = ref(false);
|
||||
const loading = ref(true);
|
||||
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||
const selection = ref<ReviewList[]>([]);
|
||||
const current = ref<ReviewList | null>(null);
|
||||
const dataCurrent = ref<ReviewList | null>(null);
|
||||
const flowCurrent = ref<ReviewList | null>(null);
|
||||
const showEdit = ref(false);
|
||||
const showDataVisible = ref(false);
|
||||
const showFlowVisible = ref(false);
|
||||
const showMove = ref(false);
|
||||
const loading = ref(true);
|
||||
|
||||
const datasource: DatasourceFunction = ({ page, limit, where, orders, filters }) => {
|
||||
if (filters) {
|
||||
where.status = filters.status;
|
||||
}
|
||||
return pageGroupReviewList({
|
||||
...where,
|
||||
...orders,
|
||||
const datasource: DatasourceFunction = ({
|
||||
page,
|
||||
limit
|
||||
});
|
||||
};
|
||||
|
||||
const columns = ref<ColumnItem[]>([
|
||||
{
|
||||
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
|
||||
}
|
||||
]);
|
||||
|
||||
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);
|
||||
});
|
||||
limit,
|
||||
where,
|
||||
orders,
|
||||
filters
|
||||
}) => {
|
||||
if (filters) {
|
||||
where.status = filters.status;
|
||||
}
|
||||
});
|
||||
};
|
||||
return pageGroupReviewList({
|
||||
...where,
|
||||
...orders,
|
||||
page,
|
||||
limit
|
||||
});
|
||||
};
|
||||
|
||||
const query = () => {
|
||||
loading.value = true;
|
||||
};
|
||||
const columns = ref<ColumnItem[]>([
|
||||
{
|
||||
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 lang="ts">
|
||||
export default {
|
||||
name: 'ReviewList'
|
||||
};
|
||||
export default {
|
||||
name: 'ReviewList'
|
||||
};
|
||||
</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__title">开始对话</div>
|
||||
<div class="dialog-card__sub">AI+党建的双向赋能</div>
|
||||
<div class="dialog-card__sub">AI+团建的双向赋能</div>
|
||||
<div class="dialog-card__main">体验全新的AI赋能团委工作</div>
|
||||
<button class="start-btn" @click="goLogin">立即开始</button>
|
||||
</div>
|
||||
@@ -63,7 +63,7 @@ const router = useRouter();
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: "AI+党建的双向赋能",
|
||||
title: "AI+团建的双向赋能",
|
||||
desc: "赋能广西医科大学团委的各项工作。项目遵循“先基础,后智能”的原则,信息化和智能化相结合,确保项目稳步推进,价值逐步释放。"
|
||||
},
|
||||
{
|
||||
@@ -75,8 +75,8 @@ const features = [
|
||||
desc: "成为团委老师和学生干部的得力助手,从重复性文案工作中解放生产力。移动端仅提供提供最基础的编辑、审核功能"
|
||||
},
|
||||
{
|
||||
title: "创新竞赛历史项目数据库及创新科研课题申报系统",
|
||||
desc: "打破信息壁垒,为学生参与创新竞赛提供数据参考;固化校内团委课题申报管理流程,提升管理效率。移动端仅用于查阅信息及简单审核"
|
||||
title: "科创竞赛历史项目数据库及创新科研课题申报系统",
|
||||
desc: "打破信息壁垒,为学生参与科创竞赛提供数据参考;固化校内团委课题申报管理流程,提升管理效率。移动端仅用于查阅信息及简单审核"
|
||||
},
|
||||
{
|
||||
title: "多模态智能宣传矩阵",
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
import { FormInstance } from 'ant-design-vue/es/form';
|
||||
import { FILE_SERVER, FILE_THUMBNAIL } from "@/config/setting";
|
||||
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 { addApp } from '@/api/app';
|
||||
import { assignObject } from 'ele-admin-pro';
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
import type { Merchant } from '@/api/merchant/model';
|
||||
import { addMerchant, updateMerchant } from '@/api/merchant';
|
||||
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 OwnerChoose from '@/components/UserChoose/index.vue';
|
||||
import { FILE_SERVER, FILE_THUMBNAIL } from "@/config/setting";
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
MerchantClerk,
|
||||
MerchantClerkParam
|
||||
} from '@/api/merchant/clerk/model';
|
||||
import {
|
||||
import type {
|
||||
ColumnItem,
|
||||
DatasourceFunction
|
||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
import type { Merchant } from '@/api/merchant/model';
|
||||
import { addMerchant, updateMerchant } from '@/api/merchant';
|
||||
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 OwnerChoose from '@/components/UserChoose/index.vue';
|
||||
import { FILE_SERVER, FILE_THUMBNAIL } from '@/config/setting';
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
updateMerchantWithdraw
|
||||
} from '@/api/merchant/withdraw';
|
||||
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 { FILE_SERVER, FILE_THUMBNAIL } from '@/config/setting';
|
||||
import useFormData from '@/utils/use-form-data';
|
||||
|
||||
@@ -517,7 +517,7 @@
|
||||
import { UploadOutlined } from '@/layout/menu-icons';
|
||||
import { FormInstance } from 'ant-design-vue/es/form';
|
||||
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 { User } from '@/api/system/user/model';
|
||||
import { uploadFile } from "@/api/system/file";
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
import type {
|
||||
ColumnItem,
|
||||
DatasourceFunction
|
||||
} from 'ele-admin-pro/es/ele-pro-table/types';
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
import type {
|
||||
ColumnItem,
|
||||
DatasourceFunction
|
||||
} 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"
|
||||
@click="openUrl('https://p.haoii123.com/')"
|
||||
>
|
||||
一键排版
|
||||
公众号排版
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +135,7 @@ import zh_Hans from 'bytemd/locales/zh_Hans.json';
|
||||
import gfm from '@bytemd/plugin-gfm';
|
||||
// 插件的中文语言文件
|
||||
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 { User } from "@/api/system/user/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 { TaskRecord } from "@/api/oa/task-record/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 SelectStaff from '@/components/SelectStaff/index.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 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 { User } from "@/api/system/user/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 { TaskRecord } from "@/api/oa/task-record/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 SelectStaff from '@/components/SelectStaff/index.vue';
|
||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
>
|
||||
账号登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['login-tab', { active: loginType === 'sso' }]"
|
||||
@click="onLoginType('sso')"
|
||||
>
|
||||
统一身份认证
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="loginType === 'sms'">
|
||||
@@ -60,7 +67,7 @@
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<template v-else-if="loginType === 'account'">
|
||||
<a-form-item v-bind="validateInfos.username">
|
||||
<a-input
|
||||
v-model:value="form.username"
|
||||
@@ -83,7 +90,33 @@
|
||||
</a-form-item>
|
||||
</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>
|
||||
与
|
||||
@@ -102,19 +135,7 @@
|
||||
{{ loading ? "登录中" : "登录" }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<div class="login-divider"></div>
|
||||
</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>
|
||||
|
||||
@@ -132,7 +153,7 @@ import { computed, onBeforeUnmount, reactive, ref, unref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Form, message } from "ant-design-vue";
|
||||
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 { THEME_STORE_NAME } from "@/config/setting";
|
||||
|
||||
@@ -144,7 +165,7 @@ const sending = ref(false);
|
||||
const countdown = ref(0);
|
||||
let countdownTimer: number | null = null;
|
||||
|
||||
const loginType = ref<"sms" | "account">("sms");
|
||||
const loginType = ref<"sms" | "account" | "sso">("sms");
|
||||
|
||||
const form = reactive<User & { code?: string }>({
|
||||
username: "",
|
||||
@@ -160,19 +181,25 @@ const toHome = () => {
|
||||
};
|
||||
|
||||
const rules = computed(() => {
|
||||
if (loginType.value === "account") {
|
||||
if (loginType.value === "account" || loginType.value === "sso") {
|
||||
return {
|
||||
username: [
|
||||
{
|
||||
required: true,
|
||||
message: "请输入账号",
|
||||
message:
|
||||
loginType.value === "sso"
|
||||
? "请输入学号"
|
||||
: "请输入账号",
|
||||
type: "string"
|
||||
}
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: "请输入密码",
|
||||
message:
|
||||
loginType.value === "sso"
|
||||
? "请输入统一身份认证密码"
|
||||
: "请输入密码",
|
||||
type: "string"
|
||||
}
|
||||
]
|
||||
@@ -216,7 +243,7 @@ const startCountdown = () => {
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const onLoginType = (type: "sms" | "account") => {
|
||||
const onLoginType = (type: "sms" | "account" | "sso") => {
|
||||
loginType.value = type;
|
||||
};
|
||||
|
||||
@@ -262,7 +289,9 @@ const submit = () => {
|
||||
if (query.tenantId) {
|
||||
form.tenantId = Number(query.tenantId);
|
||||
}
|
||||
login(form)
|
||||
const request =
|
||||
loginType.value === "sso" ? loginByGxmuSso(form) : login(form);
|
||||
request
|
||||
.then((msg) => {
|
||||
message.success(msg);
|
||||
loading.value = false;
|
||||
@@ -338,8 +367,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.login-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 28px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
justify-items: center;
|
||||
@@ -521,60 +549,6 @@ onBeforeUnmount(() => {
|
||||
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 {
|
||||
margin-top: 40px;
|
||||
font-size: 12px;
|
||||
@@ -583,19 +557,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.login-panel {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.login-qr-card {
|
||||
max-width: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -634,10 +599,5 @@ onBeforeUnmount(() => {
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.qr-frame {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -148,7 +148,7 @@ import iconData from 'ele-admin-pro/es/ele-icon-picker/icons';
|
||||
import {assignObject, isExternalLink} from 'ele-admin-pro';
|
||||
import {addApp, updateApp} from '@/api/dashboard/appstore';
|
||||
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 {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 {addApp, updateApp} from '@/api/dashboard/appstore';
|
||||
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 {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