feat: 添加用户收藏功能

- 在 UsersController 中新增获取、切换、清理用户收藏的 API 接口
- 创建 UserFavorite 模型以管理用户的收藏记录
- 更新前端 Vue 组件以支持收藏管理界面和交互
- 添加相关样式以美化收藏管理界面
This commit is contained in:
kuaifan 2025-09-22 16:09:33 +08:00
parent 0401b8a6e6
commit 379d3811a8
10 changed files with 1054 additions and 14 deletions

View File

@ -30,6 +30,7 @@ use App\Models\WebSocketDialog;
use App\Models\UserCheckinRecord;
use App\Models\WebSocketDialogMsg;
use App\Models\UserTaskBrowse;
use App\Models\UserFavorite;
use Illuminate\Support\Facades\DB;
use App\Models\UserEmailVerification;
use App\Module\AgoraIO\AgoraTokenGenerator;
@ -2825,4 +2826,170 @@ class UsersController extends AbstractController
//
return Base::retSuccess('清理完成', ['deleted_count' => $deletedCount]);
}
/**
* @api {get} api/users/favorites 46. 获取用户收藏列表
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName favorites
*
* @apiParam {String} [type] 收藏类型过滤 (task/project/file)
* @apiParam {Number} [page=1] 页码
* @apiParam {Number} [pagesize=20] 每页数量
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function favorites()
{
$user = User::auth();
//
$type = Request::input('type');
$page = intval(Request::input('page', 1));
$pageSize = min(intval(Request::input('pagesize', 20)), 100);
//
// 验证收藏类型
$allowedTypes = [UserFavorite::TYPE_TASK, UserFavorite::TYPE_PROJECT, UserFavorite::TYPE_FILE];
if ($type && !in_array($type, $allowedTypes)) {
return Base::retError('无效的收藏类型');
}
//
$result = UserFavorite::getUserFavorites($user->userid, $type, $page, $pageSize);
//
return Base::retSuccess('success', $result);
}
/**
* @api {post} api/users/favorite/toggle 47. 切换收藏状态
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName favorite__toggle
*
* @apiParam {String} type 收藏类型 (task/project/file)
* @apiParam {Number} id 收藏对象ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function favorite__toggle()
{
$user = User::auth();
//
$type = trim(Request::input('type'));
$id = intval(Request::input('id'));
//
if (!$type || $id <= 0) {
return Base::retError('参数错误');
}
//
// 验证收藏类型
$allowedTypes = [UserFavorite::TYPE_TASK, UserFavorite::TYPE_PROJECT, UserFavorite::TYPE_FILE];
if (!in_array($type, $allowedTypes)) {
return Base::retError('无效的收藏类型');
}
//
// 验证对象是否存在(简化验证,实际应该加上权限检查)
switch ($type) {
case UserFavorite::TYPE_TASK:
$object = ProjectTask::whereId($id)->first();
if (!$object) {
return Base::retError('任务不存在');
}
break;
case UserFavorite::TYPE_PROJECT:
$object = Project::whereId($id)->first();
if (!$object) {
return Base::retError('项目不存在');
}
break;
case UserFavorite::TYPE_FILE:
$object = File::whereId($id)->first();
if (!$object) {
return Base::retError('文件不存在');
}
break;
}
//
$result = UserFavorite::toggleFavorite($user->userid, $type, $id);
//
$message = $result['favorited'] ? '收藏成功' : '取消收藏成功';
return Base::retSuccess($message, $result);
}
/**
* @api {post} api/users/favorites/clean 48. 清理用户收藏
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName favorites__clean
*
* @apiParam {String} [type] 收藏类型 (task/project/file),不传则清理全部
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function favorites__clean()
{
$user = User::auth();
//
$type = trim(Request::input('type'));
//
// 验证收藏类型
if ($type) {
$allowedTypes = [UserFavorite::TYPE_TASK, UserFavorite::TYPE_PROJECT, UserFavorite::TYPE_FILE];
if (!in_array($type, $allowedTypes)) {
return Base::retError('无效的收藏类型');
}
}
//
$deletedCount = UserFavorite::cleanUserFavorites($user->userid, $type);
//
$message = $type ? "清理{$type}收藏成功" : '清理全部收藏成功';
return Base::retSuccess($message, ['deleted_count' => $deletedCount]);
}
/**
* @api {get} api/users/favorite/check 49. 检查收藏状态
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName favorite__check
*
* @apiParam {String} type 收藏类型 (task/project/file)
* @apiParam {Number} id 收藏对象ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function favorite__check()
{
$user = User::auth();
//
$type = trim(Request::input('type'));
$id = intval(Request::input('id'));
//
if (!$type || $id <= 0) {
return Base::retError('参数错误');
}
//
// 验证收藏类型
$allowedTypes = [UserFavorite::TYPE_TASK, UserFavorite::TYPE_PROJECT, UserFavorite::TYPE_FILE];
if (!in_array($type, $allowedTypes)) {
return Base::retError('无效的收藏类型');
}
//
$isFavorited = UserFavorite::isFavorited($user->userid, $type, $id);
//
return Base::retSuccess('success', ['favorited' => $isFavorited]);
}
}

256
app/Models/UserFavorite.php Normal file
View File

@ -0,0 +1,256 @@
<?php
namespace App\Models;
use Carbon\Carbon;
/**
* App\Models\UserFavorite
*
* @property int $id
* @property int $userid 用户ID
* @property string $favoritable_type 收藏类型
* @property int $favoritable_id 收藏对象ID
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Model|\Eloquent $favoritable
* @property-read \App\Models\User|null $user
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereFavoritableId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereFavoritableType($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserFavorite whereUserid($value)
* @mixin \Eloquent
*/
class UserFavorite extends AbstractModel
{
const TYPE_TASK = 'task';
const TYPE_PROJECT = 'project';
const TYPE_FILE = 'file';
protected $fillable = [
'userid',
'favoritable_type',
'favoritable_id',
];
/**
* 关联用户
*/
public function user()
{
return $this->belongsTo(User::class, 'userid', 'userid');
}
/**
* 多态关联
*/
public function favoritable()
{
return $this->morphTo();
}
/**
* 切换收藏状态
* @param int $userid 用户ID
* @param string $type 收藏类型
* @param int $id 收藏对象ID
* @return array ['favorited' => bool, 'action' => 'added'|'removed']
*/
public static function toggleFavorite($userid, $type, $id)
{
$favorite = self::whereUserid($userid)
->whereFavoritableType($type)
->whereFavoritableId($id)
->first();
if ($favorite) {
// 取消收藏
$favorite->delete();
return ['favorited' => false, 'action' => 'removed'];
} else {
// 添加收藏
self::create([
'userid' => $userid,
'favoritable_type' => $type,
'favoritable_id' => $id,
]);
return ['favorited' => true, 'action' => 'added'];
}
}
/**
* 检查是否已收藏
* @param int $userid 用户ID
* @param string $type 收藏类型
* @param int $id 收藏对象ID
* @return bool
*/
public static function isFavorited($userid, $type, $id)
{
return self::whereUserid($userid)
->whereFavoritableType($type)
->whereFavoritableId($id)
->exists();
}
/**
* 获取用户收藏列表
* @param int $userid 用户ID
* @param string|null $type 收藏类型过滤
* @param int $page 页码
* @param int $pageSize 每页数量
* @return array
*/
public static function getUserFavorites($userid, $type = null, $page = 1, $pageSize = 20)
{
$query = self::whereUserid($userid)->orderByDesc('created_at');
if ($type) {
$query->whereFavoritableType($type);
}
$favorites = $query->paginate($pageSize, ['*'], 'page', $page);
$data = [
'tasks' => [],
'projects' => [],
'files' => []
];
// 分组收集ID
$taskIds = [];
$projectIds = [];
$fileIds = [];
foreach ($favorites->items() as $favorite) {
switch ($favorite->favoritable_type) {
case self::TYPE_TASK:
$taskIds[] = $favorite->favoritable_id;
break;
case self::TYPE_PROJECT:
$projectIds[] = $favorite->favoritable_id;
break;
case self::TYPE_FILE:
$fileIds[] = $favorite->favoritable_id;
break;
}
}
// 批量查询具体数据
if (!empty($taskIds)) {
$tasks = ProjectTask::select([
'project_tasks.id',
'project_tasks.name',
'project_tasks.project_id',
'project_tasks.complete_at',
'project_tasks.created_at',
'project_tasks.flow_item_id',
'project_tasks.flow_item_name',
'projects.name as project_name'
])
->leftJoin('projects', 'project_tasks.project_id', '=', 'projects.id')
->whereIn('project_tasks.id', $taskIds)
->get()
->keyBy('id');
foreach ($favorites->items() as $favorite) {
if ($favorite->favoritable_type === self::TYPE_TASK && isset($tasks[$favorite->favoritable_id])) {
$task = $tasks[$favorite->favoritable_id];
// 解析 flow_item_name 字段格式status|name|color
$flowItemParts = explode('|', $task->flow_item_name ?: '');
$flowItemStatus = $flowItemParts[0] ?? '';
$flowItemName = $flowItemParts[1] ?? $task->flow_item_name;
$flowItemColor = $flowItemParts[2] ?? '';
$data['tasks'][] = [
'id' => $task->id,
'name' => $task->name,
'project_id' => $task->project_id,
'project_name' => $task->project_name,
'complete_at' => $task->complete_at,
'flow_item_id' => $task->flow_item_id,
'flow_item_name' => $flowItemName,
'flow_item_status' => $flowItemStatus,
'flow_item_color' => $flowItemColor,
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
];
}
}
}
if (!empty($projectIds)) {
$projects = Project::select([
'id', 'name', 'desc', 'archived_at', 'created_at'
])->whereIn('id', $projectIds)->get()->keyBy('id');
foreach ($favorites->items() as $favorite) {
if ($favorite->favoritable_type === self::TYPE_PROJECT && isset($projects[$favorite->favoritable_id])) {
$project = $projects[$favorite->favoritable_id];
$data['projects'][] = [
'id' => $project->id,
'name' => $project->name,
'desc' => $project->desc,
'archived_at' => $project->archived_at,
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
];
}
}
}
if (!empty($fileIds)) {
$files = File::select([
'id', 'name', 'ext', 'size', 'created_at'
])->whereIn('id', $fileIds)->get()->keyBy('id');
foreach ($favorites->items() as $favorite) {
if ($favorite->favoritable_type === self::TYPE_FILE && isset($files[$favorite->favoritable_id])) {
$file = $files[$favorite->favoritable_id];
$data['files'][] = [
'id' => $file->id,
'name' => $file->name,
'ext' => $file->ext,
'size' => $file->size,
'favorited_at' => Carbon::parse($favorite->created_at)->format('Y-m-d H:i:s'),
];
}
}
}
return [
'data' => $data,
'total' => $favorites->total(),
'current_page' => $favorites->currentPage(),
'per_page' => $favorites->perPage(),
'last_page' => $favorites->lastPage(),
];
}
/**
* 清理用户收藏
* @param int $userid 用户ID
* @param string|null $type 收藏类型null表示全部类型
* @return int 删除的记录数
*/
public static function cleanUserFavorites($userid, $type = null)
{
$query = self::whereUserid($userid);
if ($type) {
$query->whereFavoritableType($type);
}
return $query->delete();
}
}

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserFavoritesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Schema::hasTable('user_favorites'))
return;
Schema::create('user_favorites', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('userid')->index()->nullable()->default(0)->comment('用户ID');
$table->string('favoritable_type', 50)->index()->nullable()->default('')->comment('收藏类型(task/project/file)');
$table->bigInteger('favoritable_id')->index()->nullable()->default(0)->comment('收藏对象ID');
$table->timestamps();
// 复合索引用户ID + 收藏类型(用于按类型获取收藏列表)
$table->index(['userid', 'favoritable_type']);
// 唯一索引用户ID + 收藏类型 + 收藏对象ID防止重复收藏
$table->unique(['userid', 'favoritable_type', 'favoritable_id'], 'user_favorites_unique');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_favorites');
}
}

View File

@ -18,10 +18,11 @@
</div>
</div>
<DropdownMenu slot="list">
<template v-for="item in menu">
<template v-for="(item, index) in menu">
<!--最近打开的任务-->
<Dropdown
v-if="item.path === 'taskBrowse'"
:key="`taskBrowse-${index}`"
transfer
transfer-class-name="page-manage-menu-dropdown"
placement="right-start">
@ -32,10 +33,10 @@
</div>
</DropdownItem>
<DropdownMenu slot="list" v-if="taskBrowseLists.length > 0">
<template v-for="(item, key) in taskBrowseLists">
<DropdownItem
v-for="(item, key) in taskBrowseLists"
v-if="item.id > 0 && key < 10"
:key="key"
:key="`task-${key}`"
:style="$A.generateColorVarStyle(item.flow_item_color, [10], 'flow-item-custom-color')"
class="task-title"
@click.native="openTask(item)"
@ -43,6 +44,7 @@
<span v-if="item.flow_item_name" :class="item.flow_item_status">{{item.flow_item_name}}</span>
<div class="task-title-text">{{ item.name }}</div>
</DropdownItem>
</template>
</DropdownMenu>
<DropdownMenu v-else slot="list">
<DropdownItem style="color:darkgrey">{{ $L('暂无打开记录') }}</DropdownItem>
@ -51,6 +53,7 @@
<!-- 团队管理 -->
<Dropdown
v-else-if="item.path === 'team'"
:key="`team-${index}`"
transfer
transfer-class-name="page-manage-menu-dropdown"
placement="right-start">
@ -71,6 +74,7 @@
<!-- 其他菜单 -->
<DropdownItem
v-else-if="item.visible !== false"
:key="`menu-${index}`"
:divided="!!item.divided"
:name="item.path"
:style="item.style || {}">
@ -324,6 +328,14 @@
<Report v-if="workReportShow" v-model="workReportTab" @on-read="$store.dispatch('getReportUnread', 1000)" />
</DrawerOverlay>
<!--我的收藏-->
<DrawerOverlay
v-model="favoriteShow"
placement="right"
:size="1200">
<FavoriteManagement v-if="favoriteShow" @on-close="favoriteShow = false"/>
</DrawerOverlay>
<!--团队成员管理-->
<DrawerOverlay
v-model="allUserShow"
@ -380,6 +392,7 @@
import { mapState, mapGetters } from 'vuex'
import ProjectArchived from "./manage/components/ProjectArchived";
import TeamManagement from "./manage/components/TeamManagement";
import FavoriteManagement from "./manage/components/FavoriteManagement";
import ProjectManagement from "./manage/components/ProjectManagement";
import DrawerOverlay from "../components/DrawerOverlay";
import MobileTabbar from "../components/Mobile/Tabbar";
@ -423,6 +436,7 @@ export default {
DrawerOverlay,
ProjectManagement,
TeamManagement,
FavoriteManagement,
ProjectArchived,
MicroApps,
ComplaintManagement,
@ -472,6 +486,8 @@ export default {
allProjectShow: false,
archivedProjectShow: false,
favoriteShow: false,
natificationReady: false,
notificationManage: null,
@ -501,6 +517,7 @@ export default {
emitter.on('dialogMsgPush', this.addDialogMsg);
emitter.on('approveDetails', this.openApproveDetails);
emitter.on('openReport', this.openReport);
emitter.on('openFavorite', this.openFavorite);
//
document.addEventListener('keydown', this.shortcutEvent);
},
@ -518,6 +535,7 @@ export default {
emitter.off('dialogMsgPush', this.addDialogMsg);
emitter.off('approveDetails', this.openApproveDetails);
emitter.off('openReport', this.openReport);
emitter.off('openFavorite', this.openFavorite);
//
document.removeEventListener('keydown', this.shortcutEvent);
},
@ -649,6 +667,7 @@ export default {
const {userIsAdmin} = this;
const array = [
{path: 'taskBrowse', name: '最近打开的任务'},
{path: 'favorite', name: '我的收藏'},
{path: 'download', name: '下载内容', visible: !!this.$Electron},
];
if (userIsAdmin) {
@ -848,6 +867,9 @@ export default {
case 'workReport':
this.openReport(this.reportUnreadNumber > 0 ? 'receive' : 'my');
return;
case 'favorite':
this.openFavorite();
return;
case 'version':
emitter.emit('updateNotification', null);
return;
@ -1208,6 +1230,10 @@ export default {
this.workReportShow = true;
},
openFavorite() {
this.favoriteShow = true;
},
handleLongpress(event) {
const {type, data, element} = this.longpressData;
this.$store.commit("longpress/clear")

View File

@ -0,0 +1,360 @@
<template>
<div class="favorite-management">
<div class="management-title">
{{$L('我的收藏')}}
<div class="title-icon">
<Loading v-if="loadIng > 0"/>
</div>
</div>
<div class="search-container lr">
<ul>
<li>
<div class="search-label">
{{$L("收藏类型")}}
</div>
<div class="search-content">
<Select v-model="keys.type" :placeholder="$L('全部类型')">
<Option value="">{{$L('全部类型')}}</Option>
<Option value="task">{{$L('任务')}}</Option>
<Option value="project">{{$L('项目')}}</Option>
<Option value="file">{{$L('文件')}}</Option>
</Select>
</div>
</li>
<li>
<div class="search-label">
{{$L("名称")}}
</div>
<div class="search-content">
<Input v-model="keys.name" clearable :placeholder="$L('搜索收藏名称')"/>
</div>
</li>
<li class="search-button">
<SearchButton
:loading="loadIng > 0"
:filtering="keyIs"
placement="right"
@search="onSearch"
@refresh="getLists"
@cancelFilter="keyIs=false"/>
</li>
</ul>
</div>
<div class="table-page-box">
<Table
:columns="columns"
:data="list"
:loading="loadIng > 0"
:no-data-text="$L(noText)"
stripe/>
<Page
:total="total"
:current="page"
:page-size="pageSize"
:disabled="loadIng > 0"
:simple="windowPortrait"
:page-size-opts="[10,20,30,50,100]"
show-elevator
show-sizer
show-total
@on-change="setPage"
@on-page-size-change="setPageSize"/>
</div>
</div>
</template>
<script>
import SearchButton from "../../../components/SearchButton.vue";
export default {
name: "FavoriteManagement",
components: {SearchButton},
data() {
return {
loadIng: 0,
keys: {},
keyIs: false,
columns: [
{
title: this.$L('类型'),
key: 'type',
width: 80,
render: (h, {row}) => {
const typeMap = {
'task': this.$L('任务'),
'project': this.$L('项目'),
'file': this.$L('文件')
};
const color = {
'task': 'primary',
'project': 'success',
'file': 'warning'
};
return h('Tag', {
props: {
color: color[row.type] || 'default'
}
}, typeMap[row.type] || row.type);
}
},
{
title: this.$L('名称'),
key: 'name',
minWidth: 150,
render: (h, {row}) => {
return h('div', {
class: 'favorite-name',
on: {
click: () => this.openFavorite(row)
}
}, [
h('AutoTip', row.name)
]);
}
},
{
title: this.$L('所属项目'),
key: 'project_name',
minWidth: 120,
render: (h, {row}) => {
return row.project_name ? h('AutoTip', row.project_name) : h('span', '-');
}
},
{
title: this.$L('状态'),
minWidth: 80,
render: (h, {row}) => {
if (row.type === 'task') {
// 使
if (row.flow_item_name) {
return h('span', {
class: `flow-name ${row.flow_item_status}`,
style: this.$A.generateColorVarStyle(row.flow_item_color, [10], 'flow-item-custom-color')
}, row.flow_item_name);
} else {
//
if (row.complete_at) {
return h('span', {
class: 'favorite-status-tag favorite-status-success'
}, this.$L('已完成'));
} else {
return h('span', {
class: 'favorite-status-tag favorite-status-processing'
}, this.$L('进行中'));
}
}
} else if (row.type === 'project') {
if (row.archived_at) {
return h('span', {
class: 'favorite-status-tag favorite-status-error'
}, this.$L('已归档'));
} else {
return h('span', {
class: 'favorite-status-tag favorite-status-success'
}, this.$L('正常'));
}
}
return h('span', '-');
}
},
{
title: this.$L('收藏时间'),
key: 'favorited_at',
width: 168,
},
{
title: this.$L('操作'),
align: 'center',
width: 100,
render: (h, params) => {
const vNode = [
h('Poptip', {
props: {
title: this.$L(`确定要取消收藏"${params.row.name}"吗?`),
confirm: true,
transfer: true,
placement: 'left',
okText: this.$L('确定'),
cancelText: this.$L('取消'),
},
style: {
fontSize: '13px',
cursor: 'pointer',
color: '#f00',
},
on: {
'on-ok': () => {
this.removeFavorite(params.row)
}
},
}, this.$L('取消收藏'))
];
return h('TableAction', {
props: {
column: params.column
}
}, vNode);
},
}
],
list: [],
allData: [], //
page: 1,
pageSize: 20,
total: 0,
noText: ''
}
},
mounted() {
this.getLists();
},
watch: {
keyIs(v) {
if (!v) {
this.keys = {}
this.setPage(1)
}
}
},
methods: {
onSearch() {
this.page = 1;
this.filterData();
},
getLists() {
this.loadIng++;
this.keyIs = $A.objImplode(this.keys) != "";
this.$store.dispatch("call", {
url: 'users/favorites',
data: {
type: this.keys.type || '',
page: this.page,
pagesize: this.pageSize,
},
}).then(({data}) => {
//
this.allData = [];
//
if (data.data.tasks) {
data.data.tasks.forEach(task => {
this.allData.push({
id: task.id,
type: 'task',
name: task.name,
project_id: task.project_id,
project_name: task.project_name,
complete_at: task.complete_at,
flow_item_id: task.flow_item_id,
flow_item_name: task.flow_item_name,
flow_item_status: task.flow_item_status,
flow_item_color: task.flow_item_color,
favorited_at: task.favorited_at,
});
});
}
//
if (data.data.projects) {
data.data.projects.forEach(project => {
this.allData.push({
id: project.id,
type: 'project',
name: project.name,
desc: project.desc,
archived_at: project.archived_at,
favorited_at: project.favorited_at,
});
});
}
//
if (data.data.files) {
data.data.files.forEach(file => {
this.allData.push({
id: file.id,
type: 'file',
name: file.name,
ext: file.ext,
size: file.size,
favorited_at: file.favorited_at,
});
});
}
this.total = data.total || this.allData.length;
this.filterData();
this.noText = '没有相关的收藏';
}).catch(() => {
this.noText = '数据加载失败';
}).finally(_ => {
this.loadIng--;
})
},
filterData() {
let filteredData = this.allData;
//
if (this.keys.name) {
filteredData = filteredData.filter(item => {
return item.name && item.name.toLowerCase().includes(this.keys.name.toLowerCase());
});
}
this.list = filteredData;
},
setPage(page) {
this.page = page;
this.getLists();
},
setPageSize(pageSize) {
this.page = 1;
this.pageSize = pageSize;
this.getLists();
},
openFavorite(item) {
switch (item.type) {
case 'task':
this.$store.dispatch("openTask", {id: item.id});
break;
case 'project':
this.$router.push({
name: 'manage-project',
params: {projectId: item.id}
});
this.$emit('on-close');
break;
case 'file':
this.$router.push({name: 'manage-file'});
this.$emit('on-close');
break;
}
},
removeFavorite(item) {
this.$store.dispatch("call", {
url: 'users/favorite/toggle',
data: {
type: item.type,
id: item.id
},
method: 'post',
}).then(() => {
$A.messageSuccess('取消收藏成功');
this.getLists();
}).catch(({msg}) => {
$A.modalError(msg);
});
}
}
}
</script>

View File

@ -180,7 +180,7 @@ export default {
},
}).then(({data}) => {
this.formData = data.permissions;
this.$Message.success(this.$L('修改成功'));
$A.messageSuccess('修改成功');
}).catch(({msg}) => {
$A.modalError(msg);
}).finally(_ => {

View File

@ -47,7 +47,12 @@
<template v-if="task.parent_id === 0">
<template v-if="operationShow">
<EDropdownItem command="send" :divided="turns.length > 0">
<EDropdownItem command="favorite" :divided="turns.length > 0">
<div class="item" :class="{'favorited': isFavorited}">
<Icon :type="isFavorited ? 'ios-star' : 'ios-star-outline'" />{{$L(isFavorited ? '取消收藏' : '收藏')}}
</div>
</EDropdownItem>
<EDropdownItem command="send">
<div class="item">
<i class="taskfont movefont">&#xe629;</i>{{$L('发送')}}
</div>
@ -140,6 +145,7 @@ export default {
styles: {},
moveTaskShow: false,
isFavorited: false,
}
},
beforeDestroy() {
@ -201,6 +207,7 @@ export default {
this.placement = typeof data.placement === "undefined" ? "bottom" : data.placement;
this.projectId = typeof data.projectId === "undefined" ? 0 : data.projectId;
this.onUpdate = typeof data.onUpdate === "function" ? data.onUpdate : null;
this.checkFavoriteStatus();
//
this.$refs.icon.focus();
this.updatePopper();
@ -308,6 +315,10 @@ export default {
})
break;
case 'favorite':
this.toggleFavorite();
break;
case 'send':
this.$refs.forwarder.onSelection()
break;
@ -487,6 +498,47 @@ export default {
reject();
});
})
},
/**
* 检查收藏状态
*/
checkFavoriteStatus() {
if (!this.task.id) return;
this.$store.dispatch("call", {
url: 'users/favorite/check',
data: {
type: 'task',
id: this.task.id
},
}).then(({data}) => {
this.isFavorited = data.favorited || false;
}).catch(() => {
this.isFavorited = false;
});
},
/**
* 切换收藏状态
*/
toggleFavorite() {
if (!this.task.id) return;
this.$store.dispatch("call", {
url: 'users/favorite/toggle',
data: {
type: 'task',
id: this.task.id
},
method: 'post',
}).then(({data, msg}) => {
this.isFavorited = data.favorited;
this.hide();
$A.messageSuccess(msg);
}).catch(({msg}) => {
$A.messageError(msg || '操作失败');
});
}
},
}

View File

@ -5,6 +5,7 @@
@import "dialog-select";
@import "dialog-session-history";
@import "dialog-wrapper";
@import "favorite-management";
@import "file-content";
@import "forwarder";
@import "general-operation";

View File

@ -0,0 +1,128 @@
.favorite-management {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: 20px;
display: flex;
flex-direction: column;
.management-title {
color: $primary-title-color;
font-size: 20px;
font-weight: 500;
line-height: 1;
margin-bottom: 24px;
display: flex;
align-items: center;
.title-icon {
display: flex;
align-items: center;
width: 14px;
height: 14px;
margin-left: 4px;
margin-top: 2px;
> i {
cursor: pointer;
}
}
}
.favorite-name {
display: flex;
align-items: center;
cursor: pointer;
color: #2d8cf0;
&:hover {
text-decoration: underline;
}
.ivu-tag {
height: 18px;
line-height: 18px;
padding: 0 4px;
transform: scale(0.8);
transform-origin: right center;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap
}
}
.table-page-box {
flex: 1;
height: 0;
}
// 状态标签样式 - 使用项目标准配色
.favorite-status-tag {
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
display: inline-block;
line-height: 1.2;
border: 1px solid;
// 已完成/正常状态 - 绿色
&.favorite-status-success {
background-color: var(--flow-item-custom-color-10, rgba($flow-status-end-color, 0.1));
border-color: var(--flow-item-custom-color-30, rgba($flow-status-end-color, 0.3));
color: var(--flow-item-custom-color-100, $flow-status-end-color);
}
// 进行中状态 - 橙色
&.favorite-status-processing {
background-color: var(--flow-item-custom-color-10, rgba($flow-status-progress-color, 0.1));
border-color: var(--flow-item-custom-color-30, rgba($flow-status-progress-color, 0.3));
color: var(--flow-item-custom-color-100, $flow-status-progress-color);
}
// 已归档/错误状态 - 灰色
&.favorite-status-error {
background-color: var(--flow-item-custom-color-10, rgba($flow-status-archived-color, 0.1));
border-color: var(--flow-item-custom-color-30, rgba($flow-status-archived-color, 0.3));
color: var(--flow-item-custom-color-100, $flow-status-archived-color);
}
}
// 工作流状态样式 - 与项目面板保持一致
.flow-name {
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
display: inline-block;
line-height: 1.2;
border: 1px solid transparent;
&.start {
background-color: var(--flow-item-custom-color-10, rgba($flow-status-start-color, 0.1));
border-color: var(--flow-item-custom-color-10, rgba($flow-status-start-color, 0.1));
color: var(--flow-item-custom-color-100, $flow-status-start-color);
}
&.progress {
background-color: var(--flow-item-custom-color-10, rgba($flow-status-progress-color, 0.1));
border-color: var(--flow-item-custom-color-10, rgba($flow-status-progress-color, 0.1));
color: var(--flow-item-custom-color-100, $flow-status-progress-color);
}
&.test {
background-color: var(--flow-item-custom-color-10, rgba($flow-status-test-color, 0.1));
border-color: var(--flow-item-custom-color-10, rgba($flow-status-test-color, 0.1));
color: var(--flow-item-custom-color-100, $flow-status-test-color);
}
&.end {
background-color: var(--flow-item-custom-color-10, rgba($flow-status-end-color, 0.1));
border-color: var(--flow-item-custom-color-10, rgba($flow-status-end-color, 0.1));
color: var(--flow-item-custom-color-100, $flow-status-end-color);
}
&.archived {
background-color: var(--flow-item-custom-color-10, rgba($flow-status-archived-color, 0.1));
border-color: var(--flow-item-custom-color-10, rgba($flow-status-archived-color, 0.1));
color: var(--flow-item-custom-color-100, $flow-status-archived-color);
}
}
}

View File

@ -61,6 +61,14 @@
text-overflow: ellipsis;
white-space: nowrap;
&.favorited {
color: #faad14;
.ivu-icon {
color: #faad14;
}
}
.item-prefix {
display: flex;
align-items: center;