feat(user): 会员卡片支持查看该会员参与的项目和任务

- 新增权限闸门 UserDepartment::userWorksContext(本人/管理员/部门负责人只读,排除机器人与系统账号)
- 新增接口 project/user/projects、project/user/tasks、project/user/counts
- users/extra 返回 works_visible 标记控制入口显隐
- 会员卡片新增「项目与任务」入口,弹出 UserWorksModal(项目/待办/已完成三 Tab、角标计数、工作流状态徽章、懒加载)
- 部门只读视角下任务仅展示全员可见(visibility=1),与 findForDepartmentView 对齐
- 补充 i18n 文案与暗色样式

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-06-04 03:24:50 +00:00
parent 84a90b7760
commit fd6a8a3650
9 changed files with 902 additions and 3 deletions

View File

@ -1490,6 +1490,214 @@ class ProjectController extends AbstractController
return Base::retSuccess('success', $data);
}
/**
* @api {get} api/project/user/projects 会员参与的项目列表
*
* @apiDescription 需要token身份。用于会员卡片查看「该会员参与的项目」。
* 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
* @apiVersion 1.0.0
* @apiGroup project
* @apiName user__projects
*
* @apiParam {Number} userid 目标会员ID
* @apiParam {String} [archived] 是否归档all/yes/no默认no
* @apiParam {Object} [keys] 搜索条件keys.name 项目名称)
* @apiParam {Number} [page] 当前页默认1
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function user__projects()
{
$viewer = User::auth();
$targetId = intval(Request::input('userid'));
$context = UserDepartment::userWorksContext($viewer, $targetId);
if (!$context['allowed']) {
return Base::retError('没有查看权限');
}
$readonly = !$context['is_self'] && !$context['is_admin'];
//
$archived = Request::input('archived', 'no');
$keys = Request::input('keys');
//
$builder = Project::select(['projects.*', 'project_users.owner', 'project_users.top_at', 'project_users.sort'])
->join('project_users', function ($join) use ($targetId) {
$join->on('projects.id', '=', 'project_users.project_id')
->where('project_users.userid', '=', $targetId);
});
// 部门负责人视角:限定在允许可见的项目集合内
if ($readonly) {
$builder->whereIn('projects.id', $context['project_ids'] ?: [0]);
}
//
if ($archived == 'yes') {
$builder->whereNotNull('projects.archived_at');
} elseif ($archived == 'no') {
$builder->whereNull('projects.archived_at');
}
if (is_array($keys) && !empty($keys['name'])) {
$builder->where('projects.name', 'like', "%{$keys['name']}%");
}
//
$list = $builder
->orderByDesc('project_users.top_at')
->orderBy('project_users.sort')
->orderByDesc('projects.id')
->paginate(Base::getPaginate(100, 50));
$list->transform(function (Project $project) use ($targetId, $readonly) {
$array = $project->toArray();
$array['department_readonly'] = $readonly;
$array = array_merge($array, $project->getTaskStatistics($targetId));
return $array;
});
//
return Base::retSuccess('success', $list);
}
/**
* @api {get} api/project/user/tasks 会员参与的任务列表
*
* @apiDescription 需要token身份。用于会员卡片查看「该会员参与的任务」负责的 / 协作的)。
* 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
* @apiVersion 1.0.0
* @apiGroup project
* @apiName user__tasks
*
* @apiParam {Number} userid 目标会员ID
* @apiParam {Number} [owner] 任务身份筛选1=负责的0=协作的,不传=全部
* @apiParam {Number} [project_id] 仅查询指定项目
* @apiParam {Object} [keys] 搜索条件keys.name 任务名称keys.status completed/uncompleted
* @apiParam {Number} [page] 当前页默认1
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function user__tasks()
{
$viewer = User::auth();
$targetId = intval(Request::input('userid'));
$context = UserDepartment::userWorksContext($viewer, $targetId);
if (!$context['allowed']) {
return Base::retError('没有查看权限');
}
$readonly = !$context['is_self'] && !$context['is_admin'];
//
$owner = Request::input('owner');
$owner = is_numeric($owner) ? intval($owner) : null;
$project_id = intval(Request::input('project_id'));
$keys = Request::input('keys');
$keys = is_array($keys) ? $keys : [];
//
$builder = ProjectTask::with(['taskUser', 'taskTag', 'project:id,name'])
->select(['project_tasks.*', 'project_task_users.owner'])
->join('project_task_users', function ($join) use ($targetId) {
$join->on('project_tasks.id', '=', 'project_task_users.task_id')
->where('project_task_users.userid', '=', $targetId);
});
if ($owner !== null) {
$builder->where('project_task_users.owner', $owner);
}
// 部门负责人视角:限定可见项目集合,且仅"全员可见"(visibility=1)的任务(与 findForDepartmentView 一致,避免列出打不开的任务)
if ($readonly) {
$builder->whereIn('project_tasks.project_id', $context['project_ids'] ?: [0]);
$builder->where('project_tasks.visibility', 1);
}
if ($project_id > 0) {
$builder->where('project_tasks.project_id', $project_id);
}
if (!empty($keys['name'])) {
$builder->where(function ($query) use ($keys) {
$query->where('project_tasks.name', 'like', "%{$keys['name']}%")
->orWhere('project_tasks.desc', 'like', "%{$keys['name']}%");
});
}
if (!empty($keys['status'])) {
if ($keys['status'] == 'completed') {
$builder->whereNotNull('project_tasks.complete_at');
} elseif ($keys['status'] == 'uncompleted') {
$builder->whereNull('project_tasks.complete_at');
}
}
$builder->whereNull('project_tasks.archived_at');
//
$list = $builder->orderByDesc('project_tasks.id')->paginate(Base::getPaginate(100, 50));
$list->transform(function (ProjectTask $task) use ($readonly) {
$task->setAppends(['today', 'overdue']);
$array = $task->toArray();
$array['project_name'] = $array['project']['name'] ?? '';
$array['department_readonly'] = $readonly;
unset($array['project']);
return $array;
});
//
return Base::retSuccess('success', $list);
}
/**
* @api {get} api/project/user/counts 会员参与的项目/任务数量
*
* @apiDescription 需要token身份。用于会员卡片「项目与任务」弹窗的 Tab 角标,仅返回数量(轻量)。
* 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
* @apiVersion 1.0.0
* @apiGroup project
* @apiName user__counts
*
* @apiParam {Number} userid 目标会员ID
* @apiParam {Number} [owner] 任务身份筛选1=负责的0=协作的,不传=全部(仅影响任务数量)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data {project, todo, done}
*/
public function user__counts()
{
$viewer = User::auth();
$targetId = intval(Request::input('userid'));
$context = UserDepartment::userWorksContext($viewer, $targetId);
if (!$context['allowed']) {
return Base::retError('没有查看权限');
}
$readonly = !$context['is_self'] && !$context['is_admin'];
$owner = Request::input('owner');
$owner = is_numeric($owner) ? intval($owner) : null;
//
$projectBuilder = Project::join('project_users', function ($join) use ($targetId) {
$join->on('projects.id', '=', 'project_users.project_id')
->where('project_users.userid', '=', $targetId);
})
->whereNull('projects.archived_at');
if ($readonly) {
$projectBuilder->whereIn('projects.id', $context['project_ids'] ?: [0]);
}
$projectCount = $projectBuilder->distinct()->count('projects.id');
//
$taskBuilder = function () use ($targetId, $owner, $readonly, $context) {
$builder = ProjectTask::join('project_task_users', function ($join) use ($targetId) {
$join->on('project_tasks.id', '=', 'project_task_users.task_id')
->where('project_task_users.userid', '=', $targetId);
})
->whereNull('project_tasks.archived_at');
if ($owner !== null) {
$builder->where('project_task_users.owner', $owner);
}
if ($readonly) {
$builder->whereIn('project_tasks.project_id', $context['project_ids'] ?: [0]);
$builder->where('project_tasks.visibility', 1);
}
return $builder;
};
$todoCount = $taskBuilder()->whereNull('project_tasks.complete_at')->count();
$doneCount = $taskBuilder()->whereNotNull('project_tasks.complete_at')->count();
//
return Base::retSuccess('success', [
'project' => $projectCount,
'todo' => $todoCount,
'done' => $doneCount,
]);
}
/**
* @api {get} api/project/task/easylists 任务列表-简单的
*

View File

@ -884,7 +884,8 @@ class UsersController extends AbstractController
*/
public function extra()
{
$user = User::auth();
$viewer = User::auth();
$user = $viewer;
//
$userid = intval(Request::input('userid'));
if ($userid <= 0) {
@ -919,6 +920,8 @@ class UsersController extends AbstractController
$tagMeta = UserTag::listWithMeta($userid, $user);
$worksContext = UserDepartment::userWorksContext($viewer, $userid);
$data = [
'userid' => $userid,
'birthday' => $birthday,
@ -926,6 +929,7 @@ class UsersController extends AbstractController
'introduction' => $introduction,
'personal_tags' => $tagMeta['top'],
'personal_tags_total' => $tagMeta['total'],
'works_visible' => $worksContext['allowed'],
];
return Base::retSuccess('success', $data);

View File

@ -615,4 +615,69 @@ class UserDepartment extends AbstractModel
return $project;
}
/**
* 会员卡片「查看该会员项目/任务」的权限上下文。
* 允许条件:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。
* @param User $viewer 当前登录用户
* @param int $targetUserid 目标会员
* @return array ['allowed'=>bool, 'is_self'=>bool, 'is_admin'=>bool, 'project_ids'=>int[]]
* project_ids 仅在部门负责人视角下有意义(限定可见项目集合);本人/管理员为空数组表示不限制
*/
public static function userWorksContext(User $viewer, int $targetUserid): array
{
$result = [
'allowed' => false,
'is_self' => false,
'is_admin' => false,
'project_ids' => [],
];
if ($targetUserid <= 0) {
return $result;
}
// 机器人/系统账号(或不存在)不展示项目与任务
$target = User::select(['userid', 'bot'])->whereUserid($targetUserid)->first();
if (empty($target) || $target->bot) {
return $result;
}
// 本人
if ($viewer->userid === $targetUserid) {
$result['allowed'] = true;
$result['is_self'] = true;
return $result;
}
// 系统管理员
if ($viewer->isAdmin()) {
$result['allowed'] = true;
$result['is_admin'] = true;
return $result;
}
// 部门负责人只读视角
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
return $result;
}
$memberUserids = self::getManagedMemberUserids($viewer->userid, 'all');
if (!in_array($targetUserid, $memberUserids, true)) {
return $result;
}
// 目标会员参与、且未关闭「部门负责人视角可见」的项目
$projectIds = ProjectUser::where('project_users.userid', $targetUserid)
->join('projects', 'projects.id', '=', 'project_users.project_id')
->whereNull('projects.deleted_at')
->where(function ($query) {
$query->where('projects.department_owner_view', '<>', 'close')
->orWhereNull('projects.department_owner_view');
})
->distinct()
->pluck('projects.id')
->map(fn($v) => intval($v))
->values()
->toArray();
if (empty($projectIds)) {
return $result;
}
$result['allowed'] = true;
$result['project_ids'] = $projectIds;
return $result;
}
}

View File

@ -994,3 +994,4 @@ LDAP 用户缺少邮箱属性,请联系管理员配置
你有一条待办到提醒时间啦
发送者昵称最多不能超过20字
AI 助手
没有查看权限

View File

@ -2440,3 +2440,12 @@ AI任务分析
暂无完成
取消提醒
确定取消该成员的提醒时间吗?
项目与任务
暂无项目
暂无任务
负责
协作
成员
(*)分钟前
(*)小时前
(*)天前

View File

@ -26,8 +26,12 @@
</h1>
<div class="meta">
<span @click="commonDialogShow = true" class="common-dialog">{{ $L(userId == userData.userid ? "我的群组" : "共同群组") }}:<em>{{ $L("(*)个", commonDialog.total) }}</em></span>
<template v-if="worksVisible">
<span class="separator">|</span>
<span @click="worksModalShow = true" class="common-dialog works-entry">{{ $L("项目与任务") }}</span>
</template>
<span class="separator">|</span>
<span>{{ $L("最后在线") }}: {{$A.newDateString( userData.line_at, "YYYY-MM-DD HH:mm") || "-"}}</span>
<span :title="lineAtDisplay.title">{{ $L("最后在线") }}: {{ lineAtDisplay.text }}</span>
</div>
</div>
@ -115,6 +119,13 @@
:total-count="commonDialog.total || 0"
@open-chat="onOpenCommonDialogChat"
/>
<UserWorksModal
v-if="worksVisible && userData.userid"
v-model="worksModalShow"
:target-user-id="userData.userid"
@navigate="onHide"
/>
</ModalAlive>
</template>
@ -124,11 +135,12 @@ import { mapState } from "vuex";
import transformEmojiToHtml from "../../../utils/emoji";
import UserTagsModal from "./UserTagsModal.vue";
import CommonDialogModal from "./CommonDialogModal.vue";
import UserWorksModal from "./UserWorksModal.vue";
export default {
name: "UserDetail",
components: { UserTagsModal, CommonDialogModal },
components: { UserTagsModal, CommonDialogModal, UserWorksModal },
data() {
return {
@ -146,6 +158,7 @@ export default {
},
commonDialogShow: false,
commonDialogLoading: 0,
worksModalShow: false,
};
},
@ -189,6 +202,36 @@ export default {
commonDialogList() {
return this.commonDialog.list || [];
},
worksVisible() {
return !!this.userData.works_visible;
},
lineAtDisplay({ userData }) {
const value = userData.line_at;
if (!value) {
return { text: "-", title: "" };
}
const now = $A.daytz();
const line = $A.dayjs(value);
const title = line.format("YYYY-MM-DD HH:mm");
const seconds = now.unix() - line.unix();
let text;
if (seconds < 60) {
text = this.$L("刚刚");
} else if (seconds < 3600) {
text = this.$L("(*)分钟前", Math.floor(seconds / 60));
} else if (seconds < 3600 * 24) {
text = this.$L("(*)小时前", Math.floor(seconds / 3600));
} else if (seconds < 3600 * 24 * 7) {
text = this.$L("(*)天前", Math.floor(seconds / 86400));
} else if (line.isAfter(now.clone().subtract(1, "month"))) {
text = line.format("MM-DD HH:mm");
} else {
text = line.format("YYYY-MM-DD");
}
return { text, title };
},
},
methods: {
@ -219,6 +262,7 @@ export default {
this.showModal = false;
this.tagModalVisible = false;
this.commonDialogShow = false;
this.worksModalShow = false;
},
onOpenAvatar() {

View File

@ -0,0 +1,328 @@
<template>
<Modal
v-model="visibleProxy"
class-name="user-works-modal"
:title="$L('项目与任务')"
:footer-hide="true"
width="560">
<RadioGroup v-if="activeTab !== 'projects'" v-model="taskOwner" type="button" size="small" class="task-owner-filter" @on-change="onOwnerChange">
<Radio label="all">{{$L('全部')}}</Radio>
<Radio label="1">{{$L('负责')}}</Radio>
<Radio label="0">{{$L('协作')}}</Radio>
</RadioGroup>
<Tabs v-model="activeTab" class="user-works-tabs">
<!-- 项目 -->
<TabPane :label="projectsTabLabel" name="projects">
<div class="user-works-content">
<div v-if="projects.loading > 0 && projects.list.length === 0" class="loading-wrapper">
<Loading/>
</div>
<div v-else-if="projects.list.length === 0" class="empty-wrapper">
<div class="empty-content">
<Icon type="ios-folder-outline" size="48"/>
<p>{{$L('暂无项目')}}</p>
</div>
</div>
<div v-else class="works-list">
<div
v-for="project in projects.list"
:key="project.id"
class="works-item"
@click="onOpenProject(project)">
<div class="works-icon project">
<i class="taskfont">&#xe6f9;</i>
</div>
<div class="works-info">
<div class="works-name" v-html="transformEmojiToHtml(project.name)"></div>
<div class="works-meta">
<span class="role-badge" :class="ownerClass(project.owner)">{{ownerText(project.owner)}}</span>
<span class="works-stat">{{$L('任务')}} {{project.task_complete || 0}}/{{project.task_num || 0}}</span>
</div>
</div>
<Icon class="enter-icon" type="ios-arrow-forward"/>
</div>
<div v-if="projects.hasMore" class="load-more-wrapper">
<Button type="primary" @click="loadProjects(true)" :loading="projects.loading > 0">{{$L('加载更多')}}</Button>
</div>
</div>
</div>
</TabPane>
<!-- 待办任务 / 已完成任务 -->
<TabPane v-for="tab in taskTabs" :key="tab.key" :label="taskTabLabel(tab.key)" :name="tab.key">
<div class="user-works-content tasks">
<div v-if="tabState(tab.key).loading > 0 && tabState(tab.key).list.length === 0" class="loading-wrapper">
<Loading/>
</div>
<div v-else-if="tabState(tab.key).list.length === 0" class="empty-wrapper">
<div class="empty-content">
<Icon type="ios-list-box-outline" size="48"/>
<p>{{$L('暂无任务')}}</p>
</div>
</div>
<div v-else class="works-list">
<div
v-for="task in tabState(tab.key).list"
:key="task.id"
class="works-item"
@click="onOpenTask(task)">
<div class="works-icon task" :class="{completed: !!task.complete_at}">
<i class="taskfont">&#xe6f4;</i>
</div>
<div class="works-info">
<div class="works-name" :class="{completed: !!task.complete_at}" v-html="transformEmojiToHtml(task.name)"></div>
<div class="works-meta">
<span
v-if="task.flow_item_name"
class="flow-name"
:class="task.flow_item_status"
:style="$A.generateColorVarStyle(task.flow_item_color, [10], 'flow-item-custom-color')">{{task.flow_item_name}}</span>
<span v-else-if="task.complete_at" class="flow-name end">{{$L('已完成')}}</span>
<span class="role-badge" :class="task.owner ? 'owner' : 'assist'">{{task.owner ? $L('负责') : $L('协作')}}</span>
<span v-if="task.project_name" class="works-project">{{task.project_name}}</span>
<span v-if="task.end_at" class="works-time" :class="{overdue: task.overdue}">{{$A.timeFormat(task.end_at)}}</span>
</div>
</div>
<Icon class="enter-icon" type="ios-arrow-forward"/>
</div>
<div v-if="tabState(tab.key).hasMore" class="load-more-wrapper">
<Button type="primary" @click="loadTasks(tab.key, true)" :loading="tabState(tab.key).loading > 0">{{$L('加载更多')}}</Button>
</div>
</div>
</div>
</TabPane>
</Tabs>
</Modal>
</template>
<script>
import transformEmojiToHtml from "../../../utils/emoji";
const emptyState = () => ({list: [], page: 1, hasMore: false, loading: 0, total: null});
export default {
name: 'UserWorksModal',
props: {
value: { // v-model
type: Boolean,
default: false,
},
targetUserId: {
type: [Number, String],
required: true,
},
},
data() {
return {
activeTab: 'projects',
taskOwner: 'all',
taskTabs: [
{key: 'todo', status: 'uncompleted'},
{key: 'done', status: 'completed'},
],
counts: {project: null, todo: null, done: null},
projects: emptyState(),
todo: emptyState(),
done: emptyState(),
}
},
computed: {
visibleProxy: {
get() { return this.value; },
set(v) { this.$emit('input', v); }
},
projectsTabLabel() {
return this.withCount(this.$L('项目'), this.counts.project);
}
},
watch: {
visibleProxy(val) {
if (val) {
this.loadInitial();
}
},
activeTab(key) {
// Tab
if (key !== 'projects' && this.tabState(key).list.length === 0 && this.tabState(key).loading === 0) {
this.loadTasks(key, false);
}
},
targetUserId() {
this.resetState();
if (this.visibleProxy) {
this.loadInitial();
}
}
},
methods: {
transformEmojiToHtml,
tabState(key) {
return this[key];
},
withCount(text, total) {
return total == null ? text : `${text} (${total})`;
},
taskTabLabel(key) {
const text = key === 'todo' ? this.$L('待办') : this.$L('已完成');
return this.withCount(text, this.counts[key]);
},
resetState() {
this.counts = {project: null, todo: null, done: null};
this.projects = emptyState();
this.todo = emptyState();
this.done = emptyState();
},
// Tab + Tab Tab
loadInitial() {
this.loadCounts();
this.loadActive();
},
loadActive() {
if (this.activeTab === 'projects') {
if (this.projects.list.length === 0 && this.projects.loading === 0) {
this.loadProjects(false);
}
} else {
const state = this.tabState(this.activeTab);
if (state.list.length === 0 && state.loading === 0) {
this.loadTasks(this.activeTab, false);
}
}
},
loadCounts() {
if (!this.targetUserId) return;
const params = {userid: this.targetUserId};
if (this.taskOwner !== 'all') {
params.owner = this.taskOwner;
}
this.$store.dispatch('call', {
url: 'project/user/counts',
data: params,
}).then(({data}) => {
this.counts = {
project: typeof data.project === 'number' ? data.project : 0,
todo: typeof data.todo === 'number' ? data.todo : 0,
done: typeof data.done === 'number' ? data.done : 0,
};
}).catch(() => {
//
});
},
loadProjects(loadMore = false) {
if (!this.targetUserId) return;
this.projects.loading++;
const page = loadMore ? this.projects.page + 1 : 1;
this.$store.dispatch('call', {
url: 'project/user/projects',
data: {
userid: this.targetUserId,
page,
}
}).then(({data}) => {
const list = Array.isArray(data.data) ? data.data : [];
this.projects.list = loadMore ? [...this.projects.list, ...list] : list;
this.projects.page = data.current_page || page;
this.projects.hasMore = !!data.next_page_url;
this.projects.total = typeof data.total === 'number' ? data.total : list.length;
this.counts.project = this.projects.total;
}).catch(({msg}) => {
$A.modalError(msg || this.$L('加载失败'));
}).finally(() => {
this.projects.loading--;
});
},
loadTasks(key, loadMore = false) {
if (!this.targetUserId) return;
const state = this.tabState(key);
const tab = this.taskTabs.find(t => t.key === key);
state.loading++;
const page = loadMore ? state.page + 1 : 1;
const params = {
userid: this.targetUserId,
page,
keys: {status: tab.status},
};
if (this.taskOwner !== 'all') {
params.owner = this.taskOwner;
}
this.$store.dispatch('call', {
url: 'project/user/tasks',
data: params,
}).then(({data}) => {
const list = (Array.isArray(data.data) ? data.data : []).map(t => this.normalizeFlow(t));
state.list = loadMore ? [...state.list, ...list] : list;
state.page = data.current_page || page;
state.hasMore = !!data.next_page_url;
state.total = typeof data.total === 'number' ? data.total : list.length;
this.counts[key] = state.total;
}).catch(({msg}) => {
$A.modalError(msg || this.$L('加载失败'));
}).finally(() => {
state.loading--;
});
},
onOwnerChange() {
// / Tab Tab
this.taskTabs.forEach(tab => {
this[tab.key] = emptyState();
});
this.loadCounts();
this.loadActive();
},
// flow_item_name status|name|color
normalizeFlow(task) {
if (task.flow_item_name && task.flow_item_name.indexOf('|') !== -1) {
const info = $A.convertWorkflow(task.flow_item_name);
task.flow_item_status = info.status;
task.flow_item_name = info.name;
task.flow_item_color = info.color;
}
return task;
},
ownerText(owner) {
if (owner === 1) return this.$L('负责人');
if (owner === 2) return this.$L('管理员');
return this.$L('成员');
},
ownerClass(owner) {
if (owner === 1) return 'owner';
if (owner === 2) return 'deputy';
return 'member';
},
onOpenProject(project) {
$A.goForward({name: 'manage-project', params: {projectId: project.id}});
this.$emit('navigate');
},
onOpenTask(task) {
//
this.$store.dispatch('openTask', task);
},
}
}
</script>
<style scoped>
/* 组件自身不引入额外样式,复用全局样式类名 */
</style>

View File

@ -813,4 +813,18 @@ body.dark-mode-reverse {
}
}
}
.user-works-modal {
.user-works-content {
.works-list {
.works-item {
.works-icon {
> i {
color: #000000;
}
}
}
}
}
}
}

View File

@ -360,3 +360,229 @@
padding: 12px 0;
}
}
// 会员项目与任务弹窗样式
.user-works-modal {
.ivu-modal-body {
padding: 0 !important;
position: relative;
}
.task-owner-filter {
position: absolute;
top: 48px;
right: 12px;
z-index: 10;
}
.user-works-tabs {
.ivu-tabs-bar {
margin-bottom: 8px;
}
}
.user-works-content {
&.tasks {
.works-list {
> div:first-child {
margin-top: 28px;
}
}
}
.loading-wrapper {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
padding-top: 60px;
padding-bottom: 100px;
}
.empty-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding-top: 40px;
padding-bottom: 80px;
.empty-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%;
color: #999;
> i {
opacity: 0.3;
}
}
}
.works-list {
overflow-y: auto;
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 310px);
@media (height <= 900px) {
max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 180px);
}
.works-item {
display: flex;
align-items: center;
padding: 12px;
cursor: pointer;
border-radius: 6px;
margin: 4px 0;
transition: background-color 0.2s;
&:hover {
background-color: #f5f7fa;
}
.works-icon {
flex-shrink: 0;
margin-right: 12px;
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #ffffff;
> i {
font-size: 24px;
}
&.project {
background-color: #6E99EB;
}
&.task {
background-color: #9B96DF;
&.completed {
background-color: #c5c8ce;
}
}
}
.works-info {
flex: 1;
min-width: 0;
.works-name {
font-size: 14px;
font-weight: 500;
color: #17233d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
&.completed {
color: #808695;
text-decoration: line-through;
}
}
.works-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #808695;
.role-badge {
flex-shrink: 0;
padding: 0 6px;
line-height: 18px;
border-radius: 3px;
font-size: 12px;
background-color: #f0f2f5;
color: #808695;
&.owner {
background-color: #e8f4ff;
color: #2d8cf0;
}
&.deputy {
background-color: #fff3e0;
color: #ff9900;
}
}
// 工作流状态徽章与项目面板/收藏列表保持一致
.flow-name {
flex-shrink: 0;
padding: 0 6px;
line-height: 18px;
border-radius: 3px;
font-size: 12px;
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);
}
}
.works-project,
.works-stat {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.works-time {
flex-shrink: 0;
&.overdue {
color: #ed4014;
}
}
}
}
.enter-icon {
flex-shrink: 0;
color: #c5c8ce;
font-size: 16px;
margin-left: 8px;
}
}
}
.load-more-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 12px 0;
}
}
}