GoalTaskPane.vue
11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
<!--
目标·任务两级栏(27-P3C §七.2)—— 控制台最左侧那一栏。
**选中态永远落在 task 上**:org graph / work graph 都是 task 级的事实,选中一个 goal
画不出图。点 goal 行只做展开收起(外加把「这个 goal 是当前上下文」告诉父组件),
真正驱动右边三栏的是被选中的那条 task。
状态点的颜色一个都不新造,全部来自 goal-task-model.js(那份又是照抄 WorkGraphCanvas 的 .dot):
左栏这个点和画布上那个点说的是同一件事的两级,颜色对不上会让人以为是两套状态。
-->
<template>
<div class="pane">
<NewGoalForm :company-id="companyId" @changed="$emit('refresh')" @dispatched="onDispatched" />
<p v-if="loading && !goals.length" class="tip">加载中…</p>
<p v-else-if="!goals.length" class="tip">
还没有目标。点上面「+ 新目标」把一句需求交给这家公司 —— 它会被拆成若干条任务,逐条执行。
</p>
<ul class="goals">
<li v-for="g in goals" :key="g.goalId" class="goal" :class="{ ctx: g.goalId === selectedGoalId }">
<div class="g-head">
<button class="caret" :title="collapsed.has(g.goalId) ? '展开' : '收起'" @click="toggle(g.goalId)">
{{ collapsed.has(g.goalId) ? '▸' : '▾' }}
</button>
<span class="dot" :class="{ pulse: goalStyle(g.status).pulse }" :style="dotStyle(goalStyle(g.status))" />
<button class="g-title" :title="`${goalStyle(g.status).label} · ${g.description || ''}`" @click="toggle(g.goalId)">
{{ g.title || g.goalId }}
</button>
<span v-if="goalProgressLabel(g)" class="g-prog">{{ goalProgressLabel(g) }}</span>
<button class="more" title="更多" @click="menu = menu === g.goalId ? '' : g.goalId">⋯</button>
</div>
<!-- ⋯ 菜单:编辑 / 下达全部 / 归档 -->
<div v-if="menu === g.goalId" class="menu">
<button @click="startEdit(g)">编辑</button>
<button :disabled="busy" @click="dispatchGoal(g)">下达全部</button>
<button class="danger" :disabled="busy" @click="archiveGoal(g)">归档</button>
</div>
<!-- 内联编辑:description 在 dispatched_at 之后是冻结字段(§四.4),这里直接禁用并说明,
而不是让用户改完再吃一个 409 -->
<div v-if="editing === g.goalId" class="edit">
<input v-model="editTitle" class="in" placeholder="标题" />
<textarea
v-model="editDesc"
class="in ta"
rows="3"
:disabled="!!g.dispatchedAt"
:placeholder="g.dispatchedAt ? '已下达,描述已冻结(它是验收边界)' : '需求描述'"
/>
<div class="e-acts">
<button class="btn primary" :disabled="busy" @click="saveEdit(g)">保存</button>
<button class="btn ghost" :disabled="busy" @click="editing = ''">取消</button>
</div>
</div>
<p v-if="errors[g.goalId]" class="err">{{ errors[g.goalId] }}</p>
<template v-if="!collapsed.has(g.goalId)">
<!-- 拆分中:goal 下一条 task 都还没有,给骨架屏而不是「暂无任务」——
后者会让人以为拆分已经失败了 -->
<div v-if="g.status === 'planning'" class="planning">
<span class="p-txt">正在拆分…</span>
<i class="skel" /><i class="skel" />
</div>
<p v-else-if="g.planningError" class="degraded">
自动拆分失败,已降级为单任务。<em>{{ g.planningError }}</em>
</p>
<ul class="tasks">
<li
v-for="t in g.tasks || []"
:key="t.taskId"
class="task"
:class="{ on: t.taskId === selectedTaskId }"
@click="$emit('select', { goalId: g.goalId, taskId: t.taskId })"
>
<span class="dot" :class="{ pulse: taskStyle(t.status).pulse }" :style="dotStyle(taskStyle(t.status))" />
<div class="t-body">
<div class="t-title">
<span
v-if="(t.dependsOn || []).length"
class="dep"
:title="dependsLabel(t, g.tasks || [])"
>⇢</span>{{ t.title || t.taskId }}
</div>
<div class="t-sub">
<span class="st" :style="{ color: taskStyle(t.status).color }">{{ taskStyle(t.status).label }}</span>
<span v-if="taskSubtitle(t, now)"> · {{ taskSubtitle(t, now) }}</span>
</div>
</div>
</li>
</ul>
<p v-if="g.status !== 'planning' && !(g.tasks || []).length" class="tip small">这个目标下还没有任务。</p>
</template>
</li>
</ul>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue';
import NewGoalForm from './NewGoalForm.vue';
import {
goalStyle, taskStyle, goalProgressLabel, taskSubtitle, dependsLabel,
} from './goal-task-model.js';
defineProps({
companyId: { type: String, required: true },
goals: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
selectedTaskId: { type: String, default: '' },
selectedGoalId: { type: String, default: '' },
});
const emit = defineEmits(['select', 'refresh']);
const collapsed = ref(new Set());
const menu = ref('');
const editing = ref('');
const editTitle = ref('');
const editDesc = ref('');
const busy = ref(false);
const errors = reactive({});
/** 墙钟:running 那条的「12min」要自己走,否则不刷新页面就永远停在选中那一刻 */
const now = ref(Date.now());
let clock = null;
onMounted(() => { clock = setInterval(() => { now.value = Date.now(); }, 30000); });
onBeforeUnmount(() => clearInterval(clock));
function toggle(goalId) {
const next = new Set(collapsed.value);
if (next.has(goalId)) next.delete(goalId); else next.add(goalId);
collapsed.value = next; // Set 是引用类型,就地增删触发不了渲染
}
/** 空心圈 = 草稿:还没有任何东西发生过。实心 = 已经有过一次真实的状态转换 */
function dotStyle(s) {
return s.hollow
? { background: 'transparent', borderColor: s.color }
: { background: s.color, borderColor: s.color };
}
function startEdit(g) {
menu.value = '';
editing.value = g.goalId;
editTitle.value = g.title || '';
editDesc.value = g.description || '';
}
async function call(method, url, body) {
const r = await fetch(url, {
method,
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
// 服务端的 error / hint 原样透出(本目录惯例):409 的 hint 里写的是确切的自救路径
if (!r.ok) throw new Error([d.error || `HTTP ${r.status}`, d.hint].filter(Boolean).join(' · '));
return d;
}
async function guarded(goalId, fn) {
if (busy.value) return;
busy.value = true;
delete errors[goalId];
try {
await fn();
emit('refresh');
} catch (e) {
errors[goalId] = e.message;
} finally {
busy.value = false;
}
}
function saveEdit(g) {
const patch = { title: editTitle.value };
// 已下达的 goal 不带 description:数据层会 409,而这次编辑的本意只是改标题
if (!g.dispatchedAt) patch.description = editDesc.value;
return guarded(g.goalId, async () => {
await call('PATCH', `/api/company/${g.companyId}/goals/${g.goalId}`, patch);
editing.value = '';
});
}
function dispatchGoal(g) {
menu.value = '';
return guarded(g.goalId, async () => {
const d = await call('POST', `/api/company/${g.companyId}/goals/${g.goalId}/dispatch`);
// 起了 run 就把选中态挪到那条 task 上:用户点「下达全部」想看的就是它
if (d.started?.taskId) emit('select', { goalId: g.goalId, taskId: d.started.taskId });
});
}
function archiveGoal(g) {
menu.value = '';
return guarded(g.goalId, () => call('DELETE', `/api/company/${g.companyId}/goals/${g.goalId}`));
}
function onDispatched(d) {
if (d && d.taskId) emit('select', { goalId: d.goalId, taskId: d.taskId });
emit('refresh');
}
</script>
<style scoped>
/* 本页(GraphConsole)通篇硬编码浅色,这里跟着来 */
.pane { font-size: 12px; color: #1a1a1a; }
.tip { font-size: 11px; color: #999; line-height: 1.8; margin: 8px 2px; }
.tip.small { margin: 2px 0 6px 22px; }
.goals { list-style: none; margin: 0; padding: 0; }
.goal { border-bottom: 1px solid #f0f0f2; padding: 6px 0; }
.goal.ctx { background: #fbfcfe; }
.g-head { display: flex; align-items: center; gap: 5px; }
.caret { border: none; background: none; cursor: pointer; color: #bbb; font-size: 10px; padding: 0 2px; width: 14px; }
.g-title {
border: none; background: none; cursor: pointer; text-align: left; padding: 0;
font-size: 12px; font-weight: 700; color: #1a1a1a; flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.g-prog { font-size: 10px; color: #999; white-space: nowrap; }
.more { border: none; background: none; cursor: pointer; color: #bbb; font-size: 13px; padding: 0 2px; line-height: 1; }
.more:hover { color: #666; }
.menu { display: flex; gap: 4px; margin: 4px 0 4px 21px; }
.menu button { border: 1px solid #ddd; background: #fff; border-radius: 5px; font-size: 11px; padding: 2px 8px; cursor: pointer; color: #444; }
.menu button:disabled { opacity: .5; cursor: not-allowed; }
.menu .danger { color: #b91c1c; }
.edit { margin: 4px 0 6px 21px; display: flex; flex-direction: column; gap: 4px; }
.in { width: 100%; box-sizing: border-box; font: inherit; font-size: 11px; padding: 5px 7px; border: 1px solid #ddd; border-radius: 5px; background: #fff; color: #1a1a1a; }
.in:disabled { background: #f7f7f9; color: #999; }
.ta { resize: vertical; }
.e-acts { display: flex; gap: 5px; }
.btn { border: 1px solid #ddd; background: #fff; border-radius: 5px; font-size: 11px; padding: 3px 10px; cursor: pointer; }
.btn.primary { background: #2b6cb0; border-color: transparent; color: #fff; font-weight: 600; }
.btn.ghost { background: transparent; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.err { margin: 4px 0 4px 21px; font-size: 11px; color: #dc2626; line-height: 1.5; }
.degraded { margin: 4px 0 4px 21px; font-size: 10px; color: #a35b00; line-height: 1.5; }
.degraded em { font-style: normal; color: #b08050; }
.planning { margin: 4px 0 6px 21px; }
.p-txt { font-size: 11px; color: #2b6cb0; }
.skel { display: block; height: 22px; margin-top: 4px; border-radius: 5px; background: linear-gradient(90deg, #f2f2f4 25%, #e9e9ed 37%, #f2f2f4 63%); background-size: 400% 100%; animation: sk 1.4s ease infinite; }
@keyframes sk { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }
.tasks { list-style: none; margin: 2px 0 0; padding: 0; }
.task { display: flex; gap: 6px; align-items: flex-start; padding: 4px 6px 4px 15px; border-radius: 5px; cursor: pointer; }
.task:hover { background: #f4f6f9; }
.task.on { background: #eef4fb; box-shadow: inset 2px 0 0 #2b6cb0; }
.dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid transparent; box-sizing: border-box; margin-top: 4px; }
.dot.pulse { animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
.t-body { min-width: 0; flex: 1; }
.t-title {
font-size: 12px; line-height: 1.4; color: #1a1a1a;
display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.dep { color: #c96a00; margin-right: 3px; cursor: help; }
.t-sub { font-size: 10px; color: #999; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.st { font-weight: 600; }
</style>