feat(file): add collaboration file browser

This commit is contained in:
kuaifan 2026-08-04 21:27:43 +00:00
parent 5e87eb459e
commit 6deec313f7
35 changed files with 1991 additions and 33 deletions

View File

@ -34,6 +34,7 @@ use App\Models\WebSocketDialogMsgTodo;
use App\Models\WebSocketDialogMsgTranslate;
use App\Models\WebSocketDialogSession;
use App\Models\UserRecentItem;
use App\Services\CollaborationFileService;
use App\Module\Table\OnlineData;
use App\Module\Manticore\ManticoreMsg;
use Hhxsv5\LaravelS\Swoole\Task\Task;
@ -1974,6 +1975,7 @@ class DialogController extends AbstractController
if (empty($dialogMsg)) {
return Base::retError("文件不存在");
}
CollaborationFileService::authorizeMessage($dialogMsg, $user);
//
if ($only_update_at == 'yes') {
return Base::retSuccess('success', [
@ -2035,7 +2037,7 @@ class DialogController extends AbstractController
*/
public function msg__download()
{
User::auth();
$user = User::auth();
//
$msg_id = intval(Request::input('msg_id'));
$down = Request::input('down', 'yes');
@ -2043,6 +2045,11 @@ class DialogController extends AbstractController
$msg = WebSocketDialogMsg::whereId($msg_id)->first();
abort_if(empty($msg), 403, "This file not exist.");
abort_if($msg->type != 'file', 403, "This file not support download.");
try {
CollaborationFileService::authorizeMessage($msg, $user);
} catch (\Throwable $e) {
abort(403, $e->getMessage() ?: "This file not support download.");
}
$array = Base::json2array($msg->getRawOriginal('msg'));
//
if ($down === 'preview') {

View File

@ -12,6 +12,7 @@ use App\Models\FileLink;
use App\Models\FileUser;
use App\Models\User;
use App\Models\UserRecentItem;
use App\Services\CollaborationFileService;
use App\Module\Base;
use App\Module\Down;
use App\Module\Lock;
@ -32,6 +33,47 @@ use ZipArchive;
*/
class FileController extends AbstractController
{
/**
* @api {get} api/file/collaboration/lists 获取协作文件列表
*
* @apiDescription 汇总用户有权访问的会话、项目群聊和任务中的文件消息
* @apiVersion 1.0.0
* @apiGroup file
* @apiName collaboration__lists
*
* @apiParam {String} [scope] 范围all、conversation、project
* @apiParam {String} [conversation_type] 会话类型all、private、group
* @apiParam {Number} [project_id] 项目IDscope=project 时传0表示全部未归档项目
* @apiParam {String} [project_source] 项目来源all、project_chat、task
* @apiParam {String} [file_type] 文件类型
* @apiParam {Number} [sender_id] 发送人ID
* @apiParam {String} [key] 搜索关键词
* @apiParam {Number} [cursor] 上一页最后一条消息ID
* @apiParam {Number} [take] 获取条数默认50最大100
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.list[].image_url 图片缩略图地址,非图片时为空
*/
public function collaboration__lists()
{
$user = User::auth();
$params = Request::only([
'scope',
'conversation_type',
'project_id',
'project_source',
'file_type',
'sender_id',
'key',
'cursor',
'take',
]);
return Base::retSuccess('success', CollaborationFileService::lists($user, $params));
}
/**
* @api {get} api/file/lists 获取文件列表
*

View File

@ -0,0 +1,430 @@
<?php
namespace App\Services;
use App\Exceptions\ApiException;
use App\Models\Project;
use App\Models\ProjectUser;
use App\Models\User;
use App\Models\UserDepartment;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogUser;
use App\Module\Base;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Support\Facades\DB;
class CollaborationFileService
{
private const SCOPES = ['all', 'conversation', 'project'];
private const CONVERSATION_TYPES = ['all', 'private', 'group'];
private const PROJECT_SOURCES = ['all', 'project_chat', 'task'];
private const FILE_TYPES = ['all', 'document', 'sheet', 'slide', 'image', 'video', 'archive', 'other'];
private const FILE_TYPE_EXTENSIONS = [
'document' => ['doc', 'docx', 'dot', 'dotx', 'odt', 'ott', 'pdf', 'rtf', 'txt', 'md'],
'sheet' => ['csv', 'ods', 'ots', 'tsv', 'xls', 'xlsm', 'xlsx', 'xlt', 'xltx'],
'slide' => ['odp', 'otp', 'pot', 'potx', 'pps', 'ppsx', 'ppt', 'pptx'],
'image' => ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'tif', 'tiff', 'webp'],
'video' => ['3gp', 'avi', 'flv', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'rm', 'wmv'],
'archive' => ['7z', 'gz', 'rar', 'tar', 'tgz', 'zip'],
];
private const PREVIEW_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp'];
public static function normalizeParams(array $params): array
{
$scope = trim((string)($params['scope'] ?? 'all'));
$conversationType = trim((string)($params['conversation_type'] ?? 'all'));
$projectSource = trim((string)($params['project_source'] ?? 'all'));
$fileType = trim((string)($params['file_type'] ?? 'all'));
if (!in_array($scope, self::SCOPES, true)
|| !in_array($conversationType, self::CONVERSATION_TYPES, true)
|| !in_array($projectSource, self::PROJECT_SOURCES, true)
|| !in_array($fileType, self::FILE_TYPES, true)) {
throw new ApiException('参数错误');
}
$normalized = [
'scope' => $scope,
'conversation_type' => $conversationType,
'project_id' => max(0, intval($params['project_id'] ?? 0)),
'project_source' => $projectSource,
'file_type' => $fileType,
'sender_id' => max(0, intval($params['sender_id'] ?? 0)),
'key' => mb_substr(trim((string)($params['key'] ?? '')), 0, 100),
'cursor' => max(0, intval($params['cursor'] ?? 0)),
'take' => min(100, max(1, intval($params['take'] ?? 50))),
];
return $normalized;
}
public static function lists(User $user, array $params): array
{
$params = self::normalizeParams($params);
$departmentView = UserDepartment::ownerViewContext($user, true);
if ($params['scope'] === 'project' && $params['project_id'] > 0) {
if (!Project::whereKey($params['project_id'])->exists()) {
throw new ApiException('项目不存在或已被删除');
}
if (!self::canAccessProject($params['project_id'], intval($user->userid), $departmentView)) {
throw new ApiException('无权限访问此文件');
}
}
$query = WebSocketDialogMsg::query()
->select([
'web_socket_dialog_msgs.*',
'dialogs.type as source_dialog_type',
'dialogs.group_type as source_group_type',
'dialogs.name as source_dialog_name',
'project_chat.id as project_chat_id',
'project_chat.name as project_chat_name',
'project_task.id as source_task_id',
'project_task.name as source_task_name',
'project_task.complete_at as source_task_complete_at',
'project_task.archived_at as source_task_archived_at',
'task_project.id as task_project_id',
'task_project.name as task_project_name',
])
->join('web_socket_dialogs as dialogs', 'dialogs.id', '=', 'web_socket_dialog_msgs.dialog_id')
->leftJoin('projects as project_chat', function ($join) {
$join->on('project_chat.dialog_id', '=', 'dialogs.id')
->whereNull('project_chat.deleted_at');
})
->leftJoin('project_tasks as project_task', function ($join) {
$join->on('project_task.dialog_id', '=', 'dialogs.id')
->whereNull('project_task.deleted_at');
})
->leftJoin('projects as task_project', function ($join) {
$join->on('task_project.id', '=', 'project_task.project_id')
->whereNull('task_project.deleted_at');
})
->whereNull('dialogs.deleted_at')
->where('web_socket_dialog_msgs.type', 'file');
self::applyAccess($query, $user, $departmentView);
self::applyScope($query, $params);
self::applyFilters($query, $params);
$rows = $query
->orderByDesc('web_socket_dialog_msgs.id')
->take($params['take'] + 1)
->get();
$hasMore = $rows->count() > $params['take'];
if ($hasMore) {
$rows->pop();
}
$userIds = $rows->pluck('userid')->map(fn($id) => intval($id))->filter()->unique()->values();
$users = User::select(User::$basicField)->whereIn('userid', $userIds)->get()->keyBy('userid');
$privateNames = self::privateDialogNames($rows, intval($user->userid));
$list = $rows->map(function (WebSocketDialogMsg $row) use ($users, $privateNames, $user) {
return self::formatRow($row, $users->get($row->userid), $privateNames, $user);
})->values()->toArray();
return [
'list' => $list,
'next_cursor' => $hasMore && $rows->isNotEmpty() ? intval($rows->last()->id) : 0,
'has_more' => $hasMore,
];
}
public static function authorizeMessage(WebSocketDialogMsg $message, User $user): void
{
$dialog = WebSocketDialog::whereId($message->dialog_id)->first();
if (empty($dialog)) {
throw new ApiException('对话不存在或已被删除');
}
$departmentView = UserDepartment::ownerViewContext($user, true);
if ($dialog->group_type === 'project') {
$projectId = intval(Project::whereDialogId($dialog->id)->value('id'));
if ($projectId <= 0 || !self::canAccessProject($projectId, intval($user->userid), $departmentView)) {
throw new ApiException('无权限访问此文件');
}
return;
}
if ($dialog->group_type === 'task') {
$taskQuery = DB::table('project_tasks')
->whereNull('project_tasks.deleted_at')
->where('project_tasks.dialog_id', $dialog->id);
self::applyTaskPermission($taskQuery, 'project_tasks', intval($user->userid), $departmentView);
if (!$taskQuery->exists()) {
throw new ApiException('无权限访问此文件');
}
return;
}
if (!WebSocketDialogUser::whereDialogId($dialog->id)->whereUserid($user->userid)->exists()) {
throw new ApiException('无权限访问此文件');
}
}
private static function applyAccess(Builder $query, User $user, array $departmentView): void
{
$userid = intval($user->userid);
$query->where(function (Builder $access) use ($userid, $departmentView) {
$access->where(function (Builder $conversation) use ($userid) {
self::applyConversationType($conversation);
$conversation->whereExists(function (QueryBuilder $member) use ($userid) {
$member->selectRaw('1')
->from('web_socket_dialog_users as access_dialog_user')
->whereColumn('access_dialog_user.dialog_id', 'dialogs.id')
->where('access_dialog_user.userid', $userid);
});
})->orWhere(function (Builder $project) use ($userid, $departmentView) {
$project->where('dialogs.group_type', 'project')
->whereNotNull('project_chat.id');
self::applyProjectPermission($project, 'project_chat.id', $userid, $departmentView);
})->orWhere(function (Builder $task) use ($userid, $departmentView) {
$task->where('dialogs.group_type', 'task')
->whereNotNull('project_task.id');
self::applyTaskPermission($task, 'project_task', $userid, $departmentView);
});
});
}
private static function applyScope(Builder $query, array $params): void
{
if ($params['scope'] === 'conversation') {
self::applyConversationType($query);
if ($params['conversation_type'] === 'private') {
$query->where('dialogs.type', 'user');
} elseif ($params['conversation_type'] === 'group') {
$query->where('dialogs.type', 'group')
->whereNotIn('dialogs.group_type', ['project', 'task']);
}
return;
}
if ($params['scope'] === 'project') {
$projectId = $params['project_id'];
$query->where(function (Builder $scope) use ($projectId, $params) {
if ($params['project_source'] !== 'task') {
$scope->where(function (Builder $projectChat) use ($projectId) {
$projectChat->where('dialogs.group_type', 'project')
->whereNotNull('project_chat.id')
->whereNull('project_chat.archived_at');
if ($projectId > 0) {
$projectChat->where('project_chat.id', $projectId);
}
});
}
if ($params['project_source'] !== 'project_chat') {
$method = $params['project_source'] === 'task' ? 'where' : 'orWhere';
$scope->{$method}(function (Builder $task) use ($projectId) {
$task->where('dialogs.group_type', 'task')
->whereNotNull('task_project.id')
->whereNull('task_project.archived_at');
if ($projectId > 0) {
$task->where('task_project.id', $projectId);
}
});
}
});
}
}
private static function applyFilters(Builder $query, array $params): void
{
if ($params['sender_id'] > 0) {
$query->where('web_socket_dialog_msgs.userid', $params['sender_id']);
}
if ($params['cursor'] > 0) {
$query->where('web_socket_dialog_msgs.id', '<', $params['cursor']);
}
if ($params['file_type'] !== 'all') {
$messageTable = DB::getTablePrefix() . 'web_socket_dialog_msgs';
$extension = "LOWER(JSON_UNQUOTE(JSON_EXTRACT({$messageTable}.msg, '$.ext')))";
if ($params['file_type'] === 'other') {
$known = array_values(array_unique(array_merge(...array_values(self::FILE_TYPE_EXTENSIONS))));
$query->whereNotIn(DB::raw($extension), $known);
} else {
$query->whereIn(DB::raw($extension), self::FILE_TYPE_EXTENSIONS[$params['file_type']]);
}
}
if ($params['key'] !== '') {
$key = '%' . addcslashes($params['key'], '%_\\') . '%';
$query->where(function (Builder $search) use ($key) {
$search->where('web_socket_dialog_msgs.key', 'like', $key)
->orWhere('dialogs.name', 'like', $key)
->orWhere('project_chat.name', 'like', $key)
->orWhere('project_task.name', 'like', $key)
->orWhere('task_project.name', 'like', $key)
->orWhereExists(function (QueryBuilder $sender) use ($key) {
$sender->selectRaw('1')
->from('users as search_sender')
->whereColumn('search_sender.userid', 'web_socket_dialog_msgs.userid')
->where('search_sender.nickname', 'like', $key);
})->orWhereExists(function (QueryBuilder $privateUser) use ($key) {
$privateUser->selectRaw('1')
->from('web_socket_dialog_users as search_dialog_user')
->join('users as search_private_user', 'search_private_user.userid', '=', 'search_dialog_user.userid')
->whereColumn('search_dialog_user.dialog_id', 'dialogs.id')
->where('search_private_user.nickname', 'like', $key);
});
});
}
}
private static function applyConversationType(Builder $query): void
{
$query->where(function (Builder $type) {
$type->where('dialogs.type', 'user')
->orWhere(function (Builder $group) {
$group->where('dialogs.type', 'group')
->whereNotIn('dialogs.group_type', ['project', 'task']);
});
});
}
private static function applyProjectPermission(Builder|QueryBuilder $query, string $projectColumn, int $userid, array $departmentView): void
{
$query->where(function ($permission) use ($projectColumn, $userid, $departmentView) {
$permission->whereExists(function (QueryBuilder $member) use ($projectColumn, $userid) {
$member->selectRaw('1')
->from('project_users as access_project_user')
->whereColumn('access_project_user.project_id', $projectColumn)
->where('access_project_user.userid', $userid);
});
if (!empty($departmentView['project_ids'])) {
$permission->orWhereIn($projectColumn, $departmentView['project_ids']);
}
});
}
private static function applyTaskPermission(Builder|QueryBuilder $query, string $taskAlias, int $userid, array $departmentView): void
{
$query->where(function ($permission) use ($taskAlias, $userid, $departmentView) {
$permission->where(function ($projectVisible) use ($taskAlias, $userid, $departmentView) {
$projectVisible->where("{$taskAlias}.visibility", 1);
self::applyProjectPermission($projectVisible, "{$taskAlias}.project_id", $userid, $departmentView);
})->orWhereExists(function (QueryBuilder $projectOwner) use ($taskAlias, $userid) {
$projectOwner->selectRaw('1')
->from('project_users as access_task_project_owner')
->whereColumn('access_task_project_owner.project_id', "{$taskAlias}.project_id")
->where('access_task_project_owner.userid', $userid)
->whereIn('access_task_project_owner.owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY]);
})->orWhereExists(function (QueryBuilder $taskUser) use ($taskAlias, $userid) {
$taskUser->selectRaw('1')
->from('project_task_users as access_task_user')
->whereColumn('access_task_user.task_id', "{$taskAlias}.id")
->where('access_task_user.userid', $userid);
})->orWhereExists(function (QueryBuilder $visibleUser) use ($taskAlias, $userid) {
$visibleUser->selectRaw('1')
->from('project_task_visibility_users as access_task_visible_user')
->whereColumn('access_task_visible_user.task_id', "{$taskAlias}.id")
->where('access_task_visible_user.userid', $userid);
})->orWhereExists(function (QueryBuilder $parentVisibleUser) use ($taskAlias, $userid) {
$parentVisibleUser->selectRaw('1')
->from('project_task_visibility_users as access_parent_task_visible_user')
->whereColumn('access_parent_task_visible_user.task_id', "{$taskAlias}.parent_id")
->where('access_parent_task_visible_user.userid', $userid);
});
});
}
private static function canAccessProject(int $projectId, int $userid, array $departmentView): bool
{
if (isset($departmentView['project_id_map'][$projectId])) {
return true;
}
return DB::table('project_users')->where('project_id', $projectId)->where('userid', $userid)->exists();
}
private static function privateDialogNames($rows, int $userid): array
{
$dialogIds = $rows->filter(fn($row) => $row->source_dialog_type === 'user')
->pluck('dialog_id')->map(fn($id) => intval($id))->unique()->values();
if ($dialogIds->isEmpty()) {
return [];
}
$names = [];
$members = DB::table('web_socket_dialog_users as dialog_user')
->join('users', 'users.userid', '=', 'dialog_user.userid')
->whereIn('dialog_user.dialog_id', $dialogIds)
->where('dialog_user.userid', '!=', $userid)
->select(['dialog_user.dialog_id', 'users.nickname'])
->get();
foreach ($members as $member) {
$names[intval($member->dialog_id)] = $member->nickname;
}
return $names;
}
private static function formatRow(WebSocketDialogMsg $row, ?User $sender, array $privateNames, User $currentUser): array
{
$file = Base::json2array($row->getRawOriginal('msg'));
$ext = strtolower((string)($file['ext'] ?? pathinfo((string)($file['name'] ?? ''), PATHINFO_EXTENSION)));
$imageUrl = '';
if (in_array($ext, self::PREVIEW_IMAGE_EXTENSIONS, true)) {
$imageUrl = Base::fillUrl(($file['thumb'] ?? '') ?: ($file['path'] ?? ''));
}
$sourceType = 'group';
$sourceName = $row->source_dialog_name;
$projectId = 0;
$projectName = '';
$taskId = 0;
$taskName = '';
if ($row->source_dialog_type === 'user') {
$sourceType = 'private';
$sourceName = $privateNames[intval($row->dialog_id)] ?? $currentUser->nickname;
} elseif ($row->source_group_type === 'project') {
$sourceType = 'project_chat';
$sourceName = $row->project_chat_name ?: $row->source_dialog_name;
$projectId = intval($row->project_chat_id);
$projectName = (string)$row->project_chat_name;
} elseif ($row->source_group_type === 'task') {
$sourceType = 'task';
$sourceName = $row->source_task_name ?: $row->source_dialog_name;
$projectId = intval($row->task_project_id);
$projectName = (string)$row->task_project_name;
$taskId = intval($row->source_task_id);
$taskName = (string)$row->source_task_name;
}
return [
'msg_id' => intval($row->id),
'dialog_id' => intval($row->dialog_id),
'name' => (string)($file['name'] ?? ''),
'ext' => $ext,
'size' => intval($file['size'] ?? 0),
'thumb' => Base::fillUrl($file['thumb'] ?? Base::extIcon($ext)),
'image_url' => $imageUrl,
'width' => intval($file['width'] ?? -1),
'height' => intval($file['height'] ?? -1),
'file_type' => self::fileType($ext),
'source_type' => $sourceType,
'source_name' => (string)$sourceName,
'project_id' => $projectId,
'project_name' => $projectName,
'task_id' => $taskId,
'task_name' => $taskName,
'task_status' => $taskId > 0 ? ($row->source_task_archived_at ? 'archived' : ($row->source_task_complete_at ? 'completed' : 'active')) : '',
'sender' => $sender ? $sender->toArray() : ['userid' => intval($row->userid)],
'created_at' => $row->created_at?->toDateTimeString(),
];
}
private static function fileType(string $ext): string
{
foreach (self::FILE_TYPE_EXTENSIONS as $type => $extensions) {
if (in_array($ext, $extensions, true)) {
return $type;
}
}
return 'other';
}
}

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('web_socket_dialog_msgs', function (Blueprint $table) {
$table->index(
['type', 'deleted_at', 'id', 'dialog_id'],
'idx_dialog_file_lists'
);
});
}
public function down(): void
{
Schema::table('web_socket_dialog_msgs', function (Blueprint $table) {
$table->dropIndex('idx_dialog_file_lists');
});
}
};

View File

@ -7,6 +7,8 @@
错误的会话
打开会话失败
参数错误
请选择项目
无权限访问此文件
消息不存在或已被删除
请设置昵称
请设置联系电话

View File

@ -2658,3 +2658,35 @@ LDAP 用户禁止修改邮箱
[weekday].六
AI 助手设置
显示悬浮按钮
(*) · 任务文件
(*) · 项目群聊文件
(*) · 全部文件
个人聊天文件
普通群聊文件
全部会话文件
全部协作文件
汇总项目群聊和项目下任务中的文件
来自私聊和普通群聊的文件
包含会话、项目群聊和任务中的文件
个人会话
普通群聊
昨天 (*)
会话
全部会话
私聊
选择项目
项目群聊
所有类型
文档
演示文稿
压缩包
我发送的
宫格
已加载(*)个文件
发送人
打开来源
未知成员
没有更多文件了
没有找到相关文件
协作文件
搜索名称、会话、项目或任务

View File

@ -38350,5 +38350,401 @@
"fr": "Afficher le bouton flottant",
"id": "Tampilkan tombol mengambang",
"ru": "Показывать плавающую кнопку"
},
{
"key": "(%T1) · 任务文件",
"zh": "",
"zh-CHT": "(%T1) · 任務文件",
"en": "(%T1) · Task files",
"ko": "(%T1) · 작업 파일",
"ja": "(%T1) · タスクファイル",
"de": "(%T1) · Aufgabendateien",
"fr": "(%T1) · Fichiers de tâche",
"id": "(%T1) · File tugas",
"ru": "(%T1) · Файлы задачи"
},
{
"key": "(%T1) · 项目群聊文件",
"zh": "",
"zh-CHT": "(%T1) · 項目群聊文件",
"en": "(%T1) · Project chat files",
"ko": "(%T1) · 프로젝트 채팅 파일",
"ja": "(%T1) · プロジェクトチャットファイル",
"de": "(%T1) · Projektchat-Dateien",
"fr": "(%T1) · Fichiers du chat de projet",
"id": "(%T1) · File obrolan proyek",
"ru": "(%T1) · Файлы чата проекта"
},
{
"key": "(%T1) · 全部文件",
"zh": "",
"zh-CHT": "(%T1) · 全部文件",
"en": "(%T1) · All files",
"ko": "(%T1) · 모든 파일",
"ja": "(%T1) · すべてのファイル",
"de": "(%T1) · Alle Dateien",
"fr": "(%T1) · Tous les fichiers",
"id": "(%T1) · Semua file",
"ru": "(%T1) · Все файлы"
},
{
"key": "个人聊天文件",
"zh": "",
"zh-CHT": "個人聊天文件",
"en": "Private chat files",
"ko": "개인 채팅 파일",
"ja": "個人チャットファイル",
"de": "Private Chat-Dateien",
"fr": "Fichiers de discussion privée",
"id": "File obrolan pribadi",
"ru": "Файлы личных чатов"
},
{
"key": "普通群聊文件",
"zh": "",
"zh-CHT": "普通群聊文件",
"en": "Group chat files",
"ko": "일반 그룹 채팅 파일",
"ja": "通常グループチャットのファイル",
"de": "Gruppenchat-Dateien",
"fr": "Fichiers de discussion de groupe",
"id": "File obrolan grup biasa",
"ru": "Файлы обычных групповых чатов"
},
{
"key": "全部会话文件",
"zh": "",
"zh-CHT": "全部會話文件",
"en": "All conversation files",
"ko": "모든 대화 파일",
"ja": "すべての会話ファイル",
"de": "Alle Konversationsdateien",
"fr": "Tous les fichiers de conversation",
"id": "Semua file percakapan",
"ru": "Все файлы бесед"
},
{
"key": "全部协作文件",
"zh": "",
"zh-CHT": "全部協作文件",
"en": "All collaboration files",
"ko": "모든 협업 파일",
"ja": "すべての共同作業ファイル",
"de": "Alle Zusammenarbeitsdateien",
"fr": "Tous les fichiers collaboratifs",
"id": "Semua file kolaborasi",
"ru": "Все совместные файлы"
},
{
"key": "汇总项目群聊和项目下任务中的文件",
"zh": "",
"zh-CHT": "彙總項目群聊和項目下任務中的文件",
"en": "Files from the project chat and its tasks",
"ko": "프로젝트 채팅과 프로젝트 작업의 파일 모음",
"ja": "プロジェクトチャットとプロジェクト内タスクのファイルを集約",
"de": "Dateien aus dem Projektchat und den Projektaufgaben",
"fr": "Fichiers du chat de projet et de ses tâches",
"id": "File dari obrolan proyek dan tugas di dalamnya",
"ru": "Файлы из чата проекта и его задач"
},
{
"key": "来自私聊和普通群聊的文件",
"zh": "",
"zh-CHT": "來自私聊和普通群聊的文件",
"en": "Files from private and group chats",
"ko": "개인 및 일반 그룹 채팅의 파일",
"ja": "個人チャットと通常グループチャットのファイル",
"de": "Dateien aus privaten Chats und Gruppenchats",
"fr": "Fichiers des discussions privées et de groupe",
"id": "File dari obrolan pribadi dan grup biasa",
"ru": "Файлы из личных и обычных групповых чатов"
},
{
"key": "包含会话、项目群聊和任务中的文件",
"zh": "",
"zh-CHT": "包含會話、項目群聊和任務中的文件",
"en": "Includes files from conversations, project chats, and tasks",
"ko": "대화, 프로젝트 채팅 및 작업의 파일 포함",
"ja": "会話、プロジェクトチャット、タスクのファイルを含みます",
"de": "Enthält Dateien aus Konversationen, Projektchats und Aufgaben",
"fr": "Inclut les fichiers des conversations, chats de projet et tâches",
"id": "Mencakup file dari percakapan, obrolan proyek, dan tugas",
"ru": "Включает файлы из бесед, чатов проектов и задач"
},
{
"key": "个人会话",
"zh": "",
"zh-CHT": "個人會話",
"en": "Private conversation",
"ko": "개인 대화",
"ja": "個人会話",
"de": "Private Konversation",
"fr": "Conversation privée",
"id": "Percakapan pribadi",
"ru": "Личная беседа"
},
{
"key": "普通群聊",
"zh": "",
"zh-CHT": "普通群聊",
"en": "Group chat",
"ko": "일반 그룹 채팅",
"ja": "通常グループチャット",
"de": "Gruppenchat",
"fr": "Discussion de groupe",
"id": "Obrolan grup biasa",
"ru": "Обычный групповой чат"
},
{
"key": "昨天 (%T1)",
"zh": "",
"zh-CHT": "昨天 (%T1)",
"en": "Yesterday (%T1)",
"ko": "어제 (%T1)",
"ja": "昨日 (%T1)",
"de": "Gestern (%T1)",
"fr": "Hier (%T1)",
"id": "Kemarin (%T1)",
"ru": "Вчера (%T1)"
},
{
"key": "会话",
"zh": "",
"zh-CHT": "會話",
"en": "Conversations",
"ko": "대화",
"ja": "会話",
"de": "Konversationen",
"fr": "Conversations",
"id": "Percakapan",
"ru": "Беседы"
},
{
"key": "全部会话",
"zh": "",
"zh-CHT": "全部會話",
"en": "All conversations",
"ko": "모든 대화",
"ja": "すべての会話",
"de": "Alle Konversationen",
"fr": "Toutes les conversations",
"id": "Semua percakapan",
"ru": "Все беседы"
},
{
"key": "私聊",
"zh": "",
"zh-CHT": "私聊",
"en": "Private chats",
"ko": "개인 채팅",
"ja": "個人チャット",
"de": "Private Chats",
"fr": "Discussions privées",
"id": "Obrolan pribadi",
"ru": "Личные чаты"
},
{
"key": "选择项目",
"zh": "",
"zh-CHT": "選擇項目",
"en": "Select project",
"ko": "프로젝트 선택",
"ja": "プロジェクトを選択",
"de": "Projekt auswählen",
"fr": "Sélectionner un projet",
"id": "Pilih proyek",
"ru": "Выбрать проект"
},
{
"key": "项目群聊",
"zh": "",
"zh-CHT": "項目群聊",
"en": "Project chat",
"ko": "프로젝트 채팅",
"ja": "プロジェクトチャット",
"de": "Projektchat",
"fr": "Chat de projet",
"id": "Obrolan proyek",
"ru": "Чат проекта"
},
{
"key": "所有类型",
"zh": "",
"zh-CHT": "所有類型",
"en": "All types",
"ko": "모든 유형",
"ja": "すべての種類",
"de": "Alle Typen",
"fr": "Tous les types",
"id": "Semua jenis",
"ru": "Все типы"
},
{
"key": "文档",
"zh": "",
"zh-CHT": "文檔",
"en": "Documents",
"ko": "문서",
"ja": "ドキュメント",
"de": "Dokumente",
"fr": "Documents",
"id": "Dokumen",
"ru": "Документы"
},
{
"key": "演示文稿",
"zh": "",
"zh-CHT": "演示文稿",
"en": "Presentations",
"ko": "프레젠테이션",
"ja": "プレゼンテーション",
"de": "Präsentationen",
"fr": "Présentations",
"id": "Presentasi",
"ru": "Презентации"
},
{
"key": "压缩包",
"zh": "",
"zh-CHT": "壓縮包",
"en": "Archives",
"ko": "압축 파일",
"ja": "圧縮ファイル",
"de": "Archive",
"fr": "Archives",
"id": "Arsip",
"ru": "Архивы"
},
{
"key": "我发送的",
"zh": "",
"zh-CHT": "我發送的",
"en": "Sent by me",
"ko": "내가 보낸 파일",
"ja": "自分が送信",
"de": "Von mir gesendet",
"fr": "Envoyés par moi",
"id": "Dikirim oleh saya",
"ru": "Отправлено мной"
},
{
"key": "宫格",
"zh": "",
"zh-CHT": "宮格",
"en": "Grid",
"ko": "그리드",
"ja": "グリッド",
"de": "Raster",
"fr": "Grille",
"id": "Kisi",
"ru": "Сетка"
},
{
"key": "已加载(%T1)个文件",
"zh": "",
"zh-CHT": "已加載(%T1)個文件",
"en": "(%T1) files loaded",
"ko": "파일 (%T1)개 로드됨",
"ja": "(%T1) 件のファイルを読み込み済み",
"de": "(%T1) Dateien geladen",
"fr": "(%T1) fichiers chargés",
"id": "(%T1) file dimuat",
"ru": "Загружено файлов: (%T1)"
},
{
"key": "发送人",
"zh": "",
"zh-CHT": "發送人",
"en": "Sender",
"ko": "보낸 사람",
"ja": "送信者",
"de": "Absender",
"fr": "Expéditeur",
"id": "Pengirim",
"ru": "Отправитель"
},
{
"key": "打开来源",
"zh": "",
"zh-CHT": "打開來源",
"en": "Open source",
"ko": "출처 열기",
"ja": "送信元を開く",
"de": "Quelle öffnen",
"fr": "Ouvrir la source",
"id": "Buka sumber",
"ru": "Открыть источник"
},
{
"key": "未知成员",
"zh": "",
"zh-CHT": "未知成員",
"en": "Unknown member",
"ko": "알 수 없는 멤버",
"ja": "不明なメンバー",
"de": "Unbekanntes Mitglied",
"fr": "Membre inconnu",
"id": "Anggota tidak dikenal",
"ru": "Неизвестный участник"
},
{
"key": "没有更多文件了",
"zh": "",
"zh-CHT": "沒有更多文件了",
"en": "No more files",
"ko": "더 이상 파일이 없습니다",
"ja": "これ以上ファイルはありません",
"de": "Keine weiteren Dateien",
"fr": "Aucun autre fichier",
"id": "Tidak ada file lagi",
"ru": "Больше файлов нет"
},
{
"key": "没有找到相关文件",
"zh": "",
"zh-CHT": "沒有找到相關文件",
"en": "No matching files found",
"ko": "관련 파일을 찾을 수 없습니다",
"ja": "該当するファイルが見つかりません",
"de": "Keine passenden Dateien gefunden",
"fr": "Aucun fichier correspondant trouvé",
"id": "Tidak ada file yang sesuai",
"ru": "Подходящие файлы не найдены"
},
{
"key": "协作文件",
"zh": "",
"zh-CHT": "協作文件",
"en": "Collaboration files",
"ko": "협업 파일",
"ja": "共同作業ファイル",
"de": "Zusammenarbeitsdateien",
"fr": "Fichiers collaboratifs",
"id": "File kolaborasi",
"ru": "Совместные файлы"
},
{
"key": "搜索名称、会话、项目或任务",
"zh": "",
"zh-CHT": "搜索名稱、會話、項目或任務",
"en": "Search names, conversations, projects, or tasks",
"ko": "이름, 대화, 프로젝트 또는 작업 검색",
"ja": "名前、会話、プロジェクト、タスクを検索",
"de": "Namen, Konversationen, Projekte oder Aufgaben suchen",
"fr": "Rechercher des noms, conversations, projets ou tâches",
"id": "Cari nama, percakapan, proyek, atau tugas",
"ru": "Поиск по названию, беседе, проекту или задаче"
},
{
"key": "无权限访问此文件",
"zh": "",
"zh-CHT": "無權限訪問此文件",
"en": "You do not have permission to access this file",
"ko": "이 파일에 접근할 권한이 없습니다",
"ja": "このファイルにアクセスする権限がありません",
"de": "Sie haben keine Berechtigung für diese Datei",
"fr": "Vous navez pas lautorisation daccéder à ce fichier",
"id": "Anda tidak memiliki izin untuk mengakses file ini",
"ru": "У вас нет доступа к этому файлу"
}
]
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""]
["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -122,6 +122,7 @@ features:
- file.public-link.howto
- file.preview.concept
- file.search.howto
- file.collaboration.howto
- file.version.concept
- id: application

View File

@ -0,0 +1,44 @@
---
id: file.collaboration.howto
title: 查看会话、项目和任务中的协作文件
type: howto
feature: file
scope: end-user
locale: zh
aliases:
- 怎么找聊天里发过的文件
- 查看一个项目下的所有文件
- 找私聊和群聊附件
- 汇总任务聊天文件
related_tools: [list_files]
related_pages: [file]
prerequisites:
- 当前用户对文件来源的会话、项目或任务有查看权限
negative:
- 协作文件只汇总聊天中以文件消息发送的附件,不包含以文本链接分享的个人文件
- 协作文件中不能新建、上传、移动或删除文件,需回到原会话或对应业务页面操作
last_verified: v1.8.89
---
# 查看会话、项目和任务中的协作文件
## 入口
- 桌面端和移动端:「文件」→ 顶部「协作文件」
## 筛选方式
1. 选择「全部」,查看有权访问的全部协作文件。
2. 选择「会话」,再按「全部会话 / 私聊 / 群聊」筛选;这里的群聊指不属于项目或任务的普通群聊。
3. 选择「项目」时默认查看「全部项目」,汇总有权访问的全部未归档项目文件;也可以选择一个具体项目,再按「全部 / 项目群聊 / 任务」筛选。来源筛选中的「全部」同时汇总项目群聊与任务聊天中的文件。
4. 可继续按文件类型、所有成员或我发送的内容筛选,也可搜索文件名、会话名、项目名、任务名或发送人。
5. 点击文件预览;点击来源可打开对应聊天或任务;下载按钮直接下载原文件。
图片文件会在列表和宫格中直接显示缩略图;缩略图不可用时显示对应的文件类型图标。
## 权限与范围
- 只展示当前用户仍有权访问的来源;退出会话、项目或任务权限变化后,对应文件不再显示。
- 已撤回或删除的文件消息不显示。
- 任务文件遵循任务可见性:项目成员可看全员可见任务,项目负责人、任务成员和指定可见成员按原任务权限查看。
## 不支持
- 不汇总以文本链接方式分享的个人文件。
- 协作文件是只读聚合视图,不支持新建、上传、移动、剪切或删除。

View File

@ -17,23 +17,24 @@ prerequisites: []
negative:
- 文件入口不在「应用」二级页面,是左侧栏一级菜单
- 移动端目前没有专门的「文件」Tabbar需要从「更多」进
last_verified: v1.8.69
last_verified: v1.8.89
---
# 文件入口在哪
## 路径
DooTask 的「文件」是一级导航,相当于个人网盘 + 团队共享盘
DooTask 的「文件」是一级导航,集中展示个人文件、共享文件和协作场景中的文件
- 桌面端 Web左侧栏「文件」图标为文件夹→ 进入个人文件根目录
- 桌面端 Electron同上支持外部窗口拖入上传
- 移动端:底部 Tabbar「更多」→「文件」
## 默认视图
- 顶部一行是板块 Tab「我的文件」/「共享文件」,同一行最右是视图切换(宫格 / 列表),窄屏自动换行
- 顶部一行是板块 Tab「我的文件」/「共享文件」/「协作文件」
- 「我的文件」:我拥有的全部文件(含已共享出去的,共享不会让它从这里消失)
- 「共享文件」:双向共享——包含「我共享出去的」和「别人共享给我的」,可用「全部 / 我共享的 / 共享给我的」二次筛选
- 我共享出去的文件会同时出现在「我的文件」和「共享文件」两个板块
- 「协作文件」:汇总当前用户有权访问的私聊、普通群聊、项目群聊和任务聊天中的文件,可按会话或项目继续筛选
- 进入子文件夹后Tab 下方出现面包屑路径(以板块名为起点,可点击回到板块根目录);根目录不显示重复标题
- 主区域列出当前文件夹下的文件与文件夹(最多 500 条,超出滚动加载)
- 板块选择会记住上次停留的 Tab

View File

@ -0,0 +1,741 @@
<template>
<div class="collaboration-files">
<div class="collaboration-toolbar">
<div class="scope-segment">
<button :class="{active: scope === 'all'}" @click="setScope('all')">{{$L('全部')}}</button>
<button :class="{active: scope === 'conversation'}" @click="setScope('conversation')">{{$L('会话')}}</button>
<button :class="{active: scope === 'project'}" @click="setScope('project')">{{$L('项目')}}</button>
</div>
<div v-if="scope === 'conversation'" class="sub-segment">
<button :class="{active: conversationType === 'all'}" @click="setConversationType('all')">{{$L('全部会话')}}</button>
<button :class="{active: conversationType === 'private'}" @click="setConversationType('private')">{{$L('私聊')}}</button>
<button :class="{active: conversationType === 'group'}" @click="setConversationType('group')">{{$L('群聊')}}</button>
</div>
<template v-if="scope === 'project'">
<Select
v-model="projectId"
class="project-select"
:placeholder="$L('选择项目')"
filterable
@on-change="reload"
style="max-width:auto;">
<Option :value="0">{{$L('全部项目')}}</Option>
<Option v-for="project in projects" :key="project.id" :value="project.id">{{project.name}}</Option>
</Select>
<div class="sub-segment">
<button :class="{active: projectSource === 'all'}" @click="setProjectSource('all')">{{$L('全部')}}</button>
<button :class="{active: projectSource === 'project_chat'}" @click="setProjectSource('project_chat')">{{$L('项目群聊')}}</button>
<button :class="{active: projectSource === 'task'}" @click="setProjectSource('task')">{{$L('任务')}}</button>
</div>
</template>
<div class="toolbar-full"></div>
<Select v-model="fileType" class="type-select" @on-change="reload">
<Option value="all">{{$L('所有类型')}}</Option>
<Option value="document">{{$L('文档')}}</Option>
<Option value="sheet">{{$L('表格')}}</Option>
<Option value="slide">{{$L('演示文稿')}}</Option>
<Option value="image">{{$L('图片')}}</Option>
<Option value="video">{{$L('视频')}}</Option>
<Option value="archive">{{$L('压缩包')}}</Option>
<Option value="other">{{$L('其他')}}</Option>
</Select>
<Select v-model="senderScope" class="sender-select" @on-change="reload">
<Option value="all">{{$L('所有成员')}}</Option>
<Option value="mine">{{$L('我发送的')}}</Option>
</Select>
<div :class="['view-switch', {table: viewMode === 'list'}]">
<div :title="$L('宫格')" @click="viewMode='grid'"><i class="taskfont">&#xe60c;</i></div>
<div :title="$L('列表')" @click="viewMode='list'"><i class="taskfont">&#xe66a;</i></div>
</div>
</div>
<div class="collaboration-summary">
<div class="summary-icon"><Icon :type="summaryIcon"/></div>
<div class="summary-text">
<strong>{{summaryTitle}}</strong>
<span>{{summarySubtitle}}</span>
</div>
<div v-if="items.length" class="loaded-count">{{$L('已加载(*)个文件', items.length)}}</div>
</div>
<div ref="scroller" class="collaboration-scroll" @scroll="onScroll">
<div v-if="loading && items.length === 0" class="initial-loading"><Loading/></div>
<template v-else-if="items.length">
<div v-if="viewMode === 'list'" class="collaboration-table">
<div class="table-head">
<span>{{$L('文件名')}}</span>
<span>{{$L('来源')}}</span>
<span>{{$L('发送人')}}</span>
<span>{{$L('时间')}}</span>
<span>{{$L('大小')}}</span>
<span>{{$L('操作')}}</span>
</div>
<div v-for="item in items" :key="item.msg_id" class="table-row">
<div class="file-main" @click="preview(item)">
<div class="collaboration-file-preview">
<img
v-if="showThumbnail(item)"
class="collaboration-thumbnail"
:src="item.image_url"
alt=""
@error.stop="handleThumbnailError(item)"/>
<div v-else :class="['no-dark-content', 'collaboration-file-icon', fileIconType(item)]"></div>
</div>
<div class="file-text">
<AutoTip class="file-title">{{item.name}}</AutoTip>
<span>{{fileTypeText(item)}}</span>
</div>
</div>
<div class="source-main" @click="openSource(item)">
<div class="source-text">
<span :class="['source-type', item.source_type]">{{sourceTypeText(item)}}</span>
<span class="source-name" :title="sourcePath(item)">{{item.source_name}}</span>
</div>
</div>
<UserAvatar :userid="item.sender.userid" :size="24" showName/>
<span class="time-text">{{formatTime(item.created_at)}}</span>
<span class="size-text">{{$A.bytesToSize(item.size)}}</span>
<div class="row-actions">
<ETooltip :content="$L('打开来源')"><button @click="locateMessage(item)"><Icon type="md-open"/></button></ETooltip>
<ETooltip :content="$L('下载')"><button @click="download(item)"><Icon type="md-download"/></button></ETooltip>
</div>
</div>
</div>
<div v-else class="collaboration-grid">
<div v-for="item in items" :key="item.msg_id" class="grid-item" @click="preview(item)">
<div class="grid-preview">
<img
v-if="showThumbnail(item)"
class="collaboration-thumbnail"
:src="item.image_url"
alt=""
@error.stop="handleThumbnailError(item)"/>
<div v-else :class="['no-dark-content', 'collaboration-file-icon', fileIconType(item)]"></div>
</div>
<AutoTip class="grid-title">{{item.name}}</AutoTip>
<div class="grid-source">
<span :class="['source-type', item.source_type]">{{sourceTypeText(item)}}</span>
<AutoTip class="grid-source-name" :content="sourcePath(item)">{{item.source_name}}</AutoTip>
</div>
<div class="grid-meta">{{item.sender.nickname || $L('未知成员')}} · {{formatTime(item.created_at)}}</div>
<div class="grid-actions" @click.stop>
<button :title="$L('打开来源')" @click="locateMessage(item)"><Icon type="md-open"/></button>
<button :title="$L('下载')" @click="download(item)"><Icon type="md-download"/></button>
</div>
</div>
</div>
<div v-if="loading" class="load-more"><Loading/></div>
<div v-else-if="!hasMore" class="list-end">{{$L('没有更多文件了')}}</div>
</template>
<div v-else class="empty-state">
<Icon type="ios-folder-open-outline"/>
<p>{{$L('没有找到相关文件')}}</p>
</div>
</div>
</div>
</template>
<script>
import {mapState} from "vuex";
import {openFileInClient} from "../../../utils/file";
export default {
name: "CollaborationFileList",
props: {
searchKey: {
type: String,
default: '',
},
},
data() {
return {
scope: 'all',
conversationType: 'all',
projectId: 0,
projectSource: 'all',
fileType: 'all',
senderScope: 'all',
viewMode: 'list',
items: [],
cursor: 0,
hasMore: false,
loading: 0,
searchTimer: null,
requestId: 0,
}
},
computed: {
...mapState(['cacheProjects', 'userId']),
projects() {
return this.cacheProjects
.filter(project => !project.deleted_at && !project.archived_at)
.slice()
.sort((a, b) => a.name.localeCompare(b.name));
},
selectedProject() {
return this.projects.find(project => project.id == this.projectId);
},
summaryIcon() {
if (this.scope === 'project') return 'ios-briefcase-outline';
if (this.scope === 'conversation') return 'ios-chatbubbles-outline';
return 'ios-git-merge';
},
summaryTitle() {
if (this.scope === 'project') {
const name = this.selectedProject?.name || this.$L('全部项目');
if (this.projectSource === 'task') return this.$L('(*) · 任务文件', name);
if (this.projectSource === 'project_chat') return this.$L('(*) · 项目群聊文件', name);
return this.$L('(*) · 全部文件', name);
}
if (this.scope === 'conversation') {
if (this.conversationType === 'private') return this.$L('个人聊天文件');
if (this.conversationType === 'group') return this.$L('普通群聊文件');
return this.$L('全部会话文件');
}
return this.$L('全部协作文件');
},
summarySubtitle() {
if (this.scope === 'project') return this.$L('汇总项目群聊和项目下任务中的文件');
if (this.scope === 'conversation') return this.$L('来自私聊和普通群聊的文件');
return this.$L('包含会话、项目群聊和任务中的文件');
},
},
watch: {
searchKey() {
clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => this.reload(), 400);
},
viewMode(value) {
$A.IDBSave('collaborationFileViewMode', value);
},
},
async mounted() {
const mode = await $A.IDBString('collaborationFileViewMode');
this.viewMode = mode === 'grid' ? 'grid' : 'list';
this.$store.dispatch('getProjects').catch(() => {});
this.reload();
},
beforeDestroy() {
clearTimeout(this.searchTimer);
},
methods: {
setScope(scope) {
if (this.scope === scope) return;
this.scope = scope;
this.reload();
},
setConversationType(type) {
if (this.conversationType === type) return;
this.conversationType = type;
this.reload();
},
setProjectSource(source) {
if (this.projectSource === source) return;
this.projectSource = source;
this.reload();
},
reload() {
this.requestId++;
this.loading = 0;
this.items = [];
this.cursor = 0;
this.hasMore = false;
this.load();
},
refresh() {
this.reload();
},
load() {
if (this.loading || (this.cursor > 0 && !this.hasMore)) return;
const requestId = ++this.requestId;
this.loading++;
this.$store.dispatch('call', {
url: 'file/collaboration/lists',
data: {
scope: this.scope,
conversation_type: this.conversationType,
project_id: this.projectId,
project_source: this.projectSource,
file_type: this.fileType,
sender_id: this.senderScope === 'mine' ? this.userId : 0,
key: this.searchKey.trim(),
cursor: this.cursor,
take: 50,
},
}).then(({data}) => {
if (requestId !== this.requestId) return;
this.items.push(...data.list);
this.cursor = data.next_cursor;
this.hasMore = data.has_more;
}).catch(({msg}) => {
if (msg) $A.modalError(msg);
}).finally(() => {
if (requestId === this.requestId) {
this.loading--;
}
});
},
onScroll(event) {
const target = event.target;
if (target.scrollHeight - target.scrollTop - target.clientHeight < 180 && this.hasMore) {
this.load();
}
},
fileIconType(item) {
if (item.file_type === 'sheet') return 'excel';
if (item.file_type === 'slide') return 'ppt';
if (item.file_type === 'document') return item.ext === 'pdf' ? 'pdf' : 'word';
if (item.file_type === 'image') return 'picture';
if (item.file_type === 'video') return 'media';
if (item.file_type === 'archive') return 'archive';
return 'file';
},
showThumbnail(item) {
return !!item.image_url && !item._thumbnailError;
},
handleThumbnailError(item) {
this.$set(item, '_thumbnailError', true);
},
fileTypeText(item) {
const labels = {
document: '文档',
sheet: '表格',
slide: '演示文稿',
image: '图片',
video: '视频',
archive: '压缩包',
other: '其他',
};
return `${this.$L(labels[item.file_type] || '其他')} · ${(item.ext || '').toUpperCase()}`;
},
sourceTypeText(item) {
const labels = {
private: '私聊',
group: '群聊',
project_chat: '项目群聊',
task: '任务',
};
return this.$L(labels[item.source_type] || '群聊');
},
sourcePath(item) {
if (item.source_type === 'task') return `${item.project_name} / ${item.task_name}`;
if (item.source_type === 'project_chat') return item.project_name;
return item.source_type === 'private' ? this.$L('个人会话') : this.$L('普通群聊');
},
formatTime(time) {
if (!time) return '-';
const value = $A.dayjs(time);
if (value.isSame($A.dayjs(), 'day')) return value.format('HH:mm');
if (value.isSame($A.dayjs().subtract(1, 'day'), 'day')) return this.$L('昨天 (*)', value.format('HH:mm'));
return value.format('YYYY-MM-DD HH:mm');
},
preview(item) {
openFileInClient(this, item, {
path: `/single/file/msg/${item.msg_id}`,
windowName: `file-msg-${item.msg_id}`,
});
},
openSource(item) {
if (item.source_type === 'task' && item.task_id) {
this.$store.dispatch('openTask', {id: item.task_id, project_id: item.project_id});
return;
}
this.locateMessage(item);
},
locateMessage(item) {
this.$store.dispatch('openDialog', {
dialog_id: item.dialog_id,
search_msg_id: item.msg_id,
}).catch(({msg}) => msg && $A.modalError(msg));
},
download(item) {
$A.modalConfirm({
language: false,
title: this.$L('下载文件'),
okText: this.$L('立即下载'),
content: `${item.name} (${$A.bytesToSize(item.size)})`,
onOk: () => this.$store.dispatch('downUrl', $A.apiUrl(`dialog/msg/download?msg_id=${item.msg_id}`)),
});
},
},
}
</script>
<style lang="scss" scoped>
@import "../../../../sass/var";
.collaboration-files {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
min-width: 0;
}
.collaboration-toolbar {
min-height: 58px;
display: flex;
align-items: center;
gap: 10px;
margin: 0 32px;
border-bottom: 1px solid #e8eaec;
.toolbar-full { flex: 1; }
.project-select { width: 180px; }
.type-select { width: 118px; }
.sender-select { width: 118px; }
::v-deep .project-select .ivu-select-selection {
height: 32px;
min-height: 32px;
}
::v-deep .project-select .ivu-select-selected-value,
::v-deep .project-select .ivu-select-placeholder {
height: 30px;
line-height: 30px;
}
}
.scope-segment {
display: flex;
gap: 4px;
width: max-content;
height: 32px;
padding: 2px;
box-sizing: border-box;
border-radius: 7px;
background: #f5f6f7;
button {
min-width: 58px;
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 5px;
white-space: nowrap;
color: $primary-text-color;
background: transparent;
cursor: pointer;
&.active {
color: $primary-color;
background: #fff;
box-shadow: 0 1px 5px rgba(31, 44, 58, 0.12);
}
}
}
.sub-segment {
display: flex;
gap: 8px;
button {
height: 30px;
padding: 0 11px;
border: 1px solid transparent;
border-radius: 15px;
color: $primary-text-color;
background: rgba(0, 0, 0, 0.03);
cursor: pointer;
&.active {
color: $primary-color;
border-color: rgba($primary-color, 0.45);
background: rgba($primary-color, 0.07);
}
}
}
.view-switch {
flex-shrink: 0;
display: flex;
align-items: center;
position: relative;
border-radius: 6px;
background: #fff;
transition: box-shadow 0.2s;
&:hover { box-shadow: 0 0 10px #e6ecfa; }
&:before {
content: "";
width: 50%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 0;
border: 1px solid $primary-color;
border-radius: 6px;
background: rgba($primary-color, 0.1);
transition: left 0.2s;
}
> div {
z-index: 1;
width: 32px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: $primary-text-color;
cursor: pointer;
&:first-child { color: $primary-color; }
i { font-size: 17px; }
}
&.table {
&:before { left: 50%; }
> div:first-child { color: $primary-text-color; }
> div:last-child { color: $primary-color; }
}
}
.collaboration-summary {
min-height: 70px;
display: flex;
align-items: center;
margin: 0 32px;
border-bottom: 1px solid #e8eaec;
.summary-icon {
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
border-radius: 7px;
color: $primary-color;
background: rgba($primary-color, 0.1);
i { font-size: 20px; }
}
.summary-text {
min-width: 0;
display: flex;
flex-direction: column;
strong { font-size: 16px; color: $primary-title-color; }
span { margin-top: 4px; font-size: 12px; color: $primary-desc-color; }
}
.loaded-count { margin-left: auto; color: $primary-desc-color; font-size: 12px; }
}
.collaboration-scroll {
flex: 1;
min-height: 0;
overflow: auto;
position: relative;
}
.initial-loading {
width: 100%;
height: 100%;
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
}
.collaboration-table {
margin: 0 32px;
}
.table-head,
.table-row {
display: grid;
grid-template-columns: minmax(260px, 1.7fr) minmax(210px, 1.2fr) 130px 150px 90px 80px;
align-items: center;
column-gap: 16px;
padding: 0 12px;
}
.table-head {
height: 44px;
color: $primary-desc-color;
font-size: 12px;
border-bottom: 1px solid #e8eaec;
span:last-child { text-align: right; }
}
.table-row {
min-height: 70px;
border-bottom: 1px solid #e8eaec;
transition: background 0.15s;
&:hover { background: rgba($primary-color, 0.025); }
}
.file-main,
.source-main {
min-width: 0;
display: flex;
align-items: center;
cursor: pointer;
}
.collaboration-file-preview {
width: 38px;
height: 38px;
flex: none;
margin-right: 11px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 5px;
.collaboration-file-icon {
width: 32px;
height: 32px;
}
}
.collaboration-thumbnail {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
border-radius: 5px;
}
.collaboration-file-icon {
flex: none;
background: center / contain no-repeat url("/images/file/light/other.svg");
&.archive { background-image: url("/images/file/light/archive.svg"); }
&.excel { background-image: url("/images/file/light/excel.svg"); }
&.media { background-image: url("/images/file/light/media.svg"); }
&.pdf { background-image: url("/images/file/light/pdf.svg"); }
&.picture { background-image: url("/images/file/light/picture.svg"); }
&.ppt { background-image: url("/images/file/light/ppt.svg"); }
&.word { background-image: url("/images/file/light/word.svg"); }
}
.file-text,
.source-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
.file-title { color: $primary-title-color; font-weight: 500; }
> span { margin-top: 4px; color: $primary-desc-color; font-size: 11px; }
}
.source-text {
max-height: 40px;
overflow: hidden;
.source-type,
.source-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-type {
margin-top: 0;
color: #5276b3;
font-size: 11px;
line-height: 16px;
&.group { color: #705bb1; }
&.project_chat { color: #aa6d2e; }
&.task { color: $primary-color; }
}
.source-name {
margin-top: 0;
color: $primary-title-color;
font-size: 13px;
line-height: 20px;
}
}
.time-text,
.size-text { color: $primary-text-color; font-size: 12px; }
.row-actions {
display: flex;
justify-content: flex-end;
button,
.ivu-tooltip-rel button {
width: 30px;
height: 30px;
border: 0;
border-radius: 5px;
color: $primary-text-color;
background: transparent;
cursor: pointer;
&:hover { color: $primary-color; background: rgba($primary-color, 0.08); }
}
}
.collaboration-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 12px;
margin: 0 32px;
padding: 16px 0;
}
.grid-item {
min-height: 164px;
padding: 14px;
position: relative;
border: 1px solid #e8eaec;
border-radius: 7px;
cursor: pointer;
&:hover {
border-color: rgba($primary-color, 0.55);
.grid-actions { opacity: 1; }
}
.grid-preview {
width: 42px;
height: 42px;
overflow: hidden;
border-radius: 5px;
.collaboration-file-icon {
width: 38px;
height: 38px;
}
}
.grid-title { display: block; margin-top: 12px; color: $primary-title-color; font-weight: 500; }
.grid-source {
min-width: 0;
margin-top: 8px;
display: flex;
flex-direction: column;
align-items: flex-start;
.source-type {
color: #5276b3;
font-size: 11px;
line-height: 16px;
&.group { color: #705bb1; }
&.project_chat { color: #aa6d2e; }
&.task { color: $primary-color; }
}
.grid-source-name { max-width: 100%; color: $primary-text-color; line-height: 20px; }
}
.grid-meta { margin-top: 8px; color: $primary-desc-color; font-size: 11px; }
.grid-actions {
opacity: 0;
position: absolute;
top: 10px;
right: 8px;
display: flex;
button { width: 28px; height: 28px; border: 0; color: $primary-text-color; background: transparent; cursor: pointer; }
}
}
.empty-state {
height: 100%;
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: $primary-desc-color;
i { font-size: 64px; }
p { margin-top: 14px; }
}
.load-more,
.list-end {
height: 48px;
display: flex;
align-items: center;
justify-content: center;
color: $primary-desc-color;
font-size: 12px;
}
@media (max-width: 900px) {
.collaboration-toolbar {
flex-wrap: wrap;
margin: 0 16px;
padding: 10px 0;
}
.collaboration-summary,
.collaboration-table,
.collaboration-grid { margin-right: 16px; margin-left: 16px; }
.toolbar-full { display: none; }
.project-select { flex: 1; min-width: 160px; }
.table-head { display: none; }
.table-row {
grid-template-columns: minmax(0, 1fr) 72px;
gap: 6px 10px;
padding: 12px 4px;
.source-main { grid-column: 1; padding-left: 49px; }
> .avatar-wrapper,
> .time-text,
> .size-text { display: none; }
.row-actions { grid-column: 2; grid-row: 1 / 3; }
}
.collaboration-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
.loaded-count { display: none; }
}
</style>

View File

@ -19,7 +19,7 @@
<Loading v-if="packList.find(({status}) => status !== 'finished')"/>
<Button v-else shape="circle" icon="md-arrow-round-down"></Button>
</div>
<div class="file-search" @click="onSearchFocus">
<div :class="['file-search', {collaboration: board === 'collaboration'}]" @click="onSearchFocus">
<Input
v-model="searchKey"
ref="searchInput"
@ -27,10 +27,10 @@
@on-focus="searchIsFocus=true"
@on-blur="searchIsFocus=false"
@on-change="onSearchChange"
:placeholder="$L('搜索名称')"
:placeholder="$L(board === 'collaboration' ? '搜索名称、会话、项目或任务' : '搜索名称')"
clearable/>
</div>
<div class="file-add">
<div v-if="board !== 'collaboration'" class="file-add">
<Button shape="circle" icon="md-add" @click.stop="handleRightClick($event, null, true)"></Button>
</div>
</div>
@ -40,6 +40,7 @@
<div class="file-tabs-nav">
<div :class="['file-tab', {active: board === 'mine'}]" @click="switchBoard('mine')">{{$L('我的文件')}}</div>
<div :class="['file-tab', {active: board === 'shared'}]" @click="switchBoard('shared')">{{$L('共享文件')}}</div>
<div :class="['file-tab', {active: board === 'collaboration'}]" @click="switchBoard('collaboration')">{{$L('协作文件')}}</div>
</div>
<div class="file-tabs-full"></div>
<div v-if="board === 'shared' && pid == 0 && !searchKey" class="file-shared-src">
@ -47,12 +48,18 @@
<span :class="{on: sharedSrc === 'byme'}" @click="sharedSrc = 'byme'">{{$L('我共享的')}}</span>
<span :class="{on: sharedSrc === 'tome'}" @click="sharedSrc = 'tome'">{{$L('共享给我的')}}</span>
</div>
<div :class="['switch-button', tableMode]">
<div v-if="board !== 'collaboration'" :class="['switch-button', tableMode]">
<div @click="tableMode='block'"><i class="taskfont">&#xe60c;</i></div>
<div @click="tableMode='table'"><i class="taskfont">&#xe66a;</i></div>
</div>
</div>
<CollaborationFileList
v-if="board === 'collaboration'"
ref="collaborationFiles"
:search-key="searchKey"/>
<template v-else>
<div v-show="showNavigator" class="file-navigator">
<ul class="scrollbar-hidden" v-show="showBtnText || (!selectedItems.length && !shearFirst)">
<li v-if="pid > 0 || searchKey" @click="browseFolder(0)">
@ -275,6 +282,7 @@
</DropdownMenu>
</Dropdown>
</div>
</template>
</div>
<div v-if="uploadShow && uploadList.length > 0" class="file-upload-list">
@ -500,6 +508,7 @@ import longpress from "../../directives/longpress";
import UserSelect from "../../components/UserSelect.vue";
import UserAvatarTip from "../../components/UserAvatar/tip.vue";
import Forwarder from "./components/Forwarder/index.vue";
import CollaborationFileList from "./components/CollaborationFileList.vue";
import {chunkedUpload, CHUNK_THRESHOLD} from "../../store/chunkedUpload";
const FilePreview = () => import('./components/FilePreview');
@ -507,7 +516,7 @@ const FileContent = () => import('./components/FileContent');
const FileObject = {sort: null, mode: null, board: null};
export default {
components: {Forwarder, UserAvatarTip, UserSelect, FilePreview, DrawerOverlay, FileContent},
components: {CollaborationFileList, Forwarder, UserAvatarTip, UserSelect, FilePreview, DrawerOverlay, FileContent},
directives: {longpress},
data() {
return {
@ -571,7 +580,7 @@ export default {
],
tableMode: "",
board: "mine", // mine=shared=
board: "mine", // mine=shared=collaboration=
sharedSrc: "all", // all=byme=tome=
columns: [],
@ -639,7 +648,7 @@ export default {
created() {
this.tableMode = FileObject.mode
this.board = FileObject.board === 'shared' ? 'shared' : 'mine'
this.board = ['mine', 'shared', 'collaboration'].includes(FileObject.board) ? FileObject.board : 'mine'
this.columns = [
{
type: 'selection',
@ -1172,6 +1181,10 @@ export default {
if (this.routeName !== 'manage-file') {
return;
}
if (this.board === 'collaboration') {
this.$refs.collaborationFiles?.refresh();
return;
}
this.loadIng++;
// fileList
this.$store.dispatch("getFiles", {pid: this.pid, scope: this.board}).then(async () => {
@ -1512,6 +1525,7 @@ export default {
this.board = board;
this.sharedSrc = 'all';
this.selectedItems = [];
this.contextMenuVisible = false;
this.clearShear();
if (this.pid > 0) {
// 退pid
@ -2128,6 +2142,9 @@ export default {
},
onSearchChange() {
if (this.board === 'collaboration') {
return;
}
this.searchTimeout && clearTimeout(this.searchTimeout);
if (this.searchKey.trim() != '') {
this.searchTimeout = setTimeout(() => {

View File

@ -73,6 +73,11 @@
}
}
}
&.collaboration {
.ivu-input-wrapper .ivu-input {
width: 240px;
}
}
}
.file-add {
cursor: pointer;

View File

@ -2,7 +2,7 @@
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
接口总数314
接口总数315
## 路由规则
@ -302,6 +302,7 @@ API 使用动态路由(见 `routes/web.php`URL 段映射为控制器方
| URL | 方法名 | HTTP | 说明 |
| --- | --- | --- | --- |
| api/file/collaboration/lists | collaboration__lists() | get | 获取协作文件列表 |
| api/file/lists | lists() | get | 获取文件列表 |
| api/file/one | one() | get | 获取单条数据 |
| api/file/fetch | fetch() | get | 通过路径获取文件文本内容 |

View File

@ -0,0 +1,214 @@
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\ProjectTaskUser;
use App\Models\ProjectUser;
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogUser;
use App\Exceptions\ApiException;
use App\Services\CollaborationFileService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class CollaborationFileServiceTest extends TestCase
{
use DatabaseTransactions;
private function makeUser(string $email): User
{
$user = User::createInstance([
'email' => $email,
'userimg' => '',
'nickname' => 'File_' . substr(md5($email), 0, 6),
'profession' => '',
'password' => md5('123456'),
]);
$user->save();
return $user;
}
private function makeDialog(string $type, string $groupType, string $name, array $members): WebSocketDialog
{
$dialog = WebSocketDialog::createInstance([
'type' => $type,
'group_type' => $groupType,
'name' => $name,
]);
$dialog->save();
foreach ($members as $userid) {
WebSocketDialogUser::createInstance([
'dialog_id' => $dialog->id,
'userid' => $userid,
])->save();
}
return $dialog;
}
private function makeFile(WebSocketDialog $dialog, User $sender, string $name): WebSocketDialogMsg
{
$message = WebSocketDialogMsg::createInstance([
'dialog_id' => $dialog->id,
'userid' => $sender->userid,
'type' => 'file',
'key' => $name,
'msg' => json_encode([
'name' => $name,
'ext' => pathinfo($name, PATHINFO_EXTENSION),
'size' => 1024,
'path' => 'uploads/test/' . $name,
], JSON_UNESCAPED_UNICODE),
]);
$message->save();
return $message;
}
public function test_lists_classifies_sources_and_applies_access(): void
{
$viewer = $this->makeUser('collaboration-viewer@test.local');
$sender = $this->makeUser('collaboration-sender@test.local');
$outsider = $this->makeUser('collaboration-outsider@test.local');
$private = $this->makeDialog('user', '', '', [$viewer->userid, $sender->userid]);
$group = $this->makeDialog('group', 'user', '普通工作群', [$viewer->userid, $sender->userid]);
$hidden = $this->makeDialog('group', 'user', '不可见群', [$sender->userid, $outsider->userid]);
$projectDialog = $this->makeDialog('group', 'project', '项目群', [$viewer->userid, $sender->userid]);
$project = Project::createInstance([
'name' => '协作文件项目',
'desc' => '',
'userid' => $sender->userid,
'dialog_id' => $projectDialog->id,
'personal' => 0,
]);
$project->save();
ProjectUser::updateInsert(
['project_id' => $project->id, 'userid' => $viewer->userid],
['owner' => 0]
);
$taskDialog = $this->makeDialog('group', 'task', '任务群', [$viewer->userid, $sender->userid]);
$task = ProjectTask::createInstance([
'project_id' => $project->id,
'parent_id' => 0,
'name' => '协作文件任务',
'dialog_id' => $taskDialog->id,
'userid' => $sender->userid,
'visibility' => 2,
]);
$task->save();
ProjectTaskUser::createInstance([
'project_id' => $project->id,
'task_id' => $task->id,
'userid' => $viewer->userid,
'owner' => 0,
])->save();
$this->makeFile($private, $sender, 'private.pdf');
$this->makeFile($group, $sender, 'group.docx');
$this->makeFile($projectDialog, $sender, 'project.xlsx');
$this->makeFile($taskDialog, $sender, 'task.png');
$this->makeFile($hidden, $sender, 'hidden.zip');
$result = CollaborationFileService::lists($viewer, ['take' => 20]);
$this->assertSame(['task', 'project_chat', 'group', 'private'], array_column($result['list'], 'source_type'));
$this->assertNotContains('hidden.zip', array_column($result['list'], 'name'));
$this->assertStringEndsWith('/uploads/test/task.png', $result['list'][0]['image_url']);
$this->assertSame('', $result['list'][1]['image_url']);
$conversation = CollaborationFileService::lists($viewer, [
'scope' => 'conversation',
'conversation_type' => 'private',
]);
$this->assertSame(['private.pdf'], array_column($conversation['list'], 'name'));
$projectFiles = CollaborationFileService::lists($viewer, [
'scope' => 'project',
'project_id' => $project->id,
'project_source' => 'task',
]);
$this->assertSame(['task.png'], array_column($projectFiles['list'], 'name'));
$archivedDialog = $this->makeDialog('group', 'project', '归档项目群', [$viewer->userid, $sender->userid]);
$archivedProject = Project::createInstance([
'name' => '已归档协作文件项目',
'desc' => '',
'userid' => $sender->userid,
'dialog_id' => $archivedDialog->id,
'personal' => 0,
'archived_at' => now(),
]);
$archivedProject->save();
ProjectUser::updateInsert(
['project_id' => $archivedProject->id, 'userid' => $viewer->userid],
['owner' => 0]
);
$this->makeFile($archivedDialog, $sender, 'archived-project.zip');
$hiddenProjectDialog = $this->makeDialog('group', 'project', '不可见项目群', [$sender->userid, $outsider->userid]);
$hiddenProject = Project::createInstance([
'name' => '不可见协作文件项目',
'desc' => '',
'userid' => $sender->userid,
'dialog_id' => $hiddenProjectDialog->id,
'personal' => 0,
]);
$hiddenProject->save();
ProjectUser::updateInsert(
['project_id' => $hiddenProject->id, 'userid' => $sender->userid],
['owner' => ProjectUser::OWNER_PRIMARY]
);
$this->makeFile($hiddenProjectDialog, $sender, 'hidden-project.pdf');
$allProjectFiles = CollaborationFileService::lists($viewer, [
'scope' => 'project',
'project_id' => 0,
]);
$this->assertSame(['task.png', 'project.xlsx'], array_column($allProjectFiles['list'], 'name'));
$allProjectTaskFiles = CollaborationFileService::lists($viewer, [
'scope' => 'project',
'project_id' => 0,
'project_source' => 'task',
]);
$this->assertSame(['task.png'], array_column($allProjectTaskFiles['list'], 'name'));
}
public function test_lists_excludes_deleted_messages_and_uses_cursor(): void
{
$viewer = $this->makeUser('collaboration-page@test.local');
$dialog = $this->makeDialog('group', 'user', '分页群', [$viewer->userid]);
$first = $this->makeFile($dialog, $viewer, 'first.pdf');
$this->makeFile($dialog, $viewer, 'deleted.pdf')->delete();
$last = $this->makeFile($dialog, $viewer, 'last.png');
$page = CollaborationFileService::lists($viewer, ['take' => 1]);
$this->assertSame(['last.png'], array_column($page['list'], 'name'));
$this->assertTrue($page['has_more']);
$this->assertSame($last->id, $page['next_cursor']);
$next = CollaborationFileService::lists($viewer, [
'cursor' => $page['next_cursor'],
'file_type' => 'document',
]);
$this->assertSame([$first->id], array_column($next['list'], 'msg_id'));
$this->assertFalse($next['has_more']);
}
public function test_authorize_message_rejects_non_member(): void
{
$member = $this->makeUser('collaboration-member@test.local');
$outsider = $this->makeUser('collaboration-denied@test.local');
$dialog = $this->makeDialog('group', 'user', '权限群', [$member->userid]);
$message = $this->makeFile($dialog, $member, 'permission.pdf');
CollaborationFileService::authorizeMessage($message, $member);
$this->expectException(ApiException::class);
$this->expectExceptionMessage('无权限访问此文件');
CollaborationFileService::authorizeMessage($message, $outsider);
}
}